From 96952fb43cfba4efc26bc91f347d99a9ad2dc03b Mon Sep 17 00:00:00 2001 From: Andrey Avtomonov Date: Sun, 24 May 2026 16:57:23 +0200 Subject: [PATCH 01/74] refactor: remove legacy ktx compatibility shims (#211) * refactor: remove legacy ktx compatibility shims * fix: restore overlay collision guidance --- README.md | 4 +- .../content/docs/configuration/ktx-yaml.mdx | 2 +- .../docs/integrations/agent-clients.mdx | 6 +- packages/cli/src/connection.ts | 2 - .../src/connectors/postgres/connector.test.ts | 2 +- .../cli/src/connectors/postgres/connector.ts | 2 +- .../cli/src/connectors/sqlite/connector.ts | 2 +- packages/cli/src/context-build-view.ts | 2 +- .../src/context/connections/dialects.test.ts | 8 +- .../cli/src/context/connections/dialects.ts | 8 +- .../connections/local-query-executor.ts | 4 +- .../local-warehouse-descriptor.test.ts | 5 + .../connections/local-warehouse-descriptor.ts | 2 - .../connections/postgres-query-executor.ts | 2 +- .../connections/sqlite-query-executor.ts | 2 +- .../historic-sql/connection-dialect.ts | 2 +- .../daemon-introspection.test.ts | 2 +- .../live-database/daemon-introspection.ts | 2 +- .../ingest/adapters/looker/mapping.test.ts | 3 +- .../context/ingest/adapters/looker/mapping.ts | 3 - .../cli/src/context/ingest/local-adapters.ts | 1 - .../mcp/__snapshots__/mcp-tools-list.json | 17 +-- packages/cli/src/context/mcp/context-tools.ts | 63 ++--------- .../src/context/mcp/local-project-ports.ts | 3 - packages/cli/src/context/mcp/server.test.ts | 102 ++++++++++++++++-- .../context/project/driver-schemas.test.ts | 5 +- .../cli/src/context/project/driver-schemas.ts | 2 - .../cli/src/context/scan/enabled-tables.ts | 16 +-- .../cli/src/context/scan/local-scan.test.ts | 9 ++ packages/cli/src/context/scan/local-scan.ts | 6 +- .../cli/src/context/scan/table-ref.test.ts | 6 +- packages/cli/src/context/scan/table-ref.ts | 7 +- packages/cli/src/context/scan/types.ts | 1 - .../cli/src/context/scan/warehouse-catalog.ts | 8 +- packages/cli/src/context/sl/local-query.ts | 3 - packages/cli/src/context/sl/local-sl.test.ts | 6 +- packages/cli/src/context/sl/local-sl.ts | 17 --- .../context/sl/semantic-layer.service.test.ts | 5 +- .../src/context/sl/semantic-layer.service.ts | 5 +- packages/cli/src/doctor.test.ts | 9 +- packages/cli/src/ingest-depth.ts | 1 - packages/cli/src/local-scan-connectors.ts | 4 +- packages/cli/src/public-ingest.test.ts | 35 +++++- packages/cli/src/public-ingest.ts | 11 +- packages/cli/src/runtime-requirements.test.ts | 27 +++++ packages/cli/src/runtime-requirements.ts | 4 +- packages/cli/src/scan.test.ts | 2 +- packages/cli/src/setup-agents.test.ts | 15 +-- packages/cli/src/setup-agents.ts | 95 ++-------------- packages/cli/src/setup-databases.ts | 2 - packages/cli/src/sql.ts | 4 - packages/cli/src/status-project.ts | 31 +----- .../src/ktx_daemon/database_introspection.py | 2 +- .../src/ktx_daemon/semantic_layer.py | 2 +- .../ktx-daemon/src/ktx_daemon/sql_analysis.py | 4 +- .../src/ktx_daemon/table_identifier.py | 15 ++- .../tests/test_database_introspection.py | 12 +++ .../ktx-daemon/tests/test_semantic_layer.py | 12 +++ python/ktx-sl/semantic_layer/loader.py | 2 +- 59 files changed, 294 insertions(+), 342 deletions(-) diff --git a/README.md b/README.md index 285f92a6..cb9d25b0 100644 --- a/README.md +++ b/README.md @@ -166,8 +166,8 @@ agent also needs pinned `ktx` admin commands. After setup, **ktx** prints **Required before using agents** with the exact commands to run. If the output includes `ktx mcp start --project-dir ...`, run -it before opening your agent. Claude Desktop uses its own launcher and prints -separate skill upload steps under `.ktx/agents/claude/`. +it before opening your agent. Claude Desktop gets a stdio MCP config entry and +prints separate skill upload steps under `.ktx/agents/claude/`. ## Workspace layout diff --git a/docs-site/content/docs/configuration/ktx-yaml.mdx b/docs-site/content/docs/configuration/ktx-yaml.mdx index 2220814a..873a8acd 100644 --- a/docs-site/content/docs/configuration/ktx-yaml.mdx +++ b/docs-site/content/docs/configuration/ktx-yaml.mdx @@ -105,7 +105,7 @@ context-source drivers share the map. | Driver | Kind | Required fields | Common optional fields | |--------|------|-----------------|------------------------| -| `postgres` / `postgresql` | Warehouse | `driver` | `url`, `enabled_tables`, `historicSql`, `context.queryHistory` | +| `postgres` | Warehouse | `driver` | `url`, `enabled_tables`, `historicSql`, `context.queryHistory` | | `mysql` | Warehouse | `driver` | `url`, `enabled_tables` | | `sqlite` | Warehouse | `driver` | `url` or `path`, `enabled_tables` | | `sqlserver` | Warehouse | `driver` | `url`, `enabled_tables` | diff --git a/docs-site/content/docs/integrations/agent-clients.mdx b/docs-site/content/docs/integrations/agent-clients.mdx index f7281dda..36aef1c3 100644 --- a/docs-site/content/docs/integrations/agent-clients.mdx +++ b/docs-site/content/docs/integrations/agent-clients.mdx @@ -183,10 +183,8 @@ Claude Desktop skill packages for the **ktx** workflows: - `~/Library/Application Support/Claude/claude_desktop_config.json` (macOS) or `%AppData%/Claude/claude_desktop_config.json` (Windows) gets an - `mcpServers.ktx` entry that runs the **ktx** MCP server over stdio via a local - launcher shim at `.ktx/agents/claude/ktx-plugin-runner.sh`. The shim locates - a usable Node.js (Volta, NVM, Homebrew, system) so Claude Desktop can spawn - the server without needing `node` in PATH. + `mcpServers.ktx` entry that runs the **ktx** MCP server over stdio with the + current Node.js executable and the installed `ktx` CLI entrypoint. - `.ktx/agents/claude/ktx-analytics.zip` contains the `ktx-analytics` skill. If you choose **Ask data questions + manage ktx with CLI commands**, **ktx** also generates `.ktx/agents/claude/ktx.zip` with the admin `ktx` skill. Claude diff --git a/packages/cli/src/connection.ts b/packages/cli/src/connection.ts index bb99d4fd..335bfb47 100644 --- a/packages/cli/src/connection.ts +++ b/packages/cli/src/connection.ts @@ -274,9 +274,7 @@ async function testConnectionByDriver( if ( driver === 'sqlite' || - driver === 'sqlite3' || driver === 'postgres' || - driver === 'postgresql' || driver === 'mysql' || driver === 'clickhouse' || driver === 'sqlserver' || diff --git a/packages/cli/src/connectors/postgres/connector.test.ts b/packages/cli/src/connectors/postgres/connector.test.ts index 346c2ef2..0ab23a0a 100644 --- a/packages/cli/src/connectors/postgres/connector.test.ts +++ b/packages/cli/src/connectors/postgres/connector.test.ts @@ -99,7 +99,7 @@ function metadataResults(): Map { describe('KtxPostgresScanConnector', () => { it('resolves configuration safely', () => { expect(isKtxPostgresConnectionConfig({ driver: 'postgres', url: 'env:DATABASE_URL' })).toBe(true); - expect(isKtxPostgresConnectionConfig({ driver: 'postgresql', host: 'db', database: 'analytics' })).toBe(true); + expect(isKtxPostgresConnectionConfig({ driver: 'postgresql', host: 'db', database: 'analytics' })).toBe(false); expect(isKtxPostgresConnectionConfig({ driver: 'mysql', host: 'db' })).toBe(false); expect( postgresPoolConfigFromConfig({ diff --git a/packages/cli/src/connectors/postgres/connector.ts b/packages/cli/src/connectors/postgres/connector.ts index 5cb94bf4..44bd58b6 100644 --- a/packages/cli/src/connectors/postgres/connector.ts +++ b/packages/cli/src/connectors/postgres/connector.ts @@ -276,7 +276,7 @@ export function isKtxPostgresConnectionConfig( connection: KtxPostgresConnectionConfig | undefined, ): connection is KtxPostgresConnectionConfig { const driver = String(connection?.driver ?? '').toLowerCase(); - return driver === 'postgres' || driver === 'postgresql'; + return driver === 'postgres'; } /** @internal */ diff --git a/packages/cli/src/connectors/sqlite/connector.ts b/packages/cli/src/connectors/sqlite/connector.ts index 17b33a71..504e427d 100644 --- a/packages/cli/src/connectors/sqlite/connector.ts +++ b/packages/cli/src/connectors/sqlite/connector.ts @@ -125,7 +125,7 @@ export function isKtxSqliteConnectionConfig( connection: KtxSqliteConnectionConfig | undefined, ): connection is KtxSqliteConnectionConfig { const driver = String(connection?.driver ?? '').toLowerCase(); - return driver === 'sqlite' || driver === 'sqlite3'; + return driver === 'sqlite'; } /** @internal */ diff --git a/packages/cli/src/context-build-view.ts b/packages/cli/src/context-build-view.ts index 49ddd3eb..9a06d39a 100644 --- a/packages/cli/src/context-build-view.ts +++ b/packages/cli/src/context-build-view.ts @@ -694,7 +694,7 @@ function isLocalSqlAnalysisConnectionRefused(input: { capturedOutput?: string; f function friendlyDriverName(driver: string): string { const normalized = driver.toLowerCase(); - if (normalized === 'postgres' || normalized === 'postgresql') return 'PostgreSQL'; + if (normalized === 'postgres') return 'PostgreSQL'; if (normalized === 'mysql') return 'MySQL'; if (normalized === 'sqlserver') return 'SQL Server'; if (normalized === 'bigquery') return 'BigQuery'; diff --git a/packages/cli/src/context/connections/dialects.test.ts b/packages/cli/src/context/connections/dialects.test.ts index 6c9b6c41..d4f77997 100644 --- a/packages/cli/src/context/connections/dialects.test.ts +++ b/packages/cli/src/context/connections/dialects.test.ts @@ -4,7 +4,6 @@ import { getDialectForDriver } from './dialects.js'; describe('getDialectForDriver', () => { it.each([ ['postgres', '"public"."orders"'], - ['postgresql', '"public"."orders"'], ['mysql', '`public`.`orders`'], ['clickhouse', '`public`.`orders`'], ['sqlite', '"orders"'], @@ -24,7 +23,12 @@ describe('getDialectForDriver', () => { it('throws with a supported-driver list for unknown drivers', () => { expect(() => getDialectForDriver('oracle')).toThrow( - 'Unsupported warehouse driver "oracle". Supported drivers: bigquery, clickhouse, mysql, postgres, postgresql, sqlite, sqlite3, snowflake, sqlserver', + 'Unsupported warehouse driver "oracle". Supported drivers: bigquery, clickhouse, mysql, postgres, sqlite, snowflake, sqlserver', ); }); + + it('rejects legacy driver aliases', () => { + expect(() => getDialectForDriver('postgresql')).toThrow('Unsupported warehouse driver "postgresql"'); + expect(() => getDialectForDriver('sqlite3')).toThrow('Unsupported warehouse driver "sqlite3"'); + }); }); diff --git a/packages/cli/src/context/connections/dialects.ts b/packages/cli/src/context/connections/dialects.ts index 75a8ae4c..5c6cc27f 100644 --- a/packages/cli/src/context/connections/dialects.ts +++ b/packages/cli/src/context/connections/dialects.ts @@ -2,14 +2,12 @@ import type { KtxSchemaDimensionType, KtxTableRef } from '../scan/types.js'; type SupportedDriver = | 'postgres' - | 'postgresql' | 'mysql' | 'sqlserver' | 'snowflake' | 'bigquery' | 'clickhouse' - | 'sqlite' - | 'sqlite3'; + | 'sqlite'; export interface KtxDialect { readonly type: SupportedDriver; @@ -23,9 +21,7 @@ const supportedDrivers: SupportedDriver[] = [ 'clickhouse', 'mysql', 'postgres', - 'postgresql', 'sqlite', - 'sqlite3', 'snowflake', 'sqlserver', ]; @@ -83,11 +79,9 @@ function createDialect(type: SupportedDriver, quote: (identifier: string) => str const dialects: Record = { postgres: createDialect('postgres', doubleQuoted), - postgresql: createDialect('postgresql', doubleQuoted), mysql: createDialect('mysql', backtickQuoted), clickhouse: createDialect('clickhouse', backtickQuoted), sqlite: createDialect('sqlite', doubleQuoted, true), - sqlite3: createDialect('sqlite3', doubleQuoted, true), snowflake: createDialect('snowflake', doubleQuoted), bigquery: createDialect('bigquery', bigQueryQuoted), sqlserver: createDialect('sqlserver', bracketQuoted), diff --git a/packages/cli/src/context/connections/local-query-executor.ts b/packages/cli/src/context/connections/local-query-executor.ts index 9b5f2032..72cefe2a 100644 --- a/packages/cli/src/context/connections/local-query-executor.ts +++ b/packages/cli/src/context/connections/local-query-executor.ts @@ -22,10 +22,10 @@ export function createDefaultLocalQueryExecutor(options: DefaultLocalQueryExecut return { async execute(input: KtxSqlQueryExecutionInput): Promise { const driver = driverFor(input); - if (driver === 'postgres' || driver === 'postgresql') { + if (driver === 'postgres') { return postgres.execute(input); } - if (driver === 'sqlite' || driver === 'sqlite3') { + if (driver === 'sqlite') { return sqlite.execute(input); } throw new Error(`No local query executor is configured for driver "${input.connection?.driver ?? 'unknown'}".`); diff --git a/packages/cli/src/context/connections/local-warehouse-descriptor.test.ts b/packages/cli/src/context/connections/local-warehouse-descriptor.test.ts index 0eee9f34..7e62f1dc 100644 --- a/packages/cli/src/context/connections/local-warehouse-descriptor.test.ts +++ b/packages/cli/src/context/connections/local-warehouse-descriptor.test.ts @@ -53,6 +53,11 @@ describe('local connection info helpers', () => { expect(localConnectionTypeForConfig('snowflake', { driver: 'snowflake' })).toBe('SNOWFLAKE'); }); + it('keeps removed driver aliases as display-only labels', () => { + expect(localConnectionTypeForConfig('warehouse', { driver: 'postgresql' } as never)).toBe('postgresql'); + expect(localConnectionTypeForConfig('warehouse', { driver: 'mssql' } as never)).toBe('mssql'); + }); + it('keeps non-warehouse adapter labels for display-only local connection surfaces', () => { expect(localConnectionTypeForConfig('prod-metabase', { driver: 'metabase', api_url: 'https://metabase.example.com' })).toBe( 'metabase', diff --git a/packages/cli/src/context/connections/local-warehouse-descriptor.ts b/packages/cli/src/context/connections/local-warehouse-descriptor.ts index c2cc6516..4ad926df 100644 --- a/packages/cli/src/context/connections/local-warehouse-descriptor.ts +++ b/packages/cli/src/context/connections/local-warehouse-descriptor.ts @@ -20,10 +20,8 @@ export interface LocalConnectionInfo { const DRIVER_TO_CONNECTION_TYPE: Record = { postgres: 'POSTGRESQL', - postgresql: 'POSTGRESQL', sqlite: 'SQLITE', sqlserver: 'SQLSERVER', - mssql: 'SQLSERVER', mysql: 'MYSQL', clickhouse: 'CLICKHOUSE', snowflake: 'SNOWFLAKE', diff --git a/packages/cli/src/context/connections/postgres-query-executor.ts b/packages/cli/src/context/connections/postgres-query-executor.ts index b5f2d02e..842609f4 100644 --- a/packages/cli/src/context/connections/postgres-query-executor.ts +++ b/packages/cli/src/context/connections/postgres-query-executor.ts @@ -38,7 +38,7 @@ export function createPostgresQueryExecutor(options: PostgresQueryExecutorOption async execute(input: KtxSqlQueryExecutionInput): Promise { const driver = connectionDriver(input); const connection = input.connection; - if (driver !== 'postgres' && driver !== 'postgresql') { + if (driver !== 'postgres') { throw new Error(`Local Postgres execution cannot run driver "${connection?.driver ?? 'unknown'}".`); } if (typeof connection?.url !== 'string' || connection.url.trim().length === 0) { diff --git a/packages/cli/src/context/connections/sqlite-query-executor.ts b/packages/cli/src/context/connections/sqlite-query-executor.ts index 22c69005..40710c96 100644 --- a/packages/cli/src/context/connections/sqlite-query-executor.ts +++ b/packages/cli/src/context/connections/sqlite-query-executor.ts @@ -52,7 +52,7 @@ function sqlitePathFromUrl(url: string): string { /** @internal */ export function sqliteDatabasePathFromConnection(input: KtxSqlQueryExecutionInput): string { const driver = connectionDriver(input); - if (driver !== 'sqlite' && driver !== 'sqlite3') { + if (driver !== 'sqlite') { throw new Error(`Local SQLite execution cannot run driver "${input.connection?.driver ?? 'unknown'}".`); } diff --git a/packages/cli/src/context/ingest/adapters/historic-sql/connection-dialect.ts b/packages/cli/src/context/ingest/adapters/historic-sql/connection-dialect.ts index 846ce098..c6b4c53b 100644 --- a/packages/cli/src/context/ingest/adapters/historic-sql/connection-dialect.ts +++ b/packages/cli/src/context/ingest/adapters/historic-sql/connection-dialect.ts @@ -25,7 +25,7 @@ export function queryHistoryDialectForConnection(connection: unknown): HistoricS } const conn = recordOrNull(connection); const driver = String(conn?.driver ?? '').toLowerCase(); - if (driver === 'postgres' || driver === 'postgresql') return 'postgres'; + if (driver === 'postgres') return 'postgres'; if (driver === 'bigquery') return 'bigquery'; if (driver === 'snowflake') return 'snowflake'; return null; diff --git a/packages/cli/src/context/ingest/adapters/live-database/daemon-introspection.test.ts b/packages/cli/src/context/ingest/adapters/live-database/daemon-introspection.test.ts index 9310f148..ca62ec05 100644 --- a/packages/cli/src/context/ingest/adapters/live-database/daemon-introspection.test.ts +++ b/packages/cli/src/context/ingest/adapters/live-database/daemon-introspection.test.ts @@ -155,7 +155,7 @@ describe('createDaemonLiveDatabaseIntrospection', () => { const introspection = createDaemonLiveDatabaseIntrospection({ connections: { warehouse: { - driver: 'postgresql', + driver: 'postgres', url: 'postgres://localhost:5432/warehouse', }, }, diff --git a/packages/cli/src/context/ingest/adapters/live-database/daemon-introspection.ts b/packages/cli/src/context/ingest/adapters/live-database/daemon-introspection.ts index f71e332d..03e5953d 100644 --- a/packages/cli/src/context/ingest/adapters/live-database/daemon-introspection.ts +++ b/packages/cli/src/context/ingest/adapters/live-database/daemon-introspection.ts @@ -151,7 +151,7 @@ function optionalString(value: unknown): string | undefined { function normalizeDriver(driver: unknown): string { const normalized = String(driver ?? '').trim().toLowerCase(); - return normalized === 'postgresql' ? 'postgres' : normalized; + return normalized; } function requirePostgresConnection( diff --git a/packages/cli/src/context/ingest/adapters/looker/mapping.test.ts b/packages/cli/src/context/ingest/adapters/looker/mapping.test.ts index 796a9f05..c7b29b8a 100644 --- a/packages/cli/src/context/ingest/adapters/looker/mapping.test.ts +++ b/packages/cli/src/context/ingest/adapters/looker/mapping.test.ts @@ -72,7 +72,8 @@ describe('looker dialect and target validation helpers', () => { it('maps Looker dialect names to KTX connection types', () => { expect(lookerDialectToConnectionType('bigquery_standard_sql')).toBe('BIGQUERY'); expect(lookerDialectToConnectionType('postgres')).toBe('POSTGRESQL'); - expect(lookerDialectToConnectionType('mssql')).toBe('SQLSERVER'); + expect(lookerDialectToConnectionType('mssql')).toBeNull(); + expect(lookerDialectToConnectionType('tsql')).toBeNull(); expect(lookerDialectToConnectionType('unknown')).toBeNull(); }); diff --git a/packages/cli/src/context/ingest/adapters/looker/mapping.ts b/packages/cli/src/context/ingest/adapters/looker/mapping.ts index cbd80e80..4da6344d 100644 --- a/packages/cli/src/context/ingest/adapters/looker/mapping.ts +++ b/packages/cli/src/context/ingest/adapters/looker/mapping.ts @@ -7,12 +7,9 @@ const LOOKER_DIALECT_TO_CONNECTION_TYPE = { bigquery_standard_sql: 'BIGQUERY', snowflake: 'SNOWFLAKE', postgres: 'POSTGRESQL', - postgresql: 'POSTGRESQL', mysql: 'MYSQL', sqlite: 'SQLITE', sqlserver: 'SQLSERVER', - mssql: 'SQLSERVER', - tsql: 'SQLSERVER', clickhouse: 'CLICKHOUSE', } as const; diff --git a/packages/cli/src/context/ingest/local-adapters.ts b/packages/cli/src/context/ingest/local-adapters.ts index e7c99146..4739f4e4 100644 --- a/packages/cli/src/context/ingest/local-adapters.ts +++ b/packages/cli/src/context/ingest/local-adapters.ts @@ -168,7 +168,6 @@ function isRecord(value: unknown): value is Record { const historicSqlDialectByDriver = new Map([ ['postgres', 'postgres'], - ['postgresql', 'postgres'], ['bigquery', 'bigquery'], ['snowflake', 'snowflake'], ]); diff --git a/packages/cli/src/context/mcp/__snapshots__/mcp-tools-list.json b/packages/cli/src/context/mcp/__snapshots__/mcp-tools-list.json index db84b328..10cb0b77 100644 --- a/packages/cli/src/context/mcp/__snapshots__/mcp-tools-list.json +++ b/packages/cli/src/context/mcp/__snapshots__/mcp-tools-list.json @@ -307,7 +307,7 @@ { "name": "sl_query", "title": "Semantic Layer Query", - "description": "Execute a semantic-layer query and return rows, headers, generated SQL, and plan details. Example: sl_query({ connectionId: \"warehouse\", measures: [\"orders.order_count\"], dimensions: [{ dimension: \"orders.created_at\", granularity: \"month\" }] }).", + "description": "Execute a semantic-layer query and return rows, headers, generated SQL, and plan details. Example: sl_query({ connectionId: \"warehouse\", measures: [\"orders.order_count\"], dimensions: [{ field: \"orders.created_at\", granularity: \"month\" }] }).", "inputSchema": { "type": "object", "properties": { @@ -349,7 +349,8 @@ "description": "Measures to select. Use semantic-layer keys when available." }, "dimensions": { - "description": "Dimensions to group by. Strings and {dimension, granularity} are accepted.", + "default": [], + "description": "Dimensions to group by. Use {field, granularity?} entries.", "type": "array", "items": { "type": "object", @@ -389,7 +390,8 @@ } }, "order_by": { - "description": "Sort clauses. Strings and Cube-style {id, desc} are accepted.", + "default": [], + "description": "Sort clauses. Use {field, direction?} entries.", "type": "array", "items": { "type": "object", @@ -489,7 +491,7 @@ { "name": "entity_details", "title": "Entity Details", - "description": "Read table and column metadata from the latest live-database scan snapshot. Example: entity_details({ connectionId: \"warehouse\", entities: [{ table: { schema: \"public\", table: \"orders\" }, columns: [\"id\"] }] }).", + "description": "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\"] }] }).", "inputSchema": { "type": "object", "properties": { @@ -549,7 +551,7 @@ ] } ], - "description": "Table display string or object ref. {schema, table} is accepted as an alias for {db, name}." + "description": "Table display string or canonical object ref." }, "columns": { "description": "Optional column filter.", @@ -560,7 +562,10 @@ "description": "Column name to inspect." } } - } + }, + "required": [ + "table" + ] }, "description": "Tables or columns to inspect. Maximum 20 entities." } diff --git a/packages/cli/src/context/mcp/context-tools.ts b/packages/cli/src/context/mcp/context-tools.ts index 963ab44f..aa593f7f 100644 --- a/packages/cli/src/context/mcp/context-tools.ts +++ b/packages/cli/src/context/mcp/context-tools.ts @@ -54,13 +54,13 @@ const toolDescriptions = { '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: { schema: "public", table: "orders" }, columns: ["id"] }] }).', + '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: 'Search profile-sampled warehouse values to locate likely source columns for business values. Example: dictionary_search({ values: ["Acme Corp"], connectionId: "warehouse" }).', sl_read_source: 'Read a semantic-layer YAML source by connection id and source name. Example: sl_read_source({ connectionId: "warehouse", sourceName: "orders" }).', sl_query: - 'Execute a semantic-layer query and return rows, headers, generated SQL, and plan details. Example: sl_query({ connectionId: "warehouse", measures: ["orders.order_count"], dimensions: [{ dimension: "orders.created_at", granularity: "month" }] }).', + 'Execute a semantic-layer query and return rows, headers, generated SQL, and plan details. Example: sl_query({ connectionId: "warehouse", measures: ["orders.order_count"], dimensions: [{ field: "orders.created_at", granularity: "month" }] }).', 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 }).', memory_ingest: @@ -93,44 +93,16 @@ const slQueryMeasureSchema = z.union([ }), ]); -const slQueryDimensionSchema = z.preprocess( - (value) => { - if (typeof value === 'string') return { field: value }; - if (value && typeof value === 'object' && !Array.isArray(value)) { - const obj = { ...(value as Record) }; - if (!('field' in obj) && typeof obj.dimension === 'string') obj.field = obj.dimension; - return obj; - } - return value; - }, - z.object({ +const slQueryDimensionSchema = z.object({ field: z.string().min(1).describe('Dimension to group by, e.g. "orders.created_at" or "orders.status".'), granularity: z .string() .min(1) .optional() .describe('Time grain for time dimensions: day, week, month, quarter, or year.'), - }), -); + }); -const slQueryOrderBySchema = z.preprocess( - (value) => { - if (typeof value === 'string') { - return { field: value }; - } - if (value && typeof value === 'object' && !Array.isArray(value)) { - const obj = { ...(value as Record) }; - if (!('field' in obj) && typeof obj.id === 'string') { - obj.field = obj.id; - } - if (!('direction' in obj) && 'desc' in obj) { - obj.direction = obj.desc === true ? 'desc' : 'asc'; - } - return obj; - } - return value; - }, - z.object({ +const slQueryOrderBySchema = z.object({ field: z .string() .min(1) @@ -141,8 +113,7 @@ const slQueryOrderBySchema = z.preprocess( .enum(['asc', 'desc']) .default('asc') .describe('Sort direction: "asc" or "desc". Defaults to "asc".'), - }), -); + }); const slQuerySchema = z.object({ connectionId: connectionIdSchema @@ -152,7 +123,7 @@ const slQuerySchema = z.object({ dimensions: z .array(slQueryDimensionSchema) .default([]) - .describe('Dimensions to group by. Strings and {dimension, granularity} are accepted.'), + .describe('Dimensions to group by. Use {field, granularity?} entries.'), filters: z .array(z.string().describe('Semantic-layer filter expression, e.g. "orders.status = paid".')) .default([]) @@ -164,28 +135,16 @@ const slQuerySchema = z.object({ order_by: z .array(slQueryOrderBySchema) .default([]) - .describe('Sort clauses. Strings and Cube-style {id, desc} are accepted.'), + .describe('Sort clauses. Use {field, direction?} entries.'), limit: z.number().int().min(0).default(1000).describe('Maximum rows to return. Defaults to 1000.'), include_empty: z.boolean().default(true).describe('Whether to include empty dimension groups. Defaults to true.'), }); -const entityDetailsTableRefSchema = z.preprocess( - (value) => { - if (value && typeof value === 'object' && !Array.isArray(value)) { - const obj = { ...(value as Record) }; - if (!('db' in obj) && typeof obj.schema === 'string') obj.db = obj.schema; - if (!('name' in obj) && typeof obj.table === 'string') obj.name = obj.table; - if (!('catalog' in obj)) obj.catalog = null; - return obj; - } - return value; - }, - z.object({ +const entityDetailsTableRefSchema = z.object({ catalog: z.string().nullable().describe('Catalog/project/database. Use null when not applicable.'), db: z.string().nullable().describe('Schema/database/dataset. Use null when not applicable.'), name: z.string().min(1).describe('Table name.'), - }), -); + }); const entityDetailsSchema = z.object({ connectionId: connectionIdSchema.describe('Connection id whose latest scan snapshot should be read.'), @@ -194,7 +153,7 @@ const entityDetailsSchema = z.object({ z.object({ table: z .union([z.string().min(1), entityDetailsTableRefSchema]) - .describe('Table display string or object ref. {schema, table} is accepted as an alias for {db, name}.'), + .describe('Table display string or canonical object ref.'), columns: z .array(z.string().min(1).describe('Column name to inspect.')) .optional() diff --git a/packages/cli/src/context/mcp/local-project-ports.ts b/packages/cli/src/context/mcp/local-project-ports.ts index dbbb36d2..4c820b14 100644 --- a/packages/cli/src/context/mcp/local-project-ports.ts +++ b/packages/cli/src/context/mcp/local-project-ports.ts @@ -24,17 +24,14 @@ interface CreateLocalProjectMcpContextPortsOptions { function dialectForDriver(driver: string | undefined): string { const normalized = (driver ?? 'postgres').toUpperCase(); const map: Record = { - POSTGRESQL: 'postgres', POSTGRES: 'postgres', BIGQUERY: 'bigquery', SNOWFLAKE: 'snowflake', MYSQL: 'mysql', SQLSERVER: 'tsql', - MSSQL: 'tsql', SQLITE: 'sqlite', DUCKDB: 'duckdb', CLICKHOUSE: 'clickhouse', - REDSHIFT: 'redshift', DATABRICKS: 'databricks', }; return map[normalized] ?? 'postgres'; diff --git a/packages/cli/src/context/mcp/server.test.ts b/packages/cli/src/context/mcp/server.test.ts index bee00c00..e6666364 100644 --- a/packages/cli/src/context/mcp/server.test.ts +++ b/packages/cli/src/context/mcp/server.test.ts @@ -466,7 +466,43 @@ describe('createKtxMcpServer', () => { }); }); - it('sl_query normalizes order_by from cube-style {id, desc} and bare strings to {field, direction}', async () => { + it('sl_query rejects cube-style order_by aliases and bare strings', async () => { + const fake = makeFakeServer(); + const semanticLayer: KtxSemanticLayerMcpPort = { + readSource: vi.fn(), + query: vi.fn().mockResolvedValue({ + sql: '', + headers: [], + rows: [], + totalRows: 0, + }), + }; + + createKtxMcpServer({ + server: fake.server, + userContext: { userId: 'local-user' }, + contextTools: { semanticLayer }, + }); + + await expect( + getTool(fake.tools, 'sl_query').handler({ + connectionId: 'warehouse', + measures: ['orders.count'], + order_by: [{ id: 'orders.quarter_label', desc: false }], + }), + ).resolves.toMatchObject({ isError: true }); + await expect( + getTool(fake.tools, 'sl_query').handler({ + connectionId: 'warehouse', + measures: ['orders.count'], + order_by: ['orders.segment'], + }), + ).resolves.toMatchObject({ isError: true }); + + expect(semanticLayer.query).not.toHaveBeenCalled(); + }); + + it('sl_query accepts canonical order_by entries', async () => { const fake = makeFakeServer(); const semanticLayer: KtxSemanticLayerMcpPort = { readSource: vi.fn(), @@ -489,9 +525,7 @@ describe('createKtxMcpServer', () => { measures: ['orders.count'], order_by: [ { field: 'orders.total', direction: 'desc' }, - { id: 'orders.quarter_label', desc: false }, - { id: 'orders.created_at', desc: true }, - 'orders.segment', + { field: 'orders.segment' }, ], }); @@ -501,8 +535,6 @@ describe('createKtxMcpServer', () => { query: expect.objectContaining({ order_by: [ { field: 'orders.total', direction: 'desc' }, - { field: 'orders.quarter_label', direction: 'asc' }, - { field: 'orders.created_at', direction: 'desc' }, { field: 'orders.segment', direction: 'asc' }, ], }), @@ -511,7 +543,35 @@ describe('createKtxMcpServer', () => { ); }); - it('sl_query normalizes cube-style dimensions to field dimensions', async () => { + it('sl_query rejects cube-style dimensions and bare strings', async () => { + const fake = makeFakeServer(); + const semanticLayer = makeAllContextTools().semanticLayer!; + + createKtxMcpServer({ + server: fake.server, + userContext: { userId: 'local-user' }, + contextTools: { semanticLayer }, + }); + + await expect( + getTool(fake.tools, 'sl_query').handler({ + connectionId: 'warehouse', + measures: ['orders.count'], + dimensions: [{ dimension: 'orders.created_at', granularity: 'month' }], + }), + ).resolves.toMatchObject({ isError: true }); + await expect( + getTool(fake.tools, 'sl_query').handler({ + connectionId: 'warehouse', + measures: ['orders.count'], + dimensions: ['orders.status'], + }), + ).resolves.toMatchObject({ isError: true }); + + expect(semanticLayer.query).not.toHaveBeenCalled(); + }); + + it('sl_query accepts canonical field dimensions', async () => { const fake = makeFakeServer(); const semanticLayer = makeAllContextTools().semanticLayer!; @@ -524,7 +584,7 @@ describe('createKtxMcpServer', () => { await getTool(fake.tools, 'sl_query').handler({ connectionId: 'warehouse', measures: ['orders.count'], - dimensions: [{ dimension: 'orders.created_at', granularity: 'month' }, 'orders.status'], + dimensions: [{ field: 'orders.created_at', granularity: 'month' }, { field: 'orders.status' }], }); expect(semanticLayer.query).toHaveBeenCalledWith( @@ -538,7 +598,27 @@ describe('createKtxMcpServer', () => { ); }); - it('entity_details normalizes sql-style schema table refs', async () => { + it('entity_details rejects sql-style schema table ref aliases', async () => { + const fake = makeFakeServer(); + const entityDetails = makeAllContextTools().entityDetails!; + + createKtxMcpServer({ + server: fake.server, + userContext: { userId: 'local-user' }, + contextTools: { entityDetails }, + }); + + await expect( + getTool(fake.tools, 'entity_details').handler({ + connectionId: 'warehouse', + entities: [{ table: { schema: 'public', table: 'orders' }, columns: ['id'] }], + }), + ).resolves.toMatchObject({ isError: true }); + + expect(entityDetails.read).not.toHaveBeenCalled(); + }); + + it('entity_details accepts canonical table refs', async () => { const fake = makeFakeServer(); const entityDetails = makeAllContextTools().entityDetails!; @@ -550,7 +630,7 @@ describe('createKtxMcpServer', () => { await getTool(fake.tools, 'entity_details').handler({ connectionId: 'warehouse', - entities: [{ table: { schema: 'public', table: 'orders' }, columns: ['id'] }], + entities: [{ table: { catalog: null, db: 'public', name: 'orders' }, columns: ['id'] }], }); expect(entityDetails.read).toHaveBeenCalledWith({ @@ -1018,7 +1098,7 @@ describe('createKtxMcpServer', () => { await getTool(fake.tools, 'sl_query').handler({ connectionId: '00000000-0000-4000-8000-000000000001', measures: ['orders.count'], - dimensions: ['orders.created_at'], + dimensions: [{ field: 'orders.created_at' }], filters: ['orders.status = paid'], limit: 25, }); diff --git a/packages/cli/src/context/project/driver-schemas.test.ts b/packages/cli/src/context/project/driver-schemas.test.ts index 252f428f..1c5b1276 100644 --- a/packages/cli/src/context/project/driver-schemas.test.ts +++ b/packages/cli/src/context/project/driver-schemas.test.ts @@ -4,7 +4,6 @@ import { connectionConfigSchema } from './driver-schemas.js'; describe('connectionConfigSchema (driver discriminated union)', () => { it.each([ ['postgres', 'postgres://user:pass@host:5432/db'], // pragma: allowlist secret - ['postgresql', 'postgresql://user:pass@host:5432/db'], // pragma: allowlist secret ['mysql', 'mysql://user:pass@host:3306/db'], // pragma: allowlist secret ['snowflake', 'snowflake://account/db'], ['bigquery', 'bigquery://project/dataset'], @@ -32,6 +31,10 @@ describe('connectionConfigSchema (driver discriminated union)', () => { it('rejects an unknown driver', () => { expect(() => connectionConfigSchema.parse({ driver: 'nope', url: 'x' })).toThrow(); }); + + it('rejects legacy warehouse driver aliases', () => { + expect(() => connectionConfigSchema.parse({ driver: 'postgresql', url: 'postgresql://host/db' })).toThrow(); + }); }); describe('connectionConfigSchema - context source drivers with mappings', () => { diff --git a/packages/cli/src/context/project/driver-schemas.ts b/packages/cli/src/context/project/driver-schemas.ts index 3d6d1a84..6b4dc017 100644 --- a/packages/cli/src/context/project/driver-schemas.ts +++ b/packages/cli/src/context/project/driver-schemas.ts @@ -7,7 +7,6 @@ import { const warehouseDrivers = [ 'postgres', - 'postgresql', 'mysql', 'snowflake', 'bigquery', @@ -41,7 +40,6 @@ function warehouseConnectionSchema(driver: const warehouseConnectionSchemas = [ warehouseConnectionSchema('postgres'), - warehouseConnectionSchema('postgresql'), warehouseConnectionSchema('mysql'), warehouseConnectionSchema('snowflake'), warehouseConnectionSchema('bigquery'), diff --git a/packages/cli/src/context/scan/enabled-tables.ts b/packages/cli/src/context/scan/enabled-tables.ts index 327992ac..d4f1009c 100644 --- a/packages/cli/src/context/scan/enabled-tables.ts +++ b/packages/cli/src/context/scan/enabled-tables.ts @@ -8,12 +8,8 @@ import type { KtxTableRef } from './types.js'; * * Accepted entry forms: * "catalog.db.name" — fully qualified - * "db.name" — schema-qualified (catalog = null; legacy / Postgres-shape) + * "db.name" — schema-qualified (catalog = null) * "name" — bare (catalog = db = null; SQLite-shape) - * { catalog?, db?, name } — escape hatch for identifiers containing dots - * - * The setup wizard writes the fully-qualified form going forward; the lenient - * parser keeps existing project configs working. */ export function resolveEnabledTables( connection: Record | undefined, @@ -33,16 +29,6 @@ function parseEnabledTableEntry(value: unknown): KtxTableRef | null { if (typeof value === 'string') { return parseDottedEntry(value); } - if (value && typeof value === 'object' && !Array.isArray(value)) { - const entry = value as { catalog?: unknown; db?: unknown; name?: unknown }; - const name = typeof entry.name === 'string' ? entry.name : null; - if (!name) return null; - return { - catalog: typeof entry.catalog === 'string' ? entry.catalog : null, - db: typeof entry.db === 'string' ? entry.db : null, - name, - }; - } return null; } diff --git a/packages/cli/src/context/scan/local-scan.test.ts b/packages/cli/src/context/scan/local-scan.test.ts index 7b5af5b0..cb7e0252 100644 --- a/packages/cli/src/context/scan/local-scan.test.ts +++ b/packages/cli/src/context/scan/local-scan.test.ts @@ -1878,6 +1878,15 @@ describe('resolveEnabledTables', () => { expect(result!.has(tableRefKey({ catalog: null, db: 'public', name: 'orders' }))).toBe(true); }); + it('ignores legacy enabled_tables object entries', () => { + expect( + resolveEnabledTables({ + driver: 'postgres', + enabled_tables: [{ catalog: null, db: 'public', name: 'orders' }], + }), + ).toBeNull(); + }); + it('returns null for undefined connection', () => { expect(resolveEnabledTables(undefined)).toBeNull(); }); diff --git a/packages/cli/src/context/scan/local-scan.ts b/packages/cli/src/context/scan/local-scan.ts index cb886991..0e2842da 100644 --- a/packages/cli/src/context/scan/local-scan.ts +++ b/packages/cli/src/context/scan/local-scan.ts @@ -126,19 +126,17 @@ function normalizeDriver(driver: string | undefined): KtxConnectionDriver { const normalized = (driver ?? '').toLowerCase(); if ( normalized === 'postgres' || - normalized === 'postgresql' || normalized === 'sqlite' || - normalized === 'sqlite3' || normalized === 'mysql' || normalized === 'clickhouse' || normalized === 'sqlserver' || normalized === 'bigquery' || normalized === 'snowflake' ) { - return normalized === 'sqlite3' ? 'sqlite' : normalized; + return normalized; } throw new Error( - `Standalone ktx scan supports postgres/postgresql/sqlite/mysql/clickhouse/sqlserver/bigquery/snowflake in this phase, received "${driver ?? 'unknown'}"`, + `Standalone ktx scan supports postgres/sqlite/mysql/clickhouse/sqlserver/bigquery/snowflake in this phase, received "${driver ?? 'unknown'}"`, ); } diff --git a/packages/cli/src/context/scan/table-ref.test.ts b/packages/cli/src/context/scan/table-ref.test.ts index eb52ac9b..510b6c82 100644 --- a/packages/cli/src/context/scan/table-ref.test.ts +++ b/packages/cli/src/context/scan/table-ref.test.ts @@ -47,9 +47,9 @@ describe('scopedTableNames', () => { expect(scopedTableNames(scope, { catalog: 'ANALYTICS', db: 'STAGING' })).toEqual(['LISTINGS']); }); - it('treats null in the scope entry as a wildcard for that segment', () => { + it('requires non-null scope segments to match the namespace', () => { const scope = tableRefSet([{ catalog: null, db: 'public', name: 'users' }]); - expect(scopedTableNames(scope, { catalog: 'any-catalog', db: 'public' })).toEqual(['users']); + expect(scopedTableNames(scope, { catalog: 'any-catalog', db: 'public' })).toEqual([]); }); it('returns empty when no scope entry matches the namespace', () => { @@ -57,7 +57,7 @@ describe('scopedTableNames', () => { expect(scopedTableNames(scope, { catalog: 'X', db: 'Y' })).toEqual([]); }); - it('dedupes when the same name appears under different catalog projections', () => { + it('dedupes exact namespace matches only', () => { const scope: ReadonlySet = tableRefSet([ { catalog: null, db: 'public', name: 'users' }, { catalog: 'A', db: 'public', name: 'users' }, diff --git a/packages/cli/src/context/scan/table-ref.ts b/packages/cli/src/context/scan/table-ref.ts index 1a2abd70..368d4adb 100644 --- a/packages/cli/src/context/scan/table-ref.ts +++ b/packages/cli/src/context/scan/table-ref.ts @@ -33,8 +33,7 @@ export function tableRefSet(refs: readonly KtxTableRef[]): ReadonlySet, @@ -45,8 +44,8 @@ export function scopedTableNames( const wantDb = namespace.db ?? null; for (const key of scope) { const ref = tableRefFromKey(key); - if (wantCatalog !== null && ref.catalog !== null && ref.catalog !== wantCatalog) continue; - if (wantDb !== null && ref.db !== null && ref.db !== wantDb) continue; + if (ref.catalog !== wantCatalog) continue; + if (ref.db !== wantDb) continue; names.add(ref.name); } return [...names]; diff --git a/packages/cli/src/context/scan/types.ts b/packages/cli/src/context/scan/types.ts index 95e6b590..d8e2aa5a 100644 --- a/packages/cli/src/context/scan/types.ts +++ b/packages/cli/src/context/scan/types.ts @@ -3,7 +3,6 @@ import type { KtxTableRefKey } from './table-ref.js'; export type KtxConnectionDriver = | 'sqlite' | 'postgres' - | 'postgresql' | 'sqlserver' | 'bigquery' | 'snowflake' diff --git a/packages/cli/src/context/scan/warehouse-catalog.ts b/packages/cli/src/context/scan/warehouse-catalog.ts index 2f360eeb..b8e91492 100644 --- a/packages/cli/src/context/scan/warehouse-catalog.ts +++ b/packages/cli/src/context/scan/warehouse-catalog.ts @@ -8,7 +8,7 @@ import type { KtxTableRef, } from './types.js'; -type CatalogDriver = KtxConnectionDriver | 'sqlite3'; +type CatalogDriver = KtxConnectionDriver; export interface WarehouseCatalogServiceDeps { fileStore: KtxFileStorePort; @@ -129,7 +129,7 @@ function splitDisplay(display: string): string[] { } function formatDisplay(driver: CatalogDriver, table: KtxTableRef): string { - if (driver === 'sqlite' || driver === 'sqlite3') { + if (driver === 'sqlite') { return table.name; } return [table.catalog, table.db, table.name].filter((part): part is string => Boolean(part)).join('.'); @@ -137,7 +137,7 @@ function formatDisplay(driver: CatalogDriver, table: KtxTableRef): string { function parseDisplay(driver: CatalogDriver, display: string): KtxTableRef | null { const parts = splitDisplay(display); - if (driver === 'sqlite' || driver === 'sqlite3') { + if (driver === 'sqlite') { return parts.length === 1 ? { catalog: null, db: null, name: parts[0]! } : null; } if (driver === 'bigquery' || driver === 'snowflake' || driver === 'sqlserver') { @@ -156,7 +156,7 @@ function parseDisplay(driver: CatalogDriver, display: string): KtxTableRef | nul } function expectedDisplayPartCount(driver: CatalogDriver): number { - if (driver === 'sqlite' || driver === 'sqlite3') { + if (driver === 'sqlite') { return 1; } if (driver === 'bigquery' || driver === 'snowflake' || driver === 'sqlserver') { diff --git a/packages/cli/src/context/sl/local-query.ts b/packages/cli/src/context/sl/local-query.ts index 4d71504e..781d8b94 100644 --- a/packages/cli/src/context/sl/local-query.ts +++ b/packages/cli/src/context/sl/local-query.ts @@ -48,17 +48,14 @@ function assertSafeConnectionId(connectionId: string): string { function dialectForDriver(driver: string | undefined): string { const normalized = (driver ?? 'postgres').toUpperCase(); const map: Record = { - POSTGRESQL: 'postgres', POSTGRES: 'postgres', BIGQUERY: 'bigquery', SNOWFLAKE: 'snowflake', MYSQL: 'mysql', SQLSERVER: 'tsql', - MSSQL: 'tsql', SQLITE: 'sqlite', DUCKDB: 'duckdb', CLICKHOUSE: 'clickhouse', - REDSHIFT: 'redshift', DATABRICKS: 'databricks', }; return map[normalized] ?? 'postgres'; diff --git a/packages/cli/src/context/sl/local-sl.test.ts b/packages/cli/src/context/sl/local-sl.test.ts index 18cc7392..3ba00a92 100644 --- a/packages/cli/src/context/sl/local-sl.test.ts +++ b/packages/cli/src/context/sl/local-sl.test.ts @@ -392,7 +392,7 @@ describe('local semantic-layer helpers', () => { ).rejects.toThrow('Invalid semantic-layer source'); }); - it('reports legacy overlay column patches with a file-attributed migration hint', async () => { + it('reports overlay columns that are not computed columns', async () => { const invalidYaml = [ 'name: orders', 'columns:', @@ -406,9 +406,7 @@ describe('local semantic-layer helpers', () => { validateLocalSlSource(invalidYaml, { project, connectionId: 'warehouse', sourceName: 'orders' }), ).resolves.toEqual({ valid: false, - errors: [ - "semantic-layer/warehouse/orders.yaml: column 'status' patches a manifest column but is in 'columns:' — move it to 'column_overrides:'", - ], + errors: expect.arrayContaining([expect.stringContaining('columns.0.type')]), }); }); diff --git a/packages/cli/src/context/sl/local-sl.ts b/packages/cli/src/context/sl/local-sl.ts index 18ec8417..243ba94d 100644 --- a/packages/cli/src/context/sl/local-sl.ts +++ b/packages/cli/src/context/sl/local-sl.ts @@ -266,23 +266,6 @@ export async function validateLocalSlSource( try { const parsed = parseYamlRecord(rawYaml); const schema = parsed.table || parsed.sql ? sourceDefinitionSchema : sourceOverlaySchema; - if (schema === sourceOverlaySchema && Array.isArray(parsed.columns)) { - const sourceName = options?.sourceName ?? (typeof parsed.name === 'string' ? parsed.name : 'source'); - const path = - options?.connectionId && isSafeConnectionId(options.connectionId) - ? `semantic-layer/${options.connectionId}/${sourceName}.yaml` - : `${sourceName}.yaml`; - const legacyColumnPatchErrors = parsed.columns - .filter((column): column is Record => isRecord(column)) - .filter((column) => typeof column.name === 'string' && (!column.expr || !column.type)) - .map( - (column) => - `${path}: column '${column.name}' patches a manifest column but is in 'columns:' — move it to 'column_overrides:'`, - ); - if (legacyColumnPatchErrors.length > 0) { - return { valid: false, errors: legacyColumnPatchErrors }; - } - } const result = schema.parse(parsed); const errors: string[] = []; diff --git a/packages/cli/src/context/sl/semantic-layer.service.test.ts b/packages/cli/src/context/sl/semantic-layer.service.test.ts index 0844a3c5..cd14d66a 100644 --- a/packages/cli/src/context/sl/semantic-layer.service.test.ts +++ b/packages/cli/src/context/sl/semantic-layer.service.test.ts @@ -847,7 +847,7 @@ describe('loadAllSources — standalone enrichment via inherits_columns_from', ( }); }); - it('reports file-attributed errors for legacy overlay column patches', async () => { + it('reports file-attributed errors for overlay columns that shadow manifest columns', async () => { const schemaPath = 'semantic-layer/conn-1/_schema/marts.yaml'; const overlayPath = 'semantic-layer/conn-1/orders.yaml'; configService.listFiles.mockResolvedValue({ files: [schemaPath, overlayPath] }); @@ -871,7 +871,8 @@ describe('loadAllSources — standalone enrichment via inherits_columns_from', ( const { loadErrors } = await service.loadAllSources('conn-1'); expect(loadErrors.join('\n')).toContain(overlayPath); - expect(loadErrors.join('\n')).toContain("move it to 'column_overrides:'"); + expect(loadErrors.join('\n')).toContain("column 'id' in columns already exists on manifest source 'orders'"); + expect(loadErrors.join('\n')).not.toContain('column_overrides'); }); it('reports and logs directory listing failures instead of treating them as empty sources', async () => { diff --git a/packages/cli/src/context/sl/semantic-layer.service.ts b/packages/cli/src/context/sl/semantic-layer.service.ts index e6afdeaf..28bf826d 100644 --- a/packages/cli/src/context/sl/semantic-layer.service.ts +++ b/packages/cli/src/context/sl/semantic-layer.service.ts @@ -1082,17 +1082,14 @@ export class SemanticLayerService { static mapDialect(connectionType: string): string { const normalized = connectionType.toUpperCase(); const map: Record = { - POSTGRESQL: 'postgres', POSTGRES: 'postgres', BIGQUERY: 'bigquery', SNOWFLAKE: 'snowflake', MYSQL: 'mysql', SQLSERVER: 'tsql', - MSSQL: 'tsql', SQLITE: 'sqlite', DUCKDB: 'duckdb', CLICKHOUSE: 'clickhouse', - REDSHIFT: 'redshift', DATABRICKS: 'databricks', }; return map[normalized] ?? 'postgres'; @@ -1513,7 +1510,7 @@ export function composeOverlay(base: SemanticLayerSource, overlay: Record { expect(testIo.stdout()).toContain('ktx setup'); }); - it('warns about stale and unsupported per-driver connection fields', async () => { + it('does not warn about removed-field migration hints', async () => { process.env.ANTHROPIC_API_KEY = 'test-key'; // pragma: allowlist secret process.env.WAREHOUSE_DATABASE_URL = 'postgresql://reader@example.test/warehouse'; process.env.NOTION_TOKEN = 'notion-secret'; @@ -644,10 +644,9 @@ describe('runKtxDoctor', () => { ).resolves.toBe(0); const out = testIo.stdout(); - expect(out).toContain('Warnings'); - expect(out).toContain('connections.warehouse.readonly is no longer used.'); - expect(out).toContain('connections.local.file_path was removed.'); - expect(out).toContain('connections.docs.last_successful_cursor is local sync state.'); + expect(out).not.toContain('connections.warehouse.readonly is no longer used.'); + expect(out).not.toContain('connections.local.file_path was removed.'); + expect(out).not.toContain('connections.docs.last_successful_cursor is local sync state.'); delete process.env.ANTHROPIC_API_KEY; delete process.env.WAREHOUSE_DATABASE_URL; delete process.env.NOTION_TOKEN; diff --git a/packages/cli/src/ingest-depth.ts b/packages/cli/src/ingest-depth.ts index 489c44e8..b8957763 100644 --- a/packages/cli/src/ingest-depth.ts +++ b/packages/cli/src/ingest-depth.ts @@ -5,7 +5,6 @@ export type KtxDatabaseContextDepth = 'fast' | 'deep'; const KTX_DATABASE_DRIVER_IDS = new Set([ 'sqlite', 'postgres', - 'postgresql', 'mysql', 'clickhouse', 'sqlserver', diff --git a/packages/cli/src/local-scan-connectors.ts b/packages/cli/src/local-scan-connectors.ts index 4f763be5..31fc158e 100644 --- a/packages/cli/src/local-scan-connectors.ts +++ b/packages/cli/src/local-scan-connectors.ts @@ -17,14 +17,14 @@ export async function createKtxCliScanConnector( `Connection "${connectionId}" has no \`driver\` field in ktx.yaml. Supported drivers: ${SUPPORTED_DRIVERS}.`, ); } - if (driver === 'sqlite' || driver === 'sqlite3') { + if (driver === 'sqlite') { const { KtxSqliteScanConnector, isKtxSqliteConnectionConfig } = await import('./connectors/sqlite/connector.js');; if (!isKtxSqliteConnectionConfig(connection)) { throw invalidConnectionConfigError(connectionId, driver); } return new KtxSqliteScanConnector({ connectionId, connection, projectDir: project.projectDir }); } - if (driver === 'postgres' || driver === 'postgresql') { + if (driver === 'postgres') { const { KtxPostgresScanConnector, isKtxPostgresConnectionConfig } = await import('./connectors/postgres/connector.js');; if (!isKtxPostgresConnectionConfig(connection)) { throw invalidConnectionConfigError(connectionId, driver); diff --git a/packages/cli/src/public-ingest.test.ts b/packages/cli/src/public-ingest.test.ts index 7c400752..d6ced94d 100644 --- a/packages/cli/src/public-ingest.test.ts +++ b/packages/cli/src/public-ingest.test.ts @@ -175,6 +175,35 @@ describe('buildPublicIngestPlan', () => { ).toEqual(['--deep affects database ingest only; ignoring it for docs.']); }); + it('does not infer deep ingest from legacy scanMode values', () => { + const project = projectWithConnections({ + warehouse: { driver: 'postgres' }, + }); + + const plan = buildPublicIngestPlan(project, { + projectDir: '/tmp/project', + targetConnectionId: 'warehouse', + all: false, + scanMode: 'enriched', + }); + + expect(plan.targets[0]).toMatchObject({ + connectionId: 'warehouse', + databaseDepth: 'fast', + steps: ['database-schema'], + }); + }); + + it('rejects stale local Looker source driver aliases', () => { + const project = projectWithConnections({ + local_looker: { driver: 'local_looker' } as never, + }); + + expect(() => buildPublicIngestPlan(project, { projectDir: '/tmp/project', all: true })).toThrow( + 'unsupported public ingest driver "local_looker"', + ); + }); + it('upgrades effective depth when query history is explicitly enabled', () => { const project = projectWithConnections({ warehouse: { driver: 'postgres', context: { queryHistory: { enabled: false } } }, @@ -1045,7 +1074,7 @@ describe('runKtxPublicIngest', () => { expect(io.stdout()).toContain('warehouse requires deep ingest readiness'); }); - it('can request enriched relationship scans for setup-managed context builds', async () => { + it('does not infer enriched relationship scans from legacy scanMode values', async () => { const io = makeIo(); const project = deepReadyProject({ warehouse: { driver: 'postgres' } }); const runScan = vi.fn(async () => 0); @@ -1074,8 +1103,8 @@ describe('runKtxPublicIngest', () => { command: 'run', projectDir: '/tmp/project', connectionId: 'warehouse', - mode: 'enriched', - detectRelationships: true, + mode: 'structural', + detectRelationships: false, dryRun: false, }, expect.objectContaining({ capturedOutput: expect.any(Function) }), diff --git a/packages/cli/src/public-ingest.ts b/packages/cli/src/public-ingest.ts index ce6c6344..60bceecd 100644 --- a/packages/cli/src/public-ingest.ts +++ b/packages/cli/src/public-ingest.ts @@ -134,7 +134,6 @@ const sourceAdapterByDriver = new Map([ ['metabase', 'metabase'], ['local_metabase', 'metabase'], ['looker', 'looker'], - ['local_looker', 'looker'], ['notion', 'notion'], ['metricflow', 'metricflow'], ['dbt', 'dbt'], @@ -143,7 +142,6 @@ const sourceAdapterByDriver = new Map([ const queryHistoryDialectByDriver = new Map([ ['postgres', 'postgres'], - ['postgresql', 'postgres'], ['bigquery', 'bigquery'], ['snowflake', 'snowflake'], ]); @@ -309,12 +307,6 @@ function queryHistoryPullConfig(input: { }; } -function depthFromLegacyScanMode( - mode: Extract['mode'] | undefined, -): KtxPublicIngestDepth | undefined { - return mode === 'enriched' || mode === 'relationships' ? 'deep' : undefined; -} - function sourceDirForConnection(connection: KtxProjectConnectionConfig): string | undefined { const value = connection.source_dir; return typeof value === 'string' && value.trim().length > 0 ? value.trim() : undefined; @@ -340,8 +332,7 @@ function resolveDatabaseTargetOptions(input: { const requestedQh = explicitQueryHistory === 'enabled' || (explicitQueryHistory !== 'disabled' && (windowOverrideRequested || storedEnabled)); - let depth = - input.args.depth ?? depthFromLegacyScanMode(input.args.scanMode) ?? databaseContextDepth(input.connection) ?? 'fast'; + let depth = input.args.depth ?? databaseContextDepth(input.connection) ?? 'fast'; const queryHistory = { enabled: false, ...(input.args.queryHistoryWindowDays !== undefined diff --git a/packages/cli/src/runtime-requirements.test.ts b/packages/cli/src/runtime-requirements.test.ts index 5f8831cf..35e94eae 100644 --- a/packages/cli/src/runtime-requirements.test.ts +++ b/packages/cli/src/runtime-requirements.test.ts @@ -26,6 +26,33 @@ describe('runtime requirement detection', () => { ); }); + it('does not treat stale local Looker driver aliases as Looker sources', () => { + const config: KtxProjectConfig = { + ...buildDefaultKtxProjectConfig(), + connections: { + stale: { driver: 'local_looker' } as never, + }, + }; + + expect(resolveProjectRuntimeRequirements(config).features).toEqual([]); + expect( + resolvePublicIngestRuntimeRequirements({ + projectDir: '/tmp/project', + warnings: [], + targets: [ + { + connectionId: 'stale', + driver: 'local_looker', + operation: 'source-ingest', + adapter: 'local_looker', + debugCommand: 'ktx ingest stale --debug', + steps: ['source-ingest'], + }, + ], + }).features, + ).toEqual([]); + }); + it('requires core for query-history ingest unless SQL analysis is externally configured', () => { const config: KtxProjectConfig = { ...buildDefaultKtxProjectConfig(), diff --git a/packages/cli/src/runtime-requirements.ts b/packages/cli/src/runtime-requirements.ts index 31ad1be0..0253db8c 100644 --- a/packages/cli/src/runtime-requirements.ts +++ b/packages/cli/src/runtime-requirements.ts @@ -96,7 +96,7 @@ export function resolveProjectRuntimeRequirements( for (const [connectionId, connection] of Object.entries(config.connections)) { const driver = normalizeDriver(connection.driver); - if ((driver === 'looker' || driver === 'local_looker') && !hasDaemonOverride(env)) { + if (driver === 'looker' && !hasDaemonOverride(env)) { requirements.push({ feature: 'core', reason: 'looker-source', @@ -141,7 +141,7 @@ export function resolvePublicIngestRuntimeRequirements( detail: `${target.connectionId} query-history ingest uses SQL analysis.`, }); } - if ((driver === 'looker' || driver === 'local_looker' || adapter === 'looker') && !hasDaemonOverride(env)) { + if ((driver === 'looker' || adapter === 'looker') && !hasDaemonOverride(env)) { requirements.push({ feature: 'core', reason: 'looker-source', diff --git a/packages/cli/src/scan.test.ts b/packages/cli/src/scan.test.ts index 5ec745e6..6db8243a 100644 --- a/packages/cli/src/scan.test.ts +++ b/packages/cli/src/scan.test.ts @@ -123,7 +123,7 @@ const createPostgresLiveDatabaseIntrospection = vi.hoisted(() => ); const isKtxPostgresConnectionConfig = vi.hoisted(() => vi.fn((connection: { driver?: string } | undefined) => - ['postgres', 'postgresql'].includes(String(connection?.driver ?? '').toLowerCase()), + String(connection?.driver ?? '').toLowerCase() === 'postgres', ), ); const KtxPostgresScanConnector = vi.hoisted( diff --git a/packages/cli/src/setup-agents.test.ts b/packages/cli/src/setup-agents.test.ts index a5f488d5..a8090780 100644 --- a/packages/cli/src/setup-agents.test.ts +++ b/packages/cli/src/setup-agents.test.ts @@ -82,7 +82,6 @@ describe('setup agents', () => { { kind: 'file', path: join(tempDir, '.claude/skills/ktx-analytics/SKILL.md'), role: 'analytics-skill' }, ]); expect(plannedKtxAgentFiles({ projectDir: tempDir, target: 'claude-desktop', scope: 'global', mode: 'mcp' })).toEqual([ - { kind: 'file', path: join(tempDir, '.ktx/agents/claude/ktx-plugin-runner.sh'), role: 'launcher' }, { kind: 'file', path: join(tempDir, '.ktx/agents/claude/ktx-analytics.zip'), @@ -127,7 +126,6 @@ describe('setup agents', () => { { kind: 'file', path: join(tempDir, '.agents/skills/ktx/SKILL.md') }, ]); expect(plannedKtxAgentFiles({ projectDir: tempDir, target: 'claude-desktop', scope: 'global', mode: 'mcp-cli' })).toEqual([ - { kind: 'file', path: join(tempDir, '.ktx/agents/claude/ktx-plugin-runner.sh'), role: 'launcher' }, { kind: 'file', path: join(tempDir, '.ktx/agents/claude/ktx-analytics.zip'), @@ -518,19 +516,15 @@ describe('setup agents', () => { const launcherPath = join(tempDir, '.ktx/agents/claude/ktx-plugin-runner.sh'); await expect(stat(analyticsSkillPath)).resolves.toBeDefined(); await expect(stat(adminSkillPath)).rejects.toThrow(); - const launcherStat = await stat(launcherPath); - expect(launcherStat.mode & 0o111).not.toBe(0); - const launcher = await readFile(launcherPath, 'utf-8'); - expect(launcher).toContain('KTX_CLI_BIN='); - expect(launcher).toContain('.nvm/versions/node'); + await expect(stat(launcherPath)).rejects.toThrow(); const configPath = join(home, 'Library/Application Support/Claude/claude_desktop_config.json'); const config = JSON.parse(await readFile(configPath, 'utf-8')) as { mcpServers: { ktx: { command: string; args: string[]; env?: Record } }; }; expect(config.mcpServers.ktx).toEqual({ - command: launcherPath, - args: ['--project-dir', tempDir, 'mcp', 'stdio'], + command: process.execPath, + args: [expect.stringContaining('bin.js'), '--project-dir', tempDir, 'mcp', 'stdio'], }); expect(await readZipText(analyticsSkillPath, 'ktx-analytics/SKILL.md')).toContain('KTX Analytics Workflow'); @@ -901,7 +895,7 @@ describe('setup agents', () => { const configPath = join(home, 'Library/Application Support/Claude/claude_desktop_config.json'); await expect(stat(analyticsSkillPath)).resolves.toBeDefined(); await expect(stat(adminSkillPath)).resolves.toBeDefined(); - await expect(stat(launcherPath)).resolves.toBeDefined(); + await expect(stat(launcherPath)).rejects.toThrow(); const beforeConfig = JSON.parse(await readFile(configPath, 'utf-8')) as { mcpServers: Record; }; @@ -911,7 +905,6 @@ describe('setup agents', () => { await expect(stat(analyticsSkillPath)).rejects.toThrow(); await expect(stat(adminSkillPath)).rejects.toThrow(); - await expect(stat(launcherPath)).rejects.toThrow(); const afterConfig = JSON.parse(await readFile(configPath, 'utf-8')) as { mcpServers: Record; }; diff --git a/packages/cli/src/setup-agents.ts b/packages/cli/src/setup-agents.ts index 240622f6..113718cd 100644 --- a/packages/cli/src/setup-agents.ts +++ b/packages/cli/src/setup-agents.ts @@ -1,5 +1,5 @@ import { existsSync } from 'node:fs'; -import { chmod, mkdir, readFile, rm, writeFile } from 'node:fs/promises'; +import { mkdir, readFile, rm, writeFile } from 'node:fs/promises'; import { dirname, join, relative, resolve } from 'node:path'; import type { Writable } from 'node:stream'; import { fileURLToPath } from 'node:url'; @@ -55,7 +55,7 @@ export interface KtxAgentInstallManifest { | { kind: 'file'; path: string; - role?: 'skill' | 'rule' | 'analytics-skill' | 'claude-desktop-skill-bundle' | 'launcher'; + role?: 'skill' | 'rule' | 'analytics-skill' | 'claude-desktop-skill-bundle'; } | { kind: 'json-key'; path: string; jsonPath: string[] } >; @@ -312,15 +312,12 @@ function collectClaudeDesktopForwardedEnv(source: NodeJS.ProcessEnv): Record { +function claudeDesktopMcpEntry(input: { projectDir: string; env?: NodeJS.ProcessEnv }): Record { const captured = collectClaudeDesktopForwardedEnv(input.env ?? process.env); + const launcher = ktxCliLauncher(); return { - command: input.launcherPath, - args: ['--project-dir', input.projectDir, 'mcp', 'stdio'], + command: launcher.command, + args: [...launcher.args, '--project-dir', input.projectDir, 'mcp', 'stdio'], ...(Object.keys(captured).length > 0 ? { env: captured } : {}), }; } @@ -336,11 +333,10 @@ async function installMcpClientConfig(input: { if (input.target === 'claude-desktop') { const config = claudeDesktopConfigPath(); - const launcherPath = claudeDesktopLauncherPath(input.projectDir); await writeJsonKey( config.path, config.jsonPath, - claudeDesktopMcpEntry({ launcherPath, projectDir: input.projectDir }), + claudeDesktopMcpEntry({ projectDir: input.projectDir }), ); entries.push({ kind: 'json-key', path: config.path, jsonPath: config.jsonPath }); return { entries, snippets, notices }; @@ -406,10 +402,6 @@ function claudeDesktopAdminSkillBundlePath(projectDir: string): string { return join(resolve(projectDir), '.ktx/agents/claude/ktx.zip'); } -function claudeDesktopLauncherPath(projectDir: string): string { - return join(resolve(projectDir), '.ktx/agents/claude/ktx-plugin-runner.sh'); -} - /** @internal */ export function plannedKtxAgentFiles(input: { projectDir: string; @@ -449,7 +441,6 @@ export function plannedKtxAgentFiles(input: { } if (input.target === 'claude-desktop') { return [ - { kind: 'file', path: claudeDesktopLauncherPath(input.projectDir), role: 'launcher' as const }, { kind: 'file', path: claudeDesktopAnalyticsSkillBundlePath(input.projectDir), @@ -593,61 +584,6 @@ function cliInstructionContent(input: { projectDir: string; launcher: KtxCliLaun ].join('\n'); } -function claudeDesktopLauncherContent(input: { launcher: KtxCliLauncher }): string { - const binPath = input.launcher.args[0]; - if (!binPath) { - throw new Error('Expected KTX CLI launcher to include a bin path.'); - } - const candidates = [ - input.launcher.command, - '/opt/homebrew/bin/node', - '/usr/local/bin/node', - '/usr/bin/node', - ]; - return [ - '#!/bin/sh', - 'set -eu', - '', - `KTX_CLI_BIN=${shellScriptQuote(binPath)}`, - '', - 'run_with_node() {', - ' node_bin=$1', - ' shift', - ' exec "$node_bin" "$KTX_CLI_BIN" "$@"', - '}', - '', - 'if [ -n "${KTX_NODE:-}" ] && [ -x "${KTX_NODE:-}" ]; then', - ' run_with_node "$KTX_NODE" "$@"', - 'fi', - '', - 'if [ -x "$HOME/.volta/bin/node" ]; then', - ' run_with_node "$HOME/.volta/bin/node" "$@"', - 'fi', - '', - ...candidates.map((candidate) => - [ - `if [ -x ${shellScriptQuote(candidate)} ]; then`, - ` run_with_node ${shellScriptQuote(candidate)} "$@"`, - 'fi', - ].join('\n'), - ), - '', - 'for candidate in "$HOME"/.nvm/versions/node/*/bin/node; do', - ' if [ -x "$candidate" ]; then', - ' run_with_node "$candidate" "$@"', - ' fi', - 'done', - '', - 'if command -v node >/dev/null 2>&1; then', - ' run_with_node "$(command -v node)" "$@"', - 'fi', - '', - 'echo "KTX Claude Desktop launcher could not find Node.js. Set KTX_NODE to a Node executable and rerun ktx setup --agents." >&2', - 'exit 127', - '', - ].join('\n'); -} - async function writeClaudeDesktopSkillBundle(input: { projectDir: string; path: string; @@ -675,15 +611,6 @@ function claudeDesktopSkillNameForBundle(path: string): 'ktx-analytics' | 'ktx' throw new Error(`Unsupported Claude Desktop skill bundle path: ${path}`); } -async function writeClaudeDesktopLauncher(input: { - path: string; - launcher: KtxCliLauncher; -}): Promise { - await mkdir(dirname(input.path), { recursive: true }); - await writeFile(input.path, claudeDesktopLauncherContent({ launcher: input.launcher }), 'utf-8'); - await chmod(input.path, 0o755); -} - function ruleInstructionContent(input: { projectDir: string }): string { return [ `Use the \`ktx\` CLI to query local semantic context and wiki knowledge for this project ` + @@ -941,10 +868,6 @@ export function formatInstallSummaryLines( lines.push(`${guidanceInstallLine(install.target)}.`); } - if (hasEntryRole(targetEntries, 'launcher')) { - lines.push('Starts KTX over stdio from Claude Desktop.'); - } - return { title: `${targetDisplayName(install.target)} · ${scopeDisplayName(install.scope)}`, lines, @@ -1139,10 +1062,6 @@ async function installTarget(input: { const launcher = ktxCliLauncher(); for (const entry of entries) { if (entry.kind !== 'file') continue; - if (entry.role === 'launcher') { - await writeClaudeDesktopLauncher({ path: entry.path, launcher }); - continue; - } if (entry.role === 'claude-desktop-skill-bundle') { await writeClaudeDesktopSkillBundle({ projectDir: input.projectDir, diff --git a/packages/cli/src/setup-databases.ts b/packages/cli/src/setup-databases.ts index 392c4761..058692ae 100644 --- a/packages/cli/src/setup-databases.ts +++ b/packages/cli/src/setup-databases.ts @@ -260,8 +260,6 @@ function createPromptAdapter(): KtxSetupDatabasesPromptAdapter { function normalizeDriver(driver: string | undefined): KtxSetupDatabaseDriver | null { const normalized = String(driver ?? '').toLowerCase(); - if (normalized === 'postgresql') return 'postgres'; - if (normalized === 'sqlite3') return 'sqlite'; return DRIVER_OPTIONS.some((option) => option.value === normalized) ? (normalized as KtxSetupDatabaseDriver) : null; } diff --git a/packages/cli/src/sql.ts b/packages/cli/src/sql.ts index 1b15f92e..bfae0608 100644 --- a/packages/cli/src/sql.ts +++ b/packages/cli/src/sql.ts @@ -43,16 +43,12 @@ function sqlAnalysisDialectForDriver(driver: string | undefined): SqlAnalysisDia const normalized = String(driver ?? '').trim().toLowerCase(); const map: Record = { postgres: 'postgres', - postgresql: 'postgres', bigquery: 'bigquery', snowflake: 'snowflake', mysql: 'mysql', sqlserver: 'tsql', - mssql: 'tsql', sqlite: 'sqlite', - sqlite3: 'sqlite', clickhouse: 'clickhouse', - redshift: 'redshift', }; return map[normalized] ?? 'postgres'; } diff --git a/packages/cli/src/status-project.ts b/packages/cli/src/status-project.ts index aaecff27..07ccc3c6 100644 --- a/packages/cli/src/status-project.ts +++ b/packages/cli/src/status-project.ts @@ -92,10 +92,6 @@ type ClaudeCodeAuthProbe = (input: { const PROJECT_READY_COMMANDS = KTX_NEXT_STEP_DIRECT_COMMANDS.map((step) => step.command); -function hasOwnField(value: Record, key: string): boolean { - return Object.prototype.hasOwnProperty.call(value, key); -} - interface LocalStatsIngestPerConnection { connectionId: string; adapter: string; @@ -332,7 +328,6 @@ function buildConnectionStatus( switch (driver) { case 'postgres': - case 'postgresql': case 'mysql': case 'clickhouse': case 'sqlserver': { @@ -701,7 +696,7 @@ async function buildQueryHistoryStatus( } const ADAPTER_DRIVER_REQUIREMENT: Record = { - 'live-database': ['postgres', 'postgresql', 'mysql', 'snowflake', 'bigquery', 'clickhouse', 'sqlite', 'sqlserver'], + 'live-database': ['postgres', 'mysql', 'snowflake', 'bigquery', 'clickhouse', 'sqlite', 'sqlserver'], dbt: ['dbt', 'dbt-core', 'dbt-cloud'], notion: ['notion'], metabase: ['metabase'], @@ -740,30 +735,6 @@ function buildWarnings( ): WarningItem[] { const warnings: WarningItem[] = []; - for (const [connectionId, connection] of Object.entries(config.connections)) { - const driver = String(connection.driver ?? '').toLowerCase(); - if (hasOwnField(connection, 'readonly')) { - warnings.push({ - message: `connections.${connectionId}.readonly is no longer used.`, - fix: `Remove connections.${connectionId}.readonly from ktx.yaml.`, - }); - } - - if ((driver === 'sqlite' || driver === 'sqlite3') && hasOwnField(connection, 'file_path')) { - warnings.push({ - message: `connections.${connectionId}.file_path was removed.`, - fix: `Rename connections.${connectionId}.file_path to path.`, - }); - } - - if (driver === 'notion' && hasOwnField(connection, 'last_successful_cursor')) { - warnings.push({ - message: `connections.${connectionId}.last_successful_cursor is local sync state.`, - fix: 'Remove it from ktx.yaml. KTX stores the Notion cursor in .ktx/db.sqlite.', - }); - } - } - for (const adapter of config.ingest.adapters) { const requiredDrivers = ADAPTER_DRIVER_REQUIREMENT[adapter]; if (!requiredDrivers) continue; diff --git a/python/ktx-daemon/src/ktx_daemon/database_introspection.py b/python/ktx-daemon/src/ktx_daemon/database_introspection.py index 82058f95..6ba84265 100644 --- a/python/ktx-daemon/src/ktx_daemon/database_introspection.py +++ b/python/ktx-daemon/src/ktx_daemon/database_introspection.py @@ -327,7 +327,7 @@ def introspect_database_response( now: NowProvider | None = None, ) -> DatabaseIntrospectionResponse: driver = _driver_name(request.driver) - if driver not in {"postgres", "postgresql"}: + if driver != "postgres": raise ValueError('database introspection supports only driver "postgres"') rows = (load_rows or _load_postgres_rows)(request) diff --git a/python/ktx-daemon/src/ktx_daemon/semantic_layer.py b/python/ktx-daemon/src/ktx_daemon/semantic_layer.py index e813575e..78f57338 100644 --- a/python/ktx-daemon/src/ktx_daemon/semantic_layer.py +++ b/python/ktx-daemon/src/ktx_daemon/semantic_layer.py @@ -13,7 +13,7 @@ from semantic_layer.models import QueryResult, SourceDefinition class SemanticLayerQueryRequest(BaseModel): - model_config = ConfigDict(populate_by_name=True) + model_config = ConfigDict(extra="forbid") sources: list[dict[str, Any]] query: dict[str, Any] diff --git a/python/ktx-daemon/src/ktx_daemon/sql_analysis.py b/python/ktx-daemon/src/ktx_daemon/sql_analysis.py index ebecf83c..e831e47f 100644 --- a/python/ktx-daemon/src/ktx_daemon/sql_analysis.py +++ b/python/ktx-daemon/src/ktx_daemon/sql_analysis.py @@ -5,7 +5,7 @@ from concurrent.futures import ProcessPoolExecutor from typing import Literal import sqlglot -from pydantic import BaseModel, ConfigDict, Field +from pydantic import BaseModel, Field from sqlglot import exp SqlAnalysisClause = Literal["select", "where", "join", "groupBy", "having", "orderBy"] @@ -23,8 +23,6 @@ class AnalyzeSqlBatchRequest(BaseModel): class AnalyzeSqlBatchResult(BaseModel): - model_config = ConfigDict(populate_by_name=True) - tables_touched: list[str] = Field(default_factory=list) columns_by_clause: dict[SqlAnalysisClause, list[str]] = Field(default_factory=dict) error: str | None = None diff --git a/python/ktx-daemon/src/ktx_daemon/table_identifier.py b/python/ktx-daemon/src/ktx_daemon/table_identifier.py index 748f2dd8..297c25b4 100644 --- a/python/ktx-daemon/src/ktx_daemon/table_identifier.py +++ b/python/ktx-daemon/src/ktx_daemon/table_identifier.py @@ -1,9 +1,8 @@ from __future__ import annotations -from dataclasses import asdict from typing import Literal -from pydantic import BaseModel, ConfigDict, Field +from pydantic import BaseModel, Field from semantic_layer.table_identifier_parser import ( ParseTableIdentifierItem as SharedParseTableIdentifierItem, parse_table_identifier_batch, @@ -30,8 +29,6 @@ class ParseTableIdentifierBatchRequest(BaseModel): class ParsedIdentifier(BaseModel): - model_config = ConfigDict(populate_by_name=True) - ok: bool catalog: str | None = None schema_: str | None = Field(default=None, alias="schema") @@ -60,7 +57,15 @@ def parse_table_identifier_response( ) return ParseTableIdentifierBatchResponse( results={ - key: ParsedIdentifier.model_validate(asdict(value)) + key: ParsedIdentifier( + ok=value.ok, + catalog=value.catalog, + schema=value.schema_, + name=value.name, + canonical_table=value.canonical_table, + reason=value.reason, + detail=value.detail, + ) for key, value in shared_results.items() } ) diff --git a/python/ktx-daemon/tests/test_database_introspection.py b/python/ktx-daemon/tests/test_database_introspection.py index 0a018046..b0fb7a5b 100644 --- a/python/ktx-daemon/tests/test_database_introspection.py +++ b/python/ktx-daemon/tests/test_database_introspection.py @@ -138,6 +138,18 @@ def test_introspect_database_response_rejects_non_postgres_driver() -> None: ) +def test_introspect_database_response_rejects_legacy_postgresql_driver() -> None: + with pytest.raises(ValueError, match='supports only driver "postgres"'): + introspect_database_response( + DatabaseIntrospectionRequest( + connection_id="warehouse", + driver="postgresql", + url="postgresql://readonly@example.test/warehouse", + ), + load_rows=lambda request: DatabaseIntrospectionRows([], [], []), + ) + + def test_database_introspection_request_rejects_empty_schema_list() -> None: with pytest.raises(ValueError, match="at least one schema"): DatabaseIntrospectionRequest( diff --git a/python/ktx-daemon/tests/test_semantic_layer.py b/python/ktx-daemon/tests/test_semantic_layer.py index 8ebb7ad8..828e9359 100644 --- a/python/ktx-daemon/tests/test_semantic_layer.py +++ b/python/ktx-daemon/tests/test_semantic_layer.py @@ -3,6 +3,8 @@ from __future__ import annotations import json from pathlib import Path +import pytest + from ktx_daemon.semantic_layer import ( SemanticLayerQueryRequest, ValidateSourcesRequest, @@ -95,6 +97,16 @@ def test_query_semantic_layer_emits_plan_and_sql_debug_events( assert "public.orders" not in captured.err +def test_semantic_layer_request_rejects_project_id_field_name() -> None: + with pytest.raises(ValueError): + SemanticLayerQueryRequest( + sources=[], + dialect="postgres", + project_id="a" * 64, + query={"measures": ["orders.order_count"]}, + ) + + def test_validate_semantic_layer_reports_duplicate_measure_names() -> None: invalid_source = { **ORDERS_SOURCE, diff --git a/python/ktx-sl/semantic_layer/loader.py b/python/ktx-sl/semantic_layer/loader.py index 55a3a0ee..1f505fe5 100644 --- a/python/ktx-sl/semantic_layer/loader.py +++ b/python/ktx-sl/semantic_layer/loader.py @@ -201,7 +201,7 @@ class SourceLoader: name = col.get("name") if name in base_by_name: raise ValueError( - f"column '{name}' in columns patches a manifest column on '{base.name}' — move it to 'column_overrides:'" + f"column '{name}' in columns patches a manifest column on '{base.name}' - move it to 'column_overrides:'" ) source.columns.append(SourceColumn(**col)) From cfd1749ab91afa5834e578c99d6047d04639b7d9 Mon Sep 17 00:00:00 2001 From: Andrey Avtomonov Date: Sun, 24 May 2026 19:29:37 +0200 Subject: [PATCH 02/74] feat(cli): skip-context-sources menu + clack-style tree picker UX (#213) * feat(cli): add 'skip context sources' option to database setup menu After databases are configured, the post-setup menu now offers a 'Skip context sources' choice equivalent to passing --skip-sources, which plumbs through KtxSetupDatabasesResult.skipSources to bypass the context-source step in the same run. * feat(cli): standardize tree picker UX after clack autocomplete-multiselect Search is always on (no '/' to enter): typed printable chars feed the query, Tab toggles selection on the focused node without leaving the search bar, and Space toggles only after arrow-key navigation (isNavigating); otherwise it is appended to the query. Esc clears a non-empty query before quitting, Ctrl+A and Ctrl+N replace bare-letter bulk bindings, and the cursor refocuses on the first match when the query change would hide it. --- .../content/docs/cli-reference/ktx-setup.mdx | 4 + packages/cli/src/setup-databases.test.ts | 48 +++++++++++ packages/cli/src/setup-databases.ts | 17 +++- packages/cli/src/setup.test.ts | 61 +++++++++++++ packages/cli/src/setup.ts | 11 ++- packages/cli/src/tree-picker-state.test.ts | 41 +++++++-- packages/cli/src/tree-picker-state.ts | 54 ++++++++---- packages/cli/src/tree-picker-tui.test.tsx | 86 +++++++++++++------ packages/cli/src/tree-picker-tui.tsx | 53 ++++++------ 9 files changed, 292 insertions(+), 83 deletions(-) diff --git a/docs-site/content/docs/cli-reference/ktx-setup.mdx b/docs-site/content/docs/cli-reference/ktx-setup.mdx index a52a3eba..b94d65db 100644 --- a/docs-site/content/docs/cli-reference/ktx-setup.mdx +++ b/docs-site/content/docs/cli-reference/ktx-setup.mdx @@ -136,6 +136,10 @@ Enabling query history makes deep ingest readiness matter for later ### Context Sources +In interactive setup, after you configure a database, choose +**Skip context sources** to leave optional context-source setup complete with no +sources. This is equivalent to passing `--skip-sources` in scripted setup. + | Flag | Description | |------|-------------| | `--source ` | Context-source connector type: `dbt`, `metricflow`, `metabase`, `looker`, `lookml`, or `notion` | diff --git a/packages/cli/src/setup-databases.test.ts b/packages/cli/src/setup-databases.test.ts index a9da0f51..354ba24b 100644 --- a/packages/cli/src/setup-databases.test.ts +++ b/packages/cli/src/setup-databases.test.ts @@ -695,6 +695,7 @@ describe('setup databases step', () => { message: 'Databases configured: warehouse\nWhat would you like to do?', options: [ { value: 'continue', label: 'Continue to context sources' }, + { value: 'skip-sources', label: 'Skip context sources' }, { value: 'edit', label: 'Edit an existing database' }, { value: 'add', label: 'Add another database' }, ], @@ -703,6 +704,48 @@ describe('setup databases step', () => { expect(scanConnection).not.toHaveBeenCalled(); }); + it('can skip context sources from the configured database menu', async () => { + await writeFile( + join(tempDir, 'ktx.yaml'), + [ + 'connections:', + ' warehouse:', + ' driver: postgres', + ' url: env:DATABASE_URL', + 'setup:', + ' database_connection_ids:', + ' - warehouse', + '', + ].join('\n'), + 'utf-8', + ); + await writeKtxSetupState(tempDir, { completed_steps: ['databases'] }); + const prompts = makePromptAdapter({ selectValues: ['skip-sources'] }); + const testConnection = vi.fn(async () => 0); + const scanConnection = vi.fn(async () => 0); + + const result = await runKtxSetupDatabasesStep( + { + projectDir: tempDir, + inputMode: 'auto', + skipDatabases: false, + databaseSchemas: [], + disableQueryHistory: true, + }, + makeIo().io, + { prompts, testConnection, scanConnection }, + ); + + expect(result).toEqual({ + status: 'ready', + projectDir: tempDir, + connectionIds: ['warehouse'], + skipSources: true, + }); + expect(testConnection).not.toHaveBeenCalled(); + expect(scanConnection).not.toHaveBeenCalled(); + }); + it('preserves existing database ids when adding another database from the configured menu', async () => { await writeFile( join(tempDir, 'ktx.yaml'), @@ -753,6 +796,7 @@ describe('setup databases step', () => { message: 'Databases configured: warehouse\nWhat would you like to do?', options: [ { value: 'continue', label: 'Continue to context sources' }, + { value: 'skip-sources', label: 'Skip context sources' }, { value: 'edit', label: 'Edit an existing database' }, { value: 'add', label: 'Add another database' }, ], @@ -801,6 +845,7 @@ describe('setup databases step', () => { message: 'Databases configured: postgres-warehouse\nWhat would you like to do?', options: [ { value: 'continue', label: 'Continue to context sources' }, + { value: 'skip-sources', label: 'Skip context sources' }, { value: 'edit', label: 'Edit an existing database' }, { value: 'add', label: 'Add another database' }, ], @@ -846,6 +891,7 @@ describe('setup databases step', () => { message: 'Databases configured: postgres-warehouse\nWhat would you like to do?', options: [ { value: 'continue', label: 'Continue to context sources' }, + { value: 'skip-sources', label: 'Skip context sources' }, { value: 'edit', label: 'Edit an existing database' }, { value: 'add', label: 'Add another database' }, ], @@ -890,6 +936,7 @@ describe('setup databases step', () => { message: 'Databases configured: warehouse\nWhat would you like to do?', options: [ { value: 'continue', label: 'Continue to context sources' }, + { value: 'skip-sources', label: 'Skip context sources' }, { value: 'edit', label: 'Edit an existing database' }, { value: 'add', label: 'Add another database' }, ], @@ -936,6 +983,7 @@ describe('setup databases step', () => { message: 'Databases configured: warehouse\nWhat would you like to do?', options: [ { value: 'continue', label: 'Continue to context sources' }, + { value: 'skip-sources', label: 'Skip context sources' }, { value: 'edit', label: 'Edit an existing database' }, { value: 'add', label: 'Add another database' }, ], diff --git a/packages/cli/src/setup-databases.ts b/packages/cli/src/setup-databases.ts index 058692ae..7781610c 100644 --- a/packages/cli/src/setup-databases.ts +++ b/packages/cli/src/setup-databases.ts @@ -56,7 +56,12 @@ export interface KtxSetupDatabasesArgs { } export type KtxSetupDatabasesResult = - | { status: 'ready'; projectDir: string; connectionIds: string[] } + | { + status: 'ready'; + projectDir: string; + connectionIds: string[]; + skipSources?: boolean; + } | { status: 'skipped'; projectDir: string } | { status: 'back'; projectDir: string } | { status: 'missing-input'; projectDir: string } @@ -633,6 +638,7 @@ function configuredPrimarySourcesPrompt(connectionIds: string[]): { message: `Databases configured: ${connectionIds.join(', ')}\nWhat would you like to do?`, options: [ { value: 'continue', label: 'Continue to context sources' }, + { value: 'skip-sources', label: 'Skip context sources' }, { value: 'edit', label: 'Edit an existing database' }, { value: 'add', label: 'Add another database' }, ], @@ -2167,6 +2173,15 @@ export async function runKtxSetupDatabasesStep( await markDatabasesComplete(args.projectDir, selectedConnectionIds); return { status: 'ready', projectDir: args.projectDir, connectionIds: selectedConnectionIds }; } + if (action === 'skip-sources') { + await markDatabasesComplete(args.projectDir, selectedConnectionIds); + return { + status: 'ready', + projectDir: args.projectDir, + connectionIds: selectedConnectionIds, + skipSources: true, + }; + } if (action === 'edit') { const connectionId = await choosePrimarySourceToEdit({ projectDir: args.projectDir, diff --git a/packages/cli/src/setup.test.ts b/packages/cli/src/setup.test.ts index ff8513b7..26dc0324 100644 --- a/packages/cli/src/setup.test.ts +++ b/packages/cli/src/setup.test.ts @@ -1641,6 +1641,67 @@ describe('setup status', () => { expect(calls).toEqual(['model', 'embeddings', 'databases', 'sources']); }); + it('passes context-source skip selection from database setup into the sources step', async () => { + const calls: string[] = []; + const io = makeIo(); + await writeFile(join(tempDir, 'ktx.yaml'), ['connections: {}', ''].join('\n'), 'utf-8'); + + await expect( + runKtxSetup( + { + command: 'run', + projectDir: tempDir, + mode: 'auto', + agents: false, + skipAgents: true, + inputMode: 'disabled', + yes: true, + cliVersion: '0.2.0', + skipLlm: true, + skipEmbeddings: true, + skipDatabases: false, + skipSources: false, + databaseSchemas: [], + }, + io.io, + { + model: async () => { + calls.push('model'); + return { status: 'skipped', projectDir: tempDir }; + }, + embeddings: async () => { + calls.push('embeddings'); + return { status: 'skipped', projectDir: tempDir }; + }, + databases: async () => { + calls.push('databases'); + return { + status: 'ready', + projectDir: tempDir, + connectionIds: ['warehouse'], + skipSources: true, + }; + }, + sources: async (args) => { + expect(args.skipSources).toBe(true); + calls.push('sources'); + return { status: 'skipped', projectDir: tempDir }; + }, + runtime: async () => { + calls.push('runtime'); + return runtimeReady(tempDir); + }, + context: async () => { + calls.push('context'); + return { status: 'ready', projectDir: tempDir, runId: 'setup-context-local-test' }; + }, + }, + ), + ).resolves.toBe(0); + + expect(calls).toEqual(['model', 'embeddings', 'databases', 'sources', 'runtime', 'context']); + }); + it.each([ { backend: 'vertex', diff --git a/packages/cli/src/setup.ts b/packages/cli/src/setup.ts index 825170c0..422f95c5 100644 --- a/packages/cli/src/setup.ts +++ b/packages/cli/src/setup.ts @@ -667,6 +667,7 @@ async function runKtxSetupInner(args: KtxSetupArgs, io: KtxCliIo, deps: KtxSetup const shouldRunContext = !agentOnlySetup && (!runOnly || runOnly === 'context'); const shouldRunAgents = agentsRequested || !runOnly || runOnly === 'agents'; const showPromptInstructions = projectResult.confirmedCreation !== true; + let skipSourcesFromDatabaseMenu = false; const setupSteps: KtxSetupFlowStep[] = agentOnlySetup ? [] @@ -680,7 +681,9 @@ async function runKtxSetupInner(args: KtxSetupArgs, io: KtxCliIo, deps: KtxSetup if (step === 'models') return !args.skipLlm && shouldRunModels; if (step === 'embeddings') return !args.skipEmbeddings && shouldRunEmbeddings; if (step === 'databases') return !args.skipDatabases && shouldRunDatabases; - if (step === 'sources') return args.skipSources !== true && shouldRunSources; + if (step === 'sources') { + return args.skipSources !== true && !skipSourcesFromDatabaseMenu && shouldRunSources; + } if (step === 'runtime') return shouldRunRuntime; if (step === 'context') return shouldRunContext; return shouldRunAgents && args.skipAgents !== true; @@ -743,7 +746,7 @@ async function runKtxSetupInner(args: KtxSetupArgs, io: KtxCliIo, deps: KtxSetup const databasesRunner = deps.databases ?? ((databaseArgs, databaseIo) => runKtxSetupDatabasesStep(databaseArgs, databaseIo, deps.databasesDeps)); - stepResult = await databasesRunner( + const databaseResult = await databasesRunner( { projectDir: projectResult.projectDir, inputMode: args.inputMode, @@ -768,6 +771,8 @@ async function runKtxSetupInner(args: KtxSetupArgs, io: KtxCliIo, deps: KtxSetup }, io, ); + skipSourcesFromDatabaseMenu = databaseResult.status === 'ready' && databaseResult.skipSources === true; + stepResult = databaseResult; } else if (step === 'sources') { const sourcesRunner = deps.sources ?? ((sourceArgs, sourceIo) => runKtxSetupSourcesStep(sourceArgs, sourceIo, deps.sourcesDeps)); @@ -794,7 +799,7 @@ async function runKtxSetupInner(args: KtxSetupArgs, io: KtxCliIo, deps: KtxSetup ...(args.notionCrawlMode ? { notionCrawlMode: args.notionCrawlMode } : {}), ...(args.notionRootPageIds ? { notionRootPageIds: args.notionRootPageIds } : {}), runInitialSourceIngest: args.runInitialSourceIngest ?? false, - skipSources: args.skipSources === true || !shouldRunSources, + skipSources: args.skipSources === true || !shouldRunSources || skipSourcesFromDatabaseMenu, }, io, ); diff --git a/packages/cli/src/tree-picker-state.test.ts b/packages/cli/src/tree-picker-state.test.ts index 20c2001e..e8d6afd1 100644 --- a/packages/cli/src/tree-picker-state.test.ts +++ b/packages/cli/src/tree-picker-state.test.ts @@ -191,7 +191,7 @@ describe('search and cursor movement', () => { }); const searching = { ...state, - search: { editing: false, query: 'architecture' }, + search: { query: 'architecture' }, }; expect(filterTree(searching)).toEqual({ @@ -229,7 +229,7 @@ describe('bulk actions and reducer effects', () => { }); const searching = { ...state, - search: { editing: false, query: 'architecture' }, + search: { query: 'architecture' }, }; const selected = selectAllVisible(searching); @@ -306,12 +306,11 @@ describe('bulk actions and reducer effects', () => { next: { ...state, pendingConfirm: null }, effect: null, }); + expect(reducer(state, { type: 'search-input', value: 'a' }).next.search).toEqual({ query: 'a' }); + expect(reducer({ ...state, isNavigating: true }, { type: 'search-input', value: 'b' }).next.isNavigating).toBe(false); expect( - reducer( - reducer(reducer(state, 'search-start').next, { type: 'search-input', value: 'a' }).next, - 'search-submit', - ).next.search, - ).toEqual({ editing: false, query: 'a' }); + reducer({ ...state, search: { query: 'foo' }, isNavigating: true }, 'search-clear').next, + ).toEqual({ ...state, search: { query: '' }, isNavigating: false }); expect(reducer(state, 'quit')).toEqual({ next: state, effect: 'quit-without-save', @@ -336,6 +335,34 @@ describe('bulk actions and reducer effects', () => { }); }); + it('navigates cursor commands set isNavigating, typed input clears it, and search refocuses cursor', () => { + const state = buildInitialState({ + tree: buildPickerTree(pages()), + existingSelectedIds: [], + }); + + expect(state.isNavigating).toBe(false); + const afterDown = reducer(state, 'cursor-down').next; + expect(afterDown.isNavigating).toBe(true); + + const afterType = reducer(afterDown, { type: 'search-input', value: 'a' }).next; + expect(afterType.isNavigating).toBe(false); + expect(afterType.search.query).toBe('a'); + + const afterBackspace = reducer({ ...afterDown, search: { query: 'foo' } }, 'search-backspace').next; + expect(afterBackspace.search.query).toBe('fo'); + expect(afterBackspace.isNavigating).toBe(false); + + const withCursorOnHidden = { + ...state, + cursorId: IDS.journal, + search: { query: 'arch' }, + }; + const refocused = reducer(withCursorOnHidden, { type: 'search-input', value: 'i' }).next; + expect(refocused.search.query).toBe('archi'); + expect(visibleNodeIds(refocused)).toContain(refocused.cursorId); + }); + it('clears transient hints only when their expiry time has passed', () => { const state = buildInitialState({ tree: buildPickerTree(pages()), diff --git a/packages/cli/src/tree-picker-state.ts b/packages/cli/src/tree-picker-state.ts index 3e96e096..2392ef68 100644 --- a/packages/cli/src/tree-picker-state.ts +++ b/packages/cli/src/tree-picker-state.ts @@ -25,7 +25,8 @@ export interface PickerState { expanded: Set; checked: Set; cursorId: string; - search: { editing: boolean; query: string }; + search: { query: string }; + isNavigating: boolean; pendingConfirm: PendingConfirmKind | null; preLoadWarnings: string[]; transientHint: { text: string; expiresAt: number } | null; @@ -47,9 +48,7 @@ export type PickerCommand = | 'toggle-select-all-visible' | 'select-none' | 'clear-transient-hint' - | 'search-start' - | 'search-cancel' - | 'search-submit' + | 'search-clear' | 'search-backspace' | { type: 'search-input'; value: string } | 'save-request' @@ -464,7 +463,8 @@ export function buildInitialState(args: { expanded, checked: minimalChecked, cursorId: args.tree[0]?.id ?? '', - search: { editing: false, query: '' }, + search: { query: '' }, + isNavigating: false, pendingConfirm: null, preLoadWarnings, transientHint: null, @@ -473,6 +473,14 @@ export function buildInitialState(args: { }; } +function refocusVisibleCursor(state: PickerState): PickerState { + const ids = visibleNodeIds(state); + if (ids.length === 0 || ids.includes(state.cursorId)) { + return state; + } + return cloneState(state, { cursorId: ids[0]! }); +} + export function reducer(state: PickerState, cmd: PickerCommand, now = Date.now()): { next: PickerState; effect: PickerEffect } { if (state.pendingConfirm) { if (cmd === 'save-confirm') { @@ -491,13 +499,13 @@ export function reducer(state: PickerState, cmd: PickerCommand, now = Date.now() switch (cmd) { case 'cursor-up': - return { next: moveCursor(state, 'up'), effect: null }; + return { next: cloneState(moveCursor(state, 'up'), { isNavigating: true }), effect: null }; case 'cursor-down': - return { next: moveCursor(state, 'down'), effect: null }; + return { next: cloneState(moveCursor(state, 'down'), { isNavigating: true }), effect: null }; case 'cursor-left': - return { next: moveCursor(state, 'left'), effect: null }; + return { next: cloneState(moveCursor(state, 'left'), { isNavigating: true }), effect: null }; case 'cursor-right': - return { next: moveCursor(state, 'right'), effect: null }; + return { next: cloneState(moveCursor(state, 'right'), { isNavigating: true }), effect: null }; case 'expand': return { next: setExpanded(state, state.cursorId, 'toggle'), effect: null }; case 'collapse': @@ -521,15 +529,19 @@ export function reducer(state: PickerState, cmd: PickerCommand, now = Date.now() return { next: selectNone(state), effect: null }; case 'clear-transient-hint': return { next: clearExpiredTransientHint(state, now), effect: null }; - case 'search-start': - return { next: cloneState(state, { search: { ...state.search, editing: true } }), effect: null }; - case 'search-cancel': - return { next: cloneState(state, { search: { editing: false, query: '' } }), effect: null }; - case 'search-submit': - return { next: cloneState(state, { search: { ...state.search, editing: false } }), effect: null }; + case 'search-clear': + return { + next: cloneState(state, { search: { query: '' }, isNavigating: false }), + effect: null, + }; case 'search-backspace': return { - next: cloneState(state, { search: { ...state.search, query: state.search.query.slice(0, -1) } }), + next: refocusVisibleCursor( + cloneState(state, { + search: { query: state.search.query.slice(0, -1) }, + isNavigating: false, + }), + ), effect: null, }; case 'save-request': @@ -546,6 +558,14 @@ export function reducer(state: PickerState, cmd: PickerCommand, now = Date.now() case 'quit': return { next: state, effect: 'quit-without-save' }; default: - return { next: cloneState(state, { search: { ...state.search, query: state.search.query + cmd.value } }), effect: null }; + return { + next: refocusVisibleCursor( + cloneState(state, { + search: { query: state.search.query + cmd.value }, + isNavigating: false, + }), + ), + effect: null, + }; } } diff --git a/packages/cli/src/tree-picker-tui.test.tsx b/packages/cli/src/tree-picker-tui.test.tsx index 3a8dcc7b..18877778 100644 --- a/packages/cli/src/tree-picker-tui.test.tsx +++ b/packages/cli/src/tree-picker-tui.test.tsx @@ -83,38 +83,71 @@ function normalizeFrameWrap(frame: string | undefined): string { } describe('treePickerCommandForInkInput', () => { - it('maps browse, search, and confirm input to reducer commands', () => { - expect(treePickerCommandForInkInput('', { downArrow: true }, state().search, null)).toBe('cursor-down'); - expect(treePickerCommandForInkInput('', { upArrow: true }, state().search, null)).toBe('cursor-up'); - expect(treePickerCommandForInkInput('', { rightArrow: true }, state().search, null)).toBe('cursor-right'); - expect(treePickerCommandForInkInput('', { leftArrow: true }, state().search, null)).toBe('cursor-left'); - expect(treePickerCommandForInkInput(' ', {}, state().search, null)).toBe('toggle-check'); - expect(treePickerCommandForInkInput('/', {}, state().search, null)).toBe('search-start'); - expect(treePickerCommandForInkInput('a', {}, state().search, null)).toBe('toggle-select-all-visible'); - expect(treePickerCommandForInkInput('n', {}, state().search, null)).toBe('select-none'); - expect(treePickerCommandForInkInput('', { return: true }, state().search, null)).toBe('save-request'); - expect(treePickerCommandForInkInput('', { escape: true }, state().search, null)).toBe('quit'); - expect(treePickerCommandForInkInput('c', { ctrl: true }, state().search, null)).toBe('quit'); - expect(treePickerCommandForInkInput('s', {}, state().search, null)).toBeNull(); - expect(treePickerCommandForInkInput('q', {}, state().search, null)).toBeNull(); + const browse = (overrides: Partial<{ search: { query: string }; isNavigating: boolean; pendingConfirm: null }> = {}) => ({ + search: { query: '' }, + isNavigating: false, + pendingConfirm: null, + ...overrides, + }); + const confirming = { ...browse(), pendingConfirm: 'save-confirm' as const }; - expect(treePickerCommandForInkInput('x', {}, { editing: true, query: '' }, null)).toEqual({ + it('routes cursor and confirm keys when no query is typed', () => { + expect(treePickerCommandForInkInput('', { downArrow: true }, browse())).toBe('cursor-down'); + expect(treePickerCommandForInkInput('', { upArrow: true }, browse())).toBe('cursor-up'); + expect(treePickerCommandForInkInput('', { rightArrow: true }, browse())).toBe('cursor-right'); + expect(treePickerCommandForInkInput('', { leftArrow: true }, browse())).toBe('cursor-left'); + expect(treePickerCommandForInkInput('', { return: true }, browse())).toBe('save-request'); + expect(treePickerCommandForInkInput('', { escape: true }, browse())).toBe('quit'); + expect(treePickerCommandForInkInput('c', { ctrl: true }, browse())).toBe('quit'); + }); + + it('Tab toggles selection regardless of search/navigation state', () => { + expect(treePickerCommandForInkInput('', { tab: true }, browse())).toBe('toggle-check'); + expect(treePickerCommandForInkInput('', { tab: true }, browse({ search: { query: 'foo' }, isNavigating: false }))).toBe( + 'toggle-check', + ); + expect(treePickerCommandForInkInput('', { tab: true }, browse({ isNavigating: true }))).toBe('toggle-check'); + }); + + it('Space toggles only when navigating; otherwise typed into the search query', () => { + expect(treePickerCommandForInkInput(' ', {}, browse({ isNavigating: true }))).toBe('toggle-check'); + expect(treePickerCommandForInkInput(' ', {}, browse({ isNavigating: false }))).toEqual({ + type: 'search-input', + value: ' ', + }); + }); + + it('typed printable chars feed the search query — including a, n, and slash', () => { + expect(treePickerCommandForInkInput('a', {}, browse())).toEqual({ type: 'search-input', value: 'a' }); + expect(treePickerCommandForInkInput('n', {}, browse())).toEqual({ type: 'search-input', value: 'n' }); + expect(treePickerCommandForInkInput('/', {}, browse())).toEqual({ type: 'search-input', value: '/' }); + expect(treePickerCommandForInkInput('x', {}, browse({ search: { query: 'foo' } }))).toEqual({ type: 'search-input', value: 'x', }); - expect(treePickerCommandForInkInput('', { backspace: true }, { editing: true, query: 'x' }, null)).toBe( + }); + + it('Ctrl+A and Ctrl+N drive the bulk toggle helpers', () => { + expect(treePickerCommandForInkInput('a', { ctrl: true }, browse())).toBe('toggle-select-all-visible'); + expect(treePickerCommandForInkInput('n', { ctrl: true }, browse())).toBe('select-none'); + }); + + it('Backspace deletes from the query at any time; Esc clears query first then quits', () => { + expect(treePickerCommandForInkInput('', { backspace: true }, browse({ search: { query: 'x' } }))).toBe( 'search-backspace', ); - expect(treePickerCommandForInkInput('', { return: true }, { editing: true, query: 'x' }, null)).toBe( - 'search-submit', - ); - expect(treePickerCommandForInkInput('', { escape: true }, { editing: true, query: 'x' }, null)).toBe( - 'search-cancel', + expect(treePickerCommandForInkInput('', { delete: true }, browse({ search: { query: 'x' } }))).toBe( + 'search-backspace', ); + expect(treePickerCommandForInkInput('', { escape: true }, browse({ search: { query: 'x' } }))).toBe('search-clear'); + expect(treePickerCommandForInkInput('', { escape: true }, browse())).toBe('quit'); + }); - expect(treePickerCommandForInkInput('y', {}, state().search, 'save-confirm')).toBe('save-confirm'); - expect(treePickerCommandForInkInput('', { return: true }, state().search, 'save-confirm')).toBe('save-confirm'); - expect(treePickerCommandForInkInput('n', {}, state().search, 'save-confirm')).toBe('save-cancel'); + it('confirm prompts intercept y/n/Enter/Esc before search routing', () => { + expect(treePickerCommandForInkInput('y', {}, confirming)).toBe('save-confirm'); + expect(treePickerCommandForInkInput('', { return: true }, confirming)).toBe('save-confirm'); + expect(treePickerCommandForInkInput('n', {}, confirming)).toBe('save-cancel'); + expect(treePickerCommandForInkInput('', { escape: true }, confirming)).toBe('save-cancel'); }); }); @@ -160,8 +193,9 @@ describe('TreePickerApp', () => { expect(frame).toContain('◻ Engineering Docs ▸ (1)'); expect(frame).toContain('◻ Marketing'); expect(normalizeFrameWrap(frame)).toContain( - 'Right Arrow to expand, Up/Down to move, Space to select or unselect, Slash to filter, Enter to confirm, Escape to go back, or Ctrl+C to exit.', + 'Up/Down to move, Right/Left to expand or collapse, Tab to select, Type to search, Enter to confirm, Escape to clear search or go back, Ctrl+C to exit.', ); + expect(frame).toContain('Search:'); }); it('renders custom help text when supplied', () => { @@ -238,7 +272,7 @@ describe('TreePickerApp', () => { />, ); - stdin.write(' '); + stdin.write('\t'); await waitForInkInput(); expect(lastFrame()).toContain('◼ Engineering Docs'); diff --git a/packages/cli/src/tree-picker-tui.tsx b/packages/cli/src/tree-picker-tui.tsx index 57525270..94fc0dd6 100644 --- a/packages/cli/src/tree-picker-tui.tsx +++ b/packages/cli/src/tree-picker-tui.tsx @@ -32,7 +32,7 @@ const NO_COLOR_THEME = { type TreePickerTheme = Record; const DEFAULT_TREE_PICKER_HELP_TEXT = - 'Right Arrow to expand, Up/Down to move, Space to select or unselect, Slash to filter, Enter to confirm, Escape to go back, or Ctrl+C to exit.'; + 'Up/Down to move, Right/Left to expand or collapse, Tab to select, Type to search, Enter to confirm, Escape to clear search or go back, Ctrl+C to exit.'; const DEFAULT_SKIP_EMPTY_MESSAGE = 'Nothing selected. Skip this step? Press Enter to skip or Escape to go back.'; @@ -50,6 +50,8 @@ interface InkKey { return?: boolean; escape?: boolean; ctrl?: boolean; + tab?: boolean; + shift?: boolean; backspace?: boolean; delete?: boolean; } @@ -147,35 +149,27 @@ function truncateText(value: string, width: number): string { export function treePickerCommandForInkInput( input: string, key: InkKey, - search: PickerState['search'], - pendingConfirm: PickerState['pendingConfirm'], + state: Pick, ): PickerCommand | null { - if (pendingConfirm) { + if (state.pendingConfirm) { if (input === 'y' || key.return) return 'save-confirm'; if (input === 'n' || key.escape) return 'save-cancel'; if (key.ctrl === true && input === 'c') return 'quit'; return null; } - if (search.editing) { - if (key.escape) return 'search-cancel'; - if (key.return) return 'search-submit'; - if (key.backspace || key.delete) return 'search-backspace'; - if (key.downArrow) return 'cursor-down'; - if (key.upArrow) return 'cursor-up'; - if (input.length === 1 && input >= ' ' && input !== '') return { type: 'search-input', value: input }; - return null; - } if (key.ctrl === true && input === 'c') return 'quit'; + if (key.ctrl === true && input === 'a') return 'toggle-select-all-visible'; + if (key.ctrl === true && input === 'n') return 'select-none'; + if (key.return) return 'save-request'; if (key.upArrow) return 'cursor-up'; if (key.downArrow) return 'cursor-down'; if (key.leftArrow) return 'cursor-left'; if (key.rightArrow) return 'cursor-right'; - if (key.return) return 'save-request'; - if (input === ' ') return 'toggle-check'; - if (input === '/') return 'search-start'; - if (input === 'a') return 'toggle-select-all-visible'; - if (input === 'n') return 'select-none'; - if (key.escape) return 'quit'; + if (key.tab) return 'toggle-check'; + if (input === ' ' && state.isNavigating) return 'toggle-check'; + if (key.backspace || key.delete) return 'search-backspace'; + if (key.escape) return state.search.query.length > 0 ? 'search-clear' : 'quit'; + if (input.length === 1 && input >= ' ' && input !== '') return { type: 'search-input', value: input }; return null; } @@ -220,14 +214,13 @@ export function TreePickerApp(props: TreePickerAppProps): ReactNode { const theme = useMemo(() => resolveTheme(props.env), [props.env]); const visibleIds = visibleNodeIds(state); const selectedIndex = Math.max(0, visibleIds.indexOf(state.cursorId)); - const reservedRows = state.pendingConfirm === 'save-confirm' ? 10 : 9; + const reservedRows = state.pendingConfirm === 'save-confirm' ? 11 : 10; const visibleRows = Math.max(5, Math.min(12, (props.terminalRows ?? 24) - reservedRows)); const rows = windowItems(visibleIds, selectedIndex, visibleRows); const hiddenAbove = rows.offset; const hiddenBelow = Math.max(0, visibleIds.length - rows.offset - rows.items.length); const searchMatchCount = filterTree(state).visibleIds.size; const width = resolveTreePickerWidth(props.terminalWidth); - const showSearch = state.search.editing || state.search.query.trim().length > 0; const helpText = props.chrome.helpText ?? DEFAULT_TREE_PICKER_HELP_TEXT; const skipEmptyMessage = props.chrome.skipEmptyMessage ?? DEFAULT_SKIP_EMPTY_MESSAGE; @@ -258,7 +251,7 @@ export function TreePickerApp(props: TreePickerAppProps): ReactNode { }, [state.transientHint?.expiresAt]); useInput((input, key) => { - const command = treePickerCommandForInkInput(input, key, stateRef.current.search, stateRef.current.pendingConfirm); + const command = treePickerCommandForInkInput(input, key, stateRef.current); if (!command) { return; } @@ -308,16 +301,18 @@ export function TreePickerApp(props: TreePickerAppProps): ReactNode { {warning} ))} - {showSearch ? ( - - / + + Search: + {state.isNavigating ? ( + {state.search.query || '(type to filter)'} + ) : ( {state.search.query} - {state.search.editing ? '█' : ''} + - ({searchMatchCount} matches) - - ) : null} + )} + ({searchMatchCount} match{searchMatchCount === 1 ? '' : 'es'}) + {hiddenAbove > 0 ? ↑ {hiddenAbove} more : null} {rows.items.map((nodeId) => ( From 78b8a0c025d62696c56e945a30b72c1f34fe816e Mon Sep 17 00:00:00 2001 From: Andrey Avtomonov Date: Sun, 24 May 2026 19:30:06 +0200 Subject: [PATCH 03/74] feat(connectors): generalize readiness and constraint handling (#212) * feat(connectors): add postgres maxConnections * feat(connectors): add mysql maxConnections * feat(connectors): add sqlserver maxConnections * feat(connectors): rename snowflake pool config * docs: document connector maxConnections * feat(scan): add constraint discovery warning helper * feat(scan): carry structural warnings through reports * feat(postgres): soft-fail denied constraint discovery * feat(mysql): soft-fail denied constraint discovery * feat(sqlserver): soft-fail denied constraint discovery * feat(bigquery): soft-fail denied primary key discovery * feat(snowflake): report denied primary key discovery * test(scan): verify constraint discovery warnings * feat(historic-sql): use shared readiness probes * docs: document query history readiness probes * test(historic-sql): verify readiness probe registry * test(ingest): account for live database warnings artifact * Add skip option for agent setup --- README.md | 3 +- .../content/docs/cli-reference/ktx-setup.mdx | 10 + .../content/docs/cli-reference/ktx-status.mdx | 4 +- .../content/docs/configuration/ktx-yaml.mdx | 15 +- .../docs/integrations/agent-clients.mdx | 11 +- .../src/connectors/bigquery/connector.test.ts | 33 +- .../cli/src/connectors/bigquery/connector.ts | 54 ++- .../src/connectors/mysql/connector.test.ts | 111 +++++- .../cli/src/connectors/mysql/connector.ts | 105 +++++- .../src/connectors/postgres/connector.test.ts | 122 ++++++- .../cli/src/connectors/postgres/connector.ts | 95 +++++- .../connectors/snowflake/connector.test.ts | 93 ++++- .../cli/src/connectors/snowflake/connector.ts | 79 +++-- .../connectors/sqlserver/connector.test.ts | 89 ++++- .../cli/src/connectors/sqlserver/connector.ts | 85 ++++- .../adapters/live-database/stage.test.ts | 26 ++ .../ingest/adapters/live-database/stage.ts | 10 + .../ingest/historic-sql-probes.test.ts | 157 +++++++++ .../src/context/ingest/historic-sql-probes.ts | 141 ++++++++ .../bigquery-runner.test.ts | 110 ++++++ .../historic-sql-probes/bigquery-runner.ts | 160 +++++++++ .../postgres-runner.test.ts | 113 +++++++ .../historic-sql-probes/postgres-runner.ts | 111 ++++++ .../snowflake-runner.test.ts | 82 +++++ .../historic-sql-probes/snowflake-runner.ts | 96 ++++++ .../context/ingest/local-stage-ingest.test.ts | 2 +- .../context/scan/constraint-discovery.test.ts | 70 ++++ .../src/context/scan/constraint-discovery.ts | 42 +++ .../cli/src/context/scan/local-scan.test.ts | 49 +++ packages/cli/src/context/scan/local-scan.ts | 3 + .../scan/local-structural-artifacts.test.ts | 83 +++++ .../scan/local-structural-artifacts.ts | 56 +++ packages/cli/src/context/scan/types.ts | 4 +- packages/cli/src/doctor.test.ts | 65 +++- packages/cli/src/ingest.test.ts | 25 +- packages/cli/src/setup-agents.test.ts | 57 ++++ packages/cli/src/setup-agents.ts | 12 +- packages/cli/src/setup-databases.test.ts | 307 +++++++++++++++-- packages/cli/src/setup-databases.ts | 198 ++++------- packages/cli/src/status-project.test.ts | 107 ++++-- packages/cli/src/status-project.ts | 318 ++++-------------- uv.lock | 4 +- 42 files changed, 2763 insertions(+), 554 deletions(-) create mode 100644 packages/cli/src/context/ingest/historic-sql-probes.test.ts create mode 100644 packages/cli/src/context/ingest/historic-sql-probes.ts create mode 100644 packages/cli/src/context/ingest/historic-sql-probes/bigquery-runner.test.ts create mode 100644 packages/cli/src/context/ingest/historic-sql-probes/bigquery-runner.ts create mode 100644 packages/cli/src/context/ingest/historic-sql-probes/postgres-runner.test.ts create mode 100644 packages/cli/src/context/ingest/historic-sql-probes/postgres-runner.ts create mode 100644 packages/cli/src/context/ingest/historic-sql-probes/snowflake-runner.test.ts create mode 100644 packages/cli/src/context/ingest/historic-sql-probes/snowflake-runner.ts create mode 100644 packages/cli/src/context/scan/constraint-discovery.test.ts create mode 100644 packages/cli/src/context/scan/constraint-discovery.ts diff --git a/README.md b/README.md index cb9d25b0..8dadd3e1 100644 --- a/README.md +++ b/README.md @@ -162,7 +162,8 @@ ktx sl query --connection-id warehouse --measure orders.revenue --format sql During setup, choose **Ask data questions with ktx MCP** for agent clients. Choose **Ask data questions + manage ktx with CLI commands** when an operator -agent also needs pinned `ktx` admin commands. +agent also needs pinned `ktx` admin commands. Choose **Skip agent setup for +now** to leave agent integration incomplete and run `ktx setup --agents` later. After setup, **ktx** prints **Required before using agents** with the exact commands to run. If the output includes `ktx mcp start --project-dir ...`, run diff --git a/docs-site/content/docs/cli-reference/ktx-setup.mdx b/docs-site/content/docs/cli-reference/ktx-setup.mdx index b94d65db..17423534 100644 --- a/docs-site/content/docs/cli-reference/ktx-setup.mdx +++ b/docs-site/content/docs/cli-reference/ktx-setup.mdx @@ -134,6 +134,16 @@ window flag applies to BigQuery and Snowflake; Postgres reads the current Enabling query history makes deep ingest readiness matter for later `ktx ingest` runs. +When query history is enabled for PostgreSQL, Snowflake, or BigQuery, +`ktx setup` runs a non-blocking readiness probe after the connection test +passes. A failed probe still writes setup changes, prints the warehouse-specific +grant or extension remediation, and leaves query-history ingest disabled until +you fix the prerequisite. + +For BigQuery, the remediation tells you to grant `roles/bigquery.resourceViewer` +on the BigQuery project, or grant a custom role that contains +`bigquery.jobs.listAll`. + ### Context Sources In interactive setup, after you configure a database, choose diff --git a/docs-site/content/docs/cli-reference/ktx-status.mdx b/docs-site/content/docs/cli-reference/ktx-status.mdx index c86c12e0..51c00148 100644 --- a/docs-site/content/docs/cli-reference/ktx-status.mdx +++ b/docs-site/content/docs/cli-reference/ktx-status.mdx @@ -21,7 +21,7 @@ ktx status [options] | `--json` | Print JSON output | `false` | | `-v`, `--verbose` | Show every check, including passing ones | `false` | | `--validate` | Only validate the `ktx.yaml` schema; skip readiness checks | `false` | -| `--fast` | Skip checks that require external communication (Postgres query-history probe, Claude Code auth probe) | `false` | +| `--fast` | Skip checks that require external communication (query-history readiness probes and Claude Code auth probe) | `false` | | `--no-input` | Disable interactive terminal input | - | ## Examples @@ -39,7 +39,7 @@ ktx status --verbose # Validate ktx.yaml without running readiness checks ktx status --validate -# Skip slow probes (Postgres pg_stat_statements, Claude Code auth) +# Skip slow probes (query-history readiness, Claude Code auth) ktx status --fast # Check a project from another directory diff --git a/docs-site/content/docs/configuration/ktx-yaml.mdx b/docs-site/content/docs/configuration/ktx-yaml.mdx index 873a8acd..4a919d45 100644 --- a/docs-site/content/docs/configuration/ktx-yaml.mdx +++ b/docs-site/content/docs/configuration/ktx-yaml.mdx @@ -157,11 +157,14 @@ connections: dataset_ids: [analytics, mart] ``` -For Snowflake connections, set `maxSessions` when deep ingest needs more or -fewer concurrent warehouse sessions. The default is `4`. This caps all -concurrent Snowflake SQL work for that connector instance, including schema -introspection, table sampling, relationship profiling, relationship -validation, and read-only SQL execution. +For Postgres, MySQL, SQL Server, and Snowflake connections, set +`maxConnections` when scan or ingest work needs to stay below the target's +connection cap. Postgres, MySQL, and SQL Server default to `10`; Snowflake +defaults to `4`. This caps all concurrent SQL work for that connector instance, +including schema introspection, table sampling, relationship profiling, +relationship validation, and read-only SQL execution. BigQuery and ClickHouse +do not expose `maxConnections` because their connectors don't use client-side +connection pools. For Postgres, BigQuery, and Snowflake, `historicSql` and `context.queryHistory` toggle query-history ingest. The shape is connector-specific; the setup wizard @@ -517,7 +520,7 @@ the manifest. | `relationships.maxLlmTablesPerBatch` | `int > 0` | `40` | Max tables included in a single LLM relationship-proposal batch. | | `relationships.maxCandidatesPerColumn` | `int > 0` | `25` | Max join partners considered per column. | | `relationships.profileSampleRows` | `int > 0` | `10000` | Rows sampled per table when profiling values for relationship inference. | -| `relationships.profileConcurrency` | `int > 0` | `4` | Parallel relationship-profile queries against the database. For Snowflake, effective database concurrency is also bounded by the connection's `maxSessions`. | +| `relationships.profileConcurrency` | `int > 0` | `4` | Parallel relationship-profile queries against the database. For pooled connectors, effective database concurrency is also bounded by the connection's `maxConnections`. | | `relationships.validationConcurrency` | `int > 0` | `4` | Parallel relationship validation queries against the database. | | `relationships.validationBudget` | `all` \| `int ≥ 0` | runtime default | Cap on validation queries per scan. `all` means unlimited. | diff --git a/docs-site/content/docs/integrations/agent-clients.mdx b/docs-site/content/docs/integrations/agent-clients.mdx index 36aef1c3..46a1ec8b 100644 --- a/docs-site/content/docs/integrations/agent-clients.mdx +++ b/docs-site/content/docs/integrations/agent-clients.mdx @@ -9,7 +9,9 @@ admin surface for setup, ingest, status, daemon lifecycle, and debugging. Run `ktx setup` and select your agent client targets, or configure manually using the snippets below. Choose **Ask data questions with ktx MCP** for agent clients. Choose **Ask data questions + manage ktx with CLI commands** only when -a developer or operator agent also needs pinned `ktx` admin commands. +a developer or operator agent also needs pinned `ktx` admin commands. Choose +**Skip agent setup for now** to leave agent integration incomplete and run +`ktx setup --agents` later. ## Install with setup @@ -43,14 +45,19 @@ ktx setup --agents --target codex --global manifest lets status checks report agent readiness and lets future cleanup remove only files **ktx** installed. -The interactive command asks two questions: +The interactive command asks what agents can do first: ```txt ◆ What should agents be allowed to do with this ktx project? │ ○ Ask data questions with ktx MCP │ ○ Ask data questions + manage ktx with CLI commands +│ ○ Skip agent setup for now └ +``` +If you choose an install mode, it then asks which targets to install: + +```txt ◆ Which agent targets should ktx install? │ ◻ Claude Code │ ◻ Claude Desktop diff --git a/packages/cli/src/connectors/bigquery/connector.test.ts b/packages/cli/src/connectors/bigquery/connector.test.ts index be65af1e..b9893ccf 100644 --- a/packages/cli/src/connectors/bigquery/connector.test.ts +++ b/packages/cli/src/connectors/bigquery/connector.test.ts @@ -3,7 +3,7 @@ import { bigQueryConnectionConfigFromConfig, isKtxBigQueryConnectionConfig, type import { createBigQueryLiveDatabaseIntrospection } from '../../connectors/bigquery/live-database-introspection.js'; import { tableRefSet } from '../../context/scan/table-ref.js'; -function fakeClientFactory(): KtxBigQueryClientFactory { +function fakeClientFactory(options: { primaryKeyError?: Error } = {}): KtxBigQueryClientFactory { const queryResults = vi.fn(async (): ReturnType => [ [{ id: 1, status: 'paid' }], undefined, @@ -11,6 +11,9 @@ function fakeClientFactory(): KtxBigQueryClientFactory { ]); const createQueryJob = vi.fn(async (input: { query: string }): ReturnType => { if (input.query.includes('INFORMATION_SCHEMA.TABLE_CONSTRAINTS')) { + if (options.primaryKeyError) { + throw options.primaryKeyError; + } return [ { getQueryResults: async (): ReturnType => [ @@ -170,6 +173,34 @@ describe('KtxBigQueryScanConnector', () => { ]); }); + it.each([ + Object.assign(new Error('Access Denied'), { code: 403 }), + Object.assign(new Error('Not found'), { errors: [{ reason: 'notFound' }] }), + ])('soft-fails denied BigQuery primary-key discovery with a scan warning', async (primaryKeyError) => { + const connector = new KtxBigQueryScanConnector({ + connectionId: 'warehouse', + connection, + clientFactory: fakeClientFactory({ primaryKeyError }), + now: () => new Date('2026-04-29T17:00:00.000Z'), + }); + + const snapshot = await connector.introspect( + { connectionId: 'warehouse', driver: 'bigquery' }, + { runId: 'scan-run-bigquery-denied-pk' }, + ); + + expect(snapshot.warnings).toEqual([ + { + code: 'constraint_discovery_unauthorized', + message: 'Skipped primary-key discovery in analytics (insufficient grants on system catalogs)', + recoverable: true, + metadata: { schema: 'analytics', kind: 'primary_key' }, + }, + ]); + expect(snapshot.tables[0]?.foreignKeys).toEqual([]); + expect(snapshot.tables[0]?.columns.every((column) => column.primaryKey === false)).toBe(true); + }); + it('runs samples, read-only SQL, distinct values, dataset listing, row counts, and cleanup', async () => { const connector = new KtxBigQueryScanConnector({ connectionId: 'warehouse', diff --git a/packages/cli/src/connectors/bigquery/connector.ts b/packages/cli/src/connectors/bigquery/connector.ts index 7810e251..871f50f4 100644 --- a/packages/cli/src/connectors/bigquery/connector.ts +++ b/packages/cli/src/connectors/bigquery/connector.ts @@ -1,8 +1,28 @@ import { BigQuery, type TableField } from '@google-cloud/bigquery'; import { normalizeBigQueryProjectId, normalizeBigQueryRegion } from '../../context/connections/bigquery-identifiers.js'; import { assertReadOnlySql, limitSqlForExecution } from '../../context/connections/read-only-sql.js'; -import { createKtxConnectorCapabilities, type KtxColumnSampleInput, type KtxColumnSampleResult, type KtxColumnStatsInput, type KtxColumnStatsResult, type KtxQueryResult, type KtxReadOnlyQueryInput, type KtxScanConnector, type KtxScanContext, type KtxScanInput, type KtxSchemaColumn, type KtxSchemaSnapshot, type KtxSchemaTable, type KtxTableListEntry, type KtxTableRef, type KtxTableSampleInput, type KtxTableSampleResult } from '../../context/scan/types.js'; +import { tryConstraintQuery } from '../../context/scan/constraint-discovery.js'; import { scopedTableNames } from '../../context/scan/table-ref.js'; +import { + createKtxConnectorCapabilities, + type KtxColumnSampleInput, + type KtxColumnSampleResult, + type KtxColumnStatsInput, + type KtxColumnStatsResult, + type KtxQueryResult, + type KtxReadOnlyQueryInput, + type KtxScanConnector, + type KtxScanContext, + type KtxScanInput, + type KtxScanWarning, + type KtxSchemaColumn, + type KtxSchemaSnapshot, + type KtxSchemaTable, + type KtxTableListEntry, + type KtxTableRef, + type KtxTableSampleInput, + type KtxTableSampleResult, +} from '../../context/scan/types.js'; import { readFileSync } from 'node:fs'; import { homedir } from 'node:os'; import { resolve } from 'node:path'; @@ -185,6 +205,17 @@ function firstNumber(value: unknown): number | null { return Number.isFinite(numberValue) ? numberValue : null; } +function isDeniedError(error: unknown): boolean { + if (!error || typeof error !== 'object') { + return false; + } + const candidate = error as { code?: unknown; errors?: Array<{ reason?: unknown }> }; + return ( + candidate.code === 403 || + candidate.errors?.some((item) => item.reason === 'accessDenied' || item.reason === 'notFound') === true + ); +} + function normalizeValue(value: unknown): unknown { if (value === null || value === undefined) { return null; @@ -289,11 +320,12 @@ export class KtxBigQueryScanConnector implements KtxScanConnector { this.assertConnection(input.connectionId); const tables: KtxSchemaTable[] = []; const datasetIds = this.requireDatasetIdsForScan(); + const snapshotWarnings: KtxScanWarning[] = []; for (const datasetId of datasetIds) { const scopedNames = input.tableScope ? scopedTableNames(input.tableScope, { catalog: this.resolved.projectId, db: datasetId }) : null; - tables.push(...(await this.introspectDataset(datasetId, scopedNames))); + tables.push(...(await this.introspectDataset(datasetId, scopedNames, snapshotWarnings))); } return { connectionId: this.connectionId, @@ -307,6 +339,7 @@ export class KtxBigQueryScanConnector implements KtxScanConnector { total_columns: tables.reduce((sum, table) => sum + table.columns.length, 0), }, tables, + warnings: snapshotWarnings, }; } @@ -366,7 +399,7 @@ export class KtxBigQueryScanConnector implements KtxScanConnector { if (!datasetId) { return 0; } - const tables = await this.introspectDataset(datasetId, null); + const tables = await this.introspectDataset(datasetId, null, []); return tables.find((table) => table.name === tableName)?.estimatedRows ?? 0; } @@ -467,13 +500,24 @@ export class KtxBigQueryScanConnector implements KtxScanConnector { return firstNumber(rows[0]?.[header]); } - private async introspectDataset(datasetId: string, scopedNames: readonly string[] | null): Promise { + private async introspectDataset( + datasetId: string, + scopedNames: readonly string[] | null, + snapshotWarnings: KtxScanWarning[], + ): Promise { if (scopedNames && scopedNames.length === 0) return []; const dataset = this.getClient().dataset(datasetId); const [tableRefs] = await dataset.getTables(); const scopeSet = scopedNames ? new Set(scopedNames) : null; const filteredTableRefs = scopeSet ? tableRefs.filter((tableRef) => scopeSet.has(tableRef.id ?? '')) : tableRefs; - const primaryKeys = await this.primaryKeys(datasetId); + const primaryKeysResult = await tryConstraintQuery( + { schema: datasetId, kind: 'primary_key', isDeniedError }, + () => this.primaryKeys(datasetId), + ); + const primaryKeys = primaryKeysResult.ok ? primaryKeysResult.value : new Map>(); + if (!primaryKeysResult.ok) { + snapshotWarnings.push(primaryKeysResult.warning); + } const tables: KtxSchemaTable[] = []; for (const tableRef of filteredTableRefs) { const tableName = tableRef.id || ''; diff --git a/packages/cli/src/connectors/mysql/connector.test.ts b/packages/cli/src/connectors/mysql/connector.test.ts index 5a21ada7..6c69ea3d 100644 --- a/packages/cli/src/connectors/mysql/connector.test.ts +++ b/packages/cli/src/connectors/mysql/connector.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it, vi } from 'vitest'; import type { FieldPacket, RowDataPacket } from 'mysql2/promise'; import { createMysqlLiveDatabaseIntrospection } from '../../connectors/mysql/live-database-introspection.js'; -import { isKtxMysqlConnectionConfig, KtxMysqlScanConnector, mysqlConnectionPoolConfigFromConfig, type KtxMysqlPoolFactory } from '../../connectors/mysql/connector.js'; +import { isKtxMysqlConnectionConfig, KtxMysqlScanConnector, mysqlConnectionPoolConfigFromConfig, type KtxMysqlConnectionConfig, type KtxMysqlPoolFactory } from '../../connectors/mysql/connector.js'; import { tableRefSet } from '../../context/scan/table-ref.js'; function mysqlResult(rows: Record[], fields: Array<{ name: string; type?: number }>): [RowDataPacket[], FieldPacket[]] { @@ -86,7 +86,9 @@ function fakePoolFactory(): KtxMysqlPoolFactory { }; } -function multiSchemaMysqlPoolFactory(): KtxMysqlPoolFactory { +function multiSchemaMysqlPoolFactory( + options: { primaryKeyError?: Error; foreignKeyError?: Error } = {}, +): KtxMysqlPoolFactory { const query = vi.fn(async (sql: string, params?: unknown): Promise<[RowDataPacket[], FieldPacket[]]> => { if (sql.includes('INFORMATION_SCHEMA.TABLES')) { expect(params).toEqual(['analytics', 'mart']); @@ -141,6 +143,9 @@ function multiSchemaMysqlPoolFactory(): KtxMysqlPoolFactory { ); } if (sql.includes('INFORMATION_SCHEMA.KEY_COLUMN_USAGE') && sql.includes("CONSTRAINT_NAME = 'PRIMARY'")) { + if (options.primaryKeyError) { + throw options.primaryKeyError; + } expect(params).toEqual(['analytics', 'mart']); return mysqlResult( [ @@ -151,6 +156,9 @@ function multiSchemaMysqlPoolFactory(): KtxMysqlPoolFactory { ); } if (sql.includes('INFORMATION_SCHEMA.KEY_COLUMN_USAGE') && sql.includes('REFERENCED_TABLE_NAME IS NOT NULL')) { + if (options.foreignKeyError) { + throw options.foreignKeyError; + } expect(params).toEqual(['analytics', 'mart']); return mysqlResult([], []); } @@ -191,6 +199,46 @@ describe('KtxMysqlScanConnector', () => { }); }); + it('defaults and validates MySQL maxConnections', () => { + const baseConnection: KtxMysqlConnectionConfig = { + driver: 'mysql', + host: 'db.example.test', + database: 'analytics', + username: 'reader', + password: 'secret', // pragma: allowlist secret + }; + + expect( + mysqlConnectionPoolConfigFromConfig({ + connectionId: 'warehouse', + connection: baseConnection, + }), + ).toMatchObject({ connectionLimit: 10 }); + + expect( + mysqlConnectionPoolConfigFromConfig({ + connectionId: 'warehouse', + connection: { ...baseConnection, maxConnections: 25 }, + }), + ).toMatchObject({ connectionLimit: 25 }); + + expect( + mysqlConnectionPoolConfigFromConfig({ + connectionId: 'warehouse', + connection: { ...baseConnection, maxConnections: '12' as never }, + }), + ).toMatchObject({ connectionLimit: 12 }); + + for (const maxConnections of [0, -1, 1.5, Number.NaN, 'abc' as never]) { + expect(() => + mysqlConnectionPoolConfigFromConfig({ + connectionId: 'warehouse', + connection: { ...baseConnection, maxConnections }, + }), + ).toThrow('connections.warehouse.maxConnections must be a positive integer'); + } + }); + it('introspects schema, primary keys, comments, row counts, views, and foreign keys', async () => { const connector = new KtxMysqlScanConnector({ connectionId: 'warehouse', @@ -276,6 +324,65 @@ describe('KtxMysqlScanConnector', () => { ]); }); + it('soft-fails denied MySQL constraint discovery with one warning per schema and kind', async () => { + const connector = new KtxMysqlScanConnector({ + connectionId: 'warehouse', + connection: { + driver: 'mysql', + host: 'db.example.test', + database: 'analytics', + schemas: ['analytics', 'mart'], + username: 'reader', + password: 'secret', // pragma: allowlist secret + }, + poolFactory: multiSchemaMysqlPoolFactory({ + primaryKeyError: Object.assign(new Error('select command denied'), { + code: 'ER_TABLEACCESS_DENIED_ERROR', + errno: 1142, + }), + foreignKeyError: Object.assign(new Error('database access denied'), { + code: 'ER_DBACCESS_DENIED_ERROR', + errno: 1044, + }), + }), + now: () => new Date('2026-04-29T12:00:00.000Z'), + }); + + const snapshot = await connector.introspect( + { connectionId: 'warehouse', driver: 'mysql' }, + { runId: 'scan-run-mysql-denied-constraints' }, + ); + + expect(snapshot.warnings).toEqual([ + { + code: 'constraint_discovery_unauthorized', + message: 'Skipped primary-key discovery in analytics (insufficient grants on system catalogs)', + recoverable: true, + metadata: { schema: 'analytics', kind: 'primary_key' }, + }, + { + code: 'constraint_discovery_unauthorized', + message: 'Skipped primary-key discovery in mart (insufficient grants on system catalogs)', + recoverable: true, + metadata: { schema: 'mart', kind: 'primary_key' }, + }, + { + code: 'constraint_discovery_unauthorized', + message: 'Skipped foreign-key discovery in analytics (insufficient grants on system catalogs)', + recoverable: true, + metadata: { schema: 'analytics', kind: 'foreign_key' }, + }, + { + code: 'constraint_discovery_unauthorized', + message: 'Skipped foreign-key discovery in mart (insufficient grants on system catalogs)', + recoverable: true, + metadata: { schema: 'mart', kind: 'foreign_key' }, + }, + ]); + expect(snapshot.tables.every((table) => table.columns.every((column) => column.primaryKey === false))).toBe(true); + expect(snapshot.tables.every((table) => table.foreignKeys.length === 0)).toBe(true); + }); + it('limits introspection to tables in tableScope', async () => { const queries: Array<{ sql: string; params?: unknown }> = []; const poolFactory: KtxMysqlPoolFactory = { diff --git a/packages/cli/src/connectors/mysql/connector.ts b/packages/cli/src/connectors/mysql/connector.ts index 82a2384c..83c9712a 100644 --- a/packages/cli/src/connectors/mysql/connector.ts +++ b/packages/cli/src/connectors/mysql/connector.ts @@ -3,8 +3,33 @@ import { readFileSync } from 'node:fs'; import { homedir } from 'node:os'; import { resolve } from 'node:path'; import { assertReadOnlySql, limitSqlForExecution } from '../../context/connections/read-only-sql.js'; -import { createKtxConnectorCapabilities, type KtxColumnSampleInput, type KtxColumnSampleResult, type KtxColumnStatsInput, type KtxColumnStatsResult, type KtxQueryResult, type KtxReadOnlyQueryInput, type KtxScanConnector, type KtxScanContext, type KtxScanInput, type KtxSchemaColumn, type KtxTableListEntry, type KtxSchemaForeignKey, type KtxSchemaSnapshot, type KtxSchemaTable, type KtxTableRef, type KtxTableSampleInput, type KtxTableSampleResult } from '../../context/scan/types.js'; +import { + constraintDiscoveryWarning, + tryConstraintQuery, + type ConstraintDiscoveryKind, +} from '../../context/scan/constraint-discovery.js'; import { scopedTableNames } from '../../context/scan/table-ref.js'; +import { + createKtxConnectorCapabilities, + type KtxColumnSampleInput, + type KtxColumnSampleResult, + type KtxColumnStatsInput, + type KtxColumnStatsResult, + type KtxQueryResult, + type KtxReadOnlyQueryInput, + type KtxScanConnector, + type KtxScanContext, + type KtxScanInput, + type KtxScanWarning, + type KtxSchemaColumn, + type KtxSchemaForeignKey, + type KtxSchemaSnapshot, + type KtxSchemaTable, + type KtxTableListEntry, + type KtxTableRef, + type KtxTableSampleInput, + type KtxTableSampleResult, +} from '../../context/scan/types.js'; import { KtxMysqlDialect } from './dialect.js'; export interface KtxMysqlConnectionConfig { @@ -18,6 +43,7 @@ export interface KtxMysqlConnectionConfig { password?: string; url?: string; ssl?: boolean | { rejectUnauthorized?: boolean }; + maxConnections?: number; [key: string]: unknown; } @@ -163,6 +189,23 @@ function maybeNumber(value: unknown): number | undefined { return typeof value === 'number' && Number.isFinite(value) ? value : undefined; } +function positiveIntegerConfigValue(input: { + connection: KtxMysqlConnectionConfig; + key: keyof KtxMysqlConnectionConfig; + connectionId: string; + defaultValue: number; +}): number { + const value = input.connection[input.key]; + if (value === undefined) { + return input.defaultValue; + } + const numberValue = Number(value); + if (!Number.isInteger(numberValue) || numberValue < 1) { + throw new Error(`connections.${input.connectionId}.${String(input.key)} must be a positive integer`); + } + return numberValue; +} + function parseMysqlUrl(url: string): Partial { const parsed = new URL(url); const sslParam = parsed.searchParams.get('ssl') ?? parsed.searchParams.get('sslmode'); @@ -231,6 +274,28 @@ function primaryKeyMap(rows: MysqlPrimaryKeyRow[], fallbackDatabase: string): Ma return grouped; } +function isDeniedError(error: unknown): boolean { + if (!error || typeof error !== 'object') { + return false; + } + const code = (error as { code?: unknown }).code; + return ( + code === 'ER_TABLEACCESS_DENIED_ERROR' || + code === 'ER_SPECIFIC_ACCESS_DENIED_ERROR' || + code === 'ER_DBACCESS_DENIED_ERROR' + ); +} + +function pushConstraintWarnings( + warnings: KtxScanWarning[], + schemas: readonly string[], + kind: ConstraintDiscoveryKind, +): void { + for (const schema of schemas) { + warnings.push(constraintDiscoveryWarning({ schema, kind })); + } +} + function queryParams(params: Record | unknown[] | undefined): unknown[] | undefined { if (!params) { return undefined; @@ -262,6 +327,12 @@ export function mysqlConnectionPoolConfigFromConfig(input: { const host = stringConfigValue(merged, 'host', env); const database = stringConfigValue(merged, 'database', env); const user = stringConfigValue(merged, 'username', env) ?? stringConfigValue(merged, 'user', env); + const maxConnections = positiveIntegerConfigValue({ + connection: merged, + key: 'maxConnections', + connectionId: input.connectionId, + defaultValue: 10, + }); if (!host) { throw new Error(`Native MySQL connector requires connections.${input.connectionId}.host or url`); @@ -280,7 +351,7 @@ export function mysqlConnectionPoolConfigFromConfig(input: { database, user, password: stringConfigValue(merged, 'password', env), - connectionLimit: 10, + connectionLimit: maxConnections, waitForConnections: true, ...(ssl ? { ssl: { rejectUnauthorized: ssl.rejectUnauthorized ?? false } } : {}), }; @@ -335,6 +406,7 @@ export class KtxMysqlScanConnector implements KtxScanConnector { async introspect(input: KtxScanInput, _ctx: KtxScanContext): Promise { this.assertConnection(input.connectionId); const databases = configuredMysqlSchemas(this.connection, this.poolConfig.database); + const snapshotWarnings: KtxScanWarning[] = []; const placeholders = databases.map(() => '?').join(', '); let allScopedTables: string[] | null = null; if (input.tableScope) { @@ -368,8 +440,11 @@ export class KtxMysqlScanConnector implements KtxScanConnector { `, [...databases, ...tableNameParams], ); - const primaryKeys = await this.queryRaw( - ` + const primaryKeysResult = await tryConstraintQuery( + { schema: databases[0] ?? this.poolConfig.database, kind: 'primary_key', isDeniedError }, + () => + this.queryRaw( + ` SELECT TABLE_SCHEMA, TABLE_NAME, COLUMN_NAME FROM INFORMATION_SCHEMA.KEY_COLUMN_USAGE WHERE TABLE_SCHEMA IN (${placeholders}) @@ -377,10 +452,18 @@ export class KtxMysqlScanConnector implements KtxScanConnector { ${tableNameClause} ORDER BY TABLE_SCHEMA, TABLE_NAME, ORDINAL_POSITION `, - [...databases, ...tableNameParams], + [...databases, ...tableNameParams], + ), ); - const foreignKeys = await this.queryRaw( - ` + const primaryKeys = primaryKeysResult.ok ? primaryKeysResult.value : []; + if (!primaryKeysResult.ok) { + pushConstraintWarnings(snapshotWarnings, databases, 'primary_key'); + } + const foreignKeysResult = await tryConstraintQuery( + { schema: databases[0] ?? this.poolConfig.database, kind: 'foreign_key', isDeniedError }, + () => + this.queryRaw( + ` SELECT TABLE_SCHEMA, TABLE_NAME, COLUMN_NAME, REFERENCED_TABLE_NAME, REFERENCED_COLUMN_NAME, CONSTRAINT_NAME FROM INFORMATION_SCHEMA.KEY_COLUMN_USAGE WHERE TABLE_SCHEMA IN (${placeholders}) @@ -388,8 +471,13 @@ export class KtxMysqlScanConnector implements KtxScanConnector { ${tableNameClause} ORDER BY TABLE_SCHEMA, TABLE_NAME, COLUMN_NAME `, - [...databases, ...tableNameParams], + [...databases, ...tableNameParams], + ), ); + const foreignKeys = foreignKeysResult.ok ? foreignKeysResult.value : []; + if (!foreignKeysResult.ok) { + pushConstraintWarnings(snapshotWarnings, databases, 'foreign_key'); + } const columnsByTable = groupByTable(columns, this.poolConfig.database); const primaryKeysByTable = primaryKeyMap(primaryKeys, this.poolConfig.database); @@ -417,6 +505,7 @@ export class KtxMysqlScanConnector implements KtxScanConnector { total_columns: schemaTables.reduce((sum, table) => sum + table.columns.length, 0), }, tables: schemaTables, + warnings: snapshotWarnings, }; } diff --git a/packages/cli/src/connectors/postgres/connector.test.ts b/packages/cli/src/connectors/postgres/connector.test.ts index 0ab23a0a..d9fa45cf 100644 --- a/packages/cli/src/connectors/postgres/connector.test.ts +++ b/packages/cli/src/connectors/postgres/connector.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it, vi } from 'vitest'; import { createPostgresLiveDatabaseIntrospection } from '../../connectors/postgres/live-database-introspection.js'; -import { isKtxPostgresConnectionConfig, KtxPostgresScanConnector, postgresPoolConfigFromConfig, type KtxPostgresPoolFactory } from '../../connectors/postgres/connector.js'; +import { isKtxPostgresConnectionConfig, KtxPostgresScanConnector, postgresPoolConfigFromConfig, type KtxPostgresConnectionConfig, type KtxPostgresPoolFactory } from '../../connectors/postgres/connector.js'; import { tableRefSet } from '../../context/scan/table-ref.js'; interface FakeQueryResult { @@ -8,11 +8,16 @@ interface FakeQueryResult { fields?: Array<{ name: string; dataTypeID: number }>; } -function fakePoolFactory(results: Map): KtxPostgresPoolFactory { +type FakeQueryResponse = FakeQueryResult | Error; + +function fakePoolFactory(results: Map): KtxPostgresPoolFactory { const query = vi.fn(async (sql: string, params?: unknown[]) => { const normalized = sql.replace(/\s+/g, ' ').trim(); for (const [key, value] of results.entries()) { if (normalized.includes(key)) { + if (value instanceof Error) { + throw value; + } return value; } } @@ -33,8 +38,8 @@ function fakePoolFactory(results: Map): KtxPostgresPool }; } -function metadataResults(): Map { - return new Map([ +function metadataResults(): Map { + return new Map([ [ 'FROM pg_catalog.pg_class c JOIN pg_catalog.pg_namespace n', { @@ -154,6 +159,46 @@ describe('KtxPostgresScanConnector', () => { }); }); + it('defaults and validates Postgres maxConnections', () => { + const baseConnection: KtxPostgresConnectionConfig = { + driver: 'postgres', + host: 'db.example.test', + database: 'analytics', + username: 'reader', + password: 'test-password', // pragma: allowlist secret + }; + + expect( + postgresPoolConfigFromConfig({ + connectionId: 'warehouse', + connection: baseConnection, + }), + ).toMatchObject({ max: 10 }); + + expect( + postgresPoolConfigFromConfig({ + connectionId: 'warehouse', + connection: { ...baseConnection, maxConnections: 50 }, + }), + ).toMatchObject({ max: 50 }); + + expect( + postgresPoolConfigFromConfig({ + connectionId: 'warehouse', + connection: { ...baseConnection, maxConnections: '12' as never }, + }), + ).toMatchObject({ max: 12 }); + + for (const maxConnections of [0, -1, 1.5, Number.NaN, 'abc' as never]) { + expect(() => + postgresPoolConfigFromConfig({ + connectionId: 'warehouse', + connection: { ...baseConnection, maxConnections }, + }), + ).toThrow('connections.warehouse.maxConnections must be a positive integer'); + } + }); + it('introspects schemas, tables, views, primary keys, comments, row counts, and foreign keys', async () => { const connector = new KtxPostgresScanConnector({ connectionId: 'warehouse', @@ -212,6 +257,75 @@ describe('KtxPostgresScanConnector', () => { ]); }); + it('soft-fails denied Postgres constraint discovery with scan warnings', async () => { + const results = metadataResults(); + results.set( + "tc.constraint_type = 'PRIMARY KEY'", + Object.assign(new Error('permission denied for information_schema'), { code: '42501' }), + ); + results.set( + "tc.constraint_type = 'FOREIGN KEY'", + Object.assign(new Error('relation information_schema.key_column_usage does not exist'), { code: '42P01' }), + ); + const connector = new KtxPostgresScanConnector({ + connectionId: 'warehouse', + connection: { + driver: 'postgres', + host: 'db.example.test', + database: 'analytics', + username: 'reader', + password: 'test-password', // pragma: allowlist secret + schema: 'public', + }, + poolFactory: fakePoolFactory(results), + now: () => new Date('2026-04-29T10:00:00.000Z'), + }); + + const snapshot = await connector.introspect( + { connectionId: 'warehouse', driver: 'postgres' }, + { runId: 'scan-run-denied-constraints' }, + ); + + expect(snapshot.warnings).toEqual([ + { + code: 'constraint_discovery_unauthorized', + message: 'Skipped primary-key discovery in public (insufficient grants on system catalogs)', + recoverable: true, + metadata: { schema: 'public', kind: 'primary_key' }, + }, + { + code: 'constraint_discovery_unauthorized', + message: 'Skipped foreign-key discovery in public (insufficient grants on system catalogs)', + recoverable: true, + metadata: { schema: 'public', kind: 'foreign_key' }, + }, + ]); + expect(snapshot.tables.every((table) => table.columns.every((column) => column.primaryKey === false))).toBe(true); + expect(snapshot.tables.every((table) => table.foreignKeys.length === 0)).toBe(true); + }); + + it('propagates non-denial Postgres constraint discovery errors', async () => { + const results = metadataResults(); + const resetError = Object.assign(new Error('connection reset'), { code: 'ECONNRESET' }); + results.set("tc.constraint_type = 'PRIMARY KEY'", resetError); + const connector = new KtxPostgresScanConnector({ + connectionId: 'warehouse', + connection: { + driver: 'postgres', + host: 'db.example.test', + database: 'analytics', + username: 'reader', + password: 'test-password', // pragma: allowlist secret + schema: 'public', + }, + poolFactory: fakePoolFactory(results), + }); + + await expect( + connector.introspect({ connectionId: 'warehouse', driver: 'postgres' }, { runId: 'scan-run-network-error' }), + ).rejects.toBe(resetError); + }); + it('runs samples, distinct values, statistics, read-only SQL, and schema listing', async () => { const connector = new KtxPostgresScanConnector({ connectionId: 'warehouse', diff --git a/packages/cli/src/connectors/postgres/connector.ts b/packages/cli/src/connectors/postgres/connector.ts index 44bd58b6..1bab5e49 100644 --- a/packages/cli/src/connectors/postgres/connector.ts +++ b/packages/cli/src/connectors/postgres/connector.ts @@ -2,8 +2,29 @@ import { readFileSync } from 'node:fs'; import { homedir } from 'node:os'; import { resolve } from 'node:path'; import { assertReadOnlySql, limitSqlForExecution } from '../../context/connections/read-only-sql.js'; -import { createKtxConnectorCapabilities, type KtxColumnSampleInput, type KtxColumnSampleResult, type KtxColumnStatsInput, type KtxColumnStatsResult, type KtxQueryResult, type KtxReadOnlyQueryInput, type KtxScanConnector, type KtxScanContext, type KtxScanInput, type KtxSchemaColumn, type KtxSchemaForeignKey, type KtxSchemaSnapshot, type KtxSchemaTable, type KtxTableListEntry, type KtxTableRef, type KtxTableSampleInput, type KtxTableSampleResult } from '../../context/scan/types.js'; +import { tryConstraintQuery } from '../../context/scan/constraint-discovery.js'; import { scopedTableNames } from '../../context/scan/table-ref.js'; +import { + createKtxConnectorCapabilities, + type KtxColumnSampleInput, + type KtxColumnSampleResult, + type KtxColumnStatsInput, + type KtxColumnStatsResult, + type KtxQueryResult, + type KtxReadOnlyQueryInput, + type KtxScanConnector, + type KtxScanContext, + type KtxScanInput, + type KtxScanWarning, + type KtxSchemaColumn, + type KtxSchemaForeignKey, + type KtxSchemaSnapshot, + type KtxSchemaTable, + type KtxTableListEntry, + type KtxTableRef, + type KtxTableSampleInput, + type KtxTableSampleResult, +} from '../../context/scan/types.js'; import { Pool } from 'pg'; import { KtxPostgresDialect } from './dialect.js'; @@ -43,6 +64,7 @@ export interface KtxPostgresConnectionConfig { sslmode?: string; sslMode?: string; rejectUnauthorized?: boolean; + maxConnections?: number; [key: string]: unknown; } @@ -207,6 +229,14 @@ function primaryKeyMap(rows: PostgresPrimaryKeyRow[]): Map> return grouped; } +function isDeniedError(error: unknown): boolean { + if (!error || typeof error !== 'object') { + return false; + } + const code = (error as { code?: unknown }).code; + return code === '42501' || code === '42P01'; +} + function queryRows(result: KtxPostgresQueryResult): unknown[][] { const headers = (result.fields ?? []).map((field) => field.name); return result.rows.map((row) => headers.map((header) => row[header])); @@ -242,6 +272,23 @@ function numberValue(value: unknown): number | undefined { return typeof value === 'number' && Number.isFinite(value) ? value : undefined; } +function positiveIntegerConfigValue(input: { + connection: KtxPostgresConnectionConfig; + key: keyof KtxPostgresConnectionConfig; + connectionId: string; + defaultValue: number; +}): number { + const value = input.connection[input.key]; + if (value === undefined) { + return input.defaultValue; + } + const numberValue = Number(value); + if (!Number.isInteger(numberValue) || numberValue < 1) { + throw new Error(`connections.${input.connectionId}.${String(input.key)} must be a positive integer`); + } + return numberValue; +} + function parsePostgresUrl(url: string): Partial { const parsed = new URL(url); const sslmode = parsed.searchParams.get('sslmode') ?? undefined; @@ -299,6 +346,12 @@ export function postgresPoolConfigFromConfig(input: { const user = stringConfigValue(merged, 'username', env) ?? stringConfigValue(merged, 'user', env); const password = stringConfigValue(merged, 'password', env); const sslmode = normalizedSslMode(merged); + const maxConnections = positiveIntegerConfigValue({ + connection: merged, + key: 'maxConnections', + connectionId: input.connectionId, + defaultValue: 10, + }); if (!referencedUrl && !host) { throw new Error(`Native PostgreSQL connector requires connections.${input.connectionId}.host or url`); @@ -311,7 +364,7 @@ export function postgresPoolConfigFromConfig(input: { } const config: KtxPostgresPoolConfig = { - max: 10, + max: maxConnections, idleTimeoutMillis: 30_000, connectionTimeoutMillis: 10_000, ...(referencedUrl && sslmode !== 'prefer' && sslmode !== 'disable' @@ -379,10 +432,11 @@ export class KtxPostgresScanConnector implements KtxScanConnector { this.assertConnection(input.connectionId); const schemas = schemasFromConnection(this.connection); const allTables: KtxSchemaTable[] = []; + const snapshotWarnings: KtxScanWarning[] = []; for (const schema of schemas) { const scopedNames = input.tableScope ? scopedTableNames(input.tableScope, { catalog: null, db: schema }) : null; if (scopedNames && scopedNames.length === 0) continue; - const tables = await this.loadSchemaTables(schema, scopedNames); + const tables = await this.loadSchemaTables(schema, scopedNames, snapshotWarnings); allTables.push(...tables); } return { @@ -398,6 +452,7 @@ export class KtxPostgresScanConnector implements KtxScanConnector { total_columns: allTables.reduce((sum, table) => sum + table.columns.length, 0), }, tables: allTables, + warnings: snapshotWarnings, }; } @@ -546,7 +601,11 @@ export class KtxPostgresScanConnector implements KtxScanConnector { } } - private async loadSchemaTables(schema: string, scopedNames: readonly string[] | null): Promise { + private async loadSchemaTables( + schema: string, + scopedNames: readonly string[] | null, + snapshotWarnings: KtxScanWarning[], + ): Promise { if (scopedNames && scopedNames.length === 0) return []; const pgCatalogScopeClause = scopedNames ? 'AND c.relname = ANY($2)' : ''; const tableConstraintScopeClause = scopedNames ? 'AND tc.table_name = ANY($2)' : ''; @@ -591,8 +650,11 @@ export class KtxPostgresScanConnector implements KtxScanConnector { `, [schema, ...scopeValues], ); - const primaryKeys = await this.queryRaw( - ` + const primaryKeysResult = await tryConstraintQuery( + { schema, kind: 'primary_key', isDeniedError }, + () => + this.queryRaw( + ` SELECT tc.table_name, kcu.column_name FROM information_schema.table_constraints tc JOIN information_schema.key_column_usage kcu @@ -603,10 +665,18 @@ export class KtxPostgresScanConnector implements KtxScanConnector { ${tableConstraintScopeClause} ORDER BY tc.table_name, kcu.ordinal_position `, - [schema, ...scopeValues], + [schema, ...scopeValues], + ), ); - const foreignKeys = await this.queryRaw( - ` + const primaryKeys = primaryKeysResult.ok ? primaryKeysResult.value : []; + if (!primaryKeysResult.ok) { + snapshotWarnings.push(primaryKeysResult.warning); + } + const foreignKeysResult = await tryConstraintQuery( + { schema, kind: 'foreign_key', isDeniedError }, + () => + this.queryRaw( + ` SELECT tc.table_name, kcu.column_name, @@ -626,8 +696,13 @@ export class KtxPostgresScanConnector implements KtxScanConnector { ${tableConstraintScopeClause} ORDER BY tc.table_name, kcu.column_name `, - [schema, ...scopeValues], + [schema, ...scopeValues], + ), ); + const foreignKeys = foreignKeysResult.ok ? foreignKeysResult.value : []; + if (!foreignKeysResult.ok) { + snapshotWarnings.push(foreignKeysResult.warning); + } const columnsByTable = groupByTable(columns); const primaryKeysByTable = primaryKeyMap(primaryKeys); diff --git a/packages/cli/src/connectors/snowflake/connector.test.ts b/packages/cli/src/connectors/snowflake/connector.test.ts index a321e289..657dbaf1 100644 --- a/packages/cli/src/connectors/snowflake/connector.test.ts +++ b/packages/cli/src/connectors/snowflake/connector.test.ts @@ -8,7 +8,7 @@ vi.mock('snowflake-sdk', () => ({ })); import { createSnowflakeLiveDatabaseIntrospection } from '../../connectors/snowflake/live-database-introspection.js'; -import { isKtxSnowflakeConnectionConfig, KtxSnowflakeScanConnector, snowflakeConnectionConfigFromConfig, type KtxSnowflakeDriver, type KtxSnowflakeDriverFactory } from '../../connectors/snowflake/connector.js'; +import { isKtxSnowflakeConnectionConfig, KtxSnowflakeScanConnector, snowflakeConnectionConfigFromConfig, type KtxSnowflakeConnectionConfig, type KtxSnowflakeDriver, type KtxSnowflakeDriverFactory } from '../../connectors/snowflake/connector.js'; import { tableRefSet } from '../../context/scan/table-ref.js'; function fakeDriverFactory(): KtxSnowflakeDriverFactory { @@ -140,8 +140,8 @@ describe('KtxSnowflakeScanConnector', () => { }); }); - it('defaults and validates Snowflake maxSessions', () => { - const baseConnection = { + it('defaults and validates Snowflake maxConnections', () => { + const baseConnection: KtxSnowflakeConnectionConfig = { driver: 'snowflake', authMethod: 'password', account: 'acct', @@ -150,32 +150,59 @@ describe('KtxSnowflakeScanConnector', () => { schema_name: 'PUBLIC', username: 'reader', password: 'fixture-pass', // pragma: allowlist secret - } as const; + }; expect( snowflakeConnectionConfigFromConfig({ connectionId: 'warehouse', connection: baseConnection, }), - ).toMatchObject({ maxSessions: 4 }); + ).toMatchObject({ maxConnections: 4 }); expect( snowflakeConnectionConfigFromConfig({ connectionId: 'warehouse', - connection: { ...baseConnection, maxSessions: 8 }, + connection: { ...baseConnection, maxConnections: 8 }, }), - ).toMatchObject({ maxSessions: 8 }); + ).toMatchObject({ maxConnections: 8 }); - for (const maxSessions of [0, -1, 1.5, Number.NaN]) { + expect( + snowflakeConnectionConfigFromConfig({ + connectionId: 'warehouse', + connection: { ...baseConnection, maxConnections: '12' as never }, + }), + ).toMatchObject({ maxConnections: 12 }); + + for (const maxConnections of [0, -1, 1.5, Number.NaN, 'abc' as never]) { expect(() => snowflakeConnectionConfigFromConfig({ connectionId: 'warehouse', - connection: { ...baseConnection, maxSessions }, + connection: { ...baseConnection, maxConnections }, }), - ).toThrow('connections.warehouse.maxSessions must be a positive integer'); + ).toThrow('connections.warehouse.maxConnections must be a positive integer'); } }); + it('rejects stale Snowflake pool config key', () => { + const baseConnection: KtxSnowflakeConnectionConfig = { + driver: 'snowflake', + authMethod: 'password', + account: 'acct', + warehouse: 'WH', + database: 'ANALYTICS', + schema_name: 'PUBLIC', + username: 'reader', + password: 'fixture-pass', // pragma: allowlist secret + }; + + expect(() => + snowflakeConnectionConfigFromConfig({ + connectionId: 'warehouse', + connection: { ...baseConnection, maxSessions: 8 }, + }), + ).toThrow(/renamed to maxConnections/); + }); + it('uses one lazy Snowflake pool and drains it during cleanup', async () => { const { pool, executedSql } = installSnowflakePoolMock(); const close = vi.fn(async () => undefined); @@ -191,7 +218,7 @@ describe('KtxSnowflakeScanConnector', () => { username: 'reader', password: 'fixture-pass', // pragma: allowlist secret role: 'ANALYST', - maxSessions: 3, + maxConnections: 3, }, sdkOptionsProvider: { resolve: vi.fn(async () => ({ sdkOptions: { application: 'ktx-test' }, close })), @@ -332,12 +359,56 @@ describe('KtxSnowflakeScanConnector', () => { expect(snapshot.tables.map((table) => table.name).sort()).toEqual(['ORDERS', 'ORDER_SUMMARY']); expect(snapshot.tables.every((table) => table.columns.every((column) => column.primaryKey === false))).toBe(true); + expect(snapshot.warnings).toEqual([ + { + code: 'constraint_discovery_unauthorized', + message: 'Skipped primary-key discovery in PUBLIC (insufficient grants on system catalogs)', + recoverable: true, + metadata: { schema: 'PUBLIC', kind: 'primary_key' }, + }, + ]); expect(warn).not.toHaveBeenCalled(); } finally { warn.mockRestore(); } }); + it('propagates non-denial Snowflake primary-key discovery errors', async () => { + const driverFactory = fakeDriverFactory(); + const driver = (driverFactory.createDriver as ReturnType).getMockImplementation() as + | (() => KtxSnowflakeDriver) + | undefined; + if (!driver) throw new Error('driver mock missing'); + const built = driver(); + const networkError = new Error('network unavailable'); + (built.query as ReturnType).mockImplementation(async (sql: string) => { + if (sql.includes('TABLE_CONSTRAINTS')) { + throw networkError; + } + throw new Error(`Unexpected SQL: ${sql}`); + }); + (driverFactory.createDriver as ReturnType).mockReturnValue(built); + + const connector = new KtxSnowflakeScanConnector({ + connectionId: 'warehouse', + connection: { + driver: 'snowflake', + authMethod: 'password', + account: 'acct', + warehouse: 'WH', + database: 'ANALYTICS', + schema_name: 'PUBLIC', + username: 'reader', + password: 'fixture-pass', // pragma: allowlist secret + }, + driverFactory, + }); + + await expect( + connector.introspect({ connectionId: 'warehouse', driver: 'snowflake' }, { runId: 'scan-run-snowflake-network' }), + ).rejects.toBe(networkError); + }); + it('limits introspection to tables in tableScope', async () => { const queries: Array<{ sql: string; params?: unknown }> = []; const getSchemaMetadata = vi.fn(async (_schemaName?: string, scopedNames?: readonly string[] | null) => diff --git a/packages/cli/src/connectors/snowflake/connector.ts b/packages/cli/src/connectors/snowflake/connector.ts index 0281b298..d8737559 100644 --- a/packages/cli/src/connectors/snowflake/connector.ts +++ b/packages/cli/src/connectors/snowflake/connector.ts @@ -3,8 +3,28 @@ import { readFileSync } from 'node:fs'; import { homedir } from 'node:os'; import { resolve } from 'node:path'; import { assertReadOnlySql, limitSqlForExecution } from '../../context/connections/read-only-sql.js'; -import { createKtxConnectorCapabilities, type KtxColumnSampleInput, type KtxColumnSampleResult, type KtxColumnStatsInput, type KtxColumnStatsResult, type KtxQueryResult, type KtxReadOnlyQueryInput, type KtxScanConnector, type KtxScanContext, type KtxScanInput, type KtxSchemaColumn, type KtxSchemaSnapshot, type KtxSchemaTable, type KtxTableRef, type KtxTableSampleInput, type KtxTableListEntry, type KtxTableSampleResult } from '../../context/scan/types.js'; +import { tryConstraintQuery } from '../../context/scan/constraint-discovery.js'; import { scopedTableNames } from '../../context/scan/table-ref.js'; +import { + createKtxConnectorCapabilities, + type KtxColumnSampleInput, + type KtxColumnSampleResult, + type KtxColumnStatsInput, + type KtxColumnStatsResult, + type KtxQueryResult, + type KtxReadOnlyQueryInput, + type KtxScanConnector, + type KtxScanContext, + type KtxScanInput, + type KtxScanWarning, + type KtxSchemaColumn, + type KtxSchemaSnapshot, + type KtxSchemaTable, + type KtxTableListEntry, + type KtxTableRef, + type KtxTableSampleInput, + type KtxTableSampleResult, +} from '../../context/scan/types.js'; import snowflake from 'snowflake-sdk'; import type { Bind, Binds, Connection, ConnectionOptions } from 'snowflake-sdk'; import { KtxSnowflakeDialect } from './dialect.js'; @@ -24,7 +44,7 @@ export interface KtxSnowflakeConnectionConfig { privateKey?: string; passphrase?: string; role?: string; - maxSessions?: number; + maxConnections?: number; [key: string]: unknown; } @@ -39,7 +59,7 @@ export interface KtxSnowflakeResolvedConnectionConfig { privateKey?: string; passphrase?: string; role?: string; - maxSessions: number; + maxConnections: number; } export interface KtxSnowflakeRawColumnMetadata { @@ -166,6 +186,13 @@ function firstNumber(value: unknown): number | null { return Number.isFinite(numberValue) ? numberValue : null; } +function isDeniedError(error: unknown): boolean { + if (error instanceof Error) { + return /insufficient privileges|does not exist or not authorized/i.test(error.message); + } + return false; +} + function normalizeSnowflakeValue(value: unknown, columnType?: string): unknown { if (columnType && DATE_TYPES.some((type) => columnType.toUpperCase().includes(type))) { if (typeof value === 'number') { @@ -218,6 +245,10 @@ export function snowflakeConnectionConfigFromConfig(input: { if (!isKtxSnowflakeConnectionConfig(input.connection)) { throw new Error(`Native Snowflake connector cannot run driver "${inputDriver}"`); } + const staleMaxSessionsKey = 'max' + 'Sessions'; + if (Object.prototype.hasOwnProperty.call(input.connection, staleMaxSessionsKey)) { + throw new Error(`connections.${input.connectionId}.maxSessions has been renamed to maxConnections`); + } const env = input.env ?? process.env; const authMethod = input.connection?.authMethod ?? 'password'; const account = stringConfigValue(input.connection, 'account', env); @@ -249,9 +280,9 @@ export function snowflakeConnectionConfigFromConfig(input: { database, schemas: resolvedSchemas, username, - maxSessions: positiveIntegerConfigValue({ + maxConnections: positiveIntegerConfigValue({ connection: input.connection, - key: 'maxSessions', + key: 'maxConnections', connectionId: input.connectionId, defaultValue: 4, }), @@ -322,7 +353,7 @@ class SnowflakeSdkDriver implements KtxSnowflakeDriver { const message = error instanceof Error ? error.message : String(error); if (/timeout/i.test(message) && /pool|acquire/i.test(message)) { throw new Error( - "Snowflake session pool exhausted after 60s - consider lowering maxSessions or increasing your account's concurrent-statement limit.", + "Snowflake session pool exhausted after 60s - consider lowering maxConnections or increasing your account's concurrent-statement limit.", ); } throw error; @@ -432,7 +463,7 @@ class SnowflakeSdkDriver implements KtxSnowflakeDriver { if (!this.pool) { this.pool = snowflake.createPool(await this.resolveConnectionOptions(), { min: 0, - max: this.resolved.maxSessions, + max: this.resolved.maxConnections, evictionRunIntervalMillis: 30_000, acquireTimeoutMillis: 60_000, }); @@ -540,13 +571,23 @@ export class KtxSnowflakeScanConnector implements KtxScanConnector { async introspect(input: KtxScanInput, _ctx: KtxScanContext): Promise { this.assertConnection(input.connectionId); const tables: KtxSchemaTable[] = []; + const snapshotWarnings: KtxScanWarning[] = []; for (const schemaName of this.resolved.schemas) { const scopedNames = input.tableScope ? scopedTableNames(input.tableScope, { catalog: this.resolved.database, db: schemaName }) : null; if (scopedNames && scopedNames.length === 0) continue; const rawTables = await this.getDriver().getSchemaMetadata(schemaName, scopedNames); - const primaryKeys = await this.primaryKeys(rawTables.map((table) => table.name), schemaName); + const primaryKeysResult = await tryConstraintQuery( + { schema: schemaName, kind: 'primary_key', isDeniedError }, + () => this.primaryKeys(rawTables.map((table) => table.name), schemaName), + ); + const primaryKeys = primaryKeysResult.ok + ? primaryKeysResult.value + : new Map(rawTables.map((table) => [table.name, new Set()])); + if (!primaryKeysResult.ok) { + snapshotWarnings.push(primaryKeysResult.warning); + } tables.push(...rawTables.map((table) => this.toSchemaTable(table, primaryKeys))); } return { @@ -563,6 +604,7 @@ export class KtxSnowflakeScanConnector implements KtxScanConnector { total_columns: tables.reduce((sum, table) => sum + table.columns.length, 0), }, tables, + warnings: snapshotWarnings, }; } @@ -686,9 +728,8 @@ export class KtxSnowflakeScanConnector implements KtxScanConnector { return grouped; } const tableNamePlaceholders = tableNames.map(() => '?').join(', '); - try { - const result = await this.getDriver().query( - ` + const result = await this.getDriver().query( + ` SELECT tc.TABLE_NAME, kcu.COLUMN_NAME FROM INFORMATION_SCHEMA.TABLE_CONSTRAINTS tc JOIN INFORMATION_SCHEMA.KEY_COLUMN_USAGE kcu @@ -701,16 +742,12 @@ export class KtxSnowflakeScanConnector implements KtxScanConnector { AND tc.TABLE_NAME IN (${tableNamePlaceholders}) ORDER BY tc.TABLE_NAME, kcu.ORDINAL_POSITION `, - [schemaName, this.resolved.database, ...tableNames], - ); - for (const row of result.rows) { - const tableName = String(row[0]); - const columnName = String(row[1]); - grouped.get(tableName)?.add(columnName); - } - } catch { - // INFORMATION_SCHEMA.KEY_COLUMN_USAGE often isn't granted to read-only roles; - // continue with empty PK map and let FK inference + profiling carry the slack. + [schemaName, this.resolved.database, ...tableNames], + ); + for (const row of result.rows) { + const tableName = String(row[0]); + const columnName = String(row[1]); + grouped.get(tableName)?.add(columnName); } return grouped; } diff --git a/packages/cli/src/connectors/sqlserver/connector.test.ts b/packages/cli/src/connectors/sqlserver/connector.test.ts index ef00bd3a..4e84ff9a 100644 --- a/packages/cli/src/connectors/sqlserver/connector.test.ts +++ b/packages/cli/src/connectors/sqlserver/connector.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it, vi } from 'vitest'; import { createSqlServerLiveDatabaseIntrospection } from '../../connectors/sqlserver/live-database-introspection.js'; -import { isKtxSqlServerConnectionConfig, KtxSqlServerScanConnector, sqlServerConnectionPoolConfigFromConfig, type KtxSqlServerPoolFactory, type KtxSqlServerQueryResult } from '../../connectors/sqlserver/connector.js'; +import { isKtxSqlServerConnectionConfig, KtxSqlServerScanConnector, sqlServerConnectionPoolConfigFromConfig, type KtxSqlServerConnectionConfig, type KtxSqlServerPoolFactory, type KtxSqlServerQueryResult } from '../../connectors/sqlserver/connector.js'; import { tableRefSet } from '../../context/scan/table-ref.js'; function recordset>( @@ -16,7 +16,7 @@ function result>(rows: T[], columnNames: strin return { recordset: recordset(rows, columnNames) }; } -function fakePoolFactory(): KtxSqlServerPoolFactory { +function fakePoolFactory(options: { primaryKeyError?: Error; foreignKeyError?: Error } = {}): KtxSqlServerPoolFactory { const query = vi.fn(async (sql: string): Promise => { if (sql.includes('INFORMATION_SCHEMA.TABLES')) { return result( @@ -55,6 +55,9 @@ function fakePoolFactory(): KtxSqlServerPoolFactory { ); } if (sql.includes("CONSTRAINT_TYPE = 'PRIMARY KEY'")) { + if (options.primaryKeyError) { + throw options.primaryKeyError; + } return result( [ { table_name: 'customers', column_name: 'id' }, @@ -64,6 +67,9 @@ function fakePoolFactory(): KtxSqlServerPoolFactory { ); } if (sql.includes('REFERENTIAL_CONSTRAINTS')) { + if (options.foreignKeyError) { + throw options.foreignKeyError; + } return result( [ { @@ -164,6 +170,45 @@ describe('KtxSqlServerScanConnector', () => { }); }); + it('defaults and validates SQL Server maxConnections', () => { + const baseConnection: KtxSqlServerConnectionConfig = { + driver: 'sqlserver', + host: 'db.example.test', + database: 'analytics', + username: 'reader', + }; + + expect( + sqlServerConnectionPoolConfigFromConfig({ + connectionId: 'warehouse', + connection: baseConnection, + }), + ).toMatchObject({ pool: { max: 10 } }); + + expect( + sqlServerConnectionPoolConfigFromConfig({ + connectionId: 'warehouse', + connection: { ...baseConnection, maxConnections: 15 }, + }), + ).toMatchObject({ pool: { max: 15 } }); + + expect( + sqlServerConnectionPoolConfigFromConfig({ + connectionId: 'warehouse', + connection: { ...baseConnection, maxConnections: '12' as never }, + }), + ).toMatchObject({ pool: { max: 12 } }); + + for (const maxConnections of [0, -1, 1.5, Number.NaN, 'abc' as never]) { + expect(() => + sqlServerConnectionPoolConfigFromConfig({ + connectionId: 'warehouse', + connection: { ...baseConnection, maxConnections }, + }), + ).toThrow('connections.warehouse.maxConnections must be a positive integer'); + } + }); + it('introspects schema, primary keys, comments, row counts, views, and foreign keys', async () => { const connector = new KtxSqlServerScanConnector({ connectionId: 'warehouse', @@ -222,6 +267,46 @@ describe('KtxSqlServerScanConnector', () => { ]); }); + it('soft-fails denied SQL Server constraint discovery with scan warnings', async () => { + const connector = new KtxSqlServerScanConnector({ + connectionId: 'warehouse', + connection: { + driver: 'sqlserver', + host: 'db.example.test', + database: 'analytics', + username: 'reader', + schema: 'dbo', + }, + poolFactory: fakePoolFactory({ + primaryKeyError: Object.assign(new Error('SELECT permission denied'), { number: 229 }), + foreignKeyError: Object.assign(new Error('EXECUTE permission denied'), { number: 230 }), + }), + now: () => new Date('2026-04-29T16:00:00.000Z'), + }); + + const snapshot = await connector.introspect( + { connectionId: 'warehouse', driver: 'sqlserver' }, + { runId: 'scan-run-sqlserver-denied-constraints' }, + ); + + expect(snapshot.warnings).toEqual([ + { + code: 'constraint_discovery_unauthorized', + message: 'Skipped primary-key discovery in dbo (insufficient grants on system catalogs)', + recoverable: true, + metadata: { schema: 'dbo', kind: 'primary_key' }, + }, + { + code: 'constraint_discovery_unauthorized', + message: 'Skipped foreign-key discovery in dbo (insufficient grants on system catalogs)', + recoverable: true, + metadata: { schema: 'dbo', kind: 'foreign_key' }, + }, + ]); + expect(snapshot.tables.every((table) => table.columns.every((column) => column.primaryKey === false))).toBe(true); + expect(snapshot.tables.every((table) => table.foreignKeys.length === 0)).toBe(true); + }); + it('runs samples, distinct values, read-only SQL, row count, schema list, and cleanup', async () => { const poolFactory = fakePoolFactory(); const connector = new KtxSqlServerScanConnector({ diff --git a/packages/cli/src/connectors/sqlserver/connector.ts b/packages/cli/src/connectors/sqlserver/connector.ts index 64b8075e..9895027f 100644 --- a/packages/cli/src/connectors/sqlserver/connector.ts +++ b/packages/cli/src/connectors/sqlserver/connector.ts @@ -1,6 +1,27 @@ import { assertReadOnlySql } from '../../context/connections/read-only-sql.js'; -import { createKtxConnectorCapabilities, type KtxColumnSampleInput, type KtxColumnSampleResult, type KtxColumnStatsInput, type KtxColumnStatsResult, type KtxQueryResult, type KtxReadOnlyQueryInput, type KtxScanConnector, type KtxScanContext, type KtxScanInput, type KtxSchemaColumn, type KtxSchemaForeignKey, type KtxSchemaSnapshot, type KtxSchemaTable, type KtxTableListEntry, type KtxTableRef, type KtxTableSampleInput, type KtxTableSampleResult } from '../../context/scan/types.js'; +import { tryConstraintQuery } from '../../context/scan/constraint-discovery.js'; import { scopedTableNames } from '../../context/scan/table-ref.js'; +import { + createKtxConnectorCapabilities, + type KtxColumnSampleInput, + type KtxColumnSampleResult, + type KtxColumnStatsInput, + type KtxColumnStatsResult, + type KtxQueryResult, + type KtxReadOnlyQueryInput, + type KtxScanConnector, + type KtxScanContext, + type KtxScanInput, + type KtxScanWarning, + type KtxSchemaColumn, + type KtxSchemaForeignKey, + type KtxSchemaSnapshot, + type KtxSchemaTable, + type KtxTableListEntry, + type KtxTableRef, + type KtxTableSampleInput, + type KtxTableSampleResult, +} from '../../context/scan/types.js'; import { readFileSync } from 'node:fs'; import { homedir } from 'node:os'; import { resolve } from 'node:path'; @@ -19,6 +40,7 @@ export interface KtxSqlServerConnectionConfig { schema?: string; schemas?: string[]; trustServerCertificate?: boolean; + maxConnections?: number; [key: string]: unknown; } @@ -197,6 +219,23 @@ function maybeNumber(value: unknown): number | undefined { return typeof value === 'number' && Number.isFinite(value) ? value : undefined; } +function positiveIntegerConfigValue(input: { + connection: KtxSqlServerConnectionConfig; + key: keyof KtxSqlServerConnectionConfig; + connectionId: string; + defaultValue: number; +}): number { + const value = input.connection[input.key]; + if (value === undefined) { + return input.defaultValue; + } + const numberValue = Number(value); + if (!Number.isInteger(numberValue) || numberValue < 1) { + throw new Error(`connections.${input.connectionId}.${String(input.key)} must be a positive integer`); + } + return numberValue; +} + function schemaNames(connection: KtxSqlServerConnectionConfig, env: NodeJS.ProcessEnv): string[] { if (Array.isArray(connection.schemas) && connection.schemas.length > 0) { return connection.schemas.filter((schema) => schema.trim().length > 0).map((schema) => resolveStringReference(schema, env)); @@ -219,6 +258,14 @@ function firstNumber(value: unknown): number | null { return Number.isFinite(numberValue) ? numberValue : null; } +function isDeniedError(error: unknown): boolean { + if (!error || typeof error !== 'object') { + return false; + } + const number = (error as { number?: unknown }).number; + return number === 229 || number === 230 || number === 297; +} + function limitSqlForSqlServerExecution(sqlText: string, maxRows: number | undefined): string { const trimmed = assertReadOnlySql(sqlText).replace(/;+\s*$/, ''); if (!maxRows) { @@ -254,6 +301,12 @@ export function sqlServerConnectionPoolConfigFromConfig(input: { const server = stringConfigValue(merged, 'host', env); const database = stringConfigValue(merged, 'database', env); const user = stringConfigValue(merged, 'username', env) ?? stringConfigValue(merged, 'user', env); + const maxConnections = positiveIntegerConfigValue({ + connection: merged, + key: 'maxConnections', + connectionId: input.connectionId, + defaultValue: 10, + }); if (!server) { throw new Error(`Native SQL Server connector requires connections.${input.connectionId}.host or url`); @@ -272,7 +325,7 @@ export function sqlServerConnectionPoolConfigFromConfig(input: { user, password: stringConfigValue(merged, 'password', env), options: { encrypt: true, trustServerCertificate: merged.trustServerCertificate ?? true }, - pool: { max: 10, min: 0, idleTimeoutMillis: 30000 }, + pool: { max: maxConnections, min: 0, idleTimeoutMillis: 30000 }, }; } @@ -328,11 +381,12 @@ export class KtxSqlServerScanConnector implements KtxScanConnector { async introspect(input: KtxScanInput, _ctx: KtxScanContext): Promise { this.assertConnection(input.connectionId); const tables: KtxSchemaTable[] = []; + const snapshotWarnings: KtxScanWarning[] = []; for (const schemaName of this.schemas) { const scopedNames = input.tableScope ? scopedTableNames(input.tableScope, { catalog: this.poolConfig.database, db: schemaName }) : null; - tables.push(...(await this.introspectSchema(schemaName, scopedNames))); + tables.push(...(await this.introspectSchema(schemaName, scopedNames, snapshotWarnings))); } return { connectionId: this.connectionId, @@ -347,6 +401,7 @@ export class KtxSqlServerScanConnector implements KtxScanConnector { total_columns: tables.reduce((sum, table) => sum + table.columns.length, 0), }, tables, + warnings: snapshotWarnings, }; } @@ -479,7 +534,11 @@ export class KtxSqlServerScanConnector implements KtxScanConnector { } } - private async introspectSchema(schemaName: string, scopedNames: readonly string[] | null): Promise { + private async introspectSchema( + schemaName: string, + scopedNames: readonly string[] | null, + snapshotWarnings: KtxScanWarning[], + ): Promise { if (scopedNames && scopedNames.length === 0) return []; const tableScope = tableScopeSql(scopedNames, 'TABLE_NAME'); const tables = await this.queryRaw<{ table_name: string; table_type: string }>( @@ -510,8 +569,22 @@ export class KtxSqlServerScanConnector implements KtxScanConnector { ); const tableComments = await this.tableComments(schemaName, scopedNames); const columnComments = await this.columnComments(schemaName, scopedNames); - const primaryKeys = await this.primaryKeys(schemaName, scopedNames); - const foreignKeys = await this.foreignKeys(schemaName, scopedNames); + const primaryKeysResult = await tryConstraintQuery( + { schema: schemaName, kind: 'primary_key', isDeniedError }, + () => this.primaryKeys(schemaName, scopedNames), + ); + const foreignKeysResult = await tryConstraintQuery( + { schema: schemaName, kind: 'foreign_key', isDeniedError }, + () => this.foreignKeys(schemaName, scopedNames), + ); + const primaryKeys = primaryKeysResult.ok ? primaryKeysResult.value : new Map>(); + const foreignKeys = foreignKeysResult.ok ? foreignKeysResult.value : []; + if (!primaryKeysResult.ok) { + snapshotWarnings.push(primaryKeysResult.warning); + } + if (!foreignKeysResult.ok) { + snapshotWarnings.push(foreignKeysResult.warning); + } const rowCounts = await this.rowCounts(schemaName, scopedNames); const columnsByTable = groupByTable(columns); const foreignKeysByTable = groupByTable(foreignKeys); diff --git a/packages/cli/src/context/ingest/adapters/live-database/stage.test.ts b/packages/cli/src/context/ingest/adapters/live-database/stage.test.ts index 297071ae..8fb675a2 100644 --- a/packages/cli/src/context/ingest/adapters/live-database/stage.test.ts +++ b/packages/cli/src/context/ingest/adapters/live-database/stage.test.ts @@ -6,6 +6,7 @@ import { detectLiveDatabaseStagedDir, LIVE_DATABASE_FOREIGN_KEYS_FILE, LIVE_DATABASE_META_FILE, + LIVE_DATABASE_WARNINGS_FILE, liveDatabaseTablePath, readLiveDatabaseTableFiles, writeLiveDatabaseSnapshot, @@ -145,6 +146,31 @@ describe('live-database staged snapshot files', () => { expect(connectionJson).not.toContain('pem-value'); }); + it('writes redacted scan warnings next to live database metadata', async () => { + const dir = await mkdtemp(join(tmpdir(), 'ktx-live-db-warning-stage-')); + await writeLiveDatabaseSnapshot(dir, { + ...snapshot(), + warnings: [ + { + code: 'constraint_discovery_unauthorized', + message: 'Skipped primary-key discovery in public (insufficient grants on system catalogs)', + recoverable: true, + metadata: { + schema: 'public', + kind: 'primary_key', + url: 'postgres://reader:secret@example.test/db', // pragma: allowlist secret + }, + }, + ], + }); + + const warningsJson = await readFile(join(dir, LIVE_DATABASE_WARNINGS_FILE), 'utf8'); + expect(warningsJson).toContain('"constraint_discovery_unauthorized"'); + expect(warningsJson).toContain('"schema": "public"'); + expect(warningsJson).toContain('"url": ""'); + expect(warningsJson).not.toContain('postgres://reader:secret@example.test/db'); // pragma: allowlist secret + }); + it('returns false for a directory that is missing live database metadata', async () => { const dir = await mkdtemp(join(tmpdir(), 'ktx-live-db-empty-')); expect(await detectLiveDatabaseStagedDir(dir)).toBe(false); diff --git a/packages/cli/src/context/ingest/adapters/live-database/stage.ts b/packages/cli/src/context/ingest/adapters/live-database/stage.ts index ba925986..5dd21afd 100644 --- a/packages/cli/src/context/ingest/adapters/live-database/stage.ts +++ b/packages/cli/src/context/ingest/adapters/live-database/stage.ts @@ -7,6 +7,8 @@ import type { KtxSchemaSnapshot, KtxSchemaTable, KtxTableRef } from '../../../sc export const LIVE_DATABASE_META_FILE = 'connection.json'; export const LIVE_DATABASE_FOREIGN_KEYS_FILE = 'foreign-keys.json'; +/** @internal */ +export const LIVE_DATABASE_WARNINGS_FILE = 'warnings.json'; const LIVE_DATABASE_TABLES_DIR = 'tables'; interface LiveDatabaseTableFile { @@ -89,6 +91,13 @@ function foreignKeyIndex(snapshot: KtxSchemaSnapshot): ForeignKeyIndexEntry[] { return entries; } +function warningArtifact(snapshot: KtxSchemaSnapshot): { warnings: KtxSchemaSnapshot['warnings'] } { + const redacted = redactKtxSensitiveMetadata({ warnings: snapshot.warnings ?? [] }); + return { + warnings: Array.isArray(redacted.warnings) ? (redacted.warnings as KtxSchemaSnapshot['warnings']) : [], + }; +} + export async function writeLiveDatabaseSnapshot(stagedDir: string, snapshot: KtxSchemaSnapshot): Promise { await mkdir(join(stagedDir, LIVE_DATABASE_TABLES_DIR), { recursive: true }); const sortedTables = [...snapshot.tables].sort((a, b) => tableSortKey(a).localeCompare(tableSortKey(b))); @@ -105,6 +114,7 @@ export async function writeLiveDatabaseSnapshot(stagedDir: string, snapshot: Ktx join(stagedDir, LIVE_DATABASE_FOREIGN_KEYS_FILE), stableJson({ foreignKeys: foreignKeyIndex(snapshot) }), ); + await writeFile(join(stagedDir, LIVE_DATABASE_WARNINGS_FILE), stableJson(warningArtifact(snapshot))); for (const table of sortedTables) { await writeFile(join(stagedDir, liveDatabaseTablePath(table)), stableJson(table)); } diff --git a/packages/cli/src/context/ingest/historic-sql-probes.test.ts b/packages/cli/src/context/ingest/historic-sql-probes.test.ts new file mode 100644 index 00000000..275a84c7 --- /dev/null +++ b/packages/cli/src/context/ingest/historic-sql-probes.test.ts @@ -0,0 +1,157 @@ +import { describe, expect, it, vi } from 'vitest'; +import type { HistoricSqlDialect } from './adapters/historic-sql/types.js'; +import { + historicSqlProbeCatalogName, + runHistoricSqlReadinessProbe, + type HistoricSqlProbeRunner, + type HistoricSqlProbeRunnerFactoryEntry, +} from './historic-sql-probes.js'; + +function fakeRunner( + dialect: HistoricSqlDialect, + catalogName: string, + options: { result?: unknown; error?: unknown } = {}, +): HistoricSqlProbeRunner & { runCalls: () => number } { + let calls = 0; + return { + dialect, + catalogName, + async run() { + calls += 1; + if (options.error) { + throw options.error; + } + return options.result ?? { warnings: [], info: [] }; + }, + formatSuccessDetail() { + return { detail: `${catalogName} ready`, warnings: [] }; + }, + fixAdvice(error) { + return { + failHeadline: error instanceof Error ? error.message : String(error), + remediation: 'Fix the test probe.', + }; + }, + runCalls: () => calls, + }; +} + +function factories( + overrides: Partial>, +): Record { + const postgres = overrides.postgres ?? fakeRunner('postgres', 'pg_stat_statements'); + const snowflake = + overrides.snowflake ?? + fakeRunner('snowflake', 'SNOWFLAKE.ACCOUNT_USAGE.QUERY_HISTORY'); + const bigquery = + overrides.bigquery ?? fakeRunner('bigquery', 'INFORMATION_SCHEMA.JOBS_BY_PROJECT'); + + return { + postgres: { + catalogName: 'pg_stat_statements', + load: vi.fn(async () => postgres), + }, + snowflake: { + catalogName: 'SNOWFLAKE.ACCOUNT_USAGE.QUERY_HISTORY', + load: vi.fn(async () => snowflake), + }, + bigquery: { + catalogName: 'INFORMATION_SCHEMA.JOBS_BY_PROJECT', + load: vi.fn(async () => bigquery), + }, + }; +} + +describe('historic-SQL probe registry', () => { + it('returns null when the connection has no query-history dialect', async () => { + const deps = { factories: factories({}), cache: new Map() }; + + await expect( + runHistoricSqlReadinessProbe( + { + projectDir: '/work/project', + connectionId: 'mysql', + connection: { + driver: 'mysql', + context: { queryHistory: { enabled: true } }, + }, + env: {}, + }, + deps, + ), + ).resolves.toBeNull(); + + expect(deps.factories.postgres.load).not.toHaveBeenCalled(); + expect(deps.factories.snowflake.load).not.toHaveBeenCalled(); + expect(deps.factories.bigquery.load).not.toHaveBeenCalled(); + }); + + it('dispatches to the dialect runner and caches the runner instance', async () => { + const runner = fakeRunner('postgres', 'pg_stat_statements', { + result: { pgServerVersion: 'PostgreSQL 16.4', warnings: [], info: [] }, + }); + const deps = { factories: factories({ postgres: runner }), cache: new Map() }; + const input = { + projectDir: '/work/project', + connectionId: 'warehouse', + connection: { + driver: 'postgres' as const, + url: 'env:DATABASE_URL', + context: { queryHistory: { enabled: true } }, + }, + env: {}, + }; + + const first = await runHistoricSqlReadinessProbe(input, deps); + const second = await runHistoricSqlReadinessProbe(input, deps); + + expect(first).toMatchObject({ ok: true, dialect: 'postgres', runner }); + expect(second).toMatchObject({ ok: true, dialect: 'postgres', runner }); + expect(deps.factories.postgres.load).toHaveBeenCalledTimes(1); + expect(runner.runCalls()).toBe(2); + }); + + it('normalizes runner errors into a failed outcome', async () => { + const error = new Error('missing grants'); + const runner = fakeRunner('bigquery', 'INFORMATION_SCHEMA.JOBS_BY_PROJECT', { + error, + }); + const deps = { factories: factories({ bigquery: runner }), cache: new Map() }; + + await expect( + runHistoricSqlReadinessProbe( + { + projectDir: '/work/project', + connectionId: 'bq', + connection: { + driver: 'bigquery', + credentials_json: '{"project_id":"project-1"}', + context: { queryHistory: { enabled: true } }, + }, + env: {}, + }, + deps, + ), + ).resolves.toEqual({ + ok: false, + dialect: 'bigquery', + runner, + error, + }); + }); + + it('returns catalog names without loading runner modules', () => { + const deps = { factories: factories({}), cache: new Map() }; + + expect(historicSqlProbeCatalogName('postgres', deps)).toBe('pg_stat_statements'); + expect(historicSqlProbeCatalogName('snowflake', deps)).toBe( + 'SNOWFLAKE.ACCOUNT_USAGE.QUERY_HISTORY', + ); + expect(historicSqlProbeCatalogName('bigquery', deps)).toBe( + 'INFORMATION_SCHEMA.JOBS_BY_PROJECT', + ); + expect(deps.factories.postgres.load).not.toHaveBeenCalled(); + expect(deps.factories.snowflake.load).not.toHaveBeenCalled(); + expect(deps.factories.bigquery.load).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/cli/src/context/ingest/historic-sql-probes.ts b/packages/cli/src/context/ingest/historic-sql-probes.ts new file mode 100644 index 00000000..07204f3a --- /dev/null +++ b/packages/cli/src/context/ingest/historic-sql-probes.ts @@ -0,0 +1,141 @@ +import type { KtxProjectConnectionConfig } from '../project/config.js'; +import { queryHistoryDialectForConnection } from './adapters/historic-sql/connection-dialect.js'; +import type { HistoricSqlDialect } from './adapters/historic-sql/types.js'; + +export interface HistoricSqlFixAdvice { + failHeadline: string; + remediation: string; +} + +export interface HistoricSqlSuccessDetail { + detail: string; + warnings: string[]; +} + +export interface HistoricSqlProbeInput { + projectDir: string; + connectionId: string; + connection: KtxProjectConnectionConfig; + env?: NodeJS.ProcessEnv; +} + +export interface HistoricSqlProbeRunner { + readonly dialect: HistoricSqlDialect; + readonly catalogName: string; + run(input: HistoricSqlProbeInput): Promise; + formatSuccessDetail(result: unknown): HistoricSqlSuccessDetail; + fixAdvice(error: unknown): HistoricSqlFixAdvice; +} + +/** @internal */ +export interface HistoricSqlProbeRunnerFactoryEntry { + readonly catalogName: string; + load(): Promise; +} + +export type HistoricSqlProbeOutcome = + | { + ok: true; + dialect: HistoricSqlDialect; + runner: HistoricSqlProbeRunner; + result: unknown; + } + | { + ok: false; + dialect: HistoricSqlDialect; + runner: HistoricSqlProbeRunner; + error: unknown; + }; + +export type HistoricSqlReadinessProbe = ( + input: HistoricSqlProbeInput, +) => Promise; + +export interface HistoricSqlProbeRegistryDeps { + factories?: Record; + cache?: Map; +} + +const defaultHistoricSqlProbeRunnerFactories: Record< + HistoricSqlDialect, + HistoricSqlProbeRunnerFactoryEntry +> = { + postgres: { + catalogName: 'pg_stat_statements', + load: async () => { + const { PostgresPgssProbeRunner } = await import( + './historic-sql-probes/postgres-runner.js' + ); + return new PostgresPgssProbeRunner(); + }, + }, + snowflake: { + catalogName: 'SNOWFLAKE.ACCOUNT_USAGE.QUERY_HISTORY', + load: async () => { + const { SnowflakeAccountUsageProbeRunner } = await import( + './historic-sql-probes/snowflake-runner.js' + ); + return new SnowflakeAccountUsageProbeRunner(); + }, + }, + bigquery: { + catalogName: 'INFORMATION_SCHEMA.JOBS_BY_PROJECT', + load: async () => { + const { BigQueryJobsByProjectProbeRunner } = await import( + './historic-sql-probes/bigquery-runner.js' + ); + return new BigQueryJobsByProjectProbeRunner(); + }, + }, +}; + +const DEFAULT_RUNNER_CACHE = new Map(); + +function registryDeps(input: HistoricSqlProbeRegistryDeps) { + return { + factories: input.factories ?? defaultHistoricSqlProbeRunnerFactories, + cache: input.cache ?? DEFAULT_RUNNER_CACHE, + }; +} + +export function historicSqlProbeCatalogName( + dialect: HistoricSqlDialect, + deps: HistoricSqlProbeRegistryDeps = {}, +): string { + return registryDeps(deps).factories[dialect].catalogName; +} + +async function loadHistoricSqlProbeRunner( + dialect: HistoricSqlDialect, + deps: HistoricSqlProbeRegistryDeps = {}, +): Promise { + const { factories, cache } = registryDeps(deps); + const cached = cache.get(dialect); + if (cached) { + return cached; + } + const runner = await factories[dialect].load(); + cache.set(dialect, runner); + return runner; +} + +export async function runHistoricSqlReadinessProbe( + input: HistoricSqlProbeInput, + deps: HistoricSqlProbeRegistryDeps = {}, +): Promise { + const dialect = queryHistoryDialectForConnection(input.connection); + if (!dialect) { + return null; + } + const runner = await loadHistoricSqlProbeRunner(dialect, deps); + try { + return { + ok: true, + dialect, + runner, + result: await runner.run(input), + }; + } catch (error) { + return { ok: false, dialect, runner, error }; + } +} diff --git a/packages/cli/src/context/ingest/historic-sql-probes/bigquery-runner.test.ts b/packages/cli/src/context/ingest/historic-sql-probes/bigquery-runner.test.ts new file mode 100644 index 00000000..7a2db117 --- /dev/null +++ b/packages/cli/src/context/ingest/historic-sql-probes/bigquery-runner.test.ts @@ -0,0 +1,110 @@ +import { describe, expect, it, vi } from 'vitest'; +import { HistoricSqlGrantsMissingError } from '../adapters/historic-sql/errors.js'; +import { BigQueryJobsByProjectProbeRunner } from './bigquery-runner.js'; + +describe('BigQueryJobsByProjectProbeRunner', () => { + it('creates a region-scoped reader, runs it, and cleans up the connector', async () => { + const cleanup = vi.fn(async () => undefined); + const reader = { + probe: vi.fn(async () => ({ warnings: [], info: ['region: eu'] })), + }; + const createReader = vi.fn(() => reader); + const runner = new BigQueryJobsByProjectProbeRunner({ + createReader, + createClient: () => ({ client: { executeQuery: vi.fn() }, cleanup }), + resolveReference: () => '{"project_id":"project-1"}', + }); + + await expect( + runner.run({ + projectDir: '/work/project', + connectionId: 'bq', + connection: { + driver: 'bigquery', + credentials_json: 'env:BQ_CREDENTIALS_JSON', + location: 'EU', + }, + env: {}, + }), + ).resolves.toEqual({ warnings: [], info: ['region: eu'] }); + expect(createReader).toHaveBeenCalledWith({ projectId: 'project-1', region: 'EU' }); + expect(reader.probe).toHaveBeenCalledOnce(); + expect(cleanup).toHaveBeenCalledOnce(); + }); + + it('uses us as the default BigQuery region', async () => { + const createReader = vi.fn(() => ({ + probe: vi.fn(async () => ({ warnings: [], info: [] })), + })); + const runner = new BigQueryJobsByProjectProbeRunner({ + createReader, + createClient: () => ({ client: {}, cleanup: vi.fn(async () => undefined) }), + resolveReference: () => '{"project_id":"project-1"}', + }); + + await runner.run({ + projectDir: '/work/project', + connectionId: 'bq', + connection: { + driver: 'bigquery', + credentials_json: '{"project_id":"project-1"}', + }, + env: {}, + }); + + expect(createReader).toHaveBeenCalledWith({ projectId: 'project-1', region: 'us' }); + }); + + it('rejects missing BigQuery credentials_json.project_id', async () => { + const runner = new BigQueryJobsByProjectProbeRunner({ + createReader: vi.fn(), + createClient: () => ({ client: {}, cleanup: vi.fn() }), + resolveReference: () => '{"client_email":"svc@example.test"}', + }); + + await expect( + runner.run({ + projectDir: '/work/project', + connectionId: 'bq', + connection: { + driver: 'bigquery', + credentials_json: 'env:BQ_CREDENTIALS_JSON', + }, + env: {}, + }), + ).rejects.toThrow('Query history BigQuery connection bq requires credentials_json.project_id'); + }); + + it('formats successful BigQuery details', () => { + const runner = new BigQueryJobsByProjectProbeRunner(); + + expect( + runner.formatSuccessDetail({ + warnings: ['JOBS_BY_PROJECT is delayed'], + info: ['region: us'], + }), + ).toEqual({ + detail: 'INFORMATION_SCHEMA.JOBS_BY_PROJECT ready; region: us', + warnings: ['JOBS_BY_PROJECT is delayed'], + }); + }); + + it('maps BigQuery grant errors to runner advice', () => { + const runner = new BigQueryJobsByProjectProbeRunner(); + + expect( + runner.fixAdvice( + new HistoricSqlGrantsMissingError({ + dialect: 'bigquery', + message: 'principal cannot query JOBS_BY_PROJECT', + remediation: + 'Grant roles/bigquery.resourceViewer on the BigQuery project, or grant a custom role containing bigquery.jobs.listAll.', + }), + ), + ).toEqual({ + failHeadline: 'BigQuery principal cannot read INFORMATION_SCHEMA.JOBS_BY_PROJECT', + remediation: + 'Grant roles/bigquery.resourceViewer on the BigQuery project, or grant a custom role containing bigquery.jobs.listAll.', + }); + }); +}); diff --git a/packages/cli/src/context/ingest/historic-sql-probes/bigquery-runner.ts b/packages/cli/src/context/ingest/historic-sql-probes/bigquery-runner.ts new file mode 100644 index 00000000..09ad65d5 --- /dev/null +++ b/packages/cli/src/context/ingest/historic-sql-probes/bigquery-runner.ts @@ -0,0 +1,160 @@ +import { HistoricSqlGrantsMissingError } from '../adapters/historic-sql/errors.js'; +import { BigQueryHistoricSqlQueryHistoryReader } from '../adapters/historic-sql/bigquery-query-history-reader.js'; +import { + type HistoricSqlFixAdvice, + type HistoricSqlProbeInput, + type HistoricSqlProbeRunner, + type HistoricSqlSuccessDetail, +} from '../historic-sql-probes.js'; +import { resolveKtxConfigReference } from '../../core/config-reference.js'; +import { + isKtxBigQueryConnectionConfig, + KtxBigQueryScanConnector, + type KtxBigQueryConnectionConfig, +} from '../../../connectors/bigquery/connector.js'; + +interface GenericProbeResult { + warnings: string[]; + info?: string[]; +} + +interface ClientHandle { + client: unknown; + cleanup(): Promise; +} + +interface BigQueryJobsByProjectProbeRunnerOptions { + createReader?: (options: { projectId: string; region: string }) => { + probe(client: unknown): Promise; + }; + createClient?: ( + input: HistoricSqlProbeInput & { connection: KtxBigQueryConnectionConfig }, + ) => ClientHandle; + resolveReference?: (value: string | undefined, env: NodeJS.ProcessEnv) => string | undefined; +} + +function bigQueryProjectId( + connectionId: string, + connection: KtxBigQueryConnectionConfig, + env: NodeJS.ProcessEnv, + resolveReference: (value: string | undefined, env: NodeJS.ProcessEnv) => string | undefined, +): string { + const rawCredentials = + typeof connection.credentials_json === 'string' ? connection.credentials_json : ''; + const resolvedCredentials = resolveReference(rawCredentials, env); + if (!resolvedCredentials) { + throw new Error(`Query history BigQuery connection ${connectionId} requires credentials_json`); + } + const parsed = JSON.parse(resolvedCredentials) as { project_id?: unknown }; + if (typeof parsed.project_id !== 'string' || parsed.project_id.trim().length === 0) { + throw new Error( + `Query history BigQuery connection ${connectionId} requires credentials_json.project_id`, + ); + } + return parsed.project_id; +} + +function bigQueryRegion(connection: KtxBigQueryConnectionConfig): string { + return typeof connection.location === 'string' && connection.location.trim().length > 0 + ? connection.location.trim() + : 'us'; +} + +function infoSuffix(info: readonly string[] | undefined): string { + return info && info.length > 0 ? `; ${info.join('; ')}` : ''; +} + +export class BigQueryJobsByProjectProbeRunner implements HistoricSqlProbeRunner { + readonly dialect = 'bigquery' as const; + readonly catalogName = 'INFORMATION_SCHEMA.JOBS_BY_PROJECT'; + + private readonly createReader: (options: { projectId: string; region: string }) => { + probe(client: unknown): Promise; + }; + private readonly createClient: ( + input: HistoricSqlProbeInput & { connection: KtxBigQueryConnectionConfig }, + ) => ClientHandle; + private readonly resolveReference: ( + value: string | undefined, + env: NodeJS.ProcessEnv, + ) => string | undefined; + + constructor(options: BigQueryJobsByProjectProbeRunnerOptions = {}) { + this.createReader = + options.createReader ?? + ((readerOptions) => new BigQueryHistoricSqlQueryHistoryReader(readerOptions)); + this.createClient = + options.createClient ?? + ((input) => { + const connector = new KtxBigQueryScanConnector({ + connectionId: input.connectionId, + connection: input.connection, + env: input.env, + }); + return { + client: { + async executeQuery(sql: string) { + const result = await connector.executeReadOnly( + { connectionId: input.connectionId, sql }, + {} as never, + ); + return { + headers: result.headers, + rows: result.rows, + totalRows: result.totalRows, + }; + }, + }, + cleanup: () => connector.cleanup(), + }; + }); + this.resolveReference = options.resolveReference ?? resolveKtxConfigReference; + } + + async run(input: HistoricSqlProbeInput): Promise { + const inputDriver = input.connection.driver ?? 'unknown'; + if (!isKtxBigQueryConnectionConfig(input.connection)) { + throw new Error(`Native BigQuery connector cannot run driver "${inputDriver}"`); + } + const projectId = bigQueryProjectId( + input.connectionId, + input.connection, + input.env ?? process.env, + this.resolveReference, + ); + const reader = this.createReader({ + projectId, + region: bigQueryRegion(input.connection), + }); + const handle = this.createClient({ + ...input, + connection: input.connection, + }); + try { + return await reader.probe(handle.client); + } finally { + await handle.cleanup(); + } + } + + formatSuccessDetail(result: unknown): HistoricSqlSuccessDetail { + const probeResult = result as GenericProbeResult; + return { + detail: `${this.catalogName} ready${infoSuffix(probeResult.info)}`, + warnings: probeResult.warnings, + }; + } + + fixAdvice(error: unknown): HistoricSqlFixAdvice { + if (error instanceof HistoricSqlGrantsMissingError) { + return { + failHeadline: 'BigQuery principal cannot read INFORMATION_SCHEMA.JOBS_BY_PROJECT', + remediation: error.remediation, + }; + } + return { + failHeadline: `${this.catalogName} readiness check failed`, + remediation: error instanceof Error ? error.message : String(error), + }; + } +} diff --git a/packages/cli/src/context/ingest/historic-sql-probes/postgres-runner.test.ts b/packages/cli/src/context/ingest/historic-sql-probes/postgres-runner.test.ts new file mode 100644 index 00000000..bcd6d187 --- /dev/null +++ b/packages/cli/src/context/ingest/historic-sql-probes/postgres-runner.test.ts @@ -0,0 +1,113 @@ +import { describe, expect, it, vi } from 'vitest'; +import { + HistoricSqlExtensionMissingError, + HistoricSqlGrantsMissingError, + HistoricSqlVersionUnsupportedError, +} from '../adapters/historic-sql/errors.js'; +import { PostgresPgssProbeRunner } from './postgres-runner.js'; + +describe('PostgresPgssProbeRunner', () => { + it('runs the pg_stat_statements reader and cleans up the client', async () => { + const cleanup = vi.fn(async () => undefined); + const reader = { + probe: vi.fn(async () => ({ + pgServerVersion: 'PostgreSQL 16.4', + warnings: [], + info: ['tracked statements: 12'], + })), + }; + const runner = new PostgresPgssProbeRunner({ + reader, + createClient: () => ({ client: { executeQuery: vi.fn() }, cleanup }), + }); + + await expect( + runner.run({ + projectDir: '/work/project', + connectionId: 'warehouse', + connection: { driver: 'postgres', url: 'env:DATABASE_URL' }, + env: {}, + }), + ).resolves.toEqual({ + pgServerVersion: 'PostgreSQL 16.4', + warnings: [], + info: ['tracked statements: 12'], + }); + expect(reader.probe).toHaveBeenCalledOnce(); + expect(cleanup).toHaveBeenCalledOnce(); + }); + + it('rejects non-Postgres connections', async () => { + const runner = new PostgresPgssProbeRunner({ + reader: { probe: vi.fn() }, + createClient: () => ({ client: {}, cleanup: vi.fn() }), + }); + + await expect( + runner.run({ + projectDir: '/work/project', + connectionId: 'warehouse', + connection: { driver: 'snowflake' }, + env: {}, + }), + ).rejects.toThrow('Native PostgreSQL connector cannot run driver "snowflake"'); + }); + + it('formats successful Postgres details', () => { + const runner = new PostgresPgssProbeRunner(); + + expect( + runner.formatSuccessDetail({ + pgServerVersion: 'PostgreSQL 16.4', + warnings: ['pg_stat_statements.track is top'], + info: ['tracked statements: 12'], + }), + ).toEqual({ + detail: 'pg_stat_statements ready (PostgreSQL 16.4); tracked statements: 12', + warnings: ['pg_stat_statements.track is top'], + }); + }); + + it('maps Postgres probe errors to actionable advice', () => { + const runner = new PostgresPgssProbeRunner(); + + expect( + runner.fixAdvice( + new HistoricSqlExtensionMissingError({ + dialect: 'postgres', + message: 'pg_stat_statements missing', + remediation: 'CREATE EXTENSION pg_stat_statements;', + }), + ), + ).toEqual({ + failHeadline: 'pg_stat_statements extension is missing', + remediation: 'CREATE EXTENSION pg_stat_statements;', + }); + + expect( + runner.fixAdvice( + new HistoricSqlGrantsMissingError({ + dialect: 'postgres', + message: 'missing grants', + remediation: 'GRANT pg_read_all_stats TO ;', + }), + ), + ).toEqual({ + failHeadline: 'Postgres connection role lacks pg_read_all_stats', + remediation: 'GRANT pg_read_all_stats TO ;', + }); + + expect( + runner.fixAdvice( + new HistoricSqlVersionUnsupportedError({ + dialect: 'postgres', + detectedVersion: 'PostgreSQL 13.12', + minimumVersion: 'PostgreSQL 14', + }), + ), + ).toEqual({ + failHeadline: 'Postgres version too old', + remediation: 'Use PostgreSQL 14 or newer, or disable query history for this connection', + }); + }); +}); diff --git a/packages/cli/src/context/ingest/historic-sql-probes/postgres-runner.ts b/packages/cli/src/context/ingest/historic-sql-probes/postgres-runner.ts new file mode 100644 index 00000000..7ebf9721 --- /dev/null +++ b/packages/cli/src/context/ingest/historic-sql-probes/postgres-runner.ts @@ -0,0 +1,111 @@ +import { + HistoricSqlExtensionMissingError, + HistoricSqlGrantsMissingError, + HistoricSqlVersionUnsupportedError, +} from '../adapters/historic-sql/errors.js'; +import { PostgresPgssReader } from '../adapters/historic-sql/postgres-pgss-reader.js'; +import type { PostgresPgssProbeResult } from '../adapters/historic-sql/types.js'; +import { + type HistoricSqlFixAdvice, + type HistoricSqlProbeInput, + type HistoricSqlProbeRunner, + type HistoricSqlSuccessDetail, +} from '../historic-sql-probes.js'; +import { + isKtxPostgresConnectionConfig, + type KtxPostgresConnectionConfig, +} from '../../../connectors/postgres/connector.js'; +import { KtxPostgresHistoricSqlQueryClient } from '../../../connectors/postgres/historic-sql-query-client.js'; + +interface ClientHandle { + client: unknown; + cleanup(): Promise; +} + +interface PostgresPgssProbeRunnerOptions { + reader?: { probe(client: unknown): Promise }; + createClient?: ( + input: HistoricSqlProbeInput & { connection: KtxPostgresConnectionConfig }, + ) => ClientHandle; +} + +function genericAdvice(error: unknown, catalogName: string): HistoricSqlFixAdvice { + return { + failHeadline: `${catalogName} readiness check failed`, + remediation: error instanceof Error ? error.message : String(error), + }; +} + +function infoSuffix(info: readonly string[] | undefined): string { + return info && info.length > 0 ? `; ${info.join('; ')}` : ''; +} + +export class PostgresPgssProbeRunner implements HistoricSqlProbeRunner { + readonly dialect = 'postgres' as const; + readonly catalogName = 'pg_stat_statements'; + + private readonly reader: { probe(client: unknown): Promise }; + private readonly createClient: ( + input: HistoricSqlProbeInput & { connection: KtxPostgresConnectionConfig }, + ) => ClientHandle; + + constructor(options: PostgresPgssProbeRunnerOptions = {}) { + this.reader = options.reader ?? new PostgresPgssReader(); + this.createClient = + options.createClient ?? + ((input) => { + const client = new KtxPostgresHistoricSqlQueryClient({ + connectionId: input.connectionId, + connection: input.connection, + env: input.env, + }); + return { client, cleanup: () => client.cleanup() }; + }); + } + + async run(input: HistoricSqlProbeInput): Promise { + const inputDriver = input.connection.driver ?? 'unknown'; + if (!isKtxPostgresConnectionConfig(input.connection)) { + throw new Error(`Native PostgreSQL connector cannot run driver "${inputDriver}"`); + } + const handle = this.createClient({ + ...input, + connection: input.connection, + }); + try { + return await this.reader.probe(handle.client); + } finally { + await handle.cleanup(); + } + } + + formatSuccessDetail(result: unknown): HistoricSqlSuccessDetail { + const pgssResult = result as PostgresPgssProbeResult; + return { + detail: `pg_stat_statements ready (${pgssResult.pgServerVersion})${infoSuffix(pgssResult.info)}`, + warnings: pgssResult.warnings, + }; + } + + fixAdvice(error: unknown): HistoricSqlFixAdvice { + if (error instanceof HistoricSqlExtensionMissingError) { + return { + failHeadline: 'pg_stat_statements extension is missing', + remediation: error.remediation, + }; + } + if (error instanceof HistoricSqlGrantsMissingError) { + return { + failHeadline: 'Postgres connection role lacks pg_read_all_stats', + remediation: error.remediation, + }; + } + if (error instanceof HistoricSqlVersionUnsupportedError) { + return { + failHeadline: 'Postgres version too old', + remediation: 'Use PostgreSQL 14 or newer, or disable query history for this connection', + }; + } + return genericAdvice(error, this.catalogName); + } +} diff --git a/packages/cli/src/context/ingest/historic-sql-probes/snowflake-runner.test.ts b/packages/cli/src/context/ingest/historic-sql-probes/snowflake-runner.test.ts new file mode 100644 index 00000000..2d6835bf --- /dev/null +++ b/packages/cli/src/context/ingest/historic-sql-probes/snowflake-runner.test.ts @@ -0,0 +1,82 @@ +import { describe, expect, it, vi } from 'vitest'; +import { HistoricSqlGrantsMissingError } from '../adapters/historic-sql/errors.js'; +import { SnowflakeAccountUsageProbeRunner } from './snowflake-runner.js'; + +describe('SnowflakeAccountUsageProbeRunner', () => { + it('runs the account usage reader and cleans up the client', async () => { + const cleanup = vi.fn(async () => undefined); + const reader = { + probe: vi.fn(async () => ({ warnings: [], info: ['query history available'] })), + }; + const runner = new SnowflakeAccountUsageProbeRunner({ + reader, + createClient: () => ({ client: { executeQuery: vi.fn() }, cleanup }), + }); + + await expect( + runner.run({ + projectDir: '/work/project', + connectionId: 'warehouse', + connection: { + driver: 'snowflake', + account: 'ACCT', + warehouse: 'WH', + database: 'ANALYTICS', + username: 'reader', + }, + env: {}, + }), + ).resolves.toEqual({ warnings: [], info: ['query history available'] }); + expect(reader.probe).toHaveBeenCalledOnce(); + expect(cleanup).toHaveBeenCalledOnce(); + }); + + it('rejects non-Snowflake connections', async () => { + const runner = new SnowflakeAccountUsageProbeRunner({ + reader: { probe: vi.fn() }, + createClient: () => ({ client: {}, cleanup: vi.fn() }), + }); + + await expect( + runner.run({ + projectDir: '/work/project', + connectionId: 'warehouse', + connection: { driver: 'postgres' }, + env: {}, + }), + ).rejects.toThrow('Native Snowflake connector cannot run driver "postgres"'); + }); + + it('formats successful Snowflake details', () => { + const runner = new SnowflakeAccountUsageProbeRunner(); + + expect( + runner.formatSuccessDetail({ + warnings: ['query history is delayed'], + info: ['warehouse: WH'], + }), + ).toEqual({ + detail: 'SNOWFLAKE.ACCOUNT_USAGE.QUERY_HISTORY ready; warehouse: WH', + warnings: ['query history is delayed'], + }); + }); + + it('maps Snowflake grant errors to runner advice', () => { + const runner = new SnowflakeAccountUsageProbeRunner(); + + expect( + runner.fixAdvice( + new HistoricSqlGrantsMissingError({ + dialect: 'snowflake', + message: 'role cannot read account usage', + remediation: + 'GRANT IMPORTED PRIVILEGES ON DATABASE SNOWFLAKE TO ROLE ;', + }), + ), + ).toEqual({ + failHeadline: 'Snowflake role cannot read SNOWFLAKE.ACCOUNT_USAGE.QUERY_HISTORY', + remediation: + 'GRANT IMPORTED PRIVILEGES ON DATABASE SNOWFLAKE TO ROLE ;', + }); + }); +}); diff --git a/packages/cli/src/context/ingest/historic-sql-probes/snowflake-runner.ts b/packages/cli/src/context/ingest/historic-sql-probes/snowflake-runner.ts new file mode 100644 index 00000000..415b46d6 --- /dev/null +++ b/packages/cli/src/context/ingest/historic-sql-probes/snowflake-runner.ts @@ -0,0 +1,96 @@ +import { HistoricSqlGrantsMissingError } from '../adapters/historic-sql/errors.js'; +import { SnowflakeHistoricSqlQueryHistoryReader } from '../adapters/historic-sql/snowflake-query-history-reader.js'; +import { + type HistoricSqlFixAdvice, + type HistoricSqlProbeInput, + type HistoricSqlProbeRunner, + type HistoricSqlSuccessDetail, +} from '../historic-sql-probes.js'; +import { + isKtxSnowflakeConnectionConfig, + type KtxSnowflakeConnectionConfig, +} from '../../../connectors/snowflake/connector.js'; +import { KtxSnowflakeHistoricSqlQueryClient } from '../../../connectors/snowflake/historic-sql-query-client.js'; + +interface GenericProbeResult { + warnings: string[]; + info?: string[]; +} + +interface ClientHandle { + client: unknown; + cleanup(): Promise; +} + +interface SnowflakeAccountUsageProbeRunnerOptions { + reader?: { probe(client: unknown): Promise }; + createClient?: ( + input: HistoricSqlProbeInput & { connection: KtxSnowflakeConnectionConfig }, + ) => ClientHandle; +} + +function infoSuffix(info: readonly string[] | undefined): string { + return info && info.length > 0 ? `; ${info.join('; ')}` : ''; +} + +export class SnowflakeAccountUsageProbeRunner implements HistoricSqlProbeRunner { + readonly dialect = 'snowflake' as const; + readonly catalogName = 'SNOWFLAKE.ACCOUNT_USAGE.QUERY_HISTORY'; + + private readonly reader: { probe(client: unknown): Promise }; + private readonly createClient: ( + input: HistoricSqlProbeInput & { connection: KtxSnowflakeConnectionConfig }, + ) => ClientHandle; + + constructor(options: SnowflakeAccountUsageProbeRunnerOptions = {}) { + this.reader = options.reader ?? new SnowflakeHistoricSqlQueryHistoryReader(); + this.createClient = + options.createClient ?? + ((input) => { + const client = new KtxSnowflakeHistoricSqlQueryClient({ + connectionId: input.connectionId, + connection: input.connection, + projectDir: input.projectDir, + env: input.env, + }); + return { client, cleanup: () => client.cleanup() }; + }); + } + + async run(input: HistoricSqlProbeInput): Promise { + const inputDriver = input.connection.driver ?? 'unknown'; + if (!isKtxSnowflakeConnectionConfig(input.connection)) { + throw new Error(`Native Snowflake connector cannot run driver "${inputDriver}"`); + } + const handle = this.createClient({ + ...input, + connection: input.connection, + }); + try { + return await this.reader.probe(handle.client); + } finally { + await handle.cleanup(); + } + } + + formatSuccessDetail(result: unknown): HistoricSqlSuccessDetail { + const probeResult = result as GenericProbeResult; + return { + detail: `${this.catalogName} ready${infoSuffix(probeResult.info)}`, + warnings: probeResult.warnings, + }; + } + + fixAdvice(error: unknown): HistoricSqlFixAdvice { + if (error instanceof HistoricSqlGrantsMissingError) { + return { + failHeadline: 'Snowflake role cannot read SNOWFLAKE.ACCOUNT_USAGE.QUERY_HISTORY', + remediation: error.remediation, + }; + } + return { + failHeadline: `${this.catalogName} readiness check failed`, + remediation: error instanceof Error ? error.message : String(error), + }; + } +} diff --git a/packages/cli/src/context/ingest/local-stage-ingest.test.ts b/packages/cli/src/context/ingest/local-stage-ingest.test.ts index 7a2c5a6a..3f0e617f 100644 --- a/packages/cli/src/context/ingest/local-stage-ingest.test.ts +++ b/packages/cli/src/context/ingest/local-stage-ingest.test.ts @@ -591,7 +591,7 @@ describe('local ingest', () => { status: 'done', adapter: 'live-database', connectionId: 'warehouse', - rawFileCount: 3, + rawFileCount: 4, workUnitCount: 1, }); }); diff --git a/packages/cli/src/context/scan/constraint-discovery.test.ts b/packages/cli/src/context/scan/constraint-discovery.test.ts new file mode 100644 index 00000000..78620204 --- /dev/null +++ b/packages/cli/src/context/scan/constraint-discovery.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, it } from 'vitest'; +import { constraintDiscoveryWarning, tryConstraintQuery } from './constraint-discovery.js'; + +describe('tryConstraintQuery', () => { + it('returns the query value when the query succeeds', async () => { + await expect( + tryConstraintQuery( + { + schema: 'public', + kind: 'primary_key', + isDeniedError: () => false, + }, + async () => ['id'], + ), + ).resolves.toEqual({ ok: true, value: ['id'] }); + }); + + it('returns a recoverable warning when the classifier recognizes denial', async () => { + const error = Object.assign(new Error('permission denied'), { code: '42501' }); + + await expect( + tryConstraintQuery( + { + schema: 'analytics', + kind: 'foreign_key', + isDeniedError: (candidate) => candidate === error, + }, + async () => { + throw error; + }, + ), + ).resolves.toEqual({ + ok: false, + warning: { + code: 'constraint_discovery_unauthorized', + message: 'Skipped foreign-key discovery in analytics (insufficient grants on system catalogs)', + recoverable: true, + metadata: { schema: 'analytics', kind: 'foreign_key' }, + }, + }); + }); + + it('rethrows non-denial errors unchanged', async () => { + const error = Object.assign(new Error('connection reset'), { code: 'ECONNRESET' }); + + await expect( + tryConstraintQuery( + { + schema: 'public', + kind: 'primary_key', + isDeniedError: () => false, + }, + async () => { + throw error; + }, + ), + ).rejects.toBe(error); + }); +}); + +describe('constraintDiscoveryWarning', () => { + it('formats stable primary-key warning text and metadata', () => { + expect(constraintDiscoveryWarning({ schema: 'public', kind: 'primary_key' })).toEqual({ + code: 'constraint_discovery_unauthorized', + message: 'Skipped primary-key discovery in public (insufficient grants on system catalogs)', + recoverable: true, + metadata: { schema: 'public', kind: 'primary_key' }, + }); + }); +}); diff --git a/packages/cli/src/context/scan/constraint-discovery.ts b/packages/cli/src/context/scan/constraint-discovery.ts new file mode 100644 index 00000000..d58e9053 --- /dev/null +++ b/packages/cli/src/context/scan/constraint-discovery.ts @@ -0,0 +1,42 @@ +import type { KtxScanWarning } from './types.js'; + +export type ConstraintDiscoveryKind = 'primary_key' | 'foreign_key'; + +export interface ConstraintQueryContext { + schema: string; + kind: ConstraintDiscoveryKind; + isDeniedError: (error: unknown) => boolean; +} + +export type ConstraintQueryOutcome = { ok: true; value: T } | { ok: false; warning: KtxScanWarning }; + +export function constraintDiscoveryWarning(input: { + schema: string; + kind: ConstraintDiscoveryKind; +}): KtxScanWarning { + return { + code: 'constraint_discovery_unauthorized', + message: + `Skipped ${input.kind === 'primary_key' ? 'primary-key' : 'foreign-key'} ` + + `discovery in ${input.schema} (insufficient grants on system catalogs)`, + recoverable: true, + metadata: { schema: input.schema, kind: input.kind }, + }; +} + +export async function tryConstraintQuery( + ctx: ConstraintQueryContext, + fn: () => Promise, +): Promise> { + try { + return { ok: true, value: await fn() }; + } catch (error) { + if (!ctx.isDeniedError(error)) { + throw error; + } + return { + ok: false, + warning: constraintDiscoveryWarning({ schema: ctx.schema, kind: ctx.kind }), + }; + } +} diff --git a/packages/cli/src/context/scan/local-scan.test.ts b/packages/cli/src/context/scan/local-scan.test.ts index cb7e0252..f3c1353d 100644 --- a/packages/cli/src/context/scan/local-scan.test.ts +++ b/packages/cli/src/context/scan/local-scan.test.ts @@ -180,6 +180,13 @@ function fetchOnlyAdapter(options: { extractedAt?: () => string; snapshot?: KtxS 'utf-8', ); await writeFile(join(stagedDir, 'foreign-keys.json'), '{"foreignKeys":[]}\n', 'utf-8'); + if (scanSnapshot.warnings?.length) { + await writeFile( + join(stagedDir, 'warnings.json'), + `${JSON.stringify({ warnings: scanSnapshot.warnings })}\n`, + 'utf-8', + ); + } for (const table of scanSnapshot.tables) { await writeFile(join(stagedDir, 'tables', `${table.name}.json`), `${JSON.stringify(table)}\n`, 'utf-8'); } @@ -336,6 +343,48 @@ describe('local scan', () => { }); }); + it('threads structural snapshot warnings into the final scan report', async () => { + const result = await runLocalScan({ + project, + adapters: [ + fetchOnlyAdapter({ + snapshot: { + ...defaultFetchSnapshot(), + warnings: [ + { + code: 'constraint_discovery_unauthorized', + message: 'Skipped primary-key discovery in public (insufficient grants on system catalogs)', + recoverable: true, + metadata: { schema: 'public', kind: 'primary_key' }, + }, + ], + }, + }), + ], + connectionId: 'warehouse', + jobId: 'scan-run-structural-warnings', + now: () => new Date('2026-04-29T09:01:00.000Z'), + }); + + expect(result.report.warnings).toEqual([ + { + code: 'constraint_discovery_unauthorized', + message: 'Skipped primary-key discovery in public (insufficient grants on system catalogs)', + recoverable: true, + metadata: { schema: 'public', kind: 'primary_key' }, + }, + ]); + await expect( + readFile( + join( + project.projectDir, + 'raw-sources/warehouse/live-database/2026-04-29-090100-scan-run-structural-warnings/scan-report.json', + ), + 'utf-8', + ), + ).resolves.toContain('"constraint_discovery_unauthorized"'); + }); + it('passes enabled_tables as fetch context tableScope and does not post-filter staged snapshots', async () => { project.config.connections.warehouse = { ...project.config.connections.warehouse, diff --git a/packages/cli/src/context/scan/local-scan.ts b/packages/cli/src/context/scan/local-scan.ts index 0e2842da..703ef73f 100644 --- a/packages/cli/src/context/scan/local-scan.ts +++ b/packages/cli/src/context/scan/local-scan.ts @@ -467,6 +467,9 @@ export async function runLocalScan(options: RunLocalScanOptions): Promise { }); }); + it('rebuilds scan warnings from persisted live-database warning files', async () => { + const rawRoot = 'raw-sources/warehouse/live-database/sync-warnings'; + await project.fileStore.writeFile( + `${rawRoot}/connection.json`, + '{"connectionId":"warehouse","metadata":{}}\n', + 'ktx', + 'ktx@example.com', + 'Seed connection artifact', + ); + await project.fileStore.writeFile( + `${rawRoot}/warnings.json`, + `${JSON.stringify( + { + warnings: [ + { + code: 'constraint_discovery_unauthorized', + message: 'Skipped foreign-key discovery in public (insufficient grants on system catalogs)', + recoverable: true, + metadata: { schema: 'public', kind: 'foreign_key' }, + }, + ], + }, + null, + 2, + )}\n`, + 'ktx', + 'ktx@example.com', + 'Seed warning artifact', + ); + await project.fileStore.writeFile( + `${rawRoot}/tables/orders.json`, + '{"name":"orders","catalog":null,"db":"public","kind":"table","comment":null,"estimatedRows":null,"columns":[{"name":"id","nativeType":"integer","normalizedType":"integer","dimensionType":"number","nullable":false,"primaryKey":false,"comment":null}],"foreignKeys":[]}\n', + 'ktx', + 'ktx@example.com', + 'Seed orders artifact', + ); + + const snapshot = await readLocalScanStructuralSnapshot({ + project, + connectionId: 'warehouse', + driver: 'postgres', + rawSourcesDir: rawRoot, + extractedAtFallback: '2026-04-29T13:00:00.000Z', + }); + + expect(snapshot.warnings).toEqual([ + { + code: 'constraint_discovery_unauthorized', + message: 'Skipped foreign-key discovery in public (insufficient grants on system catalogs)', + recoverable: true, + metadata: { schema: 'public', kind: 'foreign_key' }, + }, + ]); + }); + it('uses the scan report timestamp when connection.json omits extractedAt', async () => { const rawRoot = 'raw-sources/warehouse/live-database/sync-2'; await project.fileStore.writeFile( @@ -192,4 +247,32 @@ describe('readLocalScanStructuralSnapshot', () => { expect(snapshot.extractedAt).toBe('2026-04-29T13:00:00.000Z'); }); + + it('tolerates older live-database staged directories without warnings.json', async () => { + const rawRoot = 'raw-sources/warehouse/live-database/sync-no-warnings'; + await project.fileStore.writeFile( + `${rawRoot}/connection.json`, + '{"connectionId":"warehouse","metadata":{}}\n', + 'ktx', + 'ktx@example.com', + 'Seed connection artifact', + ); + await project.fileStore.writeFile( + `${rawRoot}/tables/orders.json`, + '{"name":"orders","catalog":null,"db":null,"kind":"table","comment":null,"estimatedRows":null,"columns":[{"name":"id","nativeType":"integer","normalizedType":"integer","dimensionType":"number","nullable":false,"primaryKey":true,"comment":null}],"foreignKeys":[]}\n', + 'ktx', + 'ktx@example.com', + 'Seed orders artifact', + ); + + const snapshot = await readLocalScanStructuralSnapshot({ + project, + connectionId: 'warehouse', + driver: 'postgres', + rawSourcesDir: rawRoot, + extractedAtFallback: '2026-04-29T13:00:00.000Z', + }); + + expect(snapshot.warnings).toEqual([]); + }); }); diff --git a/packages/cli/src/context/scan/local-structural-artifacts.ts b/packages/cli/src/context/scan/local-structural-artifacts.ts index 2c968384..1abc68bc 100644 --- a/packages/cli/src/context/scan/local-structural-artifacts.ts +++ b/packages/cli/src/context/scan/local-structural-artifacts.ts @@ -1,6 +1,7 @@ import type { KtxLocalProject } from '../../context/project/project.js'; import type { KtxConnectionDriver, + KtxScanWarning, KtxSchemaColumn, KtxSchemaForeignKey, KtxSchemaSnapshot, @@ -30,6 +31,59 @@ function metadataRecord(value: unknown): Record { return isRecord(value) ? value : {}; } +const scanWarningCodes = new Set([ + 'connector_capability_missing', + 'sampling_failed', + 'statistics_failed', + 'llm_unavailable', + 'embedding_unavailable', + 'scan_enrichment_backend_not_configured', + 'relationship_validation_failed', + 'relationship_llm_invalid_reference', + 'relationship_llm_proposal_failed', + 'credential_redacted', + 'enrichment_failed', + 'description_fallback_used', + 'constraint_discovery_unauthorized', +]); + +function parseWarning(rawWarning: unknown, path: string): KtxScanWarning { + if ( + !isRecord(rawWarning) || + typeof rawWarning.code !== 'string' || + !scanWarningCodes.has(rawWarning.code as KtxScanWarning['code']) || + typeof rawWarning.message !== 'string' || + typeof rawWarning.recoverable !== 'boolean' + ) { + throw new Error(`Invalid KTX schema warning artifact: ${path}`); + } + return { + code: rawWarning.code as KtxScanWarning['code'], + message: rawWarning.message, + recoverable: rawWarning.recoverable, + ...(typeof rawWarning.table === 'string' ? { table: rawWarning.table } : {}), + ...(typeof rawWarning.column === 'string' ? { column: rawWarning.column } : {}), + ...(isRecord(rawWarning.metadata) ? { metadata: rawWarning.metadata } : {}), + }; +} + +async function readWarnings(input: ReadLocalScanStructuralSnapshotInput): Promise { + const path = `${input.rawSourcesDir}/warnings.json`; + try { + 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}`); + } + return parsed.warnings.map((warning) => parseWarning(warning, path)); + } catch (error) { + if (error instanceof Error && /not found|ENOENT|no such file/i.test(error.message)) { + return []; + } + throw error; + } +} + function optionalStringOrNull(value: unknown): string | null | undefined { if (value === undefined) { return undefined; @@ -113,6 +167,7 @@ export async function readLocalScanStructuralSnapshot( const tableRaw = await input.project.fileStore.readFile(path); tables.push(parseTable(tableRaw.content, path)); } + const warnings = await readWarnings(input); return { connectionId: typeof connection.connectionId === 'string' ? connection.connectionId : input.connectionId, @@ -121,5 +176,6 @@ export async function readLocalScanStructuralSnapshot( scope: isRecord(connection.scope) ? connection.scope : {}, metadata: metadataRecord(connection.metadata), tables, + warnings, }; } diff --git a/packages/cli/src/context/scan/types.ts b/packages/cli/src/context/scan/types.ts index d8e2aa5a..5590b465 100644 --- a/packages/cli/src/context/scan/types.ts +++ b/packages/cli/src/context/scan/types.ts @@ -90,6 +90,7 @@ export interface KtxSchemaSnapshot { scope: KtxSchemaScope; tables: KtxSchemaTable[]; metadata: Record; + warnings?: KtxScanWarning[]; } interface KtxCredentialEnvReference { @@ -364,7 +365,8 @@ type KtxScanWarningCode = | 'relationship_llm_proposal_failed' | 'credential_redacted' | 'enrichment_failed' - | 'description_fallback_used'; + | 'description_fallback_used' + | 'constraint_discovery_unauthorized'; export interface KtxScanWarning { code: KtxScanWarningCode; diff --git a/packages/cli/src/doctor.test.ts b/packages/cli/src/doctor.test.ts index fb661103..64050623 100644 --- a/packages/cli/src/doctor.test.ts +++ b/packages/cli/src/doctor.test.ts @@ -30,6 +30,30 @@ function makeIo() { }; } +function fakeDoctorHistoricSqlRunner() { + return { + dialect: 'postgres' as const, + catalogName: 'pg_stat_statements', + async run() { + return { warnings: [], info: [] }; + }, + formatSuccessDetail(result: unknown) { + const typed = result as { pgServerVersion?: string; warnings: string[]; info?: string[] }; + const info = typed.info && typed.info.length > 0 ? `; ${typed.info.join('; ')}` : ''; + return { + detail: `pg_stat_statements ready (${typed.pgServerVersion ?? 'PostgreSQL 16.4'})${info}`, + warnings: typed.warnings, + }; + }, + fixAdvice(error: unknown) { + return { + failHeadline: error instanceof Error ? error.message : String(error), + remediation: 'Fix query-history grants.', + }; + }, + }; +} + describe('formatDoctorReport', () => { it('shows the failing check and its fix in plain output', () => { const checks: DoctorCheck[] = [ @@ -539,14 +563,19 @@ describe('runKtxDoctor', () => { { command: 'project', projectDir: tempDir, outputMode: 'plain', inputMode: 'disabled' }, testIo.io, { - postgresQueryHistoryProbe: async () => { + queryHistoryReadinessProbe: async () => { probeCalls += 1; return { - pgServerVersion: 'PostgreSQL 16.4', - warnings: [], - info: [ - 'pg_stat_statements.max is 1000; set it to at least 5000 to reduce query-template eviction churn', - ], + ok: true, + dialect: 'postgres', + runner: fakeDoctorHistoricSqlRunner(), + result: { + pgServerVersion: 'PostgreSQL 16.4', + warnings: [], + info: [ + 'pg_stat_statements.max is 1000; set it to at least 5000 to reduce query-template eviction churn', + ], + }, }; }, }, @@ -558,7 +587,7 @@ describe('runKtxDoctor', () => { expect(out).toContain('Query history'); expect(out).toContain('warehouse'); expect(out).toContain('pg_stat_statements ready (PostgreSQL 16.4)'); - expect(out).toContain('info: pg_stat_statements.max is 1000'); + expect(out).toContain('pg_stat_statements.max is 1000'); expect(out).not.toContain('Update the Postgres parameter group or config'); expect(out).toContain('ktx status --json'); expect(out).toContain('ktx sl'); @@ -634,10 +663,15 @@ describe('runKtxDoctor', () => { { command: 'project', projectDir: tempDir, outputMode: 'plain', inputMode: 'disabled' }, testIo.io, { - postgresQueryHistoryProbe: async () => ({ - pgServerVersion: 'PostgreSQL 16.4', - warnings: [], - info: [], + queryHistoryReadinessProbe: async () => ({ + ok: true, + dialect: 'postgres', + runner: fakeDoctorHistoricSqlRunner(), + result: { + pgServerVersion: 'PostgreSQL 16.4', + warnings: [], + info: [], + }, }), }, ), @@ -842,9 +876,14 @@ describe('runKtxDoctor', () => { { command: 'validate', projectDir: tempDir, outputMode: 'plain', inputMode: 'disabled' }, testIo.io, { - postgresQueryHistoryProbe: async () => { + queryHistoryReadinessProbe: async () => { probeCalls += 1; - return { pgServerVersion: 'PostgreSQL 16.4', warnings: [], info: [] }; + return { + ok: true, + dialect: 'postgres', + runner: fakeDoctorHistoricSqlRunner(), + result: { pgServerVersion: 'PostgreSQL 16.4', warnings: [], info: [] }, + }; }, }, ), diff --git a/packages/cli/src/ingest.test.ts b/packages/cli/src/ingest.test.ts index 15c71e00..50401df1 100644 --- a/packages/cli/src/ingest.test.ts +++ b/packages/cli/src/ingest.test.ts @@ -284,7 +284,30 @@ describe('runKtxIngest', () => { return 0; }, scanConnection: async () => 0, - historicSqlProbe: async () => ({ ok: true, lines: ['PASS Historic SQL probe skipped in test'] }), + historicSqlReadinessProbe: async () => ({ + ok: true, + dialect: 'postgres', + runner: { + dialect: 'postgres', + catalogName: 'pg_stat_statements', + async run() { + return { warnings: [], info: [] }; + }, + formatSuccessDetail() { + return { + detail: 'pg_stat_statements ready (PostgreSQL 16.4)', + warnings: [], + }; + }, + fixAdvice() { + return { + failHeadline: 'pg_stat_statements unavailable', + remediation: 'Fix query-history grants.', + }; + }, + }, + result: { pgServerVersion: 'PostgreSQL 16.4', warnings: [], info: [] }, + }), }, context: async () => ({ status: 'skipped', projectDir }), runtime: async () => runtimeReady(projectDir), diff --git a/packages/cli/src/setup-agents.test.ts b/packages/cli/src/setup-agents.test.ts index a8090780..bd521787 100644 --- a/packages/cli/src/setup-agents.test.ts +++ b/packages/cli/src/setup-agents.test.ts @@ -418,6 +418,11 @@ describe('setup agents', () => { label: 'Ask data questions + manage KTX with CLI commands', hint: 'Adds an admin CLI skill so agents can run ktx status, sl, wiki, and setup commands.', }, + { + value: 'skip', + label: 'Skip agent setup for now', + hint: 'Leaves agent integration incomplete. You can run ktx setup --agents later.', + }, ], }); expect(prompts.multiselect).toHaveBeenCalledWith( @@ -427,6 +432,58 @@ describe('setup agents', () => { ); }); + it('lets interactive setup skip agent integration from the connection mode prompt', async () => { + const io = makeIo(); + const prompts = { + select: vi.fn(async () => 'skip'), + multiselect: vi.fn(async () => { + throw new Error('target selection should not run'); + }), + cancel: vi.fn(), + }; + + await expect( + runKtxSetupAgentsStep( + { + projectDir: tempDir, + inputMode: 'auto', + yes: false, + agents: true, + scope: 'project', + mode: 'mcp', + skipAgents: false, + }, + io.io, + { prompts }, + ), + ).resolves.toMatchObject({ status: 'skipped', projectDir: tempDir }); + + expect(prompts.select).toHaveBeenCalledWith({ + message: 'What should agents be allowed to do with this KTX project?', + options: [ + { + value: 'mcp', + label: 'Ask data questions with KTX MCP', + hint: 'Installs the MCP connection and analytics workflow skill. Best for normal use.', + }, + { + value: 'mcp-cli', + label: 'Ask data questions + manage KTX with CLI commands', + hint: 'Adds an admin CLI skill so agents can run ktx status, sl, wiki, and setup commands.', + }, + { + value: 'skip', + label: 'Skip agent setup for now', + hint: 'Leaves agent integration incomplete. You can run ktx setup --agents later.', + }, + ], + }); + expect(prompts.multiselect).not.toHaveBeenCalled(); + expect(io.stdout()).toContain('Agent integration skipped.'); + await expect(stat(join(tempDir, '.ktx/agents/install-manifest.json'))).rejects.toThrow(); + expect(await readKtxSetupState(tempDir)).toEqual({ completed_steps: [] }); + }); + it('prompts for global scope when every selected target supports it', async () => { const home = await mkdtemp(join(tmpdir(), 'ktx-setup-agents-home-')); const previousHome = process.env.HOME; diff --git a/packages/cli/src/setup-agents.ts b/packages/cli/src/setup-agents.ts index 113718cd..99d510c5 100644 --- a/packages/cli/src/setup-agents.ts +++ b/packages/cli/src/setup-agents.ts @@ -21,6 +21,7 @@ export type KtxAgentTarget = 'claude-code' | 'claude-desktop' | 'codex' | 'curso export type KtxAgentScope = 'project' | 'global' | 'local'; /** @internal */ export type KtxAgentInstallMode = 'mcp' | 'mcp-cli'; +type KtxAgentModePromptChoice = KtxAgentInstallMode | 'skip' | 'back'; export interface KtxSetupAgentsArgs { projectDir: string; @@ -1122,9 +1123,18 @@ export async function runKtxSetupAgentsStep( label: 'Ask data questions + manage KTX with CLI commands', hint: 'Adds an admin CLI skill so agents can run ktx status, sl, wiki, and setup commands.', }, + { + value: 'skip', + label: 'Skip agent setup for now', + hint: 'Leaves agent integration incomplete. You can run ktx setup --agents later.', + }, ], - })) as KtxAgentInstallMode | 'back'); + })) as KtxAgentModePromptChoice); if (mode === 'back') return { status: 'skipped', projectDir: args.projectDir }; + if (mode === 'skip') { + io.stdout.write('│ Agent integration skipped.\n'); + return { status: 'skipped', projectDir: args.projectDir }; + } const targets = args.target !== undefined diff --git a/packages/cli/src/setup-databases.test.ts b/packages/cli/src/setup-databases.test.ts index 354ba24b..57f507d5 100644 --- a/packages/cli/src/setup-databases.test.ts +++ b/packages/cli/src/setup-databases.test.ts @@ -2116,9 +2116,40 @@ describe('setup databases step', () => { expect(io.stdout()).toContain('│ Changes: 0 changes across 56 tables'); }); + function fakeHistoricSqlRunner( + dialect: 'postgres' | 'snowflake' | 'bigquery', + catalogName: string, + ) { + return { + dialect, + catalogName, + async run() { + return { warnings: [], info: [] }; + }, + formatSuccessDetail() { + return { detail: `${catalogName} ready`, warnings: [] }; + }, + fixAdvice() { + return { + failHeadline: `${catalogName} unavailable`, + remediation: 'Fix query-history grants.', + }; + }, + }; + } + it('writes query history config for supported Snowflake databases after validation succeeds', async () => { const io = makeIo(); - const historicSqlProbe = vi.fn(async () => ({ ok: true, lines: [] })); + const runner = fakeHistoricSqlRunner( + 'snowflake', + 'SNOWFLAKE.ACCOUNT_USAGE.QUERY_HISTORY', + ); + const historicSqlReadinessProbe = vi.fn(async () => ({ + ok: true as const, + dialect: 'snowflake' as const, + runner, + result: { warnings: [], info: [] }, + })); const result = await runKtxSetupDatabasesStep( { projectDir: tempDir, @@ -2136,7 +2167,7 @@ describe('setup databases step', () => { { testConnection: vi.fn(async () => 0), scanConnection: vi.fn(async () => 0), - historicSqlProbe, + historicSqlReadinessProbe, prompts: makePromptAdapter({ selectValues: ['password'], textValues: ['env:SNOWFLAKE_ACCOUNT', 'WH', 'ANALYTICS', 'reader', ''], @@ -2144,11 +2175,11 @@ describe('setup databases step', () => { }), }, ); - expect(historicSqlProbe).toHaveBeenCalledWith( + expect(historicSqlReadinessProbe).toHaveBeenCalledWith( expect.objectContaining({ projectDir: tempDir, connectionId: 'snowflake', - dialect: 'snowflake', + connection: expect.objectContaining({ driver: 'snowflake' }), }), ); @@ -2245,7 +2276,15 @@ describe('setup databases step', () => { { testConnection: vi.fn(async () => 0), scanConnection: vi.fn(async () => 0), - historicSqlProbe: vi.fn(async () => ({ ok: true, lines: [' OK pg_stat_statements ready (PostgreSQL 16.4)'] })), + historicSqlReadinessProbe: vi.fn(async () => { + const runner = fakeHistoricSqlRunner('postgres', 'pg_stat_statements'); + return { + ok: true as const, + dialect: 'postgres' as const, + runner, + result: { pgServerVersion: 'PostgreSQL 16.4', warnings: [], info: [] }, + }; + }), }, ); @@ -2315,7 +2354,13 @@ describe('setup databases step', () => { ); const io = makeIo(); const prompts = makePromptAdapter({ selectValues: ['yes', 'deep'] }); - const historicSqlProbe = vi.fn(async () => ({ ok: true, lines: [] })); + const runner = fakeHistoricSqlRunner('postgres', 'pg_stat_statements'); + const historicSqlReadinessProbe = vi.fn(async () => ({ + ok: true as const, + dialect: 'postgres' as const, + runner, + result: { pgServerVersion: 'PostgreSQL 16.4', warnings: [], info: [] }, + })); const result = await runKtxSetupDatabasesStep( { @@ -2330,7 +2375,7 @@ describe('setup databases step', () => { prompts, testConnection: vi.fn(async () => 0), scanConnection: vi.fn(async () => 0), - historicSqlProbe, + historicSqlReadinessProbe, }, ); @@ -2349,11 +2394,13 @@ describe('setup databases step', () => { message: expect.stringContaining('How much database context should KTX build?'), }), ); - expect(historicSqlProbe).toHaveBeenCalledWith({ - projectDir: tempDir, - connectionId: 'warehouse', - dialect: 'postgres', - }); + expect(historicSqlReadinessProbe).toHaveBeenCalledWith( + expect.objectContaining({ + projectDir: tempDir, + connectionId: 'warehouse', + connection: expect.objectContaining({ driver: 'postgres' }), + }), + ); const config = parseKtxProjectConfig(await readFile(join(tempDir, 'ktx.yaml'), 'utf-8')); expect(config.connections.warehouse).toMatchObject({ context: { @@ -2381,6 +2428,13 @@ describe('setup databases step', () => { 'utf-8', ); const io = makeIo(); + const runner = fakeHistoricSqlRunner('bigquery', 'INFORMATION_SCHEMA.JOBS_BY_PROJECT'); + const historicSqlReadinessProbe = vi.fn(async () => ({ + ok: true as const, + dialect: 'bigquery' as const, + runner, + result: { warnings: [], info: [] }, + })); const result = await runKtxSetupDatabasesStep( { @@ -2396,10 +2450,18 @@ describe('setup databases step', () => { { testConnection: vi.fn(async () => 0), scanConnection: vi.fn(async () => 0), + historicSqlReadinessProbe, }, ); expect(result.status).toBe('ready'); + expect(historicSqlReadinessProbe).toHaveBeenCalledWith( + expect.objectContaining({ + projectDir: tempDir, + connectionId: 'analytics', + connection: expect.objectContaining({ driver: 'bigquery' }), + }), + ); const configText = await readFile(join(tempDir, 'ktx.yaml'), 'utf-8'); const config = parseKtxProjectConfig(configText); expect(config.connections.analytics).toMatchObject({ @@ -2420,6 +2482,71 @@ describe('setup databases step', () => { expect(config.ingest.adapters).toEqual([]); }); + it('prints a non-blocking BigQuery query history probe failure with the grants remediation', async () => { + await writeFile( + join(tempDir, 'ktx.yaml'), + [ + 'connections:', + ' analytics:', + ' driver: bigquery', + ' dataset_id: analytics', + ' credentials_json: env:BIGQUERY_CREDENTIALS_JSON', + '', + ].join('\n'), + 'utf-8', + ); + const io = makeIo(); + const runner = { + ...fakeHistoricSqlRunner('bigquery', 'INFORMATION_SCHEMA.JOBS_BY_PROJECT'), + fixAdvice: () => ({ + failHeadline: 'BigQuery principal cannot read INFORMATION_SCHEMA.JOBS_BY_PROJECT', + remediation: + 'Grant roles/bigquery.resourceViewer on the BigQuery project, or grant a custom role containing bigquery.jobs.listAll.', + }), + }; + const error = new Error('access denied'); + const historicSqlReadinessProbe = vi.fn(async () => ({ + ok: false as const, + dialect: 'bigquery' as const, + runner, + error, + })); + + const result = await runKtxSetupDatabasesStep( + { + projectDir: tempDir, + inputMode: 'disabled', + databaseConnectionIds: ['analytics'], + databaseSchemas: [], + enableQueryHistory: true, + queryHistoryWindowDays: 45, + skipDatabases: false, + }, + io.io, + { + testConnection: vi.fn(async () => 0), + scanConnection: vi.fn(async () => 0), + historicSqlReadinessProbe, + }, + ); + + expect(result.status).toBe('ready'); + expect(historicSqlReadinessProbe).toHaveBeenCalledWith( + expect.objectContaining({ + projectDir: tempDir, + connectionId: 'analytics', + connection: expect.objectContaining({ driver: 'bigquery' }), + }), + ); + expect(io.stdout()).toContain('Query history probe...'); + expect(io.stdout()).toContain( + 'BigQuery principal cannot read INFORMATION_SCHEMA.JOBS_BY_PROJECT', + ); + expect(io.stdout()).toContain('roles/bigquery.resourceViewer'); + expect(io.stdout()).toContain('bigquery.jobs.listAll'); + expect(io.stdout()).toContain('Setup written; query history will be skipped until fixed.'); + }); + it('enables query history on an existing Postgres connection', async () => { await writeFile( join(tempDir, 'ktx.yaml'), @@ -2448,7 +2575,15 @@ describe('setup databases step', () => { { testConnection: vi.fn(async () => 0), scanConnection: vi.fn(async () => 0), - historicSqlProbe: vi.fn(async () => ({ ok: true, lines: [' OK pg_stat_statements ready (PostgreSQL 16.4)'] })), + historicSqlReadinessProbe: vi.fn(async () => { + const runner = fakeHistoricSqlRunner('postgres', 'pg_stat_statements'); + return { + ok: true as const, + dialect: 'postgres' as const, + runner, + result: { pgServerVersion: 'PostgreSQL 16.4', warnings: [], info: [] }, + }; + }), }, ); @@ -2465,17 +2600,104 @@ describe('setup databases step', () => { }, }, }); + expect(config.connections.warehouse.historicSql).toBeUndefined(); + }); + + it('migrates legacy historicSql to context.queryHistory during database setup', async () => { + await writeFile( + join(tempDir, 'ktx.yaml'), + [ + 'connections:', + ' warehouse:', + ' driver: postgres', + ' readonly: true', + ' historicSql:', + ' enabled: true', + ' dialect: postgres', + ' windowDays: 45', + ' minExecutions: 9', + ' concurrency: 3', + ' staleArchiveAfterDays: 120', + ' filters:', + ' dropTrivialProbes: true', + ' serviceAccounts:', + ' mode: exclude', + ' patterns:', + " - '^svc_'", + ' orchestrators:', + ' mode: exclude', + ' patterns:', + ' - airflow', + ' dropFailedBelow: 2', + ' redactionPatterns:', + " - '(?i)secret'", + '', + ].join('\n'), + 'utf-8', + ); + + const io = makeIo(); + + await expect( + runKtxSetupDatabasesStep( + { + projectDir: tempDir, + inputMode: 'disabled', + databaseConnectionIds: ['warehouse'], + databaseSchemas: [], + skipDatabases: false, + }, + io.io, + { + testConnection: vi.fn(async () => 0), + scanConnection: vi.fn(async () => 0), + historicSqlReadinessProbe: vi.fn(async () => { + const runner = fakeHistoricSqlRunner('postgres', 'pg_stat_statements'); + return { + ok: true as const, + dialect: 'postgres' as const, + runner, + result: { pgServerVersion: 'PostgreSQL 16.4', warnings: [], info: [] }, + }; + }), + }, + ), + ).resolves.toMatchObject({ status: 'ready' }); + + const config = parseKtxProjectConfig(await readFile(join(tempDir, 'ktx.yaml'), 'utf-8')); + expect(config.connections.warehouse.historicSql).toBeUndefined(); + expect(config.connections.warehouse.context).toMatchObject({ + queryHistory: { + enabled: true, + windowDays: 45, + minExecutions: 9, + concurrency: 3, + staleArchiveAfterDays: 120, + filters: { + dropTrivialProbes: true, + serviceAccounts: { mode: 'exclude', patterns: ['^svc_'] }, + orchestrators: { mode: 'exclude', patterns: ['airflow'] }, + dropFailedBelow: 2, + }, + redactionPatterns: ['(?i)secret'], + }, + }); }); it('prints a non-blocking Postgres query history probe failure after connection test succeeds', async () => { const io = makeIo(); - const historicSqlProbe = vi.fn(async () => ({ - ok: false, - lines: [ - ' FAIL pg_stat_statements extension is not installed in the connection database', - ' Fix: Run (against this database): CREATE EXTENSION pg_stat_statements;', - " Fix: Ensure shared_preload_libraries includes 'pg_stat_statements'.", - ], + const runner = { + ...fakeHistoricSqlRunner('postgres', 'pg_stat_statements'), + fixAdvice: () => ({ + failHeadline: 'pg_stat_statements extension is not installed in the connection database', + remediation: 'Run (against this database): CREATE EXTENSION pg_stat_statements;', + }), + }; + const historicSqlReadinessProbe = vi.fn(async () => ({ + ok: false as const, + dialect: 'postgres' as const, + runner, + error: new Error('missing extension'), })); const result = await runKtxSetupDatabasesStep( @@ -2493,16 +2715,16 @@ describe('setup databases step', () => { { testConnection: vi.fn(async () => 0), scanConnection: vi.fn(async () => 0), - historicSqlProbe, + historicSqlReadinessProbe, }, ); expect(result.status).toBe('ready'); - expect(historicSqlProbe).toHaveBeenCalledWith( + expect(historicSqlReadinessProbe).toHaveBeenCalledWith( expect.objectContaining({ projectDir: tempDir, connectionId: 'warehouse', - dialect: 'postgres', + connection: expect.objectContaining({ driver: 'postgres' }), }), ); expect(io.stdout()).toContain('Query history probe...'); @@ -2513,12 +2735,19 @@ describe('setup databases step', () => { it('prints a non-blocking Snowflake query history probe failure with the grants remediation', async () => { const io = makeIo(); - const historicSqlProbe = vi.fn(async () => ({ - ok: false, - lines: [ - ' FAIL Snowflake role cannot read SNOWFLAKE.ACCOUNT_USAGE.QUERY_HISTORY', - ' Fix: Run (as ACCOUNTADMIN): GRANT IMPORTED PRIVILEGES ON DATABASE SNOWFLAKE TO ROLE ;', - ], + const runner = { + ...fakeHistoricSqlRunner('snowflake', 'SNOWFLAKE.ACCOUNT_USAGE.QUERY_HISTORY'), + fixAdvice: () => ({ + failHeadline: 'Snowflake role cannot read SNOWFLAKE.ACCOUNT_USAGE.QUERY_HISTORY', + remediation: + 'Run (as ACCOUNTADMIN): GRANT IMPORTED PRIVILEGES ON DATABASE SNOWFLAKE TO ROLE ;', + }), + }; + const historicSqlReadinessProbe = vi.fn(async () => ({ + ok: false as const, + dialect: 'snowflake' as const, + runner, + error: new Error('role cannot read SNOWFLAKE.ACCOUNT_USAGE.QUERY_HISTORY'), })); const result = await runKtxSetupDatabasesStep( @@ -2535,7 +2764,7 @@ describe('setup databases step', () => { { testConnection: vi.fn(async () => 0), scanConnection: vi.fn(async () => 0), - historicSqlProbe, + historicSqlReadinessProbe, prompts: makePromptAdapter({ textValues: ['env:SNOWFLAKE_ACCOUNT', 'WH', 'ANALYTICS', 'reader', ''], passwordValues: ['env:SNOWFLAKE_PASSWORD'], @@ -2544,11 +2773,11 @@ describe('setup databases step', () => { ); expect(result.status).toBe('ready'); - expect(historicSqlProbe).toHaveBeenCalledWith( + expect(historicSqlReadinessProbe).toHaveBeenCalledWith( expect.objectContaining({ projectDir: tempDir, connectionId: 'warehouse', - dialect: 'snowflake', + connection: expect.objectContaining({ driver: 'snowflake' }), }), ); expect(io.stdout()).toContain('Query history probe...'); @@ -2559,7 +2788,15 @@ describe('setup databases step', () => { it('does not run the query history probe when the regular connection test fails', async () => { const io = makeIo(); - const historicSqlProbe = vi.fn(async () => ({ ok: true, lines: [] })); + const historicSqlReadinessProbe = vi.fn(async () => { + const runner = fakeHistoricSqlRunner('postgres', 'pg_stat_statements'); + return { + ok: true as const, + dialect: 'postgres' as const, + runner, + result: { pgServerVersion: 'PostgreSQL 16.4', warnings: [], info: [] }, + }; + }); const result = await runKtxSetupDatabasesStep( { @@ -2576,12 +2813,12 @@ describe('setup databases step', () => { { testConnection: vi.fn(async () => 1), scanConnection: vi.fn(async () => 0), - historicSqlProbe, + historicSqlReadinessProbe, }, ); expect(result.status).toBe('failed'); - expect(historicSqlProbe).not.toHaveBeenCalled(); + expect(historicSqlReadinessProbe).not.toHaveBeenCalled(); }); it('returns missing input when non-interactive database flags are incomplete', async () => { diff --git a/packages/cli/src/setup-databases.ts b/packages/cli/src/setup-databases.ts index 7781610c..ec2f017f 100644 --- a/packages/cli/src/setup-databases.ts +++ b/packages/cli/src/setup-databases.ts @@ -3,7 +3,13 @@ import { readFile, writeFile } from 'node:fs/promises'; import { delimiter, dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; import { promisify } from 'node:util'; +import { queryHistoryDialectForConnection } from './context/ingest/adapters/historic-sql/connection-dialect.js'; import type { HistoricSqlDialect } from './context/ingest/adapters/historic-sql/types.js'; +import { + runHistoricSqlReadinessProbe, + type HistoricSqlProbeOutcome, + type HistoricSqlReadinessProbe, +} from './context/ingest/historic-sql-probes.js'; import { type KtxProjectConnectionConfig, serializeKtxProjectConfig } from './context/project/config.js'; import { loadKtxProject } from './context/project/project.js'; import { markKtxSetupStateStepComplete, setKtxSetupDatabaseConnectionIds } from './context/project/setup-config.js'; @@ -89,19 +95,11 @@ export interface KtxSetupDatabasesPromptAdapter { cancel(message: string): void; } -interface KtxSetupHistoricSqlProbeInput { - projectDir: string; - connectionId: string; - dialect: HistoricSqlDialect; -} - interface KtxSetupHistoricSqlProbeResult { ok: boolean; lines: string[]; } -type KtxSetupHistoricSqlProbe = (input: KtxSetupHistoricSqlProbeInput) => Promise; - export interface KtxSetupDatabasesDeps { prompts?: KtxSetupDatabasesPromptAdapter; testConnection?: (projectDir: string, connectionId: string, io: KtxCliIo) => Promise; @@ -110,7 +108,7 @@ export interface KtxSetupDatabasesDeps { listSchemas?: (projectDir: string, connectionId: string) => Promise; listTables?: (projectDir: string, connectionId: string, schemas?: string[]) => Promise; pickDatabaseScope?: (args: PickDatabaseScopeArgs, io: KtxCliIo) => Promise; - historicSqlProbe?: KtxSetupHistoricSqlProbe; + historicSqlReadinessProbe?: HistoricSqlReadinessProbe; } const DRIVER_OPTIONS: Array<{ value: KtxSetupDatabaseDriver; label: string }> = [ @@ -265,6 +263,8 @@ function createPromptAdapter(): KtxSetupDatabasesPromptAdapter { function normalizeDriver(driver: string | undefined): KtxSetupDatabaseDriver | null { const normalized = String(driver ?? '').toLowerCase(); + if (normalized === 'postgresql') return 'postgres'; + if (normalized === 'sqlite3') return 'sqlite'; return DRIVER_OPTIONS.some((option) => option.value === normalized) ? (normalized as KtxSetupDatabaseDriver) : null; } @@ -288,6 +288,13 @@ function numberConfigField(connection: KtxProjectConnectionConfig | undefined, f return typeof value === 'number' && Number.isFinite(value) ? value : undefined; } +function historicSqlConfigRecord(connection: KtxProjectConnectionConfig | undefined): Record | null { + const historicSql = connection?.historicSql; + return historicSql && typeof historicSql === 'object' && !Array.isArray(historicSql) + ? (historicSql as Record) + : null; +} + function contextRecord(connection: KtxProjectConnectionConfig | undefined): Record { const context = connection?.context; return context && typeof context === 'object' && !Array.isArray(context) ? (context as Record) : {}; @@ -300,12 +307,19 @@ function queryHistoryConfigRecord(connection: KtxProjectConnectionConfig | undef : null; } +function stripLegacyHistoricSql(connection: KtxProjectConnectionConfig): KtxProjectConnectionConfig { + const { historicSql: _historicSql, ...rest } = connection as KtxProjectConnectionConfig & { + historicSql?: unknown; + }; + return rest; +} + function withQueryHistoryConfig( connection: KtxProjectConnectionConfig, queryHistory: Record, ): KtxProjectConnectionConfig { return { - ...connection, + ...stripLegacyHistoricSql(connection), context: { ...contextRecord(connection), queryHistory, @@ -313,121 +327,34 @@ function withQueryHistoryConfig( }; } -function historicSqlProbeFailureLines(error: unknown): string[] { - if (error instanceof Error && error.name === 'HistoricSqlExtensionMissingError') { - return [ - ' FAIL pg_stat_statements extension is not installed in the connection database', - ' Fix: Run (against this database): CREATE EXTENSION pg_stat_statements;', - " Fix: Ensure shared_preload_libraries includes 'pg_stat_statements'.", - ]; +function migrateLegacyHistoricSqlConnection(connection: KtxProjectConnectionConfig): KtxProjectConnectionConfig { + const existingQueryHistory = queryHistoryConfigRecord(connection); + const legacy = historicSqlConfigRecord(connection); + if (existingQueryHistory || !legacy) { + return existingQueryHistory ? stripLegacyHistoricSql(connection) : connection; } - if (error instanceof Error && error.name === 'HistoricSqlGrantsMissingError') { - const dialect = (error as { dialect?: unknown }).dialect; - if (dialect === 'snowflake') { - return [ - ' FAIL Snowflake role cannot read SNOWFLAKE.ACCOUNT_USAGE.QUERY_HISTORY', - ' Fix: Run (as ACCOUNTADMIN): GRANT IMPORTED PRIVILEGES ON DATABASE SNOWFLAKE TO ROLE ;', - ]; - } - return [ - ' FAIL Postgres connection role lacks pg_read_all_stats', - ' Fix: Run: GRANT pg_read_all_stats TO ;', - ]; - } - if (error instanceof Error && error.name === 'HistoricSqlVersionUnsupportedError') { - return [` FAIL ${error.message}`]; - } - return [` FAIL Query history probe failed: ${error instanceof Error ? error.message : String(error)}`]; + const { dialect: _dialect, ...queryHistory } = legacy; + return withQueryHistoryConfig(connection, queryHistory); } -async function defaultHistoricSqlProbe(input: KtxSetupHistoricSqlProbeInput): Promise { - if (input.dialect === 'postgres') { - return probePostgresHistoricSql(input); +function setupHistoricSqlProbeResult( + outcome: HistoricSqlProbeOutcome | null, +): KtxSetupHistoricSqlProbeResult { + if (!outcome) { + return { ok: true, lines: [] }; } - if (input.dialect === 'snowflake') { - return probeSnowflakeHistoricSql(input); - } - return { ok: true, lines: [] }; -} - -async function probePostgresHistoricSql( - input: KtxSetupHistoricSqlProbeInput, -): Promise { - const project = await loadKtxProject({ projectDir: input.projectDir }); - const connection = project.config.connections[input.connectionId]; - const [{ PostgresPgssReader }, { KtxPostgresHistoricSqlQueryClient }, { isKtxPostgresConnectionConfig }] = - await Promise.all([ - import('./context/ingest/adapters/historic-sql/postgres-pgss-reader.js'), - import('./connectors/postgres/historic-sql-query-client.js'), - import('./connectors/postgres/connector.js'), - ]); - - const postgresConnection = connection as Parameters[0]; - if (!isKtxPostgresConnectionConfig(postgresConnection)) { - return { - ok: false, - lines: [` FAIL Connection ${input.connectionId} is not a native Postgres connection.`], - }; - } - - const client = new KtxPostgresHistoricSqlQueryClient({ - connectionId: input.connectionId, - connection: postgresConnection, - }); - try { - const result = await new PostgresPgssReader().probe(client); + if (outcome.ok) { + const { detail, warnings } = outcome.runner.formatSuccessDetail(outcome.result); return { ok: true, - lines: [ - ` OK pg_stat_statements ready (${result.pgServerVersion})`, - ...result.warnings.map((warning: string) => ` ! ${warning}`), - ], - }; - } catch (error) { - return { ok: false, lines: historicSqlProbeFailureLines(error) }; - } finally { - await client.cleanup(); - } -} - -async function probeSnowflakeHistoricSql( - input: KtxSetupHistoricSqlProbeInput, -): Promise { - const project = await loadKtxProject({ projectDir: input.projectDir }); - const connection = project.config.connections[input.connectionId]; - const [{ SnowflakeHistoricSqlQueryHistoryReader }, { KtxSnowflakeHistoricSqlQueryClient }, { isKtxSnowflakeConnectionConfig }] = - await Promise.all([ - import('./context/ingest/adapters/historic-sql/snowflake-query-history-reader.js'), - import('./connectors/snowflake/historic-sql-query-client.js'), - import('./connectors/snowflake/connector.js'), - ]); - - if (!isKtxSnowflakeConnectionConfig(connection)) { - return { - ok: false, - lines: [` FAIL Connection ${input.connectionId} is not a native Snowflake connection.`], + lines: [` OK ${detail}`, ...warnings.map((warning) => ` ! ${warning}`)], }; } - - const client = new KtxSnowflakeHistoricSqlQueryClient({ - connectionId: input.connectionId, - connection, - projectDir: input.projectDir, - }); - try { - const result = await new SnowflakeHistoricSqlQueryHistoryReader().probe(client); - return { - ok: true, - lines: [ - ' OK SNOWFLAKE.ACCOUNT_USAGE.QUERY_HISTORY accessible', - ...result.warnings.map((warning: string) => ` ! ${warning}`), - ], - }; - } catch (error) { - return { ok: false, lines: historicSqlProbeFailureLines(error) }; - } finally { - await client.cleanup(); - } + const advice = outcome.runner.fixAdvice(outcome.error); + return { + ok: false, + lines: [` FAIL ${advice.failHeadline}`, ` Fix: ${advice.remediation}`], + }; } async function defaultListSchemas(projectDir: string, connectionId: string): Promise { @@ -1717,7 +1644,18 @@ async function ensureHistoricSqlIngestDefaults(projectDir: string): Promise { const project = await loadKtxProject({ projectDir }); - const config = setKtxSetupDatabaseConnectionIds(project.config, unique(connectionIds)); + const config = setKtxSetupDatabaseConnectionIds( + { + ...project.config, + connections: Object.fromEntries( + Object.entries(project.config.connections).map(([connectionId, connection]) => [ + connectionId, + migrateLegacyHistoricSqlConnection(connection), + ]), + ), + }, + unique(connectionIds), + ); await writeFile(project.configPath, serializeKtxProjectConfig(config), 'utf-8'); await markKtxSetupStateStepComplete(projectDir, 'databases'); } @@ -1730,24 +1668,28 @@ async function maybeRunHistoricSqlSetupProbe(input: { }): Promise { const project = await loadKtxProject({ projectDir: input.projectDir }); const connection = project.config.connections[input.connectionId]; - const queryHistory = queryHistoryConfigRecord(connection); - const driver = normalizeDriver(connection?.driver); + const queryHistory = queryHistoryConfigRecord(connection) ?? historicSqlConfigRecord(connection); if (queryHistory?.enabled !== true) { return; } - const dialect: 'postgres' | 'snowflake' | null = - driver === 'postgres' ? 'postgres' : driver === 'snowflake' ? 'snowflake' : null; + if (!connection) { + return; + } + const dialect = queryHistoryDialectForConnection(connection); if (!dialect) { return; } input.io.stdout.write('│ Query history probe...\n'); - const probe = input.deps.historicSqlProbe ?? defaultHistoricSqlProbe; - const result = await probe({ - projectDir: input.projectDir, - connectionId: input.connectionId, - dialect, - }); + const probe = input.deps.historicSqlReadinessProbe ?? runHistoricSqlReadinessProbe; + const result = setupHistoricSqlProbeResult( + await probe({ + projectDir: input.projectDir, + connectionId: input.connectionId, + connection, + env: process.env, + }), + ); for (const line of result.lines) { input.io.stdout.write(`│${line}\n`); } diff --git a/packages/cli/src/status-project.test.ts b/packages/cli/src/status-project.test.ts index 83862bfb..8f35cfe8 100644 --- a/packages/cli/src/status-project.test.ts +++ b/packages/cli/src/status-project.test.ts @@ -197,26 +197,58 @@ function withMysqlQueryHistory(config: KtxProjectConfig): KtxProjectConfig { }; } +function fakeStatusRunner( + dialect: 'postgres' | 'snowflake' | 'bigquery', + catalogName: string, +) { + return { + dialect, + catalogName, + async run() { + return { warnings: [], info: [] }; + }, + formatSuccessDetail(result: unknown) { + const typed = result as { warnings: string[]; info?: string[]; pgServerVersion?: string }; + const info = typed.info && typed.info.length > 0 ? `; ${typed.info.join('; ')}` : ''; + const base = + dialect === 'postgres' + ? `pg_stat_statements ready (${typed.pgServerVersion ?? 'PostgreSQL 16.4'})` + : `${catalogName} ready`; + return { detail: `${base}${info}`, warnings: typed.warnings }; + }, + fixAdvice(error: unknown) { + return { + failHeadline: error instanceof Error ? error.message : String(error), + remediation: 'Fix query-history grants.', + }; + }, + }; +} + describe('buildProjectStatus query history dispatch', () => { - it('runs the snowflake probe for snowflake connections, not the postgres one', async () => { - let postgresCalls = 0; - let snowflakeCalls = 0; + it('runs the shared probe for snowflake connections', async () => { + let probeCalls = 0; + const runner = fakeStatusRunner( + 'snowflake', + 'SNOWFLAKE.ACCOUNT_USAGE.QUERY_HISTORY', + ); const project = projectWithConfig(withSnowflakeQueryHistory(baseProjectConfig())); const status = await buildProjectStatus(project, { claudeCodeAuthProbe: stubClaudeCodeAuthProbe, - postgresQueryHistoryProbe: async () => { - postgresCalls += 1; - throw new Error('postgres probe should not run for snowflake'); - }, - snowflakeQueryHistoryProbe: async () => { - snowflakeCalls += 1; - return { warnings: [], info: [] }; + queryHistoryReadinessProbe: async (input) => { + probeCalls += 1; + expect(input.connectionId).toBe('warehouse'); + return { + ok: true, + dialect: 'snowflake', + runner, + result: { warnings: [], info: [] }, + }; }, }); - expect(postgresCalls).toBe(0); - expect(snowflakeCalls).toBe(1); + expect(probeCalls).toBe(1); expect(status.queryHistory).toHaveLength(1); expect(status.queryHistory[0]).toMatchObject({ connection: 'warehouse', @@ -231,19 +263,21 @@ describe('buildProjectStatus query history dispatch', () => { it('reports snowflake probe failures with the reader-provided remediation', async () => { const project = projectWithConfig(withSnowflakeQueryHistory(baseProjectConfig())); - const { HistoricSqlGrantsMissingError } = await import( - './context/ingest/adapters/historic-sql/errors.js' - ); const status = await buildProjectStatus(project, { claudeCodeAuthProbe: stubClaudeCodeAuthProbe, - snowflakeQueryHistoryProbe: async () => { - throw new HistoricSqlGrantsMissingError({ - dialect: 'snowflake', - message: 'role cannot read SNOWFLAKE.ACCOUNT_USAGE.QUERY_HISTORY', - remediation: 'GRANT IMPORTED PRIVILEGES ON DATABASE SNOWFLAKE TO ROLE ktx;', - }); - }, + queryHistoryReadinessProbe: async () => ({ + ok: false, + dialect: 'snowflake', + runner: { + ...fakeStatusRunner('snowflake', 'SNOWFLAKE.ACCOUNT_USAGE.QUERY_HISTORY'), + fixAdvice: () => ({ + failHeadline: 'Snowflake role cannot read SNOWFLAKE.ACCOUNT_USAGE.QUERY_HISTORY', + remediation: 'GRANT IMPORTED PRIVILEGES ON DATABASE SNOWFLAKE TO ROLE ktx;', + }), + }, + error: new Error('role cannot read SNOWFLAKE.ACCOUNT_USAGE.QUERY_HISTORY'), + }), }); expect(status.queryHistory[0]).toMatchObject({ @@ -257,18 +291,25 @@ describe('buildProjectStatus query history dispatch', () => { }); it('runs the bigquery probe for bigquery connections', async () => { - let bigqueryCalls = 0; + let probeCalls = 0; + const runner = fakeStatusRunner('bigquery', 'INFORMATION_SCHEMA.JOBS_BY_PROJECT'); const project = projectWithConfig(withBigQueryQueryHistory(baseProjectConfig())); const status = await buildProjectStatus(project, { claudeCodeAuthProbe: stubClaudeCodeAuthProbe, - bigqueryQueryHistoryProbe: async () => { - bigqueryCalls += 1; - return { warnings: [], info: [] }; + queryHistoryReadinessProbe: async (input) => { + probeCalls += 1; + expect(input.connectionId).toBe('bq'); + return { + ok: true, + dialect: 'bigquery', + runner, + result: { warnings: [], info: [] }, + }; }, }); - expect(bigqueryCalls).toBe(1); + expect(probeCalls).toBe(1); expect(status.queryHistory[0]).toMatchObject({ connection: 'bq', driver: 'bigquery', @@ -283,7 +324,7 @@ describe('buildProjectStatus query history dispatch', () => { const status = await buildProjectStatus(project, { claudeCodeAuthProbe: stubClaudeCodeAuthProbe, - postgresQueryHistoryProbe: async () => { + queryHistoryReadinessProbe: async () => { throw new Error('postgres probe must not run for mysql'); }, }); @@ -306,7 +347,7 @@ describe('buildProjectStatus query history dispatch', () => { describe('buildProjectStatus --fast', () => { it('skips claude-code probe and Postgres query-history probe', async () => { let claudeProbeCalls = 0; - let pgProbeCalls = 0; + let queryHistoryProbeCalls = 0; const project = projectWithConfig(withPostgresQueryHistory(baseProjectConfig())); const status = await buildProjectStatus(project, { @@ -316,14 +357,14 @@ describe('buildProjectStatus --fast', () => { claudeProbeCalls += 1; return { ok: true }; }, - postgresQueryHistoryProbe: async () => { - pgProbeCalls += 1; + queryHistoryReadinessProbe: async () => { + queryHistoryProbeCalls += 1; throw new Error('should not be called'); }, }); expect(claudeProbeCalls).toBe(0); - expect(pgProbeCalls).toBe(0); + expect(queryHistoryProbeCalls).toBe(0); expect(status.llm.status).toBe('skipped'); expect(status.llm.detail).toMatch(/--fast/); expect(status.queryHistory).toHaveLength(1); @@ -340,7 +381,7 @@ describe('buildProjectStatus --fast', () => { env: { ANALYTICS_DATABASE_URL: 'postgres://example' }, fast: true, claudeCodeAuthProbe: stubClaudeCodeAuthProbe, - postgresQueryHistoryProbe: async () => { + queryHistoryReadinessProbe: async () => { throw new Error('should not be called'); }, }); diff --git a/packages/cli/src/status-project.ts b/packages/cli/src/status-project.ts index 07ccc3c6..097f4091 100644 --- a/packages/cli/src/status-project.ts +++ b/packages/cli/src/status-project.ts @@ -4,11 +4,15 @@ import { runClaudeCodeAuthProbe } from './context/llm/claude-code-runtime.js'; import type { KtxConfigIssue, KtxProjectConfig, KtxProjectConnectionConfig, KtxProjectEmbeddingConfig, KtxProjectLlmConfig } from './context/project/config.js'; import type { KtxLocalProject } from './context/project/project.js'; import { ktxLocalStateDbPath } from './context/project/local-state-db.js'; -import type { PostgresPgssProbeResult } from './context/ingest/adapters/historic-sql/types.js'; import { isQueryHistoryEnabled, queryHistoryDialectForConnection, } from './context/ingest/adapters/historic-sql/connection-dialect.js'; +import { + historicSqlProbeCatalogName, + runHistoricSqlReadinessProbe, + type HistoricSqlReadinessProbe, +} from './context/ingest/historic-sql-probes.js'; import { formatClaudeCodePromptCachingFix, formatClaudeCodePromptCachingWarning, @@ -170,6 +174,13 @@ function resolveRef(value: unknown, env: NodeJS.ProcessEnv): { resolved: string; return { resolved: trimmed, via: 'literal' }; } +function failureDetail(error: unknown): string { + if (error instanceof Error && error.message.trim().length > 0) { + return error.message.trim().split('\n')[0] ?? error.message.trim(); + } + return String(error); +} + function envHint(value: unknown): string | undefined { if (typeof value === 'string' && value.trim().startsWith('env:')) { return value.trim().slice(4).trim(); @@ -392,232 +403,6 @@ function buildConnectionStatus( } } -interface QueryHistoryProbeInput { - projectDir: string; - connectionId: string; - connection: KtxProjectConnectionConfig; - env: NodeJS.ProcessEnv; -} - -interface GenericProbeResult { - warnings: string[]; - info?: string[]; -} - -type PostgresQueryHistoryProbe = (input: QueryHistoryProbeInput) => Promise; -type SnowflakeQueryHistoryProbe = (input: QueryHistoryProbeInput) => Promise; -type BigQueryQueryHistoryProbe = (input: QueryHistoryProbeInput) => Promise; - -function failureDetail(error: unknown): string { - if (error instanceof Error && error.message.trim().length > 0) { - return error.message.trim().split('\n')[0] ?? error.message.trim(); - } - return String(error); -} - -function postgresReadinessDetail(result: PostgresPgssProbeResult): string { - const warningText = result.warnings.length > 0 ? ` with warnings: ${result.warnings.join('; ')}` : ''; - const info = result.info ?? []; - const infoText = info.length > 0 ? `; info: ${info.join('; ')}` : ''; - return `pg_stat_statements ready (${result.pgServerVersion})${warningText}${infoText}`; -} - -function genericReadinessDetail(label: string, result: GenericProbeResult): string { - const warningText = result.warnings.length > 0 ? ` with warnings: ${result.warnings.join('; ')}` : ''; - const info = result.info ?? []; - const infoText = info.length > 0 ? `; info: ${info.join('; ')}` : ''; - return `${label} ready${warningText}${infoText}`; -} - -function probeFailureFix(error: unknown, dialect: string, connectionId: string, projectDir: string): string { - if (error instanceof Error && error.name === 'HistoricSqlExtensionMissingError' && 'remediation' in error) { - return String(error.remediation); - } - if (error instanceof Error && error.name === 'HistoricSqlGrantsMissingError' && 'remediation' in error) { - return String(error.remediation); - } - if (error instanceof Error && error.name === 'HistoricSqlVersionUnsupportedError') { - return 'Use PostgreSQL 14 or newer, or disable query history for this connection'; - } - return `Fix connections.${connectionId} ${dialect} settings, then rerun \`ktx status --project-dir ${projectDir}\``; -} - -async function defaultPostgresQueryHistoryProbe( - input: QueryHistoryProbeInput, -): Promise { - const [{ PostgresPgssReader }, { KtxPostgresHistoricSqlQueryClient }, { isKtxPostgresConnectionConfig }] = - await Promise.all([ - import('./context/ingest/adapters/historic-sql/postgres-pgss-reader.js'), - import('./connectors/postgres/historic-sql-query-client.js'), - import('./connectors/postgres/connector.js'), - ]); - - const inputDriver = input.connection.driver ?? 'unknown'; - if (!isKtxPostgresConnectionConfig(input.connection)) { - throw new Error(`Native PostgreSQL connector cannot run driver "${inputDriver}"`); - } - - const client = new KtxPostgresHistoricSqlQueryClient({ - connectionId: input.connectionId, - connection: input.connection, - env: input.env, - }); - try { - return await new PostgresPgssReader().probe(client); - } finally { - await client.cleanup(); - } -} - -async function defaultSnowflakeQueryHistoryProbe( - input: QueryHistoryProbeInput, -): Promise { - const [{ SnowflakeHistoricSqlQueryHistoryReader }, { KtxSnowflakeHistoricSqlQueryClient }, { isKtxSnowflakeConnectionConfig }] = - await Promise.all([ - import('./context/ingest/adapters/historic-sql/snowflake-query-history-reader.js'), - import('./connectors/snowflake/historic-sql-query-client.js'), - import('./connectors/snowflake/connector.js'), - ]); - - const inputDriver = input.connection.driver ?? 'unknown'; - if (!isKtxSnowflakeConnectionConfig(input.connection)) { - throw new Error(`Native Snowflake connector cannot run driver "${inputDriver}"`); - } - - const client = new KtxSnowflakeHistoricSqlQueryClient({ - connectionId: input.connectionId, - connection: input.connection, - projectDir: input.projectDir, - env: input.env, - }); - try { - return await new SnowflakeHistoricSqlQueryHistoryReader().probe(client); - } finally { - await client.cleanup(); - } -} - -async function defaultBigQueryQueryHistoryProbe( - input: QueryHistoryProbeInput, -): Promise { - const [ - { BigQueryHistoricSqlQueryHistoryReader }, - { KtxBigQueryScanConnector, isKtxBigQueryConnectionConfig }, - { resolveKtxConfigReference }, - ] = await Promise.all([ - import('./context/ingest/adapters/historic-sql/bigquery-query-history-reader.js'), - import('./connectors/bigquery/connector.js'), - import('./context/core/config-reference.js'), - ]); - - const inputDriver = input.connection.driver ?? 'unknown'; - if (!isKtxBigQueryConnectionConfig(input.connection)) { - throw new Error(`Native BigQuery connector cannot run driver "${inputDriver}"`); - } - - const rawCredentials = typeof input.connection.credentials_json === 'string' ? input.connection.credentials_json : ''; - const resolvedCredentials = resolveKtxConfigReference(rawCredentials, input.env); - if (!resolvedCredentials) { - throw new Error(`Query history BigQuery connection ${input.connectionId} requires credentials_json`); - } - const parsed = JSON.parse(resolvedCredentials) as { project_id?: unknown }; - if (typeof parsed.project_id !== 'string' || parsed.project_id.trim().length === 0) { - throw new Error(`Query history BigQuery connection ${input.connectionId} requires credentials_json.project_id`); - } - const region = - typeof input.connection.location === 'string' && input.connection.location.trim().length > 0 - ? input.connection.location.trim() - : 'us'; - - const connector = new KtxBigQueryScanConnector({ - connectionId: input.connectionId, - connection: input.connection, - }); - try { - return await new BigQueryHistoricSqlQueryHistoryReader({ - projectId: parsed.project_id, - region, - }).probe({ - async executeQuery(sql: string) { - const result = await connector.executeReadOnly({ connectionId: input.connectionId, sql }, {} as never); - return { - headers: result.headers, - rows: result.rows, - totalRows: result.totalRows, - }; - }, - }); - } finally { - await connector.cleanup(); - } -} - -interface DispatchedProbe { - label: string; - spinnerLabel: string; - fastSkipDetail: string; - run: () => Promise<{ status: ProjectStatusLevel; detail: string; fix?: string }>; -} - -function postgresProbeDispatch( - input: QueryHistoryProbeInput, - probe: PostgresQueryHistoryProbe, -): DispatchedProbe { - return { - label: 'postgres', - spinnerLabel: `Probing pg_stat_statements on ${input.connectionId}`, - fastSkipDetail: 'pg_stat_statements probe skipped (--fast)', - run: async () => { - const result = await probe(input); - return { - status: result.warnings.length > 0 ? 'warn' : 'ok', - detail: postgresReadinessDetail(result), - ...(result.warnings.length > 0 - ? { - fix: `Update the Postgres parameter group or config, then rerun \`ktx status --project-dir ${input.projectDir}\``, - } - : {}), - }; - }, - }; -} - -function snowflakeProbeDispatch( - input: QueryHistoryProbeInput, - probe: SnowflakeQueryHistoryProbe, -): DispatchedProbe { - return { - label: 'snowflake', - spinnerLabel: `Probing SNOWFLAKE.ACCOUNT_USAGE.QUERY_HISTORY on ${input.connectionId}`, - fastSkipDetail: 'SNOWFLAKE.ACCOUNT_USAGE.QUERY_HISTORY probe skipped (--fast)', - run: async () => { - const result = await probe(input); - return { - status: result.warnings.length > 0 ? 'warn' : 'ok', - detail: genericReadinessDetail('SNOWFLAKE.ACCOUNT_USAGE.QUERY_HISTORY', result), - }; - }, - }; -} - -function bigqueryProbeDispatch( - input: QueryHistoryProbeInput, - probe: BigQueryQueryHistoryProbe, -): DispatchedProbe { - return { - label: 'bigquery', - spinnerLabel: `Probing INFORMATION_SCHEMA.JOBS_BY_PROJECT on ${input.connectionId}`, - fastSkipDetail: 'INFORMATION_SCHEMA.JOBS_BY_PROJECT probe skipped (--fast)', - run: async () => { - const result = await probe(input); - return { - status: result.warnings.length > 0 ? 'warn' : 'ok', - detail: genericReadinessDetail('INFORMATION_SCHEMA.JOBS_BY_PROJECT', result), - }; - }, - }; -} - async function buildQueryHistoryStatus( project: KtxLocalProject, options: BuildProjectStatusOptions, @@ -626,9 +411,7 @@ async function buildQueryHistoryStatus( .filter(([, connection]) => isQueryHistoryEnabled(connection)) .sort(([left], [right]) => left.localeCompare(right)); - const postgresProbe = options.postgresQueryHistoryProbe ?? defaultPostgresQueryHistoryProbe; - const snowflakeProbe = options.snowflakeQueryHistoryProbe ?? defaultSnowflakeQueryHistoryProbe; - const bigqueryProbe = options.bigqueryQueryHistoryProbe ?? defaultBigQueryQueryHistoryProbe; + const probe = options.queryHistoryReadinessProbe ?? runHistoricSqlReadinessProbe; const env = options.env ?? process.env; const statuses: QueryHistoryStatus[] = []; @@ -648,18 +431,7 @@ async function buildQueryHistoryStatus( continue; } - const probeInput: QueryHistoryProbeInput = { - projectDir: project.projectDir, - connectionId, - connection, - env, - }; - const dispatched = - dialect === 'postgres' - ? postgresProbeDispatch(probeInput, postgresProbe) - : dialect === 'snowflake' - ? snowflakeProbeDispatch(probeInput, snowflakeProbe) - : bigqueryProbeDispatch(probeInput, bigqueryProbe); + const catalogName = historicSqlProbeCatalogName(dialect); if (options.fast === true) { statuses.push({ @@ -667,29 +439,61 @@ async function buildQueryHistoryStatus( driver, dialect, status: 'skipped', - detail: dispatched.fastSkipDetail, + detail: `${catalogName} probe skipped (--fast)`, }); continue; } - try { - const outcome = await withSpinner(options.useSpinner === true, dispatched.spinnerLabel, dispatched.run); + const outcome = await withSpinner( + options.useSpinner === true, + `Probing ${catalogName} on ${connectionId}`, + () => + probe({ + projectDir: project.projectDir, + connectionId, + connection, + env, + }), + ); + + if (!outcome) { statuses.push({ connection: connectionId, driver, - dialect, - ...outcome, - }); - } catch (error) { - statuses.push({ - connection: connectionId, - driver, - dialect, + dialect: driver, status: 'fail', - detail: failureDetail(error), - fix: probeFailureFix(error, dispatched.label, connectionId, project.projectDir), + detail: `query history is not supported for driver "${driver}"`, + fix: `Disable connections.${connectionId}.context.queryHistory, or use a postgres, snowflake, or bigquery connection`, }); + continue; } + + if (outcome.ok) { + const { detail, warnings } = outcome.runner.formatSuccessDetail(outcome.result); + statuses.push({ + connection: connectionId, + driver, + dialect, + status: warnings.length > 0 ? 'warn' : 'ok', + detail, + ...(dialect === 'postgres' && warnings.length > 0 + ? { + fix: `Update the Postgres parameter group or config, then rerun \`ktx status --project-dir ${project.projectDir}\``, + } + : {}), + }); + continue; + } + + const advice = outcome.runner.fixAdvice(outcome.error); + statuses.push({ + connection: connectionId, + driver, + dialect, + status: 'fail', + detail: advice.failHeadline, + fix: advice.remediation, + }); } return statuses; @@ -828,9 +632,7 @@ function buildVerdict( export interface BuildProjectStatusOptions { env?: NodeJS.ProcessEnv; - postgresQueryHistoryProbe?: PostgresQueryHistoryProbe; - snowflakeQueryHistoryProbe?: SnowflakeQueryHistoryProbe; - bigqueryQueryHistoryProbe?: BigQueryQueryHistoryProbe; + queryHistoryReadinessProbe?: HistoricSqlReadinessProbe; claudeCodeAuthProbe?: ClaudeCodeAuthProbe; configIssues?: KtxConfigIssue[]; fast?: boolean; diff --git a/uv.lock b/uv.lock index 9c580fbf..7c2c368f 100644 --- a/uv.lock +++ b/uv.lock @@ -458,7 +458,7 @@ wheels = [ [[package]] name = "ktx-daemon" -version = "0.4.1" +version = "0.5.0" source = { editable = "python/ktx-daemon" } dependencies = [ { name = "fastapi" }, @@ -515,7 +515,7 @@ dev = [ [[package]] name = "ktx-sl" -version = "0.4.1" +version = "0.5.0" source = { editable = "python/ktx-sl" } dependencies = [ { name = "pydantic" }, From a9db3797e6701fb9a629196506cc50b2de5c7fb7 Mon Sep 17 00:00:00 2001 From: Andrey Avtomonov Date: Sun, 24 May 2026 22:55:08 +0200 Subject: [PATCH 04/74] docs: ban ktx compatibility shims (#214) --- AGENTS.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index a8640c48..64ec2d4a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -24,6 +24,11 @@ database migrations, ORPC contracts, or `python-service/` layout exist here. - **MUST**: Keep package/public API changes intentional. Do not add compatibility wrappers for old **ktx** names unless the user explicitly asks for a migration bridge. +- **MUST**: Avoid compatibility shims for old **ktx** features, command shapes, + configuration formats, or internal APIs. This rule does not prohibit + compatibility support for third-party systems and libraries, such as + Metabase version differences. Keep the **ktx** codebase clean instead of + preserving stale **ktx** behavior. - **MUST**: Treat **ktx** as having no public users unless the user says otherwise. Legacy support is not necessary by default; prefer clean breaking changes over compatibility shims, migration bridges, or preserved stale behavior. From 4827437f3a12042f681f0f4a908252cf57d825e2 Mon Sep 17 00:00:00 2001 From: Andrey Avtomonov Date: Mon, 25 May 2026 16:12:39 +0200 Subject: [PATCH 05/74] ci: disable telemetry in workflows (#217) --- .github/workflows/ci.yml | 5 +++++ .github/workflows/release.yml | 5 +++++ .github/workflows/triage-issues.yml | 5 +++++ 3 files changed, 15 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3b331b02..e2737e73 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -10,6 +10,11 @@ on: permissions: contents: read +env: + DO_NOT_TRACK: "1" + KTX_TELEMETRY_DISABLED: "1" + NEXT_TELEMETRY_DISABLED: "1" + concurrency: group: ktx-ci-${{ github.ref }} cancel-in-progress: true diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 108c1989..b1efa96e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -26,6 +26,11 @@ permissions: contents: write id-token: write +env: + DO_NOT_TRACK: "1" + KTX_TELEMETRY_DISABLED: "1" + NEXT_TELEMETRY_DISABLED: "1" + concurrency: group: ktx-release-${{ github.ref }} cancel-in-progress: false diff --git a/.github/workflows/triage-issues.yml b/.github/workflows/triage-issues.yml index 5a341013..41d2f048 100644 --- a/.github/workflows/triage-issues.yml +++ b/.github/workflows/triage-issues.yml @@ -7,6 +7,11 @@ on: permissions: issues: write +env: + DO_NOT_TRACK: "1" + KTX_TELEMETRY_DISABLED: "1" + NEXT_TELEMETRY_DISABLED: "1" + jobs: label-external: name: Add needs-triage to external issues From 924868841deefa69ac86f694e8ad3c92c27d63f4 Mon Sep 17 00:00:00 2001 From: Luca Martial <48870843+luca-martial@users.noreply.github.com> Date: Mon, 25 May 2026 11:09:33 -0400 Subject: [PATCH 06/74] docs: standardize fanout terminology (#218) --- docs-site/components/semantic-layer-flow.tsx | 2 +- .../concepts/semantic-layer-internals.mdx | 16 +++++----- .../docs/concepts/the-context-layer.mdx | 4 +-- docs/terminology.md | 2 +- .../src/context/ingest/local-adapters.test.ts | 2 +- .../cli/src/context/ingest/local-ingest.ts | 2 +- .../ingest/local-metabase-ingest.test.ts | 6 ++-- packages/cli/src/ingest.test-utils.ts | 2 +- packages/cli/src/ingest.test.ts | 32 +++++++++---------- packages/cli/src/ingest.ts | 4 +-- packages/cli/src/skills/sl/SKILL.md | 4 +-- python/ktx-sl/AGENTS.md | 2 +- python/ktx-sl/semantic_layer/cli.py | 2 +- python/ktx-sl/semantic_layer/generator.py | 4 +-- python/ktx-sl/semantic_layer/planner.py | 22 ++++++------- .../ktx-sl/tests/test_aggregate_locality.py | 8 ++--- python/ktx-sl/tests/test_coverage_gaps.py | 4 +-- python/ktx-sl/tests/test_generator.py | 4 +-- python/ktx-sl/tests/test_planner.py | 10 +++--- 19 files changed, 66 insertions(+), 66 deletions(-) diff --git a/docs-site/components/semantic-layer-flow.tsx b/docs-site/components/semantic-layer-flow.tsx index 4d1ecfbd..9c6095c4 100644 --- a/docs-site/components/semantic-layer-flow.tsx +++ b/docs-site/components/semantic-layer-flow.tsx @@ -253,7 +253,7 @@ const engine: EngineNode = { }, { index: 3, - title: "Detect fan-out", + title: "Detect fanout", detail: "group measures by source, flag chasm traps", }, { diff --git a/docs-site/content/docs/concepts/semantic-layer-internals.mdx b/docs-site/content/docs/concepts/semantic-layer-internals.mdx index 99410f4f..4788ef25 100644 --- a/docs-site/content/docs/concepts/semantic-layer-internals.mdx +++ b/docs-site/content/docs/concepts/semantic-layer-internals.mdx @@ -8,7 +8,7 @@ import { SemanticLayerFlow } from "@/components/semantic-layer-flow"; **ktx**'s semantic layer is a compiler that turns intent into SQL. The agent declares _what_ it wants - measures, dimensions, filters - in a small semantic query. **ktx** figures out the _how_: which tables to join, what -grain to aggregate at, how to keep fan-out from inflating measures, and +grain to aggregate at, how to keep fanout from inflating measures, and what dialect the warehouse speaks. This page covers four mechanics: @@ -16,7 +16,7 @@ This page covers four mechanics: - The semantic query contract agents send to the compiler. - The planner steps that turn a semantic query into SQL. - The join graph that backs those steps, and how it's built. -- The fan-out failure mode the compiler is designed to prevent. +- The fanout failure mode the compiler is designed to prevent. ## Imperative SQL vs declarative semantic querying @@ -84,14 +84,14 @@ same ordered steps before any SQL is emitted. 2. **Pick an anchor and build the join tree.** Choose the largest measure source as the root, then run a shortest-path search across the typed join graph to reach every required source. -3. **Detect fan-out.** Group measures by their owning source. If more +3. **Detect fanout.** Group measures by their owning source. If more than one group exists, the planner marks the query as a chasm trap and switches to aggregate-locality compilation. 4. **Classify filters.** Split predicates into row-level (`WHERE`) and aggregate-level (`HAVING`) based on whether they reference a measure. 5. **Generate SQL.** Emit Postgres-shaped SQL with the right shape: single-source aggregation when the query is safe, per-source CTEs - when fan-out is present. + when fanout is present. 6. **Transpile to the target dialect.** Run the result through `sqlglot` so the warehouse receives syntax it understands. @@ -107,7 +107,7 @@ inverted, so the planner can traverse from any anchor. | Relationship | Planning impact | |--------------|-----------------| | `many_to_one` | Safe direction for adding dimensions | -| `one_to_many` | Multiplies measures and triggers fan-out handling | +| `one_to_many` | Multiplies measures and triggers fanout handling | | `one_to_one` | Safe in either direction when keys match | | Equal-cost paths | Treated as ambiguous; aliases or explicit joins resolve them | @@ -286,9 +286,9 @@ inference. Each input contributes a different kind of authority. -## Fan-out and aggregate locality +## Fanout and aggregate locality -Fan-out is the classic analytics failure mode. Two fact tables join to a +Fanout is the classic analytics failure mode. Two fact tables join to a shared dimension. A naive query joins them all together first, so each row from one fact is multiplied by the matching rows from the other. Measures duplicate, numbers go wrong, and the agent doesn't notice. @@ -336,5 +336,5 @@ different from what the agent first proposed. | Explain the semantic query shape | The semantic query contract | [ktx sl](/docs/cli-reference/ktx-sl) | | Describe what the planner does between query and SQL | What the planner does | [ktx sl](/docs/cli-reference/ktx-sl) | | Explain why **ktx** asks for grain and relationship types | The join graph | [Writing context](/docs/guides/writing-context) | -| Diagnose duplicated measures after a join | Fan-out and aggregate locality | [ktx sl](/docs/cli-reference/ktx-sl) | +| Diagnose duplicated measures after a join | Fanout and aggregate locality | [ktx sl](/docs/cli-reference/ktx-sl) | | Describe how semantic context stays current | Building and maintaining the graph | [Reviewing Context](/docs/guides/reviewing-context) | diff --git a/docs-site/content/docs/concepts/the-context-layer.mdx b/docs-site/content/docs/concepts/the-context-layer.mdx index 48be8c7e..a3ae6134 100644 --- a/docs-site/content/docs/concepts/the-context-layer.mdx +++ b/docs-site/content/docs/concepts/the-context-layer.mdx @@ -156,7 +156,7 @@ joins: relationship: many_to_one ``` -For how the compiler walks the join graph, handles fan-out, and transpiles +For how the compiler walks the join graph, handles fanout, and transpiles dialects, read [Semantic querying](/docs/concepts/semantic-layer-internals). ## Wiki pages @@ -240,7 +240,7 @@ models every time the warehouse changes. | **Surface** | Indexed docs and chats | Modeling language or runtime | YAML and Markdown files | | **Data-stack awareness** | None - treats data tools as text | High for declared metrics, none for the surrounding warehouse | Built in: scans schemas, dbt, BI tools, and query history | | **Maintenance** | Manual page authoring | Manual modeling, model-per-change | Auto-maintained: reconciles evidence with accepted files | -| **SQL safety** | None - generates plausible text | Compiled, dialect-correct | Compiled with join-graph and fan-out handling | +| **SQL safety** | None - generates plausible text | Compiled, dialect-correct | Compiled with join-graph and fanout handling | | **Agent edit loop** | Text-only | Tied to the modeling workflow | First-class: patch files, validate, review diffs | If you already use MetricFlow, LookML, dbt, or BI tools, **ktx** can ingest that diff --git a/docs/terminology.md b/docs/terminology.md index fab5d290..00be75e6 100644 --- a/docs/terminology.md +++ b/docs/terminology.md @@ -56,7 +56,7 @@ referent (e.g., body of a `Semantic sources` page, or `sourceName` as a CLI arg) | Wiki surface as a whole | **wiki** | "wiki context" | | A single Markdown file | **wiki page** | — | | YAML vs Markdown contrast | **wiki Markdown** (only when contrasting with **semantic source YAML**) | — | -| Joins multiplying rows (generic) | **fan-out** | — | +| Joins multiplying rows (generic) | **fanout** | — | | The two named patterns | **chasm trap** / **fan trap** | — | | Casual gloss in user prose | **double-count** | (avoid in technical/internals prose) | diff --git a/packages/cli/src/context/ingest/local-adapters.test.ts b/packages/cli/src/context/ingest/local-adapters.test.ts index 44fdc2cc..373fc125 100644 --- a/packages/cli/src/context/ingest/local-adapters.test.ts +++ b/packages/cli/src/context/ingest/local-adapters.test.ts @@ -76,7 +76,7 @@ describe('local ingest adapters', () => { expect(looker?.fetch).toBeTypeOf('function'); }); - it('returns the explicit Metabase fan-out boundary before runner construction', async () => { + it('returns the explicit Metabase fanout boundary before runner construction', async () => { const metabase = createDefaultLocalIngestAdapters(project).find((adapter) => adapter.source === 'metabase'); await expect(localPullConfigForAdapter(project, metabase!, 'warehouse')).rejects.toThrow( diff --git a/packages/cli/src/context/ingest/local-ingest.ts b/packages/cli/src/context/ingest/local-ingest.ts index 2832d9ff..2351d420 100644 --- a/packages/cli/src/context/ingest/local-ingest.ts +++ b/packages/cli/src/context/ingest/local-ingest.ts @@ -336,7 +336,7 @@ export async function runLocalMetabaseIngest( options: RunLocalMetabaseIngestOptions, ): Promise { if ((options as RunLocalMetabaseIngestOptions & { sourceDir?: string }).sourceDir) { - throw new Error('source-dir uploads are not supported for the Metabase fan-out adapter'); + throw new Error('source-dir uploads are not supported for the Metabase fanout adapter'); } const metabaseConnectionId = safeSegment('metabase connection id', options.metabaseConnectionId); diff --git a/packages/cli/src/context/ingest/local-metabase-ingest.test.ts b/packages/cli/src/context/ingest/local-metabase-ingest.test.ts index ff91d827..a6f5c4e0 100644 --- a/packages/cli/src/context/ingest/local-metabase-ingest.test.ts +++ b/packages/cli/src/context/ingest/local-metabase-ingest.test.ts @@ -148,7 +148,7 @@ describe('runLocalMetabaseIngest', () => { ).rejects.toThrow('no sync-enabled mappings with a target connection'); }); - it('seeds yaml-only Metabase mappings before the unhydrated fan-out preflight', async () => { + it('seeds yaml-only Metabase mappings before the unhydrated fanout preflight', async () => { project.config.connections['prod-metabase'].mappings = { databaseMappings: { '1': 'warehouse_a' }, syncEnabled: { '1': true }, @@ -172,7 +172,7 @@ describe('runLocalMetabaseIngest', () => { ]); }); - it('rejects source-dir uploads through the Metabase fan-out runner', async () => { + it('rejects source-dir uploads through the Metabase fanout runner', async () => { await expect( runLocalMetabaseIngest({ project, @@ -181,7 +181,7 @@ describe('runLocalMetabaseIngest', () => { agentRunner: new TestAgentRunner(), sourceDir: tempDir, } as Parameters[0] & { sourceDir: string }), - ).rejects.toThrow('source-dir uploads are not supported for the Metabase fan-out adapter'); + ).rejects.toThrow('source-dir uploads are not supported for the Metabase fanout adapter'); }); it('reports partial failure when a child job fails', async () => { diff --git a/packages/cli/src/ingest.test-utils.ts b/packages/cli/src/ingest.test-utils.ts index 9b3f16fa..81600df7 100644 --- a/packages/cli/src/ingest.test-utils.ts +++ b/packages/cli/src/ingest.test-utils.ts @@ -533,7 +533,7 @@ export async function runPublicMetabaseSyncModeCase(tempDir: string, input: Sync ).resolves.toBe(0); expect(io.stderr()).toContain('Metabase ingest: prod-metabase'); - expect(io.stdout()).toContain('Metabase fan-out: all_succeeded'); + expect(io.stdout()).toContain('Metabase fanout: all_succeeded'); expect(io.stdout()).toContain(`target=warehouse_a database=1 status=done job=${jobId}`); const report = await getLocalIngestStatus(project, jobId); diff --git a/packages/cli/src/ingest.test.ts b/packages/cli/src/ingest.test.ts index 50401df1..6de72879 100644 --- a/packages/cli/src/ingest.test.ts +++ b/packages/cli/src/ingest.test.ts @@ -346,7 +346,7 @@ describe('runKtxIngest', () => { ); }); - it('routes metabase scheduled pulls to the fan-out runner and prints child summaries', async () => { + it('routes metabase scheduled pulls to the fanout runner and prints child summaries', async () => { const projectDir = join(tempDir, 'project'); await writeMetabaseConfig(projectDir); const io = makeIo(); @@ -397,13 +397,13 @@ describe('runKtxIngest', () => { ), ).resolves.toBe(0); - expect(io.stdout()).toContain('Metabase fan-out: all_succeeded'); + expect(io.stdout()).toContain('Metabase fanout: all_succeeded'); expect(io.stdout()).toContain('warehouse_a'); expect(io.stdout()).toContain('metabase-child-1'); expect(io.stderr()).toContain('Metabase ingest: prod-metabase'); }); - it('returns a non-zero code when Metabase fan-out has failed children', async () => { + it('returns a non-zero code when Metabase fanout has failed children', async () => { const projectDir = join(tempDir, 'project'); await writeMetabaseConfig(projectDir); const io = makeIo(); @@ -467,13 +467,13 @@ describe('runKtxIngest', () => { ), ).resolves.toBe(1); - expect(io.stdout()).toContain('Metabase fan-out: partial_failure'); + expect(io.stdout()).toContain('Metabase fanout: partial_failure'); expect(io.stdout()).toContain('Failed tasks: 1'); expect(io.stdout()).toContain('status=error'); expect(io.stderr()).toContain('Metabase ingest: prod-metabase'); }); - it('prints Metabase fan-out progress before the final summary', async () => { + it('prints Metabase fanout progress before the final summary', async () => { const projectDir = join(tempDir, 'project'); await writeMetabaseConfig(projectDir); const io = makeIo(); @@ -548,11 +548,11 @@ describe('runKtxIngest', () => { expect(io.stderr()).toContain('Targets: 1 mapped database'); expect(io.stderr()).toContain('- database=1 target=warehouse_a status=running job=metabase-child-1'); expect(io.stderr()).toContain('- database=1 target=warehouse_a status=done job=metabase-child-1'); - expect(io.stdout()).toContain('Metabase fan-out: all_succeeded'); + expect(io.stdout()).toContain('Metabase fanout: all_succeeded'); expect(io.stdout()).not.toContain('status=running job=metabase-child-1'); }); - it('writes metabase fan-out progress to stderr and final result to stdout', async () => { + it('writes metabase fanout progress to stderr and final result to stdout', async () => { const projectDir = join(tempDir, 'project'); await writeMetabaseConfig(projectDir); const io = makeIo({ isTTY: true }); @@ -592,11 +592,11 @@ describe('runKtxIngest', () => { expect(io.stderr()).toContain('Metabase ingest: prod-metabase'); expect(io.stderr()).toContain('status=running job=metabase-child-1'); - expect(io.stdout()).toContain('Metabase fan-out: all_succeeded'); + expect(io.stdout()).toContain('Metabase fanout: all_succeeded'); expect(io.stdout()).not.toContain('status=running job=metabase-child-1'); }); - it('emits structured progress for Metabase fan-out without writing progress to JSON output', async () => { + it('emits structured progress for Metabase fanout without writing progress to JSON output', async () => { const projectDir = join(tempDir, 'project'); await writeMetabaseConfig(projectDir); const io = makeIo(); @@ -655,7 +655,7 @@ describe('runKtxIngest', () => { expect(io.stderr()).not.toContain('Metabase ingest: prod-metabase'); }); - it('emits structured child ingest progress during Metabase fan-out', async () => { + it('emits structured child ingest progress during Metabase fanout', async () => { const projectDir = join(tempDir, 'project'); await writeMetabaseConfig(projectDir); const io = makeIo(); @@ -766,7 +766,7 @@ describe('runKtxIngest', () => { expect(io.stderr()).not.toContain('Metabase ingest: prod-metabase'); }); - it('runs Metabase scheduled ingest through the public CLI command path with real fan-out', async () => { + it('runs Metabase scheduled ingest through the public CLI command path with real fanout', async () => { const projectDir = join(tempDir, 'metabase-cli-project'); await writeWarehouseConfig(projectDir); await writeFile( @@ -838,7 +838,7 @@ describe('runKtxIngest', () => { expect(io.stderr()).toContain('Metabase ingest: prod-metabase'); expect(io.stderr()).toContain('Targets: 2 mapped databases'); - expect(io.stdout()).toContain('Metabase fan-out: all_succeeded'); + expect(io.stdout()).toContain('Metabase fanout: all_succeeded'); expect(io.stdout()).toContain('Source: prod-metabase'); expect(io.stdout()).toContain('Children: 2'); expect(io.stdout()).toContain('target=warehouse_a database=1 status=done job=metabase-child-1'); @@ -893,7 +893,7 @@ describe('runKtxIngest', () => { }); }); - it('prints metabase fan-out JSON results', async () => { + it('prints metabase fanout JSON results', async () => { const projectDir = join(tempDir, 'project'); await writeMetabaseConfig(projectDir); const io = makeIo(); @@ -967,7 +967,7 @@ describe('runKtxIngest', () => { expect(io.stderr()).toBe(''); }); - it('rejects source-dir uploads through the metabase fan-out route', async () => { + it('rejects source-dir uploads through the metabase fanout route', async () => { const projectDir = join(tempDir, 'project'); await writeMetabaseConfig(projectDir); const io = makeIo(); @@ -985,13 +985,13 @@ describe('runKtxIngest', () => { io.io, { runLocalMetabaseIngest: async () => { - throw new Error('fan-out should not be called'); + throw new Error('fanout should not be called'); }, }, ), ).resolves.toBe(1); - expect(io.stderr()).toContain('source-dir uploads are not supported for the Metabase fan-out adapter'); + expect(io.stderr()).toContain('source-dir uploads are not supported for the Metabase fanout adapter'); expect(io.stderr()).not.toContain('ktx ingest requires llm.provider.backend'); expect(io.stdout()).toBe(''); }); diff --git a/packages/cli/src/ingest.ts b/packages/cli/src/ingest.ts index 3615f401..fb8c9a29 100644 --- a/packages/cli/src/ingest.ts +++ b/packages/cli/src/ingest.ts @@ -222,7 +222,7 @@ function writeMetabaseFanoutStatus(result: LocalMetabaseFanoutResult, io: KtxIng }, { wikiCount: 0, slCount: 0 }, ); - io.stdout.write(`Metabase fan-out: ${result.status}\n`); + io.stdout.write(`Metabase fanout: ${result.status}\n`); io.stdout.write(`Source: ${result.metabaseConnectionId}\n`); io.stdout.write(`Children: ${result.children.length}\n`); if (result.totals) { @@ -719,7 +719,7 @@ export async function runKtxIngest( localIngestOptions.queryExecutor ?? (deps.createQueryExecutor ?? createKtxCliIngestQueryExecutor)(ingestProject); if (args.adapter === 'metabase' && args.sourceDir) { - throw new Error('source-dir uploads are not supported for the Metabase fan-out adapter'); + throw new Error('source-dir uploads are not supported for the Metabase fanout adapter'); } if (args.adapter === 'metabase') { const executeMetabaseFanout = deps.runLocalMetabaseIngest ?? runLocalMetabaseIngest; diff --git a/packages/cli/src/skills/sl/SKILL.md b/packages/cli/src/skills/sl/SKILL.md index d5f334fe..53e1bd22 100644 --- a/packages/cli/src/skills/sl/SKILL.md +++ b/packages/cli/src/skills/sl/SKILL.md @@ -124,7 +124,7 @@ Every standalone column requires `name` and `type`. Overlays have computed colum ### Grain -`grain: [col_a, col_b]` - the set of columns that uniquely identify one row. The query engine uses grain to prevent fan-out in joins. Overlays inherit grain from the manifest unless they override. +`grain: [col_a, col_b]` - the set of columns that uniquely identify one row. The query engine uses grain to prevent fanout in joins. Overlays inherit grain from the manifest unless they override. ### Joins @@ -177,7 +177,7 @@ The reverse edge (wiki pages that cite this source) is derived automatically fro ## Part 2 - Querying via `sl_query` -The `sl_query` tool generates correct SQL from a structured query. It handles joins, fan-out prevention, aggregation correctness, and filter classification automatically. Prefer it over writing raw SQL whenever the SL has the relevant sources. +The `sl_query` tool generates correct SQL from a structured query. It handles joins, fanout prevention, aggregation correctness, and filter classification automatically. Prefer it over writing raw SQL whenever the SL has the relevant sources. ### When to prefer sl_query over raw SQL diff --git a/python/ktx-sl/AGENTS.md b/python/ktx-sl/AGENTS.md index b9b54f18..beba3036 100644 --- a/python/ktx-sl/AGENTS.md +++ b/python/ktx-sl/AGENTS.md @@ -59,7 +59,7 @@ uv run python -m semantic_layer.cli --model /tmp/model.yaml \ -q '{"measures":["orders.revenue"],"dimensions":["customers.segment"]}' --suggest ``` -### 3. Test fan-out / chasm traps +### 3. Test fanout / chasm traps Add multiple measure sources that fan out from a shared dimension hub: diff --git a/python/ktx-sl/semantic_layer/cli.py b/python/ktx-sl/semantic_layer/cli.py index a2782f38..d1bacaa8 100644 --- a/python/ktx-sl/semantic_layer/cli.py +++ b/python/ktx-sl/semantic_layer/cli.py @@ -160,7 +160,7 @@ def print_plan(plan) -> None: print(" Joins:") for jp in plan.join_paths: print(f" {jp}") - print(f" Fan-out: {plan.fan_out_description}") + print(f" Fanout: {plan.fan_out_description}") if plan.aggregate_locality: print(" Locality:") for al in plan.aggregate_locality: diff --git a/python/ktx-sl/semantic_layer/generator.py b/python/ktx-sl/semantic_layer/generator.py index a5979299..5309018f 100644 --- a/python/ktx-sl/semantic_layer/generator.py +++ b/python/ktx-sl/semantic_layer/generator.py @@ -92,7 +92,7 @@ class SqlGenerator: return "WITH " + source_header + ",\n" + rest return "WITH " + source_header + "\n" + outer_transpiled - # ── Path A: Simple (no fan-out) ──────────────────────────────────── + # ── Path A: Simple (no fanout) ──────────────────────────────────── def _generate_simple( self, plan: ResolvedPlan, sources: dict[str, SourceDefinition] @@ -216,7 +216,7 @@ class SqlGenerator: shared_dim_aliases = shared_dim_aliases or set() shared_dims = [dk for dk in all_dim_keys if dk["alias"] in shared_dim_aliases] - # Validate grain consistency: asymmetric dims cause FULL JOIN fan-out + # Validate grain consistency: asymmetric dims cause FULL JOIN fanout if len(plan.measure_groups) > 1: for group in plan.measure_groups: cte_dim_aliases = { diff --git a/python/ktx-sl/semantic_layer/planner.py b/python/ktx-sl/semantic_layer/planner.py index bfd1d74f..e5ebf02e 100644 --- a/python/ktx-sl/semantic_layer/planner.py +++ b/python/ktx-sl/semantic_layer/planner.py @@ -107,7 +107,7 @@ class QueryPlanner: for e in tree.edges ] - # 8. Detect fan-out / chasm trap + # 8. Detect fanout / chasm trap has_fan_out, measure_groups, fan_out_desc, locality_descs = ( self._detect_fan_out(measures, dimensions, tree, filters=query.filters) ) @@ -937,7 +937,7 @@ class QueryPlanner: filters: list[str] | None = None, ) -> tuple[bool, list[MeasureGroup], str, list[str]]: """ - Detect fan-out and chasm traps. Group measures by source. + Detect fanout and chasm traps. Group measures by source. If multiple measure sources exist, each needs its own pre-aggregation CTE. Also checks filter sources — a filter forcing a one_to_many join from the measure source is an error (cannot be safely pre-aggregated). @@ -991,7 +991,7 @@ class QueryPlanner: if len(groups) <= 1: # Single measure group: check the path FROM measure source TO dimension sources. - # Only flag fan-out if those specific paths have one_to_many edges. + # Only flag fanout if those specific paths have one_to_many edges. if groups: source_name = next(iter(groups)) source_actual = self.graph.alias_map.get(source_name, source_name) @@ -999,7 +999,7 @@ class QueryPlanner: for dim_src in dim_sources: if dim_src == source_name: continue - # Skip alias siblings (same underlying source — no fan-out) + # Skip alias siblings (same underlying source — no fanout) dim_actual = self.graph.alias_map.get(dim_src, dim_src) if dim_actual == source_actual: continue @@ -1008,7 +1008,7 @@ class QueryPlanner: has_o2m = True break - # Also check filter sources for one_to_many fan-out + # Also check filter sources for one_to_many fanout if not has_o2m: for filter_src in filter_sources - dim_sources - {source_name}: filter_actual = self.graph.alias_map.get(filter_src, filter_src) @@ -1019,7 +1019,7 @@ class QueryPlanner: raise ValueError( f"Filter on '{filter_src}' requires a one_to_many join " f"from measure source '{source_name}', which would cause " - f"incorrect aggregation (fan-out). Consider rewriting the " + f"incorrect aggregation (fanout). Consider rewriting the " f"filter as a subquery or adding the filter source as a " f"dimension source." ) @@ -1033,10 +1033,10 @@ class QueryPlanner: return ( True, measure_groups, - f"Fan-out detected: one_to_many edges from {source_name} to dimensions", + f"Fanout detected: one_to_many edges from {source_name} to dimensions", [f"Pre-aggregate {source_name} measures before joining"], ) - return False, [], "No fan-out", [] + return False, [], "No fanout", [] # Multiple measure sources. Only merge groups that are provably row-safe # (alias siblings or pure one_to_one chains). many_to_one chains are not @@ -1048,7 +1048,7 @@ class QueryPlanner: # All measure sources are on the same safe join chain if merged_groups: mg_name, mg_measures = next(iter(merged_groups.items())) - # Still check if there's fan-out to dimension sources + # Still check if there's fanout to dimension sources has_o2m = False for dim_src in dim_sources: if dim_src == mg_name: @@ -1061,10 +1061,10 @@ class QueryPlanner: return ( True, [MeasureGroup(source_name=mg_name, measures=mg_measures)], - f"Fan-out detected: one_to_many edges from {mg_name} to dimensions", + f"Fanout detected: one_to_many edges from {mg_name} to dimensions", [f"Pre-aggregate {mg_name} measures before joining"], ) - return False, [], "No fan-out", [] + return False, [], "No fanout", [] # True chasm trap — independent measure sources that can't be safely merged. # Before building groups, validate that all filter sources are reachable diff --git a/python/ktx-sl/tests/test_aggregate_locality.py b/python/ktx-sl/tests/test_aggregate_locality.py index 9080d608..7b120ef2 100644 --- a/python/ktx-sl/tests/test_aggregate_locality.py +++ b/python/ktx-sl/tests/test_aggregate_locality.py @@ -1,4 +1,4 @@ -"""Dedicated tests for aggregate locality (fan-out/chasm trap correctness).""" +"""Dedicated tests for aggregate locality (fanout/chasm trap correctness).""" import pytest import sqlglot @@ -213,7 +213,7 @@ class TestNoFanOut: sqlglot.parse(sql) def test_m2o_join_no_ctes(self, ecommerce_sources): - """orders → customers is m2o, no fan-out.""" + """orders → customers is m2o, no fanout.""" graph = JoinGraph(ecommerce_sources) graph.build() planner = QueryPlanner(ecommerce_sources, graph) @@ -540,7 +540,7 @@ class TestFactSideDimensionsInChasm: """LIMIT 1: Fact-side dimensions in chasm trap (local to one CTE only).""" def test_fact_side_dimension_in_chasm_raises_error(self): - """Asymmetric dim from fact_a only → raises error (would cause FULL JOIN fan-out).""" + """Asymmetric dim from fact_a only → raises error (would cause FULL JOIN fanout).""" hub = SourceDefinition( name="hub", table="public.hub", @@ -977,7 +977,7 @@ class TestBug13_FalseChasm_AliasAggregate: dimensions=["billing_customer.name", "shipping_customer.name"], ) plan = planner.plan(query) - assert not plan.has_fan_out, "Should not detect fan-out between alias siblings" + assert not plan.has_fan_out, "Should not detect fanout between alias siblings" sql = gen.generate(plan, sources) sqlglot.parse(sql) diff --git a/python/ktx-sl/tests/test_coverage_gaps.py b/python/ktx-sl/tests/test_coverage_gaps.py index eea91d2f..2c3a9b9b 100644 --- a/python/ktx-sl/tests/test_coverage_gaps.py +++ b/python/ktx-sl/tests/test_coverage_gaps.py @@ -305,12 +305,12 @@ class TestPredefinedMeasureDeps: assert "GROUP BY" in sql.upper() -# ── Planner: fan-out with one_to_many to dimension sources (lines 595-643) ── +# ── Planner: fanout with one_to_many to dimension sources (lines 595-643) ── class TestFanOutEdgeCases: def test_single_source_fan_out_to_dimension(self): - """Measure source with one_to_many to dimension should trigger fan-out.""" + """Measure source with one_to_many to dimension should trigger fanout.""" hub = SourceDefinition( name="hub", table="public.hub", diff --git a/python/ktx-sl/tests/test_generator.py b/python/ktx-sl/tests/test_generator.py index 9ef147ea..5b5b6894 100644 --- a/python/ktx-sl/tests/test_generator.py +++ b/python/ktx-sl/tests/test_generator.py @@ -89,10 +89,10 @@ class TestCrossSourceM2O: class TestFanOut: - """Test 3: Fan-out (aggregate locality).""" + """Test 3: Fanout (aggregate locality).""" def test_orders_by_region_no_fanout(self, planner, generator, ecommerce_sources): - """orders → customers → regions is all m2o. No fan-out needed.""" + """orders → customers → regions is all m2o. No fanout needed.""" sql = generate_sql( planner, generator, diff --git a/python/ktx-sl/tests/test_planner.py b/python/ktx-sl/tests/test_planner.py index dd6483b7..43c4c488 100644 --- a/python/ktx-sl/tests/test_planner.py +++ b/python/ktx-sl/tests/test_planner.py @@ -200,12 +200,12 @@ class TestFanOutDetection: class TestFanOutSingleSource: - """Fan-out when a single measure source has o2m path to dimension source.""" + """Fanout when a single measure source has o2m path to dimension source.""" def test_reverse_path_fan_out(self): - """Querying from customers (dimension) with measures from orders triggers fan-out + """Querying from customers (dimension) with measures from orders triggers fanout when the path from the measure source (orders) to the dimension source (customers) - is m2o — so no fan-out. But reversed: measure on customers, dim on orders.""" + is m2o — so no fanout. But reversed: measure on customers, dim on orders.""" customers = SourceDefinition( name="customers", table="t", @@ -248,7 +248,7 @@ class TestFanOutSingleSource: assert plan.has_fan_out def test_m2o_multi_hop_no_fan_out(self, planner): - """orders → customers → regions is all m2o. No fan-out.""" + """orders → customers → regions is all m2o. No fanout.""" query = SemanticQuery( measures=["sum(orders.amount)"], dimensions=["regions.name"], @@ -1116,7 +1116,7 @@ class TestDerivedMeasureEdgeCases: assert_valid_sql(result.sql) -# ── From test_edge_cases.py: filter fan-out detection ──────────────── +# ── From test_edge_cases.py: filter fanout detection ──────────────── class TestFilterFanOutDetection: From 56985b7e098ca06cb134f9ea8fd44976d23b8134 Mon Sep 17 00:00:00 2001 From: Andrey Avtomonov Date: Tue, 26 May 2026 08:49:05 +0200 Subject: [PATCH 07/74] test: split cli tests from source tree (#216) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(cli): define full warehouse dialect contract * test(cli): keep dialect edge tests focused * fix(cli): stabilize dialect contract foundation * refactor(connectors): own read-only query preparation * refactor(connectors): resolve dialects through registry * refactor(connectors): keep concrete dialect classes internal * chore(workspace): enforce dialect import boundary * refactor(cli): resolve relationship dialect at scan boundary * refactor(cli): use dialect display parsing for entity details * refactor(cli): use dialect display parsing for warehouse catalog * refactor(cli): use dialect SQL in relationship workflows * test(cli): verify solid dialect scan workflow closure * test: split cli tests from source tree * refactor(cli): standardize BigQuery scope listing * feat(sqlite): implement connector scope listing * test(connectors): cover required table listing * feat(cli): add warehouse driver registry * refactor(setup): route scope discovery through driver registry * refactor(cli): route local query execution through driver registry * refactor(historic-sql): route dialect support through driver registry * refactor(cli): test warehouse connections through driver registry * fix(cli): close driver registry type export gaps * Improve setup daemon diagnostics * refactor(setup): centralize rail-prefixed diagnostics + query-history fallback Extract errorMessage, writePrefixedLines, and flushPrefixedBufferedCommandOutput into clack.ts so the setup wizard, managed daemons, and embedding/agent steps share one rail-formatted writer. setup-databases.ts also adds a "disable query history and retry" option when the schema-context build fails and query history is the likely culprit, surfaced via a new failed-query-history-unavailable status. * fix(cli): carry catalog through the picker so BigQuery/Snowflake/SQL Server scope filters match The setup picker's KtxTableListEntry was a 2-level { schema, name }, so qualifiedTableId always wrote db.name into enabled_tables. When BigQuery, Snowflake, or SQL Server later ran fast ingest, their introspect step filtered the scope set with scopedTableNames(scope, { catalog: projectId|database, db }) — catalog was non-null on the introspect side but null in the scope refs, so every entry was rejected, the live-database adapter staged zero table files, and detect() failed with 'Adapter "live-database" did not recognize fetched source output'. Align the picker boundary with the canonical 3-level KtxTableRef: - Add catalog: string | null to KtxTableListEntry. - BigQuery/Snowflake/SQL Server listTables populate catalog from the resolved projectId / database; Postgres/MySQL/ClickHouse/SQLite set null. - qualifiedTableId emits catalog.schema.name when catalog is non-null (resolveEnabledTables already accepts the 3-part shape) and schemasFromEnabledTables now goes through parseDottedTableEntry so it recovers the schema correctly from both 2-part and 3-part entries. - Export parseDottedTableEntry from enabled-tables.ts (@internal) for picker reuse. Update listTables expectations in all seven connector tests and the setup / picker test fixtures. Add a picker regression test that covers the catalog-bearing round-trip (save + refine). * fix(cli): allow debug telemetry under opt-out env --- .../content/docs/cli-reference/ktx-setup.mdx | 6 +- docs/code-design.md | 38 + knip.json | 4 +- packages/cli/package.json | 10 +- packages/cli/src/clack.ts | 23 + packages/cli/src/connection.ts | 11 +- .../cli/src/connectors/bigquery/connector.ts | 26 +- .../cli/src/connectors/bigquery/dialect.ts | 96 +- .../src/connectors/clickhouse/connector.ts | 50 +- .../cli/src/connectors/clickhouse/dialect.ts | 160 +- .../cli/src/connectors/mysql/connector.ts | 26 +- packages/cli/src/connectors/mysql/dialect.ts | 94 +- .../cli/src/connectors/postgres/connector.ts | 30 +- .../cli/src/connectors/postgres/dialect.ts | 85 +- .../cli/src/connectors/snowflake/connector.ts | 16 +- .../cli/src/connectors/snowflake/dialect.ts | 75 +- .../cli/src/connectors/sqlite/connector.ts | 31 +- packages/cli/src/connectors/sqlite/dialect.ts | 80 +- .../cli/src/connectors/sqlserver/connector.ts | 22 +- .../cli/src/connectors/sqlserver/dialect.ts | 89 +- .../context/connections/dialect-helpers.ts | 87 + .../src/context/connections/dialects.test.ts | 34 - .../cli/src/context/connections/dialects.ts | 116 +- .../cli/src/context/connections/drivers.ts | 199 ++ .../connections/local-query-executor.ts | 39 +- .../historic-sql/connection-dialect.ts | 18 +- .../cli/src/context/scan/enabled-tables.ts | 5 +- .../cli/src/context/scan/entity-details.ts | 63 +- .../cli/src/context/scan/local-enrichment.ts | 21 +- .../context/scan/relationship-benchmarks.ts | 15 +- .../scan/relationship-composite-candidates.ts | 69 +- .../context/scan/relationship-discovery.ts | 14 +- .../context/scan/relationship-profiling.ts | 144 +- .../context/scan/relationship-validation.ts | 47 +- packages/cli/src/context/scan/types.ts | 3 + .../cli/src/context/scan/warehouse-catalog.ts | 81 +- packages/cli/src/database-tree-picker.ts | 21 +- packages/cli/src/error-message.ts | 28 + packages/cli/src/llm/embedding-health.ts | 4 +- packages/cli/src/local-scan-connectors.ts | 71 +- packages/cli/src/managed-local-embeddings.ts | 3 +- packages/cli/src/managed-python-daemon.ts | 38 +- packages/cli/src/managed-python-http.ts | 3 +- packages/cli/src/setup-agents.ts | 3 +- packages/cli/src/setup-context.ts | 3 +- packages/cli/src/setup-databases.ts | 280 +-- packages/cli/src/setup-embeddings.ts | 11 +- packages/cli/src/setup-runtime.ts | 3 +- packages/cli/src/setup-sources.ts | 3 +- packages/cli/src/telemetry/index.ts | 12 +- .../cli/{src => test}/admin-reindex.test.ts | 6 +- packages/cli/{src => test}/admin.test.ts | 2 +- .../cli-program-telemetry.test.ts | 4 +- .../cli/{src => test}/cli-program.test.ts | 4 +- .../cli/{src => test}/command-tree.test.ts | 2 +- .../commands/mcp-commands.test.ts | 4 +- .../commands/sql-commands.test.ts | 4 +- packages/cli/{src => test}/connection.test.ts | 16 +- .../connectors/bigquery/connector.test.ts | 25 +- .../connectors/bigquery/dialect.test.ts | 10 +- .../connectors/clickhouse/connector.test.ts | 43 +- .../connectors/clickhouse/dialect.test.ts | 14 +- .../connectors/mysql/connector.test.ts | 30 +- .../connectors/mysql/dialect.test.ts | 14 +- .../connectors/postgres/connector.test.ts | 39 +- .../connectors/postgres/dialect.test.ts | 22 +- .../historic-sql-query-client.test.ts | 4 +- .../connectors/snowflake/connector.test.ts | 25 +- .../connectors/snowflake/dialect.test.ts | 10 +- .../connectors/snowflake/identifiers.test.ts | 2 +- .../connectors/snowflake/sdk-logger.test.ts | 2 +- .../connectors/sqlite/connector.test.ts | 20 +- .../connectors/sqlite/dialect.test.ts | 2 +- .../connectors/sqlserver/connector.test.ts | 44 +- .../connectors/sqlserver/dialect.test.ts | 26 +- .../{src => test}/context-build-view.test.ts | 6 +- .../connections/bigquery-identifiers.test.ts | 2 +- .../test/context/connections/dialects.test.ts | 316 ++++ .../test/context/connections/drivers.test.ts | 145 ++ .../connections/local-query-executor.test.ts | 2 +- .../local-warehouse-descriptor.test.ts | 2 +- .../context/connections/notion-config.test.ts | 2 +- .../postgres-query-executor.test.ts | 2 +- .../context/connections/read-only-sql.test.ts | 2 +- .../connections/sqlite-query-executor.test.ts | 2 +- .../context/core/config-reference.test.ts | 2 +- .../git.service.assert-worktree-clean.test.ts | 6 +- .../git.service.delete-directories.test.ts | 6 +- .../context/core/git.service.patch.test.ts | 2 +- .../core/git.service.reset-hard.test.ts | 6 +- .../context/core/git.service.test.ts | 4 +- .../core/session-worktree.service.test.ts | 6 +- .../daemon/semantic-layer-compute.test.ts | 2 +- .../context/index-sync/reindex.test.ts | 8 +- .../context/ingest/action-identity.test.ts | 2 +- .../dbt-descriptions/parse-schema.test.ts | 2 +- .../context/ingest/adapters/dbt/chunk.test.ts | 2 +- .../ingest/adapters/dbt/dbt.adapter.test.ts | 4 +- .../context/ingest/adapters/dbt/fetch.test.ts | 2 +- .../context/ingest/adapters/dbt/parse.test.ts | 2 +- .../bigquery-query-history-reader.test.ts | 4 +- .../adapters/historic-sql/buckets.test.ts | 2 +- .../historic-sql/chunk-unified.test.ts | 2 +- .../historic-sql/connection-dialect.test.ts | 23 + .../adapters/historic-sql/detect.test.ts | 4 +- .../historic-sql/evidence-tool.test.ts | 2 +- .../adapters/historic-sql/evidence.test.ts | 2 +- .../historic-sql/historic-sql.adapter.test.ts | 8 +- .../local-ingest-acceptance.test.ts | 16 +- .../historic-sql/pattern-inputs.test.ts | 4 +- .../historic-sql/postgres-pgss-reader.test.ts | 4 +- .../adapters/historic-sql/projection.test.ts | 2 +- .../adapters/historic-sql/redaction.test.ts | 2 +- .../historic-sql/skill-schemas.test.ts | 2 +- .../snowflake-query-history-reader.test.ts | 4 +- .../historic-sql/stage-unified.test.ts | 6 +- .../adapters/historic-sql/types.test.ts | 2 +- .../adapters/live-database/chunk.test.ts | 6 +- .../daemon-introspection.test.ts | 4 +- .../live-database.adapter.test.ts | 4 +- .../adapters/live-database/manifest.test.ts | 2 +- .../adapters/live-database/stage.test.ts | 4 +- .../ingest/adapters/looker/chunk.test.ts | 4 +- .../adapters/looker/client-boundary.test.ts | 2 +- .../ingest/adapters/looker/client.test.ts | 2 +- .../daemon-table-identifier-parser.test.ts | 2 +- .../ingest/adapters/looker/detect.test.ts | 2 +- .../looker/evidence-documents.test.ts | 2 +- .../ingest/adapters/looker/factory.test.ts | 10 +- .../adapters/looker/fetch-report.test.ts | 2 +- .../ingest/adapters/looker/fetch.test.ts | 4 +- .../looker/local-runtime-store.test.ts | 2 +- .../adapters/looker/looker.adapter.test.ts | 4 +- .../ingest/adapters/looker/mapping.test.ts | 4 +- .../ingest/adapters/looker/reconcile.test.ts | 2 +- .../ingest/adapters/looker/scope.test.ts | 2 +- .../looker/target-connections.test.ts | 2 +- .../tools/looker-query-to-sl.tool.test.ts | 4 +- .../ingest/adapters/looker/types.test.ts | 4 +- .../ingest/adapters/lookml/chunk.test.ts | 6 +- .../ingest/adapters/lookml/detect.test.ts | 2 +- .../adapters/lookml/fetch-report.test.ts | 4 +- .../ingest/adapters/lookml/fetch.test.ts | 6 +- .../ingest/adapters/lookml/graph.test.ts | 4 +- .../adapters/lookml/lookml.adapter.test.ts | 4 +- .../ingest/adapters/lookml/parse.test.ts | 2 +- .../adapters/lookml/pull-config.test.ts | 2 +- .../adapters/metabase/card-references.test.ts | 2 +- .../ingest/adapters/metabase/chunk.test.ts | 6 +- .../adapters/metabase/client-boundary.test.ts | 4 +- .../adapters/metabase/client-port.test.ts | 6 +- .../ingest/adapters/metabase/client.test.ts | 4 +- .../ingest/adapters/metabase/detect.test.ts | 2 +- .../adapters/metabase/fanout-planner.test.ts | 2 +- .../adapters/metabase/fetch-scope.test.ts | 4 +- .../ingest/adapters/metabase/fetch.test.ts | 4 +- .../metabase/local-metabase.adapter.test.ts | 4 +- .../metabase/local-source-state-store.test.ts | 6 +- .../ingest/adapters/metabase/mapping.test.ts | 4 +- .../metabase/metabase.adapter.test.ts | 2 +- .../adapters/metabase/serialize-card.test.ts | 2 +- .../ingest/adapters/metabase/types.test.ts | 2 +- .../ingest/adapters/metricflow/chunk.test.ts | 6 +- .../adapters/metricflow/deep-parse.test.ts | 2 +- .../ingest/adapters/metricflow/detect.test.ts | 2 +- .../ingest/adapters/metricflow/fetch.test.ts | 2 +- .../ingest/adapters/metricflow/graph.test.ts | 4 +- .../metricflow/import-semantic-models.test.ts | 4 +- .../metricflow/metricflow.adapter.test.ts | 8 +- .../ingest/adapters/metricflow/parse.test.ts | 2 +- .../adapters/metricflow/pull-config.test.ts | 2 +- .../metricflow/semantic-models.test.ts | 8 +- .../ingest/adapters/notion/cluster.test.ts | 6 +- .../ingest/adapters/notion/fetch.test.ts | 4 +- .../adapters/notion/local-state-store.test.ts | 2 +- .../ingest/adapters/notion/normalize.test.ts | 2 +- .../adapters/notion/notion-client.test.ts | 2 +- .../adapters/notion/notion.adapter.test.ts | 6 +- .../context/ingest/artifact-gates.test.ts | 2 +- .../context/ingest/canonical-pins.test.ts | 4 +- .../context/ingest/clustering/kmeans.test.ts | 2 +- .../candidate-dedup.service.test.ts | 8 +- ...ext-candidate-carryforward.service.test.ts | 6 +- .../curator-pagination.service.test.ts | 6 +- .../context-candidates/embedding-text.test.ts | 2 +- .../ingest/context-candidates/store.test.ts | 6 +- .../context-evidence-index.service.test.ts | 6 +- .../sqlite-context-evidence-store.test.ts | 6 +- .../ingest/context-evidence/store.test.ts | 4 +- .../ingest/dbt-shared/project-vars.test.ts | 2 +- .../ingest/dbt-shared/schema-files.test.ts | 2 +- .../context/ingest/diff-set.service.test.ts | 2 +- .../context/ingest/final-gate-repair.test.ts | 4 +- .../context/ingest/finalization-scope.test.ts | 2 +- .../ingest/historic-sql-probes.test.ts | 4 +- .../bigquery-runner.test.ts | 4 +- .../postgres-runner.test.ts | 4 +- .../snowflake-runner.test.ts | 4 +- ...ingest-bundle.runner.isolated-diff.test.ts | 12 +- .../ingest/ingest-bundle.runner.test.ts | 10 +- .../context/ingest/ingest-prompts.test.ts | 8 +- .../ingest/ingest-runtime-assets.test.ts | 8 +- .../context/ingest/ingest-trace.test.ts | 2 +- .../ingest/isolated-diff/git-patch.test.ts | 2 +- .../isolated-diff/patch-integrator.test.ts | 6 +- .../textual-conflict-resolver.test.ts | 4 +- .../isolated-diff/work-unit-executor.test.ts | 6 +- .../context/ingest/local-adapters.test.ts | 12 +- .../ingest/local-bundle-ingest.test.ts | 16 +- .../ingest/local-bundle-runtime.test.ts | 8 +- ...cal-embedding-provider.integration.test.ts | 10 +- .../ingest/local-mapping-reconcile.test.ts | 8 +- .../ingest/local-metabase-ingest.test.ts | 10 +- .../context/ingest/local-stage-ingest.test.ts | 14 +- .../ingest/memory-flow/acceptance-fixtures.ts | 2 +- .../ingest/memory-flow/acceptance.test.ts | 4 +- .../context/ingest/memory-flow/events.test.ts | 6 +- .../ingest/memory-flow/interaction.test.ts | 4 +- .../memory-flow/interactive-render.test.ts | 6 +- .../ingest/memory-flow/live-buffer.test.ts | 4 +- .../context/ingest/memory-flow/render.test.ts | 4 +- .../context/ingest/memory-flow/schema.test.ts | 4 +- .../ingest/memory-flow/summary.test.ts | 4 +- .../ingest/memory-flow/view-model.test.ts | 4 +- .../ingest/memory-flow/visuals.test.ts | 4 +- .../page-triage/page-triage.service.test.ts | 2 +- .../context/ingest/raw-sources-paths.test.ts | 2 +- .../context/ingest/repo-fetch.test.ts | 12 +- .../context/ingest/report-snapshot.test.ts | 2 +- .../semantic-layer-target-policy.test.ts | 2 +- .../ingest/source-adapter-registry.test.ts | 4 +- .../ingest/sqlite-bundle-ingest-store.test.ts | 8 +- .../ingest/sqlite-local-ingest-store.test.ts | 4 +- ...concile-context.context-candidates.test.ts | 2 +- .../stages/build-reconcile-context.test.ts | 2 +- .../ingest/stages/build-wu-context.test.ts | 2 +- .../stages/stage-1-stage-raw-files.test.ts | 2 +- .../ingest/stages/stage-3-work-units.test.ts | 8 +- .../stages/stage-4-reconciliation.test.ts | 2 +- .../ingest/stages/validate-wu-sources.test.ts | 2 +- .../emit-reconciliation-records.tool.test.ts | 10 +- .../ingest/tools/eviction-list.tool.test.ts | 2 +- .../ingest/tools/read-raw-file.tool.test.ts | 2 +- .../ingest/tools/read-raw-span.tool.test.ts | 2 +- .../ingest/tools/stage-diff.tool.test.ts | 2 +- .../ingest/tools/stage-list.tool.test.ts | 2 +- .../tools/tool-transcript-summary.test.ts | 4 +- .../discover-data.tool.test.ts | 6 +- .../entity-details.tool.test.ts | 8 +- .../sql-execution.tool.test.ts | 6 +- .../context/ingest/wiki-body-refs.test.ts | 2 +- .../context/ingest/wiki-sl-ref-repair.test.ts | 2 +- .../context/llm/ai-sdk-runtime.test.ts | 4 +- .../context/llm/claude-code-env.test.ts | 2 +- .../context/llm/claude-code-models.test.ts | 2 +- .../context/llm/claude-code-runtime.test.ts | 2 +- .../llm/debug-request-recorder.test.ts | 2 +- .../context/llm/embedding-port.test.ts | 2 +- .../context/llm/local-config.test.ts | 4 +- .../context/llm/runtime-local-config.test.ts | 2 +- .../context/llm/runtime-tools.test.ts | 4 +- .../mcp/__snapshots__/mcp-tools-list.json | 1620 +++++++++++++++++ .../context/mcp/local-project-ports.test.ts | 10 +- .../{src => test}/context/mcp/server.test.ts | 14 +- .../context/memory/local-memory.test.ts | 6 +- .../memory-agent.service.ingest.test.ts | 4 +- .../memory/memory-agent.service.test.ts | 8 +- .../context/memory/memory-runs.test.ts | 6 +- .../memory/memory-runtime-assets.test.ts | 12 +- .../context/project/config.test.ts | 2 +- .../context/project/driver-schemas.test.ts | 2 +- .../project/local-git-file-store.test.ts | 6 +- .../project/mappings-yaml-schema.test.ts | 2 +- .../context/project/project.test.ts | 2 +- .../context/project/setup-config.test.ts | 4 +- .../context/prompts/prompt.service.test.ts | 2 +- .../context/scan/constraint-discovery.test.ts | 2 +- .../context/scan/credentials.test.ts | 6 +- .../context/scan/data-dictionary.test.ts | 2 +- .../scan/description-generation.test.ts | 6 +- .../context/scan/embedding-text.test.ts | 2 +- .../context/scan/enrichment-state.test.ts | 6 +- .../context/scan/enrichment-summary.test.ts | 2 +- .../context/scan/enrichment-types.test.ts | 2 +- .../context/scan/entity-details.test.ts | 22 +- .../scan/local-enrichment-artifacts.test.ts | 8 +- .../context/scan/local-enrichment.test.ts | 31 +- .../context/scan/local-scan.test.ts | 37 +- .../scan/local-structural-artifacts.test.ts | 4 +- .../relationship-benchmark-report.test.ts | 4 +- .../scan/relationship-benchmarks.test.ts | 38 +- .../context/scan/relationship-budget.test.ts | 2 +- .../scan/relationship-candidates.test.ts | 8 +- .../relationship-composite-candidates.test.ts | 17 +- .../scan/relationship-diagnostics.test.ts | 6 +- .../scan/relationship-discovery.test.ts | 39 +- .../scan/relationship-formal-metadata.test.ts | 4 +- .../scan/relationship-graph-resolver.test.ts | 8 +- .../scan/relationship-llm-proposal.test.ts | 8 +- .../scan/relationship-locality.test.ts | 4 +- .../scan/relationship-name-similarity.test.ts | 2 +- .../scan/relationship-profiling.test.ts | 48 +- .../context/scan/relationship-scoring.test.ts | 2 +- .../scan/relationship-validation.test.ts | 39 +- .../context/scan/table-ref.test.ts | 2 +- .../context/scan/type-normalization.test.ts | 2 +- .../{src => test}/context/scan/types.test.ts | 6 +- .../context/scan/warehouse-catalog.test.ts | 15 +- .../backend-conformance.test-utils.test.ts | 14 +- .../search/backend-conformance.test-utils.ts | 2 +- .../context/search/discover.test.ts | 6 +- .../context/search/hybrid-search-core.test.ts | 4 +- .../search/pglite-owner-process.test.ts | 4 +- .../search/pglite-runtime-boundary.test.ts | 2 +- .../context/search/pglite-spike.test.ts | 4 +- .../context/search/query.test.ts | 2 +- .../{src => test}/context/search/rrf.test.ts | 4 +- .../skills/skills-registry.service.test.ts | 2 +- .../context/sl/dictionary-search.test.ts | 4 +- .../context/sl/local-query.test.ts | 6 +- .../{src => test}/context/sl/local-sl.test.ts | 4 +- .../sl/pglite-sl-search-prototype.test.ts | 8 +- .../context/sl/schemas.contract.test.ts | 8 +- .../context/sl/semantic-layer.service.test.ts | 6 +- .../context/sl/sl-dictionary-profile.test.ts | 4 +- .../context/sl/sl-search.service.test.ts | 4 +- .../sl/sqlite-sl-sources-index.test.ts | 2 +- .../sl/tools/connection-id-schema.test.ts | 2 +- .../context/sl/tools/sl-discover.tool.test.ts | 10 +- .../sl/tools/sl-edit-source.tool.test.ts | 8 +- .../tools/sl-read-source.tool.session.test.ts | 8 +- .../context/sl/tools/sl-rollback.tool.test.ts | 8 +- .../context/sl/tools/sl-validate.tool.test.ts | 12 +- .../sl/tools/sl-warehouse-validation.test.ts | 2 +- .../sl/tools/sl-write-source.tool.test.ts | 8 +- .../http-sql-analysis-port.test.ts | 2 +- .../context/test/make-local-git-repo.ts | 2 +- .../tools/context-evidence-tools.test.ts | 22 +- .../context/tools/touched-sl-sources.test.ts | 2 +- .../wiki/knowledge-wiki.service.test.ts | 2 +- .../context/wiki/local-knowledge.test.ts | 4 +- .../wiki/sqlite-knowledge-index.test.ts | 2 +- .../wiki/tools/wiki-list-tags.tool.test.ts | 4 +- .../context/wiki/tools/wiki-read.tool.test.ts | 8 +- .../wiki/tools/wiki-remove.tool.test.ts | 8 +- .../wiki/tools/wiki-search.tool.test.ts | 2 +- .../wiki/tools/wiki-write.tool.test.ts | 8 +- .../context/wiki/wiki-ref-validation.test.ts | 2 +- .../database-tree-picker.test.ts | 73 +- .../cli/{src => test}/demo-assets.test.ts | 2 +- .../cli/{src => test}/demo-metrics.test.ts | 4 +- packages/cli/{src => test}/doctor.test.ts | 2 +- .../embedding-resolution.test.ts | 8 +- .../cli/{src => test}/example-smoke.test.ts | 0 .../lookml/extends-chain/orders.model.lkml | 0 .../lookml/extends-chain/views/base.view.lkml | 0 .../extends-chain/views/orders.view.lkml | 0 .../extends-chain/views/orders_ext.view.lkml | 0 .../lookml/multi-model/marketing.model.lkml | 0 .../lookml/multi-model/orders.model.lkml | 0 .../multi-model/views/campaigns.view.lkml | 0 .../lookml/multi-model/views/orders.view.lkml | 0 .../multi-model/views/shared_dims.view.lkml | 0 .../lookml/single-model/orders.model.lkml | 0 .../single-model/views/customers.view.lkml | 0 .../single-model/views/orders.view.lkml | 0 .../lookml/three-churn/billing.model.lkml | 0 .../lookml/three-churn/customers.model.lkml | 0 .../lookml/three-churn/support.model.lkml | 0 .../billing/billing_churn_risk.view.lkml | 0 .../customers/customer_churn_risk.view.lkml | 0 .../support/support_churn_risk.view.lkml | 0 .../fixtures/metabase/card-ref/cards/10.json | 0 .../fixtures/metabase/card-ref/cards/11.json | 0 .../metabase/card-ref/collections/5.json | 0 .../metabase/card-ref/databases/42.json | 0 .../metabase/card-ref/sync-config.json | 0 .../metabase/multi-collection/cards/1.json | 0 .../metabase/multi-collection/cards/2.json | 0 .../metabase/multi-collection/cards/3.json | 0 .../multi-collection/collections/5.json | 0 .../multi-collection/collections/6.json | 0 .../multi-collection/databases/42.json | 0 .../multi-collection/sync-config.json | 0 .../fixtures/metabase/simple/cards/1.json | 0 .../fixtures/metabase/simple/cards/2.json | 0 .../metabase/simple/collections/5.json | 0 .../metabase/simple/databases/42.json | 0 .../fixtures/metabase/simple/sync-config.json | 0 .../metricflow/dbt-mixed/dbt_project.yml | 0 .../metricflow/dbt-mixed/models/orders.yml | 0 .../extends-chain/metrics/orders_final.yml | 0 .../extends-chain/models/orders.yml | 0 .../extends-chain/models/orders_ext.yml | 0 .../models/marketing/campaigns.yml | 0 .../multi-component/models/sales/orders.yml | 0 .../metricflow/single-model/models/orders.yml | 0 .../data.sqlite | Bin .../expected-links.yaml | 0 .../fixture.yaml | 0 .../snapshot.json | 0 .../expected-links.yaml | 0 .../fixture.yaml | 0 .../snapshot.json | 0 .../expected-links.yaml | 0 .../fixture.yaml | 0 .../snapshot.json | 0 .../data.sqlite | Bin .../expected-links.yaml | 0 .../fixture.yaml | 0 .../snapshot.json | 0 .../expected-links.yaml | 0 .../fixture.yaml | 0 .../snapshot.json | 0 .../data.sqlite | Bin .../expected-links.yaml | 0 .../fixture.yaml | 0 .../snapshot.json | 0 .../demo_b2b_declared_metadata/data.sqlite | Bin .../expected-links.yaml | 0 .../demo_b2b_declared_metadata/fixture.yaml | 0 .../demo_b2b_declared_metadata/snapshot.json | 0 .../data.sqlite | Bin .../expected-links.yaml | 0 .../fixture.yaml | 0 .../snapshot.json | 0 .../data.sqlite | Bin .../expected-links.yaml | 0 .../fixture.yaml | 0 .../snapshot.json | 0 .../data.sqlite | Bin .../expected-links.yaml | 0 .../fixture.yaml | 0 .../snapshot.json | 0 .../data.sqlite | Bin .../expected-links.yaml | 0 .../fixture.yaml | 0 .../snapshot.json | 0 .../expected-links.yaml | 0 .../fixture.yaml | 0 .../snapshot.json | 0 .../data.sqlite | Bin .../expected-links.yaml | 0 .../fixture.yaml | 0 .../snapshot.json | 0 .../data.sqlite | Bin .../expected-links.yaml | 0 .../fixture.yaml | 0 .../snapshot.json | 0 .../data.sqlite | Bin .../expected-links.yaml | 0 .../fixture.yaml | 0 .../snapshot.json | 0 .../expected-links.yaml | 0 .../fixture.yaml | 0 .../snapshot.json | 0 .../data.sqlite.gz | Bin .../expected-links.yaml | 0 .../fixture.yaml | 0 .../snapshot.json.gz | Bin .../column-embeddings.json | 0 .../data.sqlite | Bin .../expected-links.yaml | 0 .../fixture.yaml | 0 .../snapshot.json | 0 packages/cli/{src => test}/index.test.ts | 4 +- .../ingest-query-executor.test.ts | 8 +- .../{src => test}/ingest-report-file.test.ts | 2 +- packages/cli/{src => test}/ingest-viz.test.ts | 8 +- .../cli/{src => test}/ingest.test-utils.ts | 30 +- packages/cli/{src => test}/ingest.test.ts | 20 +- packages/cli/{src => test}/io/logger.test.ts | 2 +- packages/cli/{src => test}/io/mode.test.ts | 4 +- .../cli/{src => test}/io/print-list.test.ts | 6 +- packages/cli/{src => test}/knowledge.test.ts | 8 +- .../llm/embedding-health.test.ts | 2 +- .../llm/embedding-provider.test.ts | 4 +- .../{src => test}/llm/message-builder.test.ts | 4 +- .../{src => test}/llm/model-health.test.ts | 2 +- .../{src => test}/llm/model-provider.test.ts | 2 +- packages/cli/{src => test}/llm/repair.test.ts | 2 +- .../cli/{src => test}/local-adapters.test.ts | 4 +- .../local-scan-connectors.test.ts | 6 +- .../managed-local-embeddings.test.ts | 8 +- .../{src => test}/managed-mcp-daemon.test.ts | 2 +- .../managed-python-command.test.ts | 4 +- .../managed-python-daemon.test.ts | 75 +- .../{src => test}/managed-python-http.test.ts | 2 +- .../managed-python-runtime.test.ts | 2 +- .../cli/{src => test}/mcp-http-server.test.ts | 2 +- .../{src => test}/mcp-server-factory.test.ts | 30 +- .../memory-flow-interactive.test.ts | 4 +- .../{src => test}/memory-flow-tui.test.tsx | 4 +- packages/cli/{src => test}/next-steps.test.ts | 2 +- .../{src => test}/notion-page-picker.test.ts | 6 +- .../{src => test}/print-command-tree.test.ts | 2 +- .../cli/{src => test}/project-dir.test.ts | 2 +- .../{src => test}/project-resolver.test.ts | 2 +- .../{src => test}/prompt-navigation.test.ts | 2 +- packages/cli/{src => test}/proxy-env.test.ts | 2 +- .../{src => test}/public-ingest-copy.test.ts | 2 +- .../cli/{src => test}/public-ingest.test.ts | 8 +- .../runtime-requirements.test.ts | 4 +- packages/cli/{src => test}/runtime.test.ts | 6 +- packages/cli/{src => test}/scan.test.ts | 26 +- .../cli/{src => test}/setup-agents.test.ts | 4 +- .../cli/{src => test}/setup-context.test.ts | 6 +- .../cli/{src => test}/setup-databases.test.ts | 144 +- .../cli/{src => test}/setup-demo-tour.test.ts | 4 +- .../{src => test}/setup-embeddings.test.ts | 43 +- .../cli/{src => test}/setup-interrupt.test.ts | 2 +- .../cli/{src => test}/setup-models.test.ts | 8 +- .../cli/{src => test}/setup-project.test.ts | 6 +- .../cli/{src => test}/setup-prompts.test.ts | 8 +- .../{src => test}/setup-ready-menu.test.ts | 4 +- .../cli/{src => test}/setup-runtime.test.ts | 6 +- .../cli/{src => test}/setup-secrets.test.ts | 2 +- .../setup-sources-notion.test.ts | 10 +- .../cli/{src => test}/setup-sources.test.ts | 10 +- packages/cli/{src => test}/setup.test.ts | 10 +- packages/cli/{src => test}/sl.test.ts | 4 +- .../cli/{src => test}/source-mapping.test.ts | 4 +- packages/cli/{src => test}/sql.test.ts | 12 +- .../{src => test}/standalone-smoke.test.ts | 2 +- .../cli/{src => test}/status-project.test.ts | 6 +- .../telemetry/command-hook.test.ts | 2 +- .../telemetry/demo-detect.test.ts | 2 +- .../{src => test}/telemetry/emitter.test.ts | 4 +- .../telemetry/events.snapshot.test.ts | 2 +- .../{src => test}/telemetry/events.test.ts | 2 +- .../{src => test}/telemetry/identity.test.ts | 2 +- packages/cli/test/telemetry/index.test.ts | 63 + .../telemetry/project-snapshot.test.ts | 2 +- .../telemetry/schema-writer.test.ts | 2 +- .../{src => test}/telemetry/scrubber.test.ts | 2 +- .../cli/{src => test}/text-ingest.test.ts | 6 +- .../{src => test}/tree-picker-state.test.ts | 2 +- .../{src => test}/tree-picker-tui.test.tsx | 4 +- .../cli/{src => test}/viz-fallback.test.ts | 2 +- packages/cli/tsconfig.test.json | 10 + packages/cli/vitest.config.ts | 2 +- scripts/anti-fixture-conditional.test.mjs | 2 +- scripts/build-benchmark-snapshot.test.mjs | 2 +- scripts/check-boundaries.mjs | 27 + scripts/check-boundaries.test.mjs | 43 +- scripts/examples-docs.test.mjs | 8 +- scripts/relationship-benchmark-report.mjs | 2 +- scripts/test-tiering.test.mjs | 64 +- 548 files changed, 5048 insertions(+), 2228 deletions(-) create mode 100644 packages/cli/src/context/connections/dialect-helpers.ts delete mode 100644 packages/cli/src/context/connections/dialects.test.ts create mode 100644 packages/cli/src/context/connections/drivers.ts create mode 100644 packages/cli/src/error-message.ts rename packages/cli/{src => test}/admin-reindex.test.ts (96%) rename packages/cli/{src => test}/admin.test.ts (99%) rename packages/cli/{src => test}/cli-program-telemetry.test.ts (96%) rename packages/cli/{src => test}/cli-program.test.ts (93%) rename packages/cli/{src => test}/command-tree.test.ts (98%) rename packages/cli/{src => test}/commands/mcp-commands.test.ts (97%) rename packages/cli/{src => test}/commands/sql-commands.test.ts (95%) rename packages/cli/{src => test}/connection.test.ts (97%) rename packages/cli/{src => test}/connectors/bigquery/connector.test.ts (93%) rename packages/cli/{src => test}/connectors/bigquery/dialect.test.ts (81%) rename packages/cli/{src => test}/connectors/clickhouse/connector.test.ts (88%) rename packages/cli/{src => test}/connectors/clickhouse/dialect.test.ts (75%) rename packages/cli/{src => test}/connectors/mysql/connector.test.ts (92%) rename packages/cli/{src => test}/connectors/mysql/dialect.test.ts (75%) rename packages/cli/{src => test}/connectors/postgres/connector.test.ts (91%) rename packages/cli/{src => test}/connectors/postgres/dialect.test.ts (64%) rename packages/cli/{src => test}/connectors/postgres/historic-sql-query-client.test.ts (90%) rename packages/cli/{src => test}/connectors/snowflake/connector.test.ts (94%) rename packages/cli/{src => test}/connectors/snowflake/dialect.test.ts (80%) rename packages/cli/{src => test}/connectors/snowflake/identifiers.test.ts (93%) rename packages/cli/{src => test}/connectors/snowflake/sdk-logger.test.ts (96%) rename packages/cli/{src => test}/connectors/sqlite/connector.test.ts (91%) rename packages/cli/{src => test}/connectors/sqlite/dialect.test.ts (95%) rename packages/cli/{src => test}/connectors/sqlserver/connector.test.ts (89%) rename packages/cli/{src => test}/connectors/sqlserver/dialect.test.ts (64%) rename packages/cli/{src => test}/context-build-view.test.ts (99%) rename packages/cli/{src => test}/context/connections/bigquery-identifiers.test.ts (92%) create mode 100644 packages/cli/test/context/connections/dialects.test.ts create mode 100644 packages/cli/test/context/connections/drivers.test.ts rename packages/cli/{src => test}/context/connections/local-query-executor.test.ts (93%) rename packages/cli/{src => test}/context/connections/local-warehouse-descriptor.test.ts (97%) rename packages/cli/{src => test}/context/connections/notion-config.test.ts (98%) rename packages/cli/{src => test}/context/connections/postgres-query-executor.test.ts (96%) rename packages/cli/{src => test}/context/connections/read-only-sql.test.ts (91%) rename packages/cli/{src => test}/context/connections/sqlite-query-executor.test.ts (98%) rename packages/cli/{src => test}/context/core/config-reference.test.ts (96%) rename packages/cli/{src => test}/context/core/git.service.assert-worktree-clean.test.ts (92%) rename packages/cli/{src => test}/context/core/git.service.delete-directories.test.ts (92%) rename packages/cli/{src => test}/context/core/git.service.patch.test.ts (96%) rename packages/cli/{src => test}/context/core/git.service.reset-hard.test.ts (90%) rename packages/cli/{src => test}/context/core/git.service.test.ts (99%) rename packages/cli/{src => test}/context/core/session-worktree.service.test.ts (95%) rename packages/cli/{src => test}/context/daemon/semantic-layer-compute.test.ts (99%) rename packages/cli/{src => test}/context/index-sync/reindex.test.ts (95%) rename packages/cli/{src => test}/context/ingest/action-identity.test.ts (96%) rename packages/cli/{src => test}/context/ingest/adapters/dbt-descriptions/parse-schema.test.ts (97%) rename packages/cli/{src => test}/context/ingest/adapters/dbt/chunk.test.ts (94%) rename packages/cli/{src => test}/context/ingest/adapters/dbt/dbt.adapter.test.ts (92%) rename packages/cli/{src => test}/context/ingest/adapters/dbt/fetch.test.ts (94%) rename packages/cli/{src => test}/context/ingest/adapters/dbt/parse.test.ts (73%) rename packages/cli/{src => test}/context/ingest/adapters/historic-sql/bigquery-query-history-reader.test.ts (95%) rename packages/cli/{src => test}/context/ingest/adapters/historic-sql/buckets.test.ts (95%) rename packages/cli/{src => test}/context/ingest/adapters/historic-sql/chunk-unified.test.ts (98%) create mode 100644 packages/cli/test/context/ingest/adapters/historic-sql/connection-dialect.test.ts rename packages/cli/{src => test}/context/ingest/adapters/historic-sql/detect.test.ts (91%) rename packages/cli/{src => test}/context/ingest/adapters/historic-sql/evidence-tool.test.ts (95%) rename packages/cli/{src => test}/context/ingest/adapters/historic-sql/evidence.test.ts (96%) rename packages/cli/{src => test}/context/ingest/adapters/historic-sql/historic-sql.adapter.test.ts (89%) rename packages/cli/{src => test}/context/ingest/adapters/historic-sql/local-ingest-acceptance.test.ts (93%) rename packages/cli/{src => test}/context/ingest/adapters/historic-sql/pattern-inputs.test.ts (95%) rename packages/cli/{src => test}/context/ingest/adapters/historic-sql/postgres-pgss-reader.test.ts (97%) rename packages/cli/{src => test}/context/ingest/adapters/historic-sql/projection.test.ts (99%) rename packages/cli/{src => test}/context/ingest/adapters/historic-sql/redaction.test.ts (94%) rename packages/cli/{src => test}/context/ingest/adapters/historic-sql/skill-schemas.test.ts (96%) rename packages/cli/{src => test}/context/ingest/adapters/historic-sql/snowflake-query-history-reader.test.ts (94%) rename packages/cli/{src => test}/context/ingest/adapters/historic-sql/stage-unified.test.ts (97%) rename packages/cli/{src => test}/context/ingest/adapters/historic-sql/types.test.ts (97%) rename packages/cli/{src => test}/context/ingest/adapters/live-database/chunk.test.ts (92%) rename packages/cli/{src => test}/context/ingest/adapters/live-database/daemon-introspection.test.ts (97%) rename packages/cli/{src => test}/context/ingest/adapters/live-database/live-database.adapter.test.ts (94%) rename packages/cli/{src => test}/context/ingest/adapters/live-database/manifest.test.ts (99%) rename packages/cli/{src => test}/context/ingest/adapters/live-database/stage.test.ts (97%) rename packages/cli/{src => test}/context/ingest/adapters/looker/chunk.test.ts (96%) rename packages/cli/{src => test}/context/ingest/adapters/looker/client-boundary.test.ts (77%) rename packages/cli/{src => test}/context/ingest/adapters/looker/client.test.ts (99%) rename packages/cli/{src => test}/context/ingest/adapters/looker/daemon-table-identifier-parser.test.ts (90%) rename packages/cli/{src => test}/context/ingest/adapters/looker/detect.test.ts (94%) rename packages/cli/{src => test}/context/ingest/adapters/looker/evidence-documents.test.ts (98%) rename packages/cli/{src => test}/context/ingest/adapters/looker/factory.test.ts (86%) rename packages/cli/{src => test}/context/ingest/adapters/looker/fetch-report.test.ts (97%) rename packages/cli/{src => test}/context/ingest/adapters/looker/fetch.test.ts (99%) rename packages/cli/{src => test}/context/ingest/adapters/looker/local-runtime-store.test.ts (96%) rename packages/cli/{src => test}/context/ingest/adapters/looker/looker.adapter.test.ts (95%) rename packages/cli/{src => test}/context/ingest/adapters/looker/mapping.test.ts (98%) rename packages/cli/{src => test}/context/ingest/adapters/looker/reconcile.test.ts (84%) rename packages/cli/{src => test}/context/ingest/adapters/looker/scope.test.ts (98%) rename packages/cli/{src => test}/context/ingest/adapters/looker/target-connections.test.ts (95%) rename packages/cli/{src => test}/context/ingest/adapters/looker/tools/looker-query-to-sl.tool.test.ts (97%) rename packages/cli/{src => test}/context/ingest/adapters/looker/types.test.ts (98%) rename packages/cli/{src => test}/context/ingest/adapters/lookml/chunk.test.ts (97%) rename packages/cli/{src => test}/context/ingest/adapters/lookml/detect.test.ts (94%) rename packages/cli/{src => test}/context/ingest/adapters/lookml/fetch-report.test.ts (95%) rename packages/cli/{src => test}/context/ingest/adapters/lookml/fetch.test.ts (95%) rename packages/cli/{src => test}/context/ingest/adapters/lookml/graph.test.ts (96%) rename packages/cli/{src => test}/context/ingest/adapters/lookml/lookml.adapter.test.ts (92%) rename packages/cli/{src => test}/context/ingest/adapters/lookml/parse.test.ts (97%) rename packages/cli/{src => test}/context/ingest/adapters/lookml/pull-config.test.ts (98%) rename packages/cli/{src => test}/context/ingest/adapters/metabase/card-references.test.ts (96%) rename packages/cli/{src => test}/context/ingest/adapters/metabase/chunk.test.ts (97%) rename packages/cli/{src => test}/context/ingest/adapters/metabase/client-boundary.test.ts (92%) rename packages/cli/{src => test}/context/ingest/adapters/metabase/client-port.test.ts (92%) rename packages/cli/{src => test}/context/ingest/adapters/metabase/client.test.ts (98%) rename packages/cli/{src => test}/context/ingest/adapters/metabase/detect.test.ts (94%) rename packages/cli/{src => test}/context/ingest/adapters/metabase/fanout-planner.test.ts (94%) rename packages/cli/{src => test}/context/ingest/adapters/metabase/fetch-scope.test.ts (96%) rename packages/cli/{src => test}/context/ingest/adapters/metabase/fetch.test.ts (99%) rename packages/cli/{src => test}/context/ingest/adapters/metabase/local-metabase.adapter.test.ts (91%) rename packages/cli/{src => test}/context/ingest/adapters/metabase/local-source-state-store.test.ts (93%) rename packages/cli/{src => test}/context/ingest/adapters/metabase/mapping.test.ts (98%) rename packages/cli/{src => test}/context/ingest/adapters/metabase/metabase.adapter.test.ts (97%) rename packages/cli/{src => test}/context/ingest/adapters/metabase/serialize-card.test.ts (98%) rename packages/cli/{src => test}/context/ingest/adapters/metabase/types.test.ts (97%) rename packages/cli/{src => test}/context/ingest/adapters/metricflow/chunk.test.ts (94%) rename packages/cli/{src => test}/context/ingest/adapters/metricflow/deep-parse.test.ts (99%) rename packages/cli/{src => test}/context/ingest/adapters/metricflow/detect.test.ts (94%) rename packages/cli/{src => test}/context/ingest/adapters/metricflow/fetch.test.ts (97%) rename packages/cli/{src => test}/context/ingest/adapters/metricflow/graph.test.ts (97%) rename packages/cli/{src => test}/context/ingest/adapters/metricflow/import-semantic-models.test.ts (98%) rename packages/cli/{src => test}/context/ingest/adapters/metricflow/metricflow.adapter.test.ts (95%) rename packages/cli/{src => test}/context/ingest/adapters/metricflow/parse.test.ts (98%) rename packages/cli/{src => test}/context/ingest/adapters/metricflow/pull-config.test.ts (95%) rename packages/cli/{src => test}/context/ingest/adapters/metricflow/semantic-models.test.ts (96%) rename packages/cli/{src => test}/context/ingest/adapters/notion/cluster.test.ts (94%) rename packages/cli/{src => test}/context/ingest/adapters/notion/fetch.test.ts (98%) rename packages/cli/{src => test}/context/ingest/adapters/notion/local-state-store.test.ts (91%) rename packages/cli/{src => test}/context/ingest/adapters/notion/normalize.test.ts (96%) rename packages/cli/{src => test}/context/ingest/adapters/notion/notion-client.test.ts (95%) rename packages/cli/{src => test}/context/ingest/adapters/notion/notion.adapter.test.ts (97%) rename packages/cli/{src => test}/context/ingest/artifact-gates.test.ts (99%) rename packages/cli/{src => test}/context/ingest/canonical-pins.test.ts (92%) rename packages/cli/{src => test}/context/ingest/clustering/kmeans.test.ts (95%) rename packages/cli/{src => test}/context/ingest/context-candidates/candidate-dedup.service.test.ts (96%) rename packages/cli/{src => test}/context/ingest/context-candidates/context-candidate-carryforward.service.test.ts (94%) rename packages/cli/{src => test}/context/ingest/context-candidates/curator-pagination.service.test.ts (96%) rename packages/cli/{src => test}/context/ingest/context-candidates/embedding-text.test.ts (78%) rename packages/cli/{src => test}/context/ingest/context-candidates/store.test.ts (89%) rename packages/cli/{src => test}/context/ingest/context-evidence/context-evidence-index.service.test.ts (97%) rename packages/cli/{src => test}/context/ingest/context-evidence/sqlite-context-evidence-store.test.ts (98%) rename packages/cli/{src => test}/context/ingest/context-evidence/store.test.ts (92%) rename packages/cli/{src => test}/context/ingest/dbt-shared/project-vars.test.ts (98%) rename packages/cli/{src => test}/context/ingest/dbt-shared/schema-files.test.ts (93%) rename packages/cli/{src => test}/context/ingest/diff-set.service.test.ts (97%) rename packages/cli/{src => test}/context/ingest/final-gate-repair.test.ts (96%) rename packages/cli/{src => test}/context/ingest/finalization-scope.test.ts (98%) rename packages/cli/{src => test}/context/ingest/historic-sql-probes.test.ts (96%) rename packages/cli/{src => test}/context/ingest/historic-sql-probes/bigquery-runner.test.ts (93%) rename packages/cli/{src => test}/context/ingest/historic-sql-probes/postgres-runner.test.ts (94%) rename packages/cli/{src => test}/context/ingest/historic-sql-probes/snowflake-runner.test.ts (91%) rename packages/cli/{src => test}/context/ingest/ingest-bundle.runner.isolated-diff.test.ts (99%) rename packages/cli/{src => test}/context/ingest/ingest-bundle.runner.test.ts (99%) rename packages/cli/{src => test}/context/ingest/ingest-prompts.test.ts (79%) rename packages/cli/{src => test}/context/ingest/ingest-runtime-assets.test.ts (92%) rename packages/cli/{src => test}/context/ingest/ingest-trace.test.ts (98%) rename packages/cli/{src => test}/context/ingest/isolated-diff/git-patch.test.ts (97%) rename packages/cli/{src => test}/context/ingest/isolated-diff/patch-integrator.test.ts (98%) rename packages/cli/{src => test}/context/ingest/isolated-diff/textual-conflict-resolver.test.ts (94%) rename packages/cli/{src => test}/context/ingest/isolated-diff/work-unit-executor.test.ts (95%) rename packages/cli/{src => test}/context/ingest/local-adapters.test.ts (97%) rename packages/cli/{src => test}/context/ingest/local-bundle-ingest.test.ts (97%) rename packages/cli/{src => test}/context/ingest/local-bundle-runtime.test.ts (97%) rename packages/cli/{src => test}/context/ingest/local-embedding-provider.integration.test.ts (90%) rename packages/cli/{src => test}/context/ingest/local-mapping-reconcile.test.ts (87%) rename packages/cli/{src => test}/context/ingest/local-metabase-ingest.test.ts (95%) rename packages/cli/{src => test}/context/ingest/local-stage-ingest.test.ts (97%) rename packages/cli/{src => test}/context/ingest/memory-flow/acceptance-fixtures.ts (98%) rename packages/cli/{src => test}/context/ingest/memory-flow/acceptance.test.ts (92%) rename packages/cli/{src => test}/context/ingest/memory-flow/events.test.ts (97%) rename packages/cli/{src => test}/context/ingest/memory-flow/interaction.test.ts (98%) rename packages/cli/{src => test}/context/ingest/memory-flow/interactive-render.test.ts (95%) rename packages/cli/{src => test}/context/ingest/memory-flow/live-buffer.test.ts (95%) rename packages/cli/{src => test}/context/ingest/memory-flow/render.test.ts (95%) rename packages/cli/{src => test}/context/ingest/memory-flow/schema.test.ts (97%) rename packages/cli/{src => test}/context/ingest/memory-flow/summary.test.ts (96%) rename packages/cli/{src => test}/context/ingest/memory-flow/view-model.test.ts (98%) rename packages/cli/{src => test}/context/ingest/memory-flow/visuals.test.ts (94%) rename packages/cli/{src => test}/context/ingest/page-triage/page-triage.service.test.ts (99%) rename packages/cli/{src => test}/context/ingest/raw-sources-paths.test.ts (92%) rename packages/cli/{src => test}/context/ingest/repo-fetch.test.ts (95%) rename packages/cli/{src => test}/context/ingest/report-snapshot.test.ts (99%) rename packages/cli/{src => test}/context/ingest/semantic-layer-target-policy.test.ts (95%) rename packages/cli/{src => test}/context/ingest/source-adapter-registry.test.ts (87%) rename packages/cli/{src => test}/context/ingest/sqlite-bundle-ingest-store.test.ts (98%) rename packages/cli/{src => test}/context/ingest/sqlite-local-ingest-store.test.ts (95%) rename packages/cli/{src => test}/context/ingest/stages/build-reconcile-context.context-candidates.test.ts (97%) rename packages/cli/{src => test}/context/ingest/stages/build-reconcile-context.test.ts (98%) rename packages/cli/{src => test}/context/ingest/stages/build-wu-context.test.ts (99%) rename packages/cli/{src => test}/context/ingest/stages/stage-1-stage-raw-files.test.ts (95%) rename packages/cli/{src => test}/context/ingest/stages/stage-3-work-units.test.ts (96%) rename packages/cli/{src => test}/context/ingest/stages/stage-4-reconciliation.test.ts (97%) rename packages/cli/{src => test}/context/ingest/stages/validate-wu-sources.test.ts (93%) rename packages/cli/{src => test}/context/ingest/tools/emit-reconciliation-records.tool.test.ts (93%) rename packages/cli/{src => test}/context/ingest/tools/eviction-list.tool.test.ts (94%) rename packages/cli/{src => test}/context/ingest/tools/read-raw-file.tool.test.ts (96%) rename packages/cli/{src => test}/context/ingest/tools/read-raw-span.tool.test.ts (95%) rename packages/cli/{src => test}/context/ingest/tools/stage-diff.tool.test.ts (97%) rename packages/cli/{src => test}/context/ingest/tools/stage-list.tool.test.ts (95%) rename packages/cli/{src => test}/context/ingest/tools/tool-transcript-summary.test.ts (97%) rename packages/cli/{src => test}/context/ingest/tools/warehouse-verification/discover-data.tool.test.ts (94%) rename packages/cli/{src => test}/context/ingest/tools/warehouse-verification/entity-details.tool.test.ts (95%) rename packages/cli/{src => test}/context/ingest/tools/warehouse-verification/sql-execution.tool.test.ts (89%) rename packages/cli/{src => test}/context/ingest/wiki-body-refs.test.ts (98%) rename packages/cli/{src => test}/context/ingest/wiki-sl-ref-repair.test.ts (97%) rename packages/cli/{src => test}/context/llm/ai-sdk-runtime.test.ts (98%) rename packages/cli/{src => test}/context/llm/claude-code-env.test.ts (92%) rename packages/cli/{src => test}/context/llm/claude-code-models.test.ts (85%) rename packages/cli/{src => test}/context/llm/claude-code-runtime.test.ts (99%) rename packages/cli/{src => test}/context/llm/debug-request-recorder.test.ts (98%) rename packages/cli/{src => test}/context/llm/embedding-port.test.ts (95%) rename packages/cli/{src => test}/context/llm/local-config.test.ts (98%) rename packages/cli/{src => test}/context/llm/runtime-local-config.test.ts (93%) rename packages/cli/{src => test}/context/llm/runtime-tools.test.ts (91%) create mode 100644 packages/cli/test/context/mcp/__snapshots__/mcp-tools-list.json rename packages/cli/{src => test}/context/mcp/local-project-ports.test.ts (98%) rename packages/cli/{src => test}/context/mcp/server.test.ts (98%) rename packages/cli/{src => test}/context/memory/local-memory.test.ts (96%) rename packages/cli/{src => test}/context/memory/memory-agent.service.ingest.test.ts (98%) rename packages/cli/{src => test}/context/memory/memory-agent.service.test.ts (98%) rename packages/cli/{src => test}/context/memory/memory-runs.test.ts (95%) rename packages/cli/{src => test}/context/memory/memory-runtime-assets.test.ts (94%) rename packages/cli/{src => test}/context/project/config.test.ts (99%) rename packages/cli/{src => test}/context/project/driver-schemas.test.ts (98%) rename packages/cli/{src => test}/context/project/local-git-file-store.test.ts (94%) rename packages/cli/{src => test}/context/project/mappings-yaml-schema.test.ts (98%) rename packages/cli/{src => test}/context/project/project.test.ts (96%) rename packages/cli/{src => test}/context/project/setup-config.test.ts (93%) rename packages/cli/{src => test}/context/prompts/prompt.service.test.ts (95%) rename packages/cli/{src => test}/context/scan/constraint-discovery.test.ts (97%) rename packages/cli/{src => test}/context/scan/credentials.test.ts (96%) rename packages/cli/{src => test}/context/scan/data-dictionary.test.ts (99%) rename packages/cli/{src => test}/context/scan/description-generation.test.ts (99%) rename packages/cli/{src => test}/context/scan/embedding-text.test.ts (94%) rename packages/cli/{src => test}/context/scan/enrichment-state.test.ts (95%) rename packages/cli/{src => test}/context/scan/enrichment-summary.test.ts (96%) rename packages/cli/{src => test}/context/scan/enrichment-types.test.ts (99%) rename packages/cli/{src => test}/context/scan/entity-details.test.ts (91%) rename packages/cli/{src => test}/context/scan/local-enrichment-artifacts.test.ts (98%) rename packages/cli/{src => test}/context/scan/local-enrichment.test.ts (96%) rename packages/cli/{src => test}/context/scan/local-scan.test.ts (98%) rename packages/cli/{src => test}/context/scan/local-structural-artifacts.test.ts (97%) rename packages/cli/{src => test}/context/scan/relationship-benchmark-report.test.ts (99%) rename packages/cli/{src => test}/context/scan/relationship-benchmarks.test.ts (96%) rename packages/cli/{src => test}/context/scan/relationship-budget.test.ts (97%) rename packages/cli/{src => test}/context/scan/relationship-candidates.test.ts (98%) rename packages/cli/{src => test}/context/scan/relationship-composite-candidates.test.ts (80%) rename packages/cli/{src => test}/context/scan/relationship-diagnostics.test.ts (98%) rename packages/cli/{src => test}/context/scan/relationship-discovery.test.ts (94%) rename packages/cli/{src => test}/context/scan/relationship-formal-metadata.test.ts (96%) rename packages/cli/{src => test}/context/scan/relationship-graph-resolver.test.ts (98%) rename packages/cli/{src => test}/context/scan/relationship-llm-proposal.test.ts (94%) rename packages/cli/{src => test}/context/scan/relationship-locality.test.ts (96%) rename packages/cli/{src => test}/context/scan/relationship-name-similarity.test.ts (97%) rename packages/cli/{src => test}/context/scan/relationship-profiling.test.ts (90%) rename packages/cli/{src => test}/context/scan/relationship-scoring.test.ts (98%) rename packages/cli/{src => test}/context/scan/relationship-validation.test.ts (93%) rename packages/cli/{src => test}/context/scan/table-ref.test.ts (98%) rename packages/cli/{src => test}/context/scan/type-normalization.test.ts (92%) rename packages/cli/{src => test}/context/scan/types.test.ts (97%) rename packages/cli/{src => test}/context/scan/warehouse-catalog.test.ts (92%) rename packages/cli/{src => test}/context/search/backend-conformance.test-utils.test.ts (95%) rename packages/cli/{src => test}/context/search/backend-conformance.test-utils.ts (99%) rename packages/cli/{src => test}/context/search/discover.test.ts (97%) rename packages/cli/{src => test}/context/search/hybrid-search-core.test.ts (96%) rename packages/cli/{src => test}/context/search/pglite-owner-process.test.ts (98%) rename packages/cli/{src => test}/context/search/pglite-runtime-boundary.test.ts (96%) rename packages/cli/{src => test}/context/search/pglite-spike.test.ts (98%) rename packages/cli/{src => test}/context/search/query.test.ts (95%) rename packages/cli/{src => test}/context/search/rrf.test.ts (90%) rename packages/cli/{src => test}/context/skills/skills-registry.service.test.ts (98%) rename packages/cli/{src => test}/context/sl/dictionary-search.test.ts (97%) rename packages/cli/{src => test}/context/sl/local-query.test.ts (97%) rename packages/cli/{src => test}/context/sl/local-sl.test.ts (98%) rename packages/cli/{src => test}/context/sl/pglite-sl-search-prototype.test.ts (95%) rename packages/cli/{src => test}/context/sl/schemas.contract.test.ts (88%) rename packages/cli/{src => test}/context/sl/semantic-layer.service.test.ts (99%) rename packages/cli/{src => test}/context/sl/sl-dictionary-profile.test.ts (94%) rename packages/cli/{src => test}/context/sl/sl-search.service.test.ts (98%) rename packages/cli/{src => test}/context/sl/sqlite-sl-sources-index.test.ts (98%) rename packages/cli/{src => test}/context/sl/tools/connection-id-schema.test.ts (87%) rename packages/cli/{src => test}/context/sl/tools/sl-discover.tool.test.ts (86%) rename packages/cli/{src => test}/context/sl/tools/sl-edit-source.tool.test.ts (96%) rename packages/cli/{src => test}/context/sl/tools/sl-read-source.tool.session.test.ts (87%) rename packages/cli/{src => test}/context/sl/tools/sl-rollback.tool.test.ts (90%) rename packages/cli/{src => test}/context/sl/tools/sl-validate.tool.test.ts (84%) rename packages/cli/{src => test}/context/sl/tools/sl-warehouse-validation.test.ts (98%) rename packages/cli/{src => test}/context/sl/tools/sl-write-source.tool.test.ts (97%) rename packages/cli/{src => test}/context/sql-analysis/http-sql-analysis-port.test.ts (98%) rename packages/cli/{src => test}/context/test/make-local-git-repo.ts (95%) rename packages/cli/{src => test}/context/tools/context-evidence-tools.test.ts (95%) rename packages/cli/{src => test}/context/tools/touched-sl-sources.test.ts (96%) rename packages/cli/{src => test}/context/wiki/knowledge-wiki.service.test.ts (98%) rename packages/cli/{src => test}/context/wiki/local-knowledge.test.ts (98%) rename packages/cli/{src => test}/context/wiki/sqlite-knowledge-index.test.ts (98%) rename packages/cli/{src => test}/context/wiki/tools/wiki-list-tags.tool.test.ts (91%) rename packages/cli/{src => test}/context/wiki/tools/wiki-read.tool.test.ts (91%) rename packages/cli/{src => test}/context/wiki/tools/wiki-remove.tool.test.ts (93%) rename packages/cli/{src => test}/context/wiki/tools/wiki-search.tool.test.ts (93%) rename packages/cli/{src => test}/context/wiki/tools/wiki-write.tool.test.ts (97%) rename packages/cli/{src => test}/context/wiki/wiki-ref-validation.test.ts (96%) rename packages/cli/{src => test}/database-tree-picker.test.ts (73%) rename packages/cli/{src => test}/demo-assets.test.ts (99%) rename packages/cli/{src => test}/demo-metrics.test.ts (97%) rename packages/cli/{src => test}/doctor.test.ts (99%) rename packages/cli/{src => test}/embedding-resolution.test.ts (94%) rename packages/cli/{src => test}/example-smoke.test.ts (100%) rename packages/cli/{src => }/test/fixtures/lookml/extends-chain/orders.model.lkml (100%) rename packages/cli/{src => }/test/fixtures/lookml/extends-chain/views/base.view.lkml (100%) rename packages/cli/{src => }/test/fixtures/lookml/extends-chain/views/orders.view.lkml (100%) rename packages/cli/{src => }/test/fixtures/lookml/extends-chain/views/orders_ext.view.lkml (100%) rename packages/cli/{src => }/test/fixtures/lookml/multi-model/marketing.model.lkml (100%) rename packages/cli/{src => }/test/fixtures/lookml/multi-model/orders.model.lkml (100%) rename packages/cli/{src => }/test/fixtures/lookml/multi-model/views/campaigns.view.lkml (100%) rename packages/cli/{src => }/test/fixtures/lookml/multi-model/views/orders.view.lkml (100%) rename packages/cli/{src => }/test/fixtures/lookml/multi-model/views/shared_dims.view.lkml (100%) rename packages/cli/{src => }/test/fixtures/lookml/single-model/orders.model.lkml (100%) rename packages/cli/{src => }/test/fixtures/lookml/single-model/views/customers.view.lkml (100%) rename packages/cli/{src => }/test/fixtures/lookml/single-model/views/orders.view.lkml (100%) rename packages/cli/{src => }/test/fixtures/lookml/three-churn/billing.model.lkml (100%) rename packages/cli/{src => }/test/fixtures/lookml/three-churn/customers.model.lkml (100%) rename packages/cli/{src => }/test/fixtures/lookml/three-churn/support.model.lkml (100%) rename packages/cli/{src => }/test/fixtures/lookml/three-churn/views/billing/billing_churn_risk.view.lkml (100%) rename packages/cli/{src => }/test/fixtures/lookml/three-churn/views/customers/customer_churn_risk.view.lkml (100%) rename packages/cli/{src => }/test/fixtures/lookml/three-churn/views/support/support_churn_risk.view.lkml (100%) rename packages/cli/{src => }/test/fixtures/metabase/card-ref/cards/10.json (100%) rename packages/cli/{src => }/test/fixtures/metabase/card-ref/cards/11.json (100%) rename packages/cli/{src => }/test/fixtures/metabase/card-ref/collections/5.json (100%) rename packages/cli/{src => }/test/fixtures/metabase/card-ref/databases/42.json (100%) rename packages/cli/{src => }/test/fixtures/metabase/card-ref/sync-config.json (100%) rename packages/cli/{src => }/test/fixtures/metabase/multi-collection/cards/1.json (100%) rename packages/cli/{src => }/test/fixtures/metabase/multi-collection/cards/2.json (100%) rename packages/cli/{src => }/test/fixtures/metabase/multi-collection/cards/3.json (100%) rename packages/cli/{src => }/test/fixtures/metabase/multi-collection/collections/5.json (100%) rename packages/cli/{src => }/test/fixtures/metabase/multi-collection/collections/6.json (100%) rename packages/cli/{src => }/test/fixtures/metabase/multi-collection/databases/42.json (100%) rename packages/cli/{src => }/test/fixtures/metabase/multi-collection/sync-config.json (100%) rename packages/cli/{src => }/test/fixtures/metabase/simple/cards/1.json (100%) rename packages/cli/{src => }/test/fixtures/metabase/simple/cards/2.json (100%) rename packages/cli/{src => }/test/fixtures/metabase/simple/collections/5.json (100%) rename packages/cli/{src => }/test/fixtures/metabase/simple/databases/42.json (100%) rename packages/cli/{src => }/test/fixtures/metabase/simple/sync-config.json (100%) rename packages/cli/{src => }/test/fixtures/metricflow/dbt-mixed/dbt_project.yml (100%) rename packages/cli/{src => }/test/fixtures/metricflow/dbt-mixed/models/orders.yml (100%) rename packages/cli/{src => }/test/fixtures/metricflow/extends-chain/metrics/orders_final.yml (100%) rename packages/cli/{src => }/test/fixtures/metricflow/extends-chain/models/orders.yml (100%) rename packages/cli/{src => }/test/fixtures/metricflow/extends-chain/models/orders_ext.yml (100%) rename packages/cli/{src => }/test/fixtures/metricflow/multi-component/models/marketing/campaigns.yml (100%) rename packages/cli/{src => }/test/fixtures/metricflow/multi-component/models/sales/orders.yml (100%) rename packages/cli/{src => }/test/fixtures/metricflow/single-model/models/orders.yml (100%) rename packages/cli/{src => }/test/fixtures/relationship-benchmarks/abbreviated_old_no_declared_constraints/data.sqlite (100%) rename packages/cli/{src => }/test/fixtures/relationship-benchmarks/abbreviated_old_no_declared_constraints/expected-links.yaml (100%) rename packages/cli/{src => }/test/fixtures/relationship-benchmarks/abbreviated_old_no_declared_constraints/fixture.yaml (100%) rename packages/cli/{src => }/test/fixtures/relationship-benchmarks/abbreviated_old_no_declared_constraints/snapshot.json (100%) rename packages/cli/{src => }/test/fixtures/relationship-benchmarks/adventureworks_oltp_with_declared_metadata/expected-links.yaml (100%) rename packages/cli/{src => }/test/fixtures/relationship-benchmarks/adventureworks_oltp_with_declared_metadata/fixture.yaml (100%) rename packages/cli/{src => }/test/fixtures/relationship-benchmarks/adventureworks_oltp_with_declared_metadata/snapshot.json (100%) rename packages/cli/{src => }/test/fixtures/relationship-benchmarks/adventureworkslt_with_declared_metadata/expected-links.yaml (100%) rename packages/cli/{src => }/test/fixtures/relationship-benchmarks/adventureworkslt_with_declared_metadata/fixture.yaml (100%) rename packages/cli/{src => }/test/fixtures/relationship-benchmarks/adventureworkslt_with_declared_metadata/snapshot.json (100%) rename packages/cli/{src => }/test/fixtures/relationship-benchmarks/analytical_warehouse_no_naming_convention/data.sqlite (100%) rename packages/cli/{src => }/test/fixtures/relationship-benchmarks/analytical_warehouse_no_naming_convention/expected-links.yaml (100%) rename packages/cli/{src => }/test/fixtures/relationship-benchmarks/analytical_warehouse_no_naming_convention/fixture.yaml (100%) rename packages/cli/{src => }/test/fixtures/relationship-benchmarks/analytical_warehouse_no_naming_convention/snapshot.json (100%) rename packages/cli/{src => }/test/fixtures/relationship-benchmarks/chinook_with_declared_metadata/expected-links.yaml (100%) rename packages/cli/{src => }/test/fixtures/relationship-benchmarks/chinook_with_declared_metadata/fixture.yaml (100%) rename packages/cli/{src => }/test/fixtures/relationship-benchmarks/chinook_with_declared_metadata/snapshot.json (100%) rename packages/cli/{src => }/test/fixtures/relationship-benchmarks/composite_keys_no_declared_constraints/data.sqlite (100%) rename packages/cli/{src => }/test/fixtures/relationship-benchmarks/composite_keys_no_declared_constraints/expected-links.yaml (100%) rename packages/cli/{src => }/test/fixtures/relationship-benchmarks/composite_keys_no_declared_constraints/fixture.yaml (100%) rename packages/cli/{src => }/test/fixtures/relationship-benchmarks/composite_keys_no_declared_constraints/snapshot.json (100%) rename packages/cli/{src => }/test/fixtures/relationship-benchmarks/demo_b2b_declared_metadata/data.sqlite (100%) rename packages/cli/{src => }/test/fixtures/relationship-benchmarks/demo_b2b_declared_metadata/expected-links.yaml (100%) rename packages/cli/{src => }/test/fixtures/relationship-benchmarks/demo_b2b_declared_metadata/fixture.yaml (100%) rename packages/cli/{src => }/test/fixtures/relationship-benchmarks/demo_b2b_declared_metadata/snapshot.json (100%) rename packages/cli/{src => }/test/fixtures/relationship-benchmarks/demo_b2b_no_declared_constraints/data.sqlite (100%) rename packages/cli/{src => }/test/fixtures/relationship-benchmarks/demo_b2b_no_declared_constraints/expected-links.yaml (100%) rename packages/cli/{src => }/test/fixtures/relationship-benchmarks/demo_b2b_no_declared_constraints/fixture.yaml (100%) rename packages/cli/{src => }/test/fixtures/relationship-benchmarks/demo_b2b_no_declared_constraints/snapshot.json (100%) rename packages/cli/{src => }/test/fixtures/relationship-benchmarks/mixed_case_within_schema_no_declared_constraints/data.sqlite (100%) rename packages/cli/{src => }/test/fixtures/relationship-benchmarks/mixed_case_within_schema_no_declared_constraints/expected-links.yaml (100%) rename packages/cli/{src => }/test/fixtures/relationship-benchmarks/mixed_case_within_schema_no_declared_constraints/fixture.yaml (100%) rename packages/cli/{src => }/test/fixtures/relationship-benchmarks/mixed_case_within_schema_no_declared_constraints/snapshot.json (100%) rename packages/cli/{src => }/test/fixtures/relationship-benchmarks/natural_keys_no_declared_constraints/data.sqlite (100%) rename packages/cli/{src => }/test/fixtures/relationship-benchmarks/natural_keys_no_declared_constraints/expected-links.yaml (100%) rename packages/cli/{src => }/test/fixtures/relationship-benchmarks/natural_keys_no_declared_constraints/fixture.yaml (100%) rename packages/cli/{src => }/test/fixtures/relationship-benchmarks/natural_keys_no_declared_constraints/snapshot.json (100%) rename packages/cli/{src => }/test/fixtures/relationship-benchmarks/non_english_naming_no_declared_constraints/data.sqlite (100%) rename packages/cli/{src => }/test/fixtures/relationship-benchmarks/non_english_naming_no_declared_constraints/expected-links.yaml (100%) rename packages/cli/{src => }/test/fixtures/relationship-benchmarks/non_english_naming_no_declared_constraints/fixture.yaml (100%) rename packages/cli/{src => }/test/fixtures/relationship-benchmarks/non_english_naming_no_declared_constraints/snapshot.json (100%) rename packages/cli/{src => }/test/fixtures/relationship-benchmarks/northwind_with_declared_metadata/expected-links.yaml (100%) rename packages/cli/{src => }/test/fixtures/relationship-benchmarks/northwind_with_declared_metadata/fixture.yaml (100%) rename packages/cli/{src => }/test/fixtures/relationship-benchmarks/northwind_with_declared_metadata/snapshot.json (100%) rename packages/cli/{src => }/test/fixtures/relationship-benchmarks/orbit_style_product_no_declared_constraints/data.sqlite (100%) rename packages/cli/{src => }/test/fixtures/relationship-benchmarks/orbit_style_product_no_declared_constraints/expected-links.yaml (100%) rename packages/cli/{src => }/test/fixtures/relationship-benchmarks/orbit_style_product_no_declared_constraints/fixture.yaml (100%) rename packages/cli/{src => }/test/fixtures/relationship-benchmarks/orbit_style_product_no_declared_constraints/snapshot.json (100%) rename packages/cli/{src => }/test/fixtures/relationship-benchmarks/plan_code_no_declared_constraints/data.sqlite (100%) rename packages/cli/{src => }/test/fixtures/relationship-benchmarks/plan_code_no_declared_constraints/expected-links.yaml (100%) rename packages/cli/{src => }/test/fixtures/relationship-benchmarks/plan_code_no_declared_constraints/fixture.yaml (100%) rename packages/cli/{src => }/test/fixtures/relationship-benchmarks/plan_code_no_declared_constraints/snapshot.json (100%) rename packages/cli/{src => }/test/fixtures/relationship-benchmarks/polymorphic_partial_overlap_no_declared_constraints/data.sqlite (100%) rename packages/cli/{src => }/test/fixtures/relationship-benchmarks/polymorphic_partial_overlap_no_declared_constraints/expected-links.yaml (100%) rename packages/cli/{src => }/test/fixtures/relationship-benchmarks/polymorphic_partial_overlap_no_declared_constraints/fixture.yaml (100%) rename packages/cli/{src => }/test/fixtures/relationship-benchmarks/polymorphic_partial_overlap_no_declared_constraints/snapshot.json (100%) rename packages/cli/{src => }/test/fixtures/relationship-benchmarks/sakila_with_declared_metadata/expected-links.yaml (100%) rename packages/cli/{src => }/test/fixtures/relationship-benchmarks/sakila_with_declared_metadata/fixture.yaml (100%) rename packages/cli/{src => }/test/fixtures/relationship-benchmarks/sakila_with_declared_metadata/snapshot.json (100%) rename packages/cli/{src => }/test/fixtures/relationship-benchmarks/scale_stress_no_declared_constraints/data.sqlite.gz (100%) rename packages/cli/{src => }/test/fixtures/relationship-benchmarks/scale_stress_no_declared_constraints/expected-links.yaml (100%) rename packages/cli/{src => }/test/fixtures/relationship-benchmarks/scale_stress_no_declared_constraints/fixture.yaml (100%) rename packages/cli/{src => }/test/fixtures/relationship-benchmarks/scale_stress_no_declared_constraints/snapshot.json.gz (100%) rename packages/cli/{src => }/test/fixtures/relationship-benchmarks/semantic_embedding_aliases_no_declared_constraints/column-embeddings.json (100%) rename packages/cli/{src => }/test/fixtures/relationship-benchmarks/semantic_embedding_aliases_no_declared_constraints/data.sqlite (100%) rename packages/cli/{src => }/test/fixtures/relationship-benchmarks/semantic_embedding_aliases_no_declared_constraints/expected-links.yaml (100%) rename packages/cli/{src => }/test/fixtures/relationship-benchmarks/semantic_embedding_aliases_no_declared_constraints/fixture.yaml (100%) rename packages/cli/{src => }/test/fixtures/relationship-benchmarks/semantic_embedding_aliases_no_declared_constraints/snapshot.json (100%) rename packages/cli/{src => test}/index.test.ts (99%) rename packages/cli/{src => test}/ingest-query-executor.test.ts (89%) rename packages/cli/{src => test}/ingest-report-file.test.ts (96%) rename packages/cli/{src => test}/ingest-viz.test.ts (99%) rename packages/cli/{src => test}/ingest.test-utils.ts (95%) rename packages/cli/{src => test}/ingest.test.ts (98%) rename packages/cli/{src => test}/io/logger.test.ts (97%) rename packages/cli/{src => test}/io/mode.test.ts (95%) rename packages/cli/{src => test}/io/print-list.test.ts (98%) rename packages/cli/{src => test}/knowledge.test.ts (96%) rename packages/cli/{src => test}/llm/embedding-health.test.ts (97%) rename packages/cli/{src => test}/llm/embedding-provider.test.ts (96%) rename packages/cli/{src => test}/llm/message-builder.test.ts (97%) rename packages/cli/{src => test}/llm/model-health.test.ts (97%) rename packages/cli/{src => test}/llm/model-provider.test.ts (99%) rename packages/cli/{src => test}/llm/repair.test.ts (97%) rename packages/cli/{src => test}/local-adapters.test.ts (97%) rename packages/cli/{src => test}/local-scan-connectors.test.ts (94%) rename packages/cli/{src => test}/managed-local-embeddings.test.ts (96%) rename packages/cli/{src => test}/managed-mcp-daemon.test.ts (99%) rename packages/cli/{src => test}/managed-python-command.test.ts (99%) rename packages/cli/{src => test}/managed-python-daemon.test.ts (83%) rename packages/cli/{src => test}/managed-python-http.test.ts (99%) rename packages/cli/{src => test}/managed-python-runtime.test.ts (99%) rename packages/cli/{src => test}/mcp-http-server.test.ts (99%) rename packages/cli/{src => test}/mcp-server-factory.test.ts (85%) rename packages/cli/{src => test}/memory-flow-interactive.test.ts (97%) rename packages/cli/{src => test}/memory-flow-tui.test.tsx (99%) rename packages/cli/{src => test}/next-steps.test.ts (99%) rename packages/cli/{src => test}/notion-page-picker.test.ts (98%) rename packages/cli/{src => test}/print-command-tree.test.ts (96%) rename packages/cli/{src => test}/project-dir.test.ts (98%) rename packages/cli/{src => test}/project-resolver.test.ts (98%) rename packages/cli/{src => test}/prompt-navigation.test.ts (97%) rename packages/cli/{src => test}/proxy-env.test.ts (92%) rename packages/cli/{src => test}/public-ingest-copy.test.ts (98%) rename packages/cli/{src => test}/public-ingest.test.ts (99%) rename packages/cli/{src => test}/runtime-requirements.test.ts (97%) rename packages/cli/{src => test}/runtime.test.ts (99%) rename packages/cli/{src => test}/scan.test.ts (98%) rename packages/cli/{src => test}/setup-agents.test.ts (99%) rename packages/cli/{src => test}/setup-context.test.ts (99%) rename packages/cli/{src => test}/setup-databases.test.ts (94%) rename packages/cli/{src => test}/setup-demo-tour.test.ts (98%) rename packages/cli/{src => test}/setup-embeddings.test.ts (92%) rename packages/cli/{src => test}/setup-interrupt.test.ts (99%) rename packages/cli/{src => test}/setup-models.test.ts (99%) rename packages/cli/{src => test}/setup-project.test.ts (98%) rename packages/cli/{src => test}/setup-prompts.test.ts (97%) rename packages/cli/{src => test}/setup-ready-menu.test.ts (96%) rename packages/cli/{src => test}/setup-runtime.test.ts (95%) rename packages/cli/{src => test}/setup-secrets.test.ts (97%) rename packages/cli/{src => test}/setup-sources-notion.test.ts (90%) rename packages/cli/{src => test}/setup-sources.test.ts (99%) rename packages/cli/{src => test}/setup.test.ts (99%) rename packages/cli/{src => test}/sl.test.ts (99%) rename packages/cli/{src => test}/source-mapping.test.ts (94%) rename packages/cli/{src => test}/sql.test.ts (95%) rename packages/cli/{src => test}/standalone-smoke.test.ts (99%) rename packages/cli/{src => test}/status-project.test.ts (99%) rename packages/cli/{src => test}/telemetry/command-hook.test.ts (95%) rename packages/cli/{src => test}/telemetry/demo-detect.test.ts (91%) rename packages/cli/{src => test}/telemetry/emitter.test.ts (96%) rename packages/cli/{src => test}/telemetry/events.snapshot.test.ts (99%) rename packages/cli/{src => test}/telemetry/events.test.ts (99%) rename packages/cli/{src => test}/telemetry/identity.test.ts (99%) create mode 100644 packages/cli/test/telemetry/index.test.ts rename packages/cli/{src => test}/telemetry/project-snapshot.test.ts (96%) rename packages/cli/{src => test}/telemetry/schema-writer.test.ts (90%) rename packages/cli/{src => test}/telemetry/scrubber.test.ts (94%) rename packages/cli/{src => test}/text-ingest.test.ts (97%) rename packages/cli/{src => test}/tree-picker-state.test.ts (99%) rename packages/cli/{src => test}/tree-picker-tui.test.tsx (99%) rename packages/cli/{src => test}/viz-fallback.test.ts (99%) create mode 100644 packages/cli/tsconfig.test.json diff --git a/docs-site/content/docs/cli-reference/ktx-setup.mdx b/docs-site/content/docs/cli-reference/ktx-setup.mdx index 17423534..2c19bd07 100644 --- a/docs-site/content/docs/cli-reference/ktx-setup.mdx +++ b/docs-site/content/docs/cli-reference/ktx-setup.mdx @@ -137,8 +137,10 @@ Enabling query history makes deep ingest readiness matter for later When query history is enabled for PostgreSQL, Snowflake, or BigQuery, `ktx setup` runs a non-blocking readiness probe after the connection test passes. A failed probe still writes setup changes, prints the warehouse-specific -grant or extension remediation, and leaves query-history ingest disabled until -you fix the prerequisite. +grant or extension remediation, and skips query-history processing until you +fix the prerequisite. If the later schema-context build also fails, interactive +setup offers **Disable query history and retry** so you can finish database +setup with `connections..context.queryHistory.enabled: false`. For BigQuery, the remediation tells you to grant `roles/bigquery.resourceViewer` on the BigQuery project, or grant a custom role that contains diff --git a/docs/code-design.md b/docs/code-design.md index c9f93c81..15d369bf 100644 --- a/docs/code-design.md +++ b/docs/code-design.md @@ -89,3 +89,41 @@ enough reason to fix it even when the local code "works." (`loadX` vs `loadHigherX`, `createY` vs `createDefaultY`, `xClient` vs `xService`), assume callers will pick the wrong one. Unify, or document inline why both must exist. + +## Dispatch and contract leaks across per-variant layers + +Layers with multiple per-variant implementations (warehouse drivers, +dialects, LLM providers, ingest adapters, historic-SQL probes) drift +toward parallel switches and informal contracts. The patterns below +look locally reasonable per file but multiply with the number of +variants times the number of consumers — every fix has to be applied +N times, and silent drift between variants is invisible until a user +hits it. + +- **MUST NOT**: Maintain two or more files that switch on the same + enum or string union to dispatch to per-variant behavior. Promote + the dispatch to a single registry table keyed by the union, exposed + through one resolution function. If you find yourself writing the + third such switch, the second one was already a bug. +- **MUST**: When every variant of an abstraction implements the same + method, the method belongs on the shared interface. An informal + contract that every implementation happens to satisfy is a leak + waiting to happen — callers will reach for the concrete class + instead of the contract, and the next variant added will silently + forget to implement it. +- **MUST**: When a layer has both a thin shared interface and rich + per-variant concrete classes, they must agree. Either widen the + interface so callers never need the concrete class, or make the + concrete class private (test-only `/** @internal */` JSDoc plus a + boundary check in `scripts/check-boundaries.mjs`). A class that is + public AND has methods the interface does not expose is the exact + configuration that produces leaks. + +The warehouse driver / dialect layer in +`packages/cli/src/connectors//` plus +`packages/cli/src/context/connections/{dialects,drivers}.ts` is the +canonical worked example: per-driver dialect classes carry +`/** @internal */`, `scripts/check-boundaries.mjs` enforces the import +boundary, and dispatch lives in the two registry files. Apply the +same shape to any other per-variant layer that grows beyond two +implementations. diff --git a/knip.json b/knip.json index 178ff87e..270c2310 100644 --- a/knip.json +++ b/knip.json @@ -14,8 +14,8 @@ "src/telemetry/schema-writer.ts!", "src/telemetry/index.ts!", "scripts/**/*.mjs", - "src/**/*.test-utils.ts", - "src/**/acceptance-fixtures.ts", + "test/**/*.test-utils.ts", + "test/**/acceptance-fixtures.ts", "src/context/scan/relationship-benchmarks.ts!", "src/context/scan/relationship-benchmark-report.ts!" ] diff --git a/packages/cli/package.json b/packages/cli/package.json index d10c1b2b..5e5a585a 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -32,12 +32,12 @@ "build": "tsc -p tsconfig.json && node dist/telemetry/schema-writer.js src/telemetry/events.schema.json ../../python/ktx-daemon/src/ktx_daemon/telemetry/events.schema.json && node scripts/copy-runtime-assets.mjs && node ../../scripts/prepare-cli-bin.mjs", "clean": "node -e \"fs.rmSync('dist', { recursive: true, force: true })\"", "docs:commands": "pnpm run build && node dist/print-command-tree.js", - "smoke": "vitest run src/standalone-smoke.test.ts src/example-smoke.test.ts --testTimeout 30000", - "test": "vitest run --exclude src/standalone-smoke.test.ts --exclude src/example-smoke.test.ts --exclude src/setup-databases.test.ts --exclude src/scan.test.ts --exclude src/commands/connection-metabase-setup.test.ts --exclude src/setup-models.test.ts --exclude src/setup-sources.test.ts --exclude src/setup.test.ts --exclude src/connection.test.ts --exclude src/setup-embeddings.test.ts --exclude src/ingest.test.ts --exclude src/commands/connection-mapping.test.ts --exclude src/ingest-viz.test.ts --exclude src/demo.test.ts --exclude src/setup-project.test.ts --exclude src/sl.test.ts --exclude src/local-scan-connectors.test.ts --exclude src/commands/connection-notion.test.ts --exclude src/context/scan/local-scan.test.ts --exclude src/context/mcp/local-project-ports.test.ts --exclude src/context/ingest/local-stage-ingest.test.ts --exclude src/context/sl/pglite-sl-search-prototype.test.ts --exclude src/context/core/git.service.test.ts --exclude src/context/ingest/local-adapters.test.ts --exclude src/context/ingest/local-bundle-ingest.test.ts --exclude src/context/ingest/local-metabase-ingest.test.ts --exclude src/context/sl/local-sl.test.ts --exclude src/context/search/pglite-owner-process.test.ts --exclude src/context/scan/local-enrichment-artifacts.test.ts --exclude src/context/search/pglite-spike.test.ts --exclude src/context/wiki/local-knowledge.test.ts --exclude src/context/sl/local-query.test.ts --exclude src/context/scan/relationship-review-decisions.test.ts --exclude src/context/scan/relationship-profiling.test.ts", - "test:slow": "vitest run src/setup-databases.test.ts src/scan.test.ts src/commands/connection-metabase-setup.test.ts src/setup-models.test.ts src/setup-sources.test.ts src/setup.test.ts src/connection.test.ts src/setup-embeddings.test.ts src/ingest.test.ts src/commands/connection-mapping.test.ts src/ingest-viz.test.ts src/demo.test.ts src/setup-project.test.ts src/sl.test.ts src/local-scan-connectors.test.ts src/commands/connection-notion.test.ts src/context/scan/local-scan.test.ts src/context/mcp/local-project-ports.test.ts src/context/ingest/local-stage-ingest.test.ts src/context/sl/pglite-sl-search-prototype.test.ts src/context/core/git.service.test.ts src/context/ingest/local-adapters.test.ts src/context/ingest/local-bundle-ingest.test.ts src/context/ingest/local-metabase-ingest.test.ts src/context/sl/local-sl.test.ts src/context/search/pglite-owner-process.test.ts src/context/scan/local-enrichment-artifacts.test.ts src/context/search/pglite-spike.test.ts src/context/wiki/local-knowledge.test.ts src/context/sl/local-query.test.ts src/context/scan/relationship-review-decisions.test.ts src/context/scan/relationship-profiling.test.ts --testTimeout 30000", - "type-check": "tsc -p tsconfig.json --noEmit", + "smoke": "vitest run test/standalone-smoke.test.ts test/example-smoke.test.ts --testTimeout 30000", + "test": "vitest run --exclude test/standalone-smoke.test.ts --exclude test/example-smoke.test.ts --exclude test/setup-databases.test.ts --exclude test/scan.test.ts --exclude test/commands/connection-metabase-setup.test.ts --exclude test/setup-models.test.ts --exclude test/setup-sources.test.ts --exclude test/setup.test.ts --exclude test/connection.test.ts --exclude test/setup-embeddings.test.ts --exclude test/ingest.test.ts --exclude test/commands/connection-mapping.test.ts --exclude test/ingest-viz.test.ts --exclude test/demo.test.ts --exclude test/setup-project.test.ts --exclude test/sl.test.ts --exclude test/local-scan-connectors.test.ts --exclude test/commands/connection-notion.test.ts --exclude test/context/scan/local-scan.test.ts --exclude test/context/mcp/local-project-ports.test.ts --exclude test/context/ingest/local-stage-ingest.test.ts --exclude test/context/sl/pglite-sl-search-prototype.test.ts --exclude test/context/core/git.service.test.ts --exclude test/context/ingest/local-adapters.test.ts --exclude test/context/ingest/local-bundle-ingest.test.ts --exclude test/context/ingest/local-metabase-ingest.test.ts --exclude test/context/sl/local-sl.test.ts --exclude test/context/search/pglite-owner-process.test.ts --exclude test/context/scan/local-enrichment-artifacts.test.ts --exclude test/context/search/pglite-spike.test.ts --exclude test/context/wiki/local-knowledge.test.ts --exclude test/context/sl/local-query.test.ts --exclude test/context/scan/relationship-review-decisions.test.ts --exclude test/context/scan/relationship-profiling.test.ts", + "test:slow": "vitest run test/setup-databases.test.ts test/scan.test.ts test/commands/connection-metabase-setup.test.ts test/setup-models.test.ts test/setup-sources.test.ts test/setup.test.ts test/connection.test.ts test/setup-embeddings.test.ts test/ingest.test.ts test/commands/connection-mapping.test.ts test/ingest-viz.test.ts test/demo.test.ts test/setup-project.test.ts test/sl.test.ts test/local-scan-connectors.test.ts test/commands/connection-notion.test.ts test/context/scan/local-scan.test.ts test/context/mcp/local-project-ports.test.ts test/context/ingest/local-stage-ingest.test.ts test/context/sl/pglite-sl-search-prototype.test.ts test/context/core/git.service.test.ts test/context/ingest/local-adapters.test.ts test/context/ingest/local-bundle-ingest.test.ts test/context/ingest/local-metabase-ingest.test.ts test/context/sl/local-sl.test.ts test/context/search/pglite-owner-process.test.ts test/context/scan/local-enrichment-artifacts.test.ts test/context/search/pglite-spike.test.ts test/context/wiki/local-knowledge.test.ts test/context/sl/local-query.test.ts test/context/scan/relationship-review-decisions.test.ts test/context/scan/relationship-profiling.test.ts --testTimeout 30000", + "type-check": "tsc -p tsconfig.json --noEmit && tsc -p tsconfig.test.json --noEmit", "relationships:benchmarks": "pnpm --silent run build && node ../../scripts/relationship-benchmark-report.mjs", - "relationships:benchmarks:test": "KTX_RUN_RELATIONSHIP_BENCHMARKS=1 vitest run src/context/scan/relationship-benchmarks.test.ts", + "relationships:benchmarks:test": "KTX_RUN_RELATIONSHIP_BENCHMARKS=1 vitest run test/context/scan/relationship-benchmarks.test.ts", "search:pglite-spike": "node ../../scripts/pglite-hybrid-search-spike.mjs", "search:pglite-owner-prototype": "node ../../scripts/pglite-owner-process-prototype.mjs", "search:pglite-sl-prototype": "node ../../scripts/pglite-sl-search-prototype.mjs" diff --git a/packages/cli/src/clack.ts b/packages/cli/src/clack.ts index 55d3e802..2ad51e6c 100644 --- a/packages/cli/src/clack.ts +++ b/packages/cli/src/clack.ts @@ -1,7 +1,30 @@ import { cancel, confirm, isCancel, log, spinner } from '@clack/prompts'; +import type { KtxCliIo } from './cli-runtime.js'; const ESC = String.fromCharCode(0x1b); +export interface RailBufferedSource { + stdoutText(): string; + stderrText(): string; +} + +export function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +export function writePrefixedLines(write: (chunk: string) => void, output: string): void { + for (const line of output.split(/\r?\n/)) { + if (line.length > 0) { + write(`│ ${line}\n`); + } + } +} + +export function flushPrefixedBufferedCommandOutput(io: KtxCliIo, buffered: RailBufferedSource): void { + writePrefixedLines((chunk) => io.stdout.write(chunk), buffered.stdoutText()); + writePrefixedLines((chunk) => io.stderr.write(chunk), buffered.stderrText()); +} + export interface KtxCliSpinner { start(message: string): void; message(message: string): void; diff --git a/packages/cli/src/connection.ts b/packages/cli/src/connection.ts index 335bfb47..abc501a6 100644 --- a/packages/cli/src/connection.ts +++ b/packages/cli/src/connection.ts @@ -6,6 +6,7 @@ import { type NotionBotInfo, NotionClient } from './context/ingest/adapters/noti import { createLocalLookerCredentialResolver } from './context/ingest/adapters/looker/local-looker.adapter.js'; import { metabaseRuntimeConfigFromLocalConnection } from './context/ingest/adapters/metabase/local-metabase.adapter.js'; import { testRepoConnection } from './context/ingest/repo-fetch.js'; +import { getDriverRegistration } from './context/connections/drivers.js'; import { parseNotionConnectionConfig, resolveNotionConnectionAuthToken } from './context/connections/notion-config.js'; import { resolveKtxConfigReference } from './context/core/config-reference.js'; import { type KtxLocalProject, loadKtxProject } from './context/project/project.js'; @@ -272,15 +273,7 @@ async function testConnectionByDriver( return { driver, detailKey: 'Repo', detailValue: result.repoUrl }; } - if ( - driver === 'sqlite' || - driver === 'postgres' || - driver === 'mysql' || - driver === 'clickhouse' || - driver === 'sqlserver' || - driver === 'bigquery' || - driver === 'snowflake' - ) { + if (getDriverRegistration(driver)) { const result = await testNativeConnection( project, connectionId, diff --git a/packages/cli/src/connectors/bigquery/connector.ts b/packages/cli/src/connectors/bigquery/connector.ts index 871f50f4..edebe284 100644 --- a/packages/cli/src/connectors/bigquery/connector.ts +++ b/packages/cli/src/connectors/bigquery/connector.ts @@ -1,5 +1,6 @@ import { BigQuery, type TableField } from '@google-cloud/bigquery'; import { normalizeBigQueryProjectId, normalizeBigQueryRegion } from '../../context/connections/bigquery-identifiers.js'; +import { getDialectForDriver } from '../../context/connections/dialects.js'; import { assertReadOnlySql, limitSqlForExecution } from '../../context/connections/read-only-sql.js'; import { tryConstraintQuery } from '../../context/scan/constraint-discovery.js'; import { scopedTableNames } from '../../context/scan/table-ref.js'; @@ -26,7 +27,6 @@ import { import { readFileSync } from 'node:fs'; import { homedir } from 'node:os'; import { resolve } from 'node:path'; -import { KtxBigQueryDialect } from './dialect.js'; export interface KtxBigQueryConnectionConfig { driver?: string; @@ -235,6 +235,23 @@ function normalizeValue(value: unknown): unknown { return value; } +/** @internal */ +export function prepareBigQueryReadOnlyQuery( + sql: string, + params?: Record, +): { sql: string; params?: Record } { + if (!params) { + return { sql, params: undefined }; + } + let processedSql = sql; + const processedParams: Record = {}; + for (const [key, value] of Object.entries(params)) { + processedSql = processedSql.replace(new RegExp(`:${key}\\b`, 'g'), `@${key}`); + processedParams[key] = value; + } + return { sql: processedSql, params: Object.keys(processedParams).length > 0 ? processedParams : undefined }; +} + export function isKtxBigQueryConnectionConfig( connection: KtxBigQueryConnectionConfig | undefined, ): connection is KtxBigQueryConnectionConfig { @@ -286,7 +303,7 @@ export class KtxBigQueryScanConnector implements KtxScanConnector { private readonly now: () => Date; private readonly maxBytesBilled?: number | string; private readonly queryTimeoutMs?: number; - private readonly dialect = new KtxBigQueryDialect(); + private readonly dialect = getDialectForDriver('bigquery'); private client: KtxBigQueryClient | null = null; constructor(options: KtxBigQueryScanConnectorOptions) { @@ -364,7 +381,7 @@ export class KtxBigQueryScanConnector implements KtxScanConnector { async executeReadOnly(input: KtxBigQueryReadOnlyQueryInput, _ctx: KtxScanContext): Promise { this.assertConnection(input.connectionId); const limitedSql = limitSqlForExecution(assertReadOnlySql(input.sql), input.maxRows); - const prepared = this.dialect.prepareQuery(limitedSql, input.params); + const prepared = prepareBigQueryReadOnlyQuery(limitedSql, input.params); const result = await this.query(prepared.sql, prepared.params); return { ...result, rowCount: result.rows.length }; } @@ -411,7 +428,7 @@ export class KtxBigQueryScanConnector implements KtxScanConnector { return this.dialect.quoteIdentifier(identifier); } - async listDatasets(): Promise { + async listSchemas(): Promise { const [datasets] = await this.getClient().getDatasets(); return datasets.map((dataset) => dataset.id).filter((id): id is string => Boolean(id)); } @@ -437,6 +454,7 @@ export class KtxBigQueryScanConnector implements KtxScanConnector { params, ); return rows.map((row) => ({ + catalog: this.resolved.projectId, schema: row.table_schema, name: row.table_name, kind: diff --git a/packages/cli/src/connectors/bigquery/dialect.ts b/packages/cli/src/connectors/bigquery/dialect.ts index 02d904ed..0e2f883e 100644 --- a/packages/cli/src/connectors/bigquery/dialect.ts +++ b/packages/cli/src/connectors/bigquery/dialect.ts @@ -1,9 +1,18 @@ +import type { KtxDialect } from '../../context/connections/dialects.js'; +import { + columnDisplayPartCount, + formatDialectDisplayRef, + formatDialectTableName, + limitOffsetClause, + parseDialectDisplayRef, +} from '../../context/connections/dialect-helpers.js'; import type { KtxSchemaDimensionType, KtxTableRef } from '../../context/scan/types.js'; type BigQueryTableNameRef = Pick & Partial>; -export class KtxBigQueryDialect { - readonly type = 'bigquery'; +/** @internal */ +export class KtxBigQueryDialect implements KtxDialect { + readonly type = 'bigquery' as const; private readonly typeMappings: Record = { TIMESTAMP: 'time', @@ -27,13 +36,19 @@ export class KtxBigQueryDialect { } formatTableName(table: BigQueryTableNameRef): string { - if (table.catalog && table.db) { - return `${this.quoteIdentifier(table.catalog)}.${this.quoteIdentifier(table.db)}.${this.quoteIdentifier(table.name)}`; - } - if (table.db) { - return `${this.quoteIdentifier(table.db)}.${this.quoteIdentifier(table.name)}`; - } - return this.quoteIdentifier(table.name); + return formatDialectTableName(table, this.quoteIdentifier.bind(this), 'three-part'); + } + + formatDisplayRef(table: BigQueryTableNameRef): string { + return formatDialectDisplayRef(table, 'three-part'); + } + + parseDisplayRef(display: string): KtxTableRef | null { + return parseDialectDisplayRef(display, 'three-part'); + } + + columnDisplayTablePartCount(): 1 | 2 | 3 { + return columnDisplayPartCount('three-part'); } mapDataType(nativeType: string): string { @@ -93,19 +108,6 @@ export class KtxBigQueryDialect { return `SELECT ${quotedColumn} FROM ${tableName} WHERE ${quotedColumn} IS NOT NULL AND TRIM(CAST(${quotedColumn} AS STRING)) != '' ORDER BY RAND() LIMIT ${limit}`; } - prepareQuery(sql: string, params?: Record): { sql: string; params?: Record } { - if (!params) { - return { sql, params: undefined }; - } - let processedSql = sql; - const processedParams: Record = {}; - for (const [key, value] of Object.entries(params)) { - processedSql = processedSql.replace(new RegExp(`:${key}\\b`, 'g'), `@${key}`); - processedParams[key] = value; - } - return { sql: processedSql, params: Object.keys(processedParams).length > 0 ? processedParams : undefined }; - } - getRandomSampleFilter(samplePct: number): string { if (samplePct <= 0 || samplePct >= 1) { return ''; @@ -121,7 +123,11 @@ export class KtxBigQueryDialect { } getLimitOffsetClause(limit: number, offset?: number): string { - return offset !== undefined && offset > 0 ? `LIMIT ${limit} OFFSET ${offset}` : `LIMIT ${limit}`; + return limitOffsetClause(limit, offset); + } + + getTopClause(_limit: number): string { + return ''; } getNullCountExpression(column: string): string { @@ -132,6 +138,18 @@ export class KtxBigQueryDialect { return `APPROX_COUNT_DISTINCT(${column})`; } + textLengthExpression(columnSql: string): string { + return `LENGTH(CAST(${columnSql} AS STRING))`; + } + + castToText(columnSql: string): string { + return `CAST(${columnSql} AS STRING)`; + } + + getSampleValueAggregation(innerSql: string): string { + return `(SELECT STRING_AGG(CAST(value AS STRING), '\\u001F') FROM (${innerSql}) AS relationship_profile_values)`; + } + generateCardinalitySampleQuery(tableName: string, columnName: string, sampleSize: number): string { return ` WITH sampled AS ( @@ -172,36 +190,4 @@ export class KtxBigQueryDialect { FROM sampled `; } - - getTimeTruncExpression( - column: string, - granularity: 'day' | 'week' | 'month' | 'quarter' | 'year', - timezone?: string, - ): string { - const bigQueryGranularity = granularity.toUpperCase(); - if (timezone) { - return `DATE_TRUNC(DATETIME(${column}, '${timezone}'), ${bigQueryGranularity})`; - } - return `DATE_TRUNC(${column}, ${bigQueryGranularity})`; - } - - getCustomTimeTruncExpression(column: string, interval: string, origin?: string, timezone?: string): string { - const col = timezone ? `DATETIME(${column}, '${timezone}')` : column; - const [rawAmount, rawUnit] = interval.split(' '); - let diffUnit = rawUnit!.toUpperCase(); - let amount = Number(rawAmount); - let addUnit = diffUnit; - if (diffUnit === 'WEEK') { - diffUnit = 'DAY'; - amount = amount * 7; - addUnit = 'DAY'; - } - const originExpr = origin ? `TIMESTAMP '${origin}'` : `TIMESTAMP '1970-01-01'`; - return `TIMESTAMP_ADD(${originExpr}, INTERVAL CAST(FLOOR(TIMESTAMP_DIFF(${col}, ${originExpr}, ${diffUnit}) / ${amount}) * ${amount} AS INT64) ${addUnit})`; - } - - parseIntervalToSql(interval: string): string { - const [amount, unit] = interval.split(' '); - return `INTERVAL ${amount} ${unit!.toUpperCase()}`; - } } diff --git a/packages/cli/src/connectors/clickhouse/connector.ts b/packages/cli/src/connectors/clickhouse/connector.ts index a2ee568c..74ef7a77 100644 --- a/packages/cli/src/connectors/clickhouse/connector.ts +++ b/packages/cli/src/connectors/clickhouse/connector.ts @@ -1,4 +1,5 @@ import { createClient } from '@clickhouse/client'; +import { getDialectForDriver } from '../../context/connections/dialects.js'; import { assertReadOnlySql, limitSqlForExecution } from '../../context/connections/read-only-sql.js'; import { createKtxConnectorCapabilities, type KtxColumnSampleInput, type KtxColumnSampleResult, type KtxColumnStatsInput, type KtxColumnStatsResult, type KtxQueryResult, type KtxReadOnlyQueryInput, type KtxScanConnector, type KtxScanContext, type KtxScanInput, type KtxSchemaColumn, type KtxSchemaSnapshot, type KtxSchemaTable, type KtxTableRef, type KtxTableSampleInput, type KtxTableListEntry, type KtxTableSampleResult } from '../../context/scan/types.js'; import { scopedTableNames } from '../../context/scan/table-ref.js'; @@ -6,7 +7,6 @@ import { readFileSync } from 'node:fs'; import { Agent as HttpsAgent } from 'node:https'; import { homedir } from 'node:os'; import { resolve } from 'node:path'; -import { KtxClickHouseDialect } from './dialect.js'; export interface KtxClickHouseConnectionConfig { driver?: string; @@ -198,6 +198,49 @@ function clickHouseTableKey(database: string, table: string): string { return `${database}.${table}`; } +function inferClickHouseQueryParamType(value: unknown): string { + if (value === null || value === undefined) { + return 'String'; + } + if (typeof value === 'boolean') { + return 'Bool'; + } + if (typeof value === 'number') { + return Number.isInteger(value) ? 'Int64' : 'Float64'; + } + if (value instanceof Date) { + return 'DateTime'; + } + return 'String'; +} + +/** @internal */ +export function prepareClickHouseReadOnlyQuery( + sql: string, + params?: Record, +): { sql: string; params?: Record } { + if (!params) { + return { sql, params: undefined }; + } + + let parameterizedQuery = sql; + const queryParams: Record = {}; + const sortedKeys = Object.keys(params).sort((a, b) => b.length - a.length); + + for (const key of sortedKeys) { + const placeholder = `:${key}`; + if (parameterizedQuery.includes(placeholder)) { + parameterizedQuery = parameterizedQuery.replace( + new RegExp(`:${key}\\b`, 'g'), + `{${key}:${inferClickHouseQueryParamType(params[key])}}`, + ); + queryParams[key] = params[key]; + } + } + + return { sql: parameterizedQuery, params: Object.keys(queryParams).length > 0 ? queryParams : undefined }; +} + export function isKtxClickHouseConnectionConfig( connection: KtxClickHouseConnectionConfig | undefined, ): connection is KtxClickHouseConnectionConfig { @@ -256,7 +299,7 @@ export class KtxClickHouseScanConnector implements KtxScanConnector { private readonly clientFactory: KtxClickHouseClientFactory; private readonly endpointResolver?: KtxClickHouseEndpointResolver; private readonly now: () => Date; - private readonly dialect = new KtxClickHouseDialect(); + private readonly dialect = getDialectForDriver('clickhouse'); private client: KtxClickHouseClient | null = null; private resolvedEndpoint: KtxClickHouseResolvedEndpoint | null = null; @@ -408,7 +451,7 @@ export class KtxClickHouseScanConnector implements KtxScanConnector { async executeReadOnly(input: KtxClickHouseReadOnlyQueryInput, _ctx: KtxScanContext): Promise { this.assertConnection(input.connectionId); const limitedSql = limitSqlForExecution(assertReadOnlySql(input.sql), input.maxRows); - const prepared = this.dialect.prepareQuery(limitedSql, input.params); + const prepared = prepareClickHouseReadOnlyQuery(limitedSql, input.params); const result = await this.query(prepared.sql, prepared.params); return { ...result, rowCount: result.rows.length }; } @@ -488,6 +531,7 @@ export class KtxClickHouseScanConnector implements KtxScanConnector { { schemas: filterSchemas }, ); return rows.map((row) => ({ + catalog: null, schema: row.database, name: row.name, kind: row.engine === 'View' || row.engine === 'MaterializedView' ? ('view' as const) : ('table' as const), diff --git a/packages/cli/src/connectors/clickhouse/dialect.ts b/packages/cli/src/connectors/clickhouse/dialect.ts index 48452ea6..9e470cae 100644 --- a/packages/cli/src/connectors/clickhouse/dialect.ts +++ b/packages/cli/src/connectors/clickhouse/dialect.ts @@ -1,9 +1,18 @@ +import type { KtxDialect } from '../../context/connections/dialects.js'; +import { + columnDisplayPartCount, + formatDialectDisplayRef, + formatDialectTableName, + limitOffsetClause, + parseDialectDisplayRef, +} from '../../context/connections/dialect-helpers.js'; import type { KtxSchemaDimensionType, KtxTableRef } from '../../context/scan/types.js'; type ClickHouseTableNameRef = Pick & Partial>; -export class KtxClickHouseDialect { - readonly type = 'clickhouse'; +/** @internal */ +export class KtxClickHouseDialect implements KtxDialect { + readonly type = 'clickhouse' as const; private readonly typeMappings: Record = { date: 'time', @@ -45,9 +54,19 @@ export class KtxClickHouseDialect { } formatTableName(table: ClickHouseTableNameRef): string { - return table.db - ? `${this.quoteIdentifier(table.db)}.${this.quoteIdentifier(table.name)}` - : this.quoteIdentifier(table.name); + return formatDialectTableName(table, this.quoteIdentifier.bind(this), 'ansi'); + } + + formatDisplayRef(table: ClickHouseTableNameRef): string { + return formatDialectDisplayRef(table, 'ansi'); + } + + parseDisplayRef(display: string): KtxTableRef | null { + return parseDialectDisplayRef(display, 'ansi'); + } + + columnDisplayTablePartCount(): 1 | 2 | 3 { + return columnDisplayPartCount('ansi'); } mapDataType(nativeType: string): string { @@ -97,29 +116,6 @@ export class KtxClickHouseDialect { return `SELECT ${quotedColumn} FROM ${tableName} WHERE ${quotedColumn} IS NOT NULL AND trim(toString(${quotedColumn})) != '' LIMIT ${limit}`; } - prepareQuery(sql: string, params?: Record): { sql: string; params?: Record } { - if (!params) { - return { sql, params: undefined }; - } - - let parameterizedQuery = sql; - const queryParams: Record = {}; - const sortedKeys = Object.keys(params).sort((a, b) => b.length - a.length); - - for (const key of sortedKeys) { - const placeholder = `:${key}`; - if (parameterizedQuery.includes(placeholder)) { - parameterizedQuery = parameterizedQuery.replace( - new RegExp(`:${key}\\b`, 'g'), - `{${key}:${this.inferClickHouseType(params[key])}}`, - ); - queryParams[key] = params[key]; - } - } - - return { sql: parameterizedQuery, params: queryParams }; - } - getRandomSampleFilter(samplePct: number): string { if (samplePct <= 0 || samplePct >= 1) { return ''; @@ -132,7 +128,11 @@ export class KtxClickHouseDialect { } getLimitOffsetClause(limit: number, offset?: number): string { - return offset !== undefined && offset > 0 ? `LIMIT ${limit} OFFSET ${offset}` : `LIMIT ${limit}`; + return limitOffsetClause(limit, offset); + } + + getTopClause(_limit: number): string { + return ''; } getNullCountExpression(column: string): string { @@ -143,6 +143,18 @@ export class KtxClickHouseDialect { return `COUNT(DISTINCT ${column})`; } + textLengthExpression(columnSql: string): string { + return `length(toString(${columnSql}))`; + } + + castToText(columnSql: string): string { + return `toString(${columnSql})`; + } + + getSampleValueAggregation(innerSql: string): string { + return `(SELECT arrayStringConcat(groupArray(toString(value)), '\\x1F') FROM (${innerSql}) AS relationship_profile_values)`; + } + generateCardinalitySampleQuery(tableName: string, columnName: string, sampleSize: number): string { return ` SELECT COUNT(DISTINCT val) AS cardinality @@ -181,99 +193,9 @@ export class KtxClickHouseDialect { ) `; } - - getTimeTruncExpression( - column: string, - granularity: 'day' | 'week' | 'month' | 'quarter' | 'year', - timezone?: string, - ): string { - const tz = timezone ? `, '${timezone}'` : ''; - switch (granularity) { - case 'day': - return `toStartOfDay(${column}${tz})`; - case 'week': - return `toStartOfWeek(${column}, 1${tz})`; - case 'month': - return `toStartOfMonth(${column}${tz})`; - case 'quarter': - return `toStartOfQuarter(${column}${tz})`; - case 'year': - return `toStartOfYear(${column}${tz})`; - } - } - - getCustomTimeTruncExpression(column: string, interval: string, origin?: string, timezone?: string): string { - const col = timezone ? `toTimezone(${column}, '${timezone}')` : column; - const [rawAmount, rawUnit] = interval.split(' '); - const amount = Number(rawAmount); - const unit = rawUnit!.toLowerCase(); - const originExpr = origin ? `toDateTime('${origin}')` : "toDateTime('1970-01-01')"; - const calendarUnit = this.toClickHouseDateDiffUnit(unit); - if (calendarUnit) { - return `dateAdd(${calendarUnit}, intDiv(dateDiff(${calendarUnit}, ${originExpr}, ${col}), ${amount}) * ${amount}, ${originExpr})`; - } - const seconds = this.intervalToSeconds(amount, unit); - return `addSeconds(${originExpr}, intDiv(toUInt64(dateDiff('second', ${originExpr}, ${col})), ${seconds}) * ${seconds})`; - } - - parseIntervalToSql(interval: string): string { - const [amount, unit] = interval.split(' '); - return `INTERVAL ${amount} ${unit!.toUpperCase()}`; - } - private unwrapClickHouseType(value: string, wrapper: string): string { const prefix = `${wrapper}(`; return value.startsWith(prefix) && value.endsWith(')') ? value.slice(prefix.length, -1) : value; } - private inferClickHouseType(value: unknown): string { - if (value === null || value === undefined) { - return 'String'; - } - if (typeof value === 'boolean') { - return 'Bool'; - } - if (typeof value === 'number') { - return Number.isInteger(value) ? 'Int64' : 'Float64'; - } - if (value instanceof Date) { - return 'DateTime'; - } - return 'String'; - } - - private toClickHouseDateDiffUnit(unit: string): string | null { - if (unit === 'month' || unit === 'months') { - return "'month'"; - } - if (unit === 'quarter' || unit === 'quarters') { - return "'quarter'"; - } - if (unit === 'year' || unit === 'years') { - return "'year'"; - } - return null; - } - - private intervalToSeconds(amount: number, unit: string): number { - switch (unit) { - case 'second': - case 'seconds': - return amount; - case 'minute': - case 'minutes': - return amount * 60; - case 'hour': - case 'hours': - return amount * 3600; - case 'day': - case 'days': - return amount * 86400; - case 'week': - case 'weeks': - return amount * 604800; - default: - return amount * 86400; - } - } } diff --git a/packages/cli/src/connectors/mysql/connector.ts b/packages/cli/src/connectors/mysql/connector.ts index 83c9712a..29dacc26 100644 --- a/packages/cli/src/connectors/mysql/connector.ts +++ b/packages/cli/src/connectors/mysql/connector.ts @@ -2,6 +2,7 @@ import mysql, { type FieldPacket, type Pool, type RowDataPacket } from 'mysql2/p import { readFileSync } from 'node:fs'; import { homedir } from 'node:os'; import { resolve } from 'node:path'; +import { getDialectForDriver } from '../../context/connections/dialects.js'; import { assertReadOnlySql, limitSqlForExecution } from '../../context/connections/read-only-sql.js'; import { constraintDiscoveryWarning, @@ -30,7 +31,6 @@ import { type KtxTableSampleInput, type KtxTableSampleResult, } from '../../context/scan/types.js'; -import { KtxMysqlDialect } from './dialect.js'; export interface KtxMysqlConnectionConfig { driver?: string; @@ -303,6 +303,25 @@ function queryParams(params: Record | unknown[] | undefined): u return Array.isArray(params) ? params : Object.values(params); } +/** @internal */ +export function prepareMysqlReadOnlyQuery( + sql: string, + params?: Record, +): { sql: string; params?: unknown[] } { + if (!params) { + return { sql, params: undefined }; + } + const values: unknown[] = []; + const parameterizedQuery = sql.replace(/:([A-Za-z_][A-Za-z0-9_]*)\b/g, (placeholder, key: string) => { + if (!(key in params)) { + return placeholder; + } + values.push(params[key]); + return '?'; + }); + return { sql: parameterizedQuery, params: values }; +} + export function isKtxMysqlConnectionConfig( connection: KtxMysqlConnectionConfig | undefined, ): connection is KtxMysqlConnectionConfig { @@ -376,7 +395,7 @@ export class KtxMysqlScanConnector implements KtxScanConnector { private readonly poolFactory: KtxMysqlPoolFactory; private readonly endpointResolver?: KtxMysqlEndpointResolver; private readonly now: () => Date; - private readonly dialect = new KtxMysqlDialect(); + private readonly dialect = getDialectForDriver('mysql'); private pool: KtxMysqlPool | null = null; private resolvedEndpoint: KtxMysqlResolvedEndpoint | null = null; @@ -550,7 +569,7 @@ export class KtxMysqlScanConnector implements KtxScanConnector { const limitedSql = limitSqlForExecution(assertReadOnlySql(input.sql), input.maxRows); const prepared = Array.isArray(input.params) ? { sql: limitedSql, params: input.params } - : this.dialect.prepareQuery(limitedSql, input.params); + : prepareMysqlReadOnlyQuery(limitedSql, input.params); const result = await this.query(prepared.sql, prepared.params); return { ...result, rowCount: result.rows.length }; } @@ -625,6 +644,7 @@ export class KtxMysqlScanConnector implements KtxScanConnector { filterSchemas, ); return rows.map((row) => ({ + catalog: null, schema: row.TABLE_SCHEMA, name: row.TABLE_NAME, kind: row.TABLE_TYPE === 'VIEW' ? ('view' as const) : ('table' as const), diff --git a/packages/cli/src/connectors/mysql/dialect.ts b/packages/cli/src/connectors/mysql/dialect.ts index d61db36c..7f9cc725 100644 --- a/packages/cli/src/connectors/mysql/dialect.ts +++ b/packages/cli/src/connectors/mysql/dialect.ts @@ -1,9 +1,18 @@ +import type { KtxDialect } from '../../context/connections/dialects.js'; +import { + columnDisplayPartCount, + formatDialectDisplayRef, + formatDialectTableName, + limitOffsetClause, + parseDialectDisplayRef, +} from '../../context/connections/dialect-helpers.js'; import type { KtxSchemaDimensionType, KtxTableRef } from '../../context/scan/types.js'; type MysqlTableNameRef = Pick & Partial>; -export class KtxMysqlDialect { - readonly type = 'mysql'; +/** @internal */ +export class KtxMysqlDialect implements KtxDialect { + readonly type = 'mysql' as const; private readonly typeMappings: Record = { datetime: 'time', @@ -41,9 +50,19 @@ export class KtxMysqlDialect { } formatTableName(table: MysqlTableNameRef): string { - return table.db - ? `${this.quoteIdentifier(table.db)}.${this.quoteIdentifier(table.name)}` - : this.quoteIdentifier(table.name); + return formatDialectTableName(table, this.quoteIdentifier.bind(this), 'ansi'); + } + + formatDisplayRef(table: MysqlTableNameRef): string { + return formatDialectDisplayRef(table, 'ansi'); + } + + parseDisplayRef(display: string): KtxTableRef | null { + return parseDialectDisplayRef(display, 'ansi'); + } + + columnDisplayTablePartCount(): 1 | 2 | 3 { + return columnDisplayPartCount('ansi'); } mapDataType(nativeType: string): string { @@ -91,21 +110,6 @@ export class KtxMysqlDialect { return `SELECT ${quotedColumn} FROM ${tableName} WHERE ${quotedColumn} IS NOT NULL AND TRIM(CAST(${quotedColumn} AS CHAR)) != '' LIMIT ${limit}`; } - prepareQuery(sql: string, params?: Record): { sql: string; params?: unknown[] } { - if (!params) { - return { sql, params: undefined }; - } - const values: unknown[] = []; - const parameterizedQuery = sql.replace(/:([A-Za-z_][A-Za-z0-9_]*)\b/g, (placeholder, key: string) => { - if (!(key in params)) { - return placeholder; - } - values.push(params[key]); - return '?'; - }); - return { sql: parameterizedQuery, params: values }; - } - getRandomSampleFilter(samplePct: number): string { if (samplePct <= 0 || samplePct >= 1) { return ''; @@ -118,7 +122,11 @@ export class KtxMysqlDialect { } getLimitOffsetClause(limit: number, offset?: number): string { - return offset !== undefined && offset > 0 ? `LIMIT ${limit} OFFSET ${offset}` : `LIMIT ${limit}`; + return limitOffsetClause(limit, offset); + } + + getTopClause(_limit: number): string { + return ''; } getNullCountExpression(column: string): string { @@ -129,6 +137,18 @@ export class KtxMysqlDialect { return `COUNT(DISTINCT ${column})`; } + textLengthExpression(columnSql: string): string { + return `CHAR_LENGTH(CAST(${columnSql} AS CHAR))`; + } + + castToText(columnSql: string): string { + return `CAST(${columnSql} AS CHAR)`; + } + + getSampleValueAggregation(innerSql: string): string { + return `(SELECT GROUP_CONCAT(CAST(value AS CHAR) SEPARATOR CHAR(31)) FROM (${innerSql}) AS relationship_profile_values)`; + } + generateCardinalitySampleQuery(tableName: string, columnName: string, sampleSize: number): string { return ` SELECT COUNT(DISTINCT val) AS cardinality @@ -167,36 +187,4 @@ export class KtxMysqlDialect { ) AS sampled `; } - - getTimeTruncExpression( - column: string, - granularity: 'day' | 'week' | 'month' | 'quarter' | 'year', - timezone?: string, - ): string { - const col = timezone ? `CONVERT_TZ(${column}, '+00:00', '${timezone}')` : column; - switch (granularity) { - case 'day': - return `DATE(${col})`; - case 'week': - return `DATE(${col} - INTERVAL WEEKDAY(${col}) DAY)`; - case 'month': - return `DATE_FORMAT(${col}, '%Y-%m-01')`; - case 'quarter': - return `MAKEDATE(YEAR(${col}), 1) + INTERVAL (QUARTER(${col}) - 1) QUARTER`; - case 'year': - return `DATE_FORMAT(${col}, '%Y-01-01')`; - } - } - - getCustomTimeTruncExpression(column: string, interval: string, origin?: string, timezone?: string): string { - const col = timezone ? `CONVERT_TZ(${column}, '+00:00', '${timezone}')` : column; - const [amount, unit] = interval.split(' '); - const originExpr = origin ? `'${origin}'` : `'1970-01-01'`; - return `DATE_ADD(${originExpr}, INTERVAL FLOOR(TIMESTAMPDIFF(${unit!.toUpperCase()}, ${originExpr}, ${col}) / ${amount}) * ${amount} ${unit!.toUpperCase()})`; - } - - parseIntervalToSql(interval: string): string { - const [amount, unit] = interval.split(' '); - return `INTERVAL ${amount} ${unit!.toUpperCase()}`; - } } diff --git a/packages/cli/src/connectors/postgres/connector.ts b/packages/cli/src/connectors/postgres/connector.ts index 1bab5e49..f206fa6a 100644 --- a/packages/cli/src/connectors/postgres/connector.ts +++ b/packages/cli/src/connectors/postgres/connector.ts @@ -1,6 +1,7 @@ import { readFileSync } from 'node:fs'; import { homedir } from 'node:os'; import { resolve } from 'node:path'; +import { getDialectForDriver } from '../../context/connections/dialects.js'; import { assertReadOnlySql, limitSqlForExecution } from '../../context/connections/read-only-sql.js'; import { tryConstraintQuery } from '../../context/scan/constraint-discovery.js'; import { scopedTableNames } from '../../context/scan/table-ref.js'; @@ -26,7 +27,6 @@ import { type KtxTableSampleResult, } from '../../context/scan/types.js'; import { Pool } from 'pg'; -import { KtxPostgresDialect } from './dialect.js'; const PG_OID_TYPE_MAP: Record = { 16: 'boolean', @@ -219,6 +219,29 @@ function groupByTable(rows: T[]): Map, +): { sql: string; params?: unknown[] } { + if (!params) { + return { sql, params: undefined }; + } + const paramNames = Object.keys(params); + const values: unknown[] = new Array(paramNames.length); + const paramIndexMap = new Map(); + paramNames.forEach((name, index) => { + paramIndexMap.set(name, index + 1); + values[index] = params[name]; + }); + const sortedKeys = [...paramNames].sort((a, b) => b.length - a.length); + let parameterizedQuery = sql; + for (const name of sortedKeys) { + parameterizedQuery = parameterizedQuery.replace(new RegExp(`:${name}\\b`, 'g'), `$${paramIndexMap.get(name)}`); + } + return { sql: parameterizedQuery, params: values }; +} + function primaryKeyMap(rows: PostgresPrimaryKeyRow[]): Map> { const grouped = new Map>(); for (const row of rows) { @@ -400,7 +423,7 @@ export class KtxPostgresScanConnector implements KtxScanConnector { private readonly poolFactory: KtxPostgresPoolFactory; private readonly endpointResolver?: KtxPostgresEndpointResolver; private readonly now: () => Date; - private readonly dialect = new KtxPostgresDialect(); + private readonly dialect = getDialectForDriver('postgres'); private pool: KtxPostgresPool | null = null; private lastIdlePoolError: Error | null = null; private resolvedEndpoint: KtxPostgresResolvedEndpoint | null = null; @@ -489,7 +512,7 @@ export class KtxPostgresScanConnector implements KtxScanConnector { const limitedSql = limitSqlForExecution(assertReadOnlySql(input.sql), input.maxRows); const prepared = Array.isArray(input.params) ? { sql: limitedSql, params: input.params } - : this.dialect.prepareQuery(limitedSql, input.params); + : preparePostgresReadOnlyQuery(limitedSql, input.params); const result = await this.query(prepared.sql, prepared.params); return { ...result, rowCount: result.rows.length }; } @@ -584,6 +607,7 @@ export class KtxPostgresScanConnector implements KtxScanConnector { [filterSchemas], ); return rows.map((row) => ({ + catalog: null, schema: row.schema_name, name: row.table_name, kind: row.table_kind === 'v' ? ('view' as const) : ('table' as const), diff --git a/packages/cli/src/connectors/postgres/dialect.ts b/packages/cli/src/connectors/postgres/dialect.ts index ea0590b8..49d5677d 100644 --- a/packages/cli/src/connectors/postgres/dialect.ts +++ b/packages/cli/src/connectors/postgres/dialect.ts @@ -1,9 +1,18 @@ +import type { KtxDialect } from '../../context/connections/dialects.js'; +import { + columnDisplayPartCount, + formatDialectDisplayRef, + formatDialectTableName, + limitOffsetClause, + parseDialectDisplayRef, +} from '../../context/connections/dialect-helpers.js'; import type { KtxSchemaDimensionType, KtxTableRef } from '../../context/scan/types.js'; type PostgresTableNameRef = Pick & Partial>; -export class KtxPostgresDialect { - readonly type = 'postgresql'; +/** @internal */ +export class KtxPostgresDialect implements KtxDialect { + readonly type = 'postgres' as const; private readonly typeMappings: Record = { timestamp: 'time', @@ -45,9 +54,19 @@ export class KtxPostgresDialect { } formatTableName(table: PostgresTableNameRef): string { - return table.db - ? `${this.quoteIdentifier(table.db)}.${this.quoteIdentifier(table.name)}` - : this.quoteIdentifier(table.name); + return formatDialectTableName(table, this.quoteIdentifier.bind(this), 'ansi'); + } + + formatDisplayRef(table: PostgresTableNameRef): string { + return formatDialectDisplayRef(table, 'ansi'); + } + + parseDisplayRef(display: string): KtxTableRef | null { + return parseDialectDisplayRef(display, 'ansi'); + } + + columnDisplayTablePartCount(): 1 | 2 | 3 { + return columnDisplayPartCount('ansi'); } mapDataType(nativeType: string): string { @@ -92,25 +111,6 @@ export class KtxPostgresDialect { return `SELECT ${quotedColumn} FROM ${tableName} WHERE ${quotedColumn} IS NOT NULL AND TRIM(CAST(${quotedColumn} AS TEXT)) != '' LIMIT ${limit}`; } - prepareQuery(sql: string, params?: Record): { sql: string; params?: unknown[] } { - if (!params) { - return { sql, params: undefined }; - } - const paramNames = Object.keys(params); - const values: unknown[] = new Array(paramNames.length); - const paramIndexMap = new Map(); - paramNames.forEach((name, index) => { - paramIndexMap.set(name, index + 1); - values[index] = params[name]; - }); - const sortedKeys = [...paramNames].sort((a, b) => b.length - a.length); - let parameterizedQuery = sql; - for (const name of sortedKeys) { - parameterizedQuery = parameterizedQuery.replace(new RegExp(`:${name}\\b`, 'g'), `$${paramIndexMap.get(name)}`); - } - return { sql: parameterizedQuery, params: values }; - } - getRandomSampleFilter(samplePct: number): string { if (samplePct <= 0 || samplePct >= 1) { return ''; @@ -126,7 +126,11 @@ export class KtxPostgresDialect { } getLimitOffsetClause(limit: number, offset?: number): string { - return offset !== undefined && offset > 0 ? `LIMIT ${limit} OFFSET ${offset}` : `LIMIT ${limit}`; + return limitOffsetClause(limit, offset); + } + + getTopClause(_limit: number): string { + return ''; } getNullCountExpression(column: string): string { @@ -137,6 +141,18 @@ export class KtxPostgresDialect { return `COUNT(DISTINCT ${column})`; } + textLengthExpression(columnSql: string): string { + return `LENGTH(CAST(${columnSql} AS TEXT))`; + } + + castToText(columnSql: string): string { + return `CAST(${columnSql} AS TEXT)`; + } + + getSampleValueAggregation(innerSql: string): string { + return `(SELECT STRING_AGG(CAST(value AS TEXT), CHR(31)) FROM (${innerSql}) AS relationship_profile_values)`; + } + generateCardinalitySampleQuery(tableName: string, columnName: string, sampleSize: number): string { return ` WITH sampled AS ( @@ -191,23 +207,4 @@ export class KtxPostgresDialect { FROM sampled `; } - - getTimeTruncExpression( - column: string, - granularity: 'day' | 'week' | 'month' | 'quarter' | 'year', - timezone?: string, - ): string { - const col = timezone ? `(${column} AT TIME ZONE '${timezone.replace(/'/g, "''")}')` : column; - return `DATE_TRUNC('${granularity}', ${col})`; - } - - getCustomTimeTruncExpression(column: string, interval: string, origin?: string, timezone?: string): string { - const col = timezone ? `(${column} AT TIME ZONE '${timezone.replace(/'/g, "''")}')` : column; - const originExpr = origin ? `TIMESTAMP '${origin.replace(/'/g, "''")}'` : "TIMESTAMP '1970-01-01'"; - return `${originExpr} + FLOOR(EXTRACT(EPOCH FROM (${col} - ${originExpr})) / EXTRACT(EPOCH FROM INTERVAL '${interval.replace(/'/g, "''")}')) * INTERVAL '${interval.replace(/'/g, "''")}'`; - } - - parseIntervalToSql(interval: string): string { - return `INTERVAL '${interval.replace(/'/g, "''")}'`; - } } diff --git a/packages/cli/src/connectors/snowflake/connector.ts b/packages/cli/src/connectors/snowflake/connector.ts index d8737559..86d7ebe7 100644 --- a/packages/cli/src/connectors/snowflake/connector.ts +++ b/packages/cli/src/connectors/snowflake/connector.ts @@ -2,6 +2,7 @@ import { createPrivateKey } from 'node:crypto'; import { readFileSync } from 'node:fs'; import { homedir } from 'node:os'; import { resolve } from 'node:path'; +import { getDialectForDriver } from '../../context/connections/dialects.js'; import { assertReadOnlySql, limitSqlForExecution } from '../../context/connections/read-only-sql.js'; import { tryConstraintQuery } from '../../context/scan/constraint-discovery.js'; import { scopedTableNames } from '../../context/scan/table-ref.js'; @@ -27,7 +28,6 @@ import { } from '../../context/scan/types.js'; import snowflake from 'snowflake-sdk'; import type { Bind, Binds, Connection, ConnectionOptions } from 'snowflake-sdk'; -import { KtxSnowflakeDialect } from './dialect.js'; import { assertSafeSnowflakeIdentifier, quoteSnowflakeIdentifier } from './identifiers.js'; import { configureSnowflakeSdkLogger } from './sdk-logger.js'; @@ -229,6 +229,14 @@ function toSnowflakeBinds(params: unknown[] | undefined): Binds | undefined { return params?.map((value) => toSnowflakeBind(value)); } +/** @internal */ +export function prepareSnowflakeReadOnlyQuery( + sql: string, + params?: Record, +): { sql: string; params?: unknown[] } { + return { sql, params: params ? Object.values(params) : undefined }; +} + export function isKtxSnowflakeConnectionConfig( connection: KtxSnowflakeConnectionConfig | undefined, ): connection is KtxSnowflakeConnectionConfig { @@ -430,6 +438,7 @@ class SnowflakeSdkDriver implements KtxSnowflakeDriver { [this.resolved.database, ...(schemas ?? [])], ); return result.rows.map((row) => ({ + catalog: this.resolved.database, schema: String(row[0]), name: String(row[1]), kind: String(row[2]) === 'VIEW' ? ('view' as const) : ('table' as const), @@ -550,7 +559,7 @@ export class KtxSnowflakeScanConnector implements KtxScanConnector { private readonly resolved: KtxSnowflakeResolvedConnectionConfig; private readonly driverFactory: KtxSnowflakeDriverFactory; - private readonly dialect = new KtxSnowflakeDialect(); + private readonly dialect = getDialectForDriver('snowflake'); private readonly now: () => Date; private driverInstance: KtxSnowflakeDriver | null = null; @@ -635,7 +644,7 @@ export class KtxSnowflakeScanConnector implements KtxScanConnector { async executeReadOnly(input: KtxSnowflakeReadOnlyQueryInput, _ctx: KtxScanContext): Promise { this.assertConnection(input.connectionId); const limitedSql = limitSqlForExecution(assertReadOnlySql(input.sql), input.maxRows); - const prepared = this.dialect.prepareQuery(limitedSql, input.params); + const prepared = prepareSnowflakeReadOnlyQuery(limitedSql, input.params); return this.getDriver().query(prepared.sql, prepared.params); } @@ -696,6 +705,7 @@ export class KtxSnowflakeScanConnector implements KtxScanConnector { [this.resolved.database, ...(schemas ?? [])], ); return result.rows.map((row) => ({ + catalog: this.resolved.database, schema: String(row[0]), name: String(row[1]), kind: String(row[2]) === 'VIEW' ? ('view' as const) : ('table' as const), diff --git a/packages/cli/src/connectors/snowflake/dialect.ts b/packages/cli/src/connectors/snowflake/dialect.ts index db508134..3fe04101 100644 --- a/packages/cli/src/connectors/snowflake/dialect.ts +++ b/packages/cli/src/connectors/snowflake/dialect.ts @@ -1,9 +1,18 @@ +import type { KtxDialect } from '../../context/connections/dialects.js'; +import { + columnDisplayPartCount, + formatDialectDisplayRef, + formatDialectTableName, + limitOffsetClause, + parseDialectDisplayRef, +} from '../../context/connections/dialect-helpers.js'; import type { KtxSchemaDimensionType, KtxTableRef } from '../../context/scan/types.js'; type SnowflakeTableNameRef = Pick & Partial>; -export class KtxSnowflakeDialect { - readonly type = 'snowflake'; +/** @internal */ +export class KtxSnowflakeDialect implements KtxDialect { + readonly type = 'snowflake' as const; private readonly typeMappings: Record = { TIMESTAMP_NTZ: 'time', @@ -45,13 +54,19 @@ export class KtxSnowflakeDialect { } formatTableName(table: SnowflakeTableNameRef): string { - if (table.catalog && table.db) { - return `${this.quoteIdentifier(table.catalog)}.${this.quoteIdentifier(table.db)}.${this.quoteIdentifier(table.name)}`; - } - if (table.db) { - return `${this.quoteIdentifier(table.db)}.${this.quoteIdentifier(table.name)}`; - } - return this.quoteIdentifier(table.name); + return formatDialectTableName(table, this.quoteIdentifier.bind(this), 'three-part'); + } + + formatDisplayRef(table: SnowflakeTableNameRef): string { + return formatDialectDisplayRef(table, 'three-part'); + } + + parseDisplayRef(display: string): KtxTableRef | null { + return parseDialectDisplayRef(display, 'three-part'); + } + + columnDisplayTablePartCount(): 1 | 2 | 3 { + return columnDisplayPartCount('three-part'); } mapDataType(nativeType: string): string { @@ -96,10 +111,6 @@ export class KtxSnowflakeDialect { return `SELECT ${quotedColumn} FROM ${tableName} WHERE ${quotedColumn} IS NOT NULL AND TRIM(CAST(${quotedColumn} AS STRING)) != '' LIMIT ${limit}`; } - prepareQuery(sql: string, params?: Record): { sql: string; params?: unknown[] } { - return { sql, params: params ? Object.values(params) : undefined }; - } - getRandomSampleFilter(samplePct: number): string { if (samplePct <= 0 || samplePct >= 1) { return ''; @@ -115,7 +126,11 @@ export class KtxSnowflakeDialect { } getLimitOffsetClause(limit: number, offset?: number): string { - return offset !== undefined && offset > 0 ? `LIMIT ${limit} OFFSET ${offset}` : `LIMIT ${limit}`; + return limitOffsetClause(limit, offset); + } + + getTopClause(_limit: number): string { + return ''; } getNullCountExpression(column: string): string { @@ -126,6 +141,18 @@ export class KtxSnowflakeDialect { return `APPROX_COUNT_DISTINCT(${column})`; } + textLengthExpression(columnSql: string): string { + return `LENGTH(CAST(${columnSql} AS TEXT))`; + } + + castToText(columnSql: string): string { + return `CAST(${columnSql} AS VARCHAR)`; + } + + getSampleValueAggregation(innerSql: string): string { + return `(SELECT LISTAGG(CAST(value AS VARCHAR), '\\x1f') FROM (${innerSql}) AS relationship_profile_values)`; + } + generateCardinalitySampleQuery(tableName: string, columnName: string, sampleSize: number): string { return ` WITH sampled AS ( @@ -164,24 +191,4 @@ export class KtxSnowflakeDialect { FROM sampled `; } - - getTimeTruncExpression( - column: string, - granularity: 'day' | 'week' | 'month' | 'quarter' | 'year', - timezone?: string, - ): string { - const target = timezone ? `CONVERT_TIMEZONE('UTC', '${timezone}', ${column})` : column; - return `DATE_TRUNC('${granularity}', ${target})`; - } - - getCustomTimeTruncExpression(column: string, interval: string, origin?: string, timezone?: string): string { - const target = timezone ? `CONVERT_TIMEZONE('UTC', '${timezone}', ${column})` : column; - const [amount, unit] = interval.split(' '); - const originExpr = origin ? `'${origin}'::TIMESTAMP` : `'1970-01-01'::TIMESTAMP`; - return `DATEADD(${unit}, FLOOR(DATEDIFF(${unit}, ${originExpr}, ${target}) / ${amount}) * ${amount}, ${originExpr})`; - } - - parseIntervalToSql(interval: string): string { - return `INTERVAL '${interval}'`; - } } diff --git a/packages/cli/src/connectors/sqlite/connector.ts b/packages/cli/src/connectors/sqlite/connector.ts index 504e427d..e996bc25 100644 --- a/packages/cli/src/connectors/sqlite/connector.ts +++ b/packages/cli/src/connectors/sqlite/connector.ts @@ -3,11 +3,11 @@ import { existsSync, readFileSync, statSync } from 'node:fs'; import { homedir } from 'node:os'; import { isAbsolute, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; +import { getDialectForDriver } from '../../context/connections/dialects.js'; import { assertReadOnlySql, limitSqlForExecution } from '../../context/connections/read-only-sql.js'; import { normalizeQueryRows } from '../../context/connections/query-executor.js'; -import { createKtxConnectorCapabilities, type KtxColumnSampleInput, type KtxColumnSampleResult, type KtxColumnStatsInput, type KtxColumnStatsResult, type KtxQueryResult, type KtxReadOnlyQueryInput, type KtxScanConnector, type KtxScanContext, type KtxScanInput, type KtxSchemaForeignKey, type KtxSchemaSnapshot, type KtxSchemaTable, type KtxTableRef, type KtxTableSampleInput, type KtxTableSampleResult } from '../../context/scan/types.js'; +import { createKtxConnectorCapabilities, type KtxColumnSampleInput, type KtxColumnSampleResult, type KtxColumnStatsInput, type KtxColumnStatsResult, type KtxQueryResult, type KtxReadOnlyQueryInput, type KtxScanConnector, type KtxScanContext, type KtxScanInput, type KtxSchemaForeignKey, type KtxSchemaSnapshot, type KtxSchemaTable, type KtxTableListEntry, type KtxTableRef, type KtxTableSampleInput, type KtxTableSampleResult } from '../../context/scan/types.js'; import { scopedTableNames } from '../../context/scan/table-ref.js'; -import { KtxSqliteDialect } from './dialect.js'; export interface KtxSqliteConnectionConfig { driver?: string; @@ -157,7 +157,7 @@ export class KtxSqliteScanConnector implements KtxScanConnector { private readonly connectionId: string; private readonly dbPath: string; private readonly now: () => Date; - private readonly dialect = new KtxSqliteDialect(); + private readonly dialect = getDialectForDriver('sqlite'); private db: Database.Database | null = null; constructor(options: KtxSqliteScanConnectorOptions) { @@ -209,6 +209,31 @@ export class KtxSqliteScanConnector implements KtxScanConnector { }; } + async listSchemas(): Promise { + return []; + } + + async listTables(_schemas?: string[]): Promise { + const rows = this.database() + .prepare( + ` + SELECT name, type + FROM sqlite_master + WHERE type IN ('table', 'view') + AND name NOT LIKE 'sqlite_%' + ORDER BY name + `, + ) + .all() as SqliteMasterRow[]; + + return rows.map((row) => ({ + catalog: null, + schema: '', + name: row.name, + kind: row.type === 'view' ? ('view' as const) : ('table' as const), + })); + } + async sampleTable(input: KtxTableSampleInput, _ctx: KtxScanContext): Promise { this.assertConnection(input.connectionId); const result = this.query(this.dialect.generateSampleQuery(this.qTableName(input.table), input.limit, input.columns)); diff --git a/packages/cli/src/connectors/sqlite/dialect.ts b/packages/cli/src/connectors/sqlite/dialect.ts index b5771b62..5472b674 100644 --- a/packages/cli/src/connectors/sqlite/dialect.ts +++ b/packages/cli/src/connectors/sqlite/dialect.ts @@ -1,9 +1,18 @@ +import type { KtxDialect } from '../../context/connections/dialects.js'; +import { + columnDisplayPartCount, + formatDialectDisplayRef, + formatDialectTableName, + limitOffsetClause, + parseDialectDisplayRef, +} from '../../context/connections/dialect-helpers.js'; import type { KtxSchemaDimensionType, KtxTableRef } from '../../context/scan/types.js'; type SqliteTableNameRef = Pick & Partial>; -export class KtxSqliteDialect { - readonly type = 'sqlite'; +/** @internal */ +export class KtxSqliteDialect implements KtxDialect { + readonly type = 'sqlite' as const; private readonly typeMappings: Record = { DATETIME: 'time', @@ -29,7 +38,19 @@ export class KtxSqliteDialect { } formatTableName(table: SqliteTableNameRef): string { - return this.quoteIdentifier(table.name); + return formatDialectTableName(table, this.quoteIdentifier.bind(this), 'sqlite'); + } + + formatDisplayRef(table: SqliteTableNameRef): string { + return formatDialectDisplayRef(table, 'sqlite'); + } + + parseDisplayRef(display: string): KtxTableRef | null { + return parseDialectDisplayRef(display, 'sqlite'); + } + + columnDisplayTablePartCount(): 1 | 2 | 3 { + return columnDisplayPartCount('sqlite'); } mapDataType(nativeType: string): string { @@ -76,10 +97,6 @@ export class KtxSqliteDialect { return `SELECT ${quoted} FROM ${tableName} WHERE ${quoted} IS NOT NULL AND TRIM(CAST(${quoted} AS TEXT)) != '' LIMIT ${limit}`; } - prepareQuery(sql: string, params?: Record): { sql: string; params?: unknown } { - return params ? { sql, params } : { sql }; - } - getRandomSampleFilter(samplePct: number): string { if (samplePct <= 0 || samplePct >= 1) { return ''; @@ -92,7 +109,11 @@ export class KtxSqliteDialect { } getLimitOffsetClause(limit: number, offset?: number): string { - return offset !== undefined && offset > 0 ? `LIMIT ${limit} OFFSET ${offset}` : `LIMIT ${limit}`; + return limitOffsetClause(limit, offset); + } + + getTopClause(_limit: number): string { + return ''; } getNullCountExpression(column: string): string { @@ -103,6 +124,18 @@ export class KtxSqliteDialect { return `COUNT(DISTINCT ${column})`; } + textLengthExpression(columnSql: string): string { + return `LENGTH(CAST(${columnSql} AS TEXT))`; + } + + castToText(columnSql: string): string { + return `CAST(${columnSql} AS TEXT)`; + } + + getSampleValueAggregation(innerSql: string): string { + return `(SELECT GROUP_CONCAT(CAST(value AS TEXT), char(31)) FROM (${innerSql}) AS relationship_profile_values)`; + } + generateCardinalitySampleQuery(tableName: string, columnName: string, sampleSize: number): string { return ` WITH sampled AS ( @@ -143,35 +176,4 @@ export class KtxSqliteDialect { FROM sampled `; } - - getTimeTruncExpression( - column: string, - granularity: 'day' | 'week' | 'month' | 'quarter' | 'year', - _timezone?: string, - ): string { - switch (granularity) { - case 'day': - return `DATE(${column})`; - case 'week': - return `DATE(${column}, 'weekday 0', '-6 days')`; - case 'month': - return `DATE(${column}, 'start of month')`; - case 'quarter': - return `DATE(${column}, 'start of month', '-' || ((CAST(STRFTIME('%m', ${column}) AS INTEGER) - 1) % 3) || ' months')`; - case 'year': - return `DATE(${column}, 'start of year')`; - } - } - - getCustomTimeTruncExpression(column: string, interval: string, origin?: string, _timezone?: string): string { - const [amount, unit] = interval.split(' '); - const originExpr = origin ? `julianday('${origin}')` : `julianday('1970-01-01')`; - const unitDays = unit === 'day' ? 1 : unit === 'week' ? 7 : 30; - const intervalDays = Number(amount) * unitDays; - return `DATE(julianday('1970-01-01') + (CAST((julianday(${column}) - ${originExpr}) / ${intervalDays} AS INTEGER) * ${intervalDays}))`; - } - - parseIntervalToSql(interval: string): string { - return `'${interval}'`; - } } diff --git a/packages/cli/src/connectors/sqlserver/connector.ts b/packages/cli/src/connectors/sqlserver/connector.ts index 9895027f..0115781d 100644 --- a/packages/cli/src/connectors/sqlserver/connector.ts +++ b/packages/cli/src/connectors/sqlserver/connector.ts @@ -1,4 +1,5 @@ import { assertReadOnlySql } from '../../context/connections/read-only-sql.js'; +import { getDialectForDriver } from '../../context/connections/dialects.js'; import { tryConstraintQuery } from '../../context/scan/constraint-discovery.js'; import { scopedTableNames } from '../../context/scan/table-ref.js'; import { @@ -26,7 +27,6 @@ import { readFileSync } from 'node:fs'; import { homedir } from 'node:os'; import { resolve } from 'node:path'; import sql from 'mssql'; -import { KtxSqlServerDialect } from './dialect.js'; export interface KtxSqlServerConnectionConfig { driver?: string; @@ -158,6 +158,21 @@ function tableScopeSql( return { clause: `AND ${columnExpression} IN (${placeholders.join(', ')})`, params }; } +/** @internal */ +export function prepareSqlServerReadOnlyQuery( + sql: string, + params?: Record, +): { sql: string; params?: Record } { + if (!params) { + return { sql, params: undefined }; + } + let parameterizedQuery = sql; + for (const key of Object.keys(params)) { + parameterizedQuery = parameterizedQuery.replace(new RegExp(`:${key}\\b`, 'g'), `@${key}`); + } + return { sql: parameterizedQuery, params }; +} + class DefaultSqlServerPoolFactory implements KtxSqlServerPoolFactory { async createPool(config: KtxSqlServerPoolConfig): Promise { const pool = await new sql.ConnectionPool(config as sql.config).connect(); @@ -349,7 +364,7 @@ export class KtxSqlServerScanConnector implements KtxScanConnector { private readonly poolFactory: KtxSqlServerPoolFactory; private readonly endpointResolver?: KtxSqlServerEndpointResolver; private readonly now: () => Date; - private readonly dialect = new KtxSqlServerDialect(); + private readonly dialect = getDialectForDriver('sqlserver'); private pool: KtxSqlServerPool | null = null; private resolvedEndpoint: KtxSqlServerResolvedEndpoint | null = null; @@ -427,7 +442,7 @@ export class KtxSqlServerScanConnector implements KtxScanConnector { async executeReadOnly(input: KtxSqlServerReadOnlyQueryInput, _ctx: KtxScanContext): Promise { this.assertConnection(input.connectionId); const limitedSql = limitSqlForSqlServerExecution(input.sql, input.maxRows); - const prepared = this.dialect.prepareQuery(limitedSql, input.params); + const prepared = prepareSqlServerReadOnlyQuery(limitedSql, input.params); const result = await this.query(prepared.sql, prepared.params); return { ...result, rowCount: result.rows.length }; } @@ -517,6 +532,7 @@ export class KtxSqlServerScanConnector implements KtxScanConnector { params, ); return rows.map((row) => ({ + catalog: this.poolConfig.database, schema: row.schema_name, name: row.table_name, kind: row.table_type === 'VIEW' ? ('view' as const) : ('table' as const), diff --git a/packages/cli/src/connectors/sqlserver/dialect.ts b/packages/cli/src/connectors/sqlserver/dialect.ts index 8444317d..6b1804f4 100644 --- a/packages/cli/src/connectors/sqlserver/dialect.ts +++ b/packages/cli/src/connectors/sqlserver/dialect.ts @@ -1,9 +1,18 @@ +import type { KtxDialect } from '../../context/connections/dialects.js'; +import { + columnDisplayPartCount, + formatDialectDisplayRef, + formatDialectTableName, + parseDialectDisplayRef, + safeSqlLimit, +} from '../../context/connections/dialect-helpers.js'; import type { KtxSchemaDimensionType, KtxTableRef } from '../../context/scan/types.js'; type SqlServerTableNameRef = Pick & Partial>; -export class KtxSqlServerDialect { - readonly type = 'sqlserver'; +/** @internal */ +export class KtxSqlServerDialect implements KtxDialect { + readonly type = 'sqlserver' as const; private readonly typeMappings: Record = { datetime: 'time', @@ -39,9 +48,19 @@ export class KtxSqlServerDialect { } formatTableName(table: SqlServerTableNameRef): string { - return table.db - ? `${this.quoteIdentifier(table.db)}.${this.quoteIdentifier(table.name)}` - : this.quoteIdentifier(table.name); + return formatDialectTableName(table, this.quoteIdentifier.bind(this), 'three-part'); + } + + formatDisplayRef(table: SqlServerTableNameRef): string { + return formatDialectDisplayRef(table, 'three-part'); + } + + parseDisplayRef(display: string): KtxTableRef | null { + return parseDialectDisplayRef(display, 'three-part'); + } + + columnDisplayTablePartCount(): 1 | 2 | 3 { + return columnDisplayPartCount('three-part'); } mapDataType(nativeType: string): string { @@ -86,17 +105,6 @@ export class KtxSqlServerDialect { return `SELECT TOP ${limit} ${quotedColumn} FROM ${tableName} WHERE ${quotedColumn} IS NOT NULL AND LTRIM(RTRIM(CAST(${quotedColumn} AS NVARCHAR(MAX)))) != ''`; } - prepareQuery(sql: string, params?: Record): { sql: string; params?: Record } { - if (!params) { - return { sql, params: undefined }; - } - let parameterizedQuery = sql; - for (const key of Object.keys(params)) { - parameterizedQuery = parameterizedQuery.replace(new RegExp(`:${key}\\b`, 'g'), `@${key}`); - } - return { sql: parameterizedQuery, params }; - } - getRandomSampleFilter(samplePct: number): string { if (samplePct <= 0 || samplePct >= 1) { return ''; @@ -111,12 +119,12 @@ export class KtxSqlServerDialect { return `TABLESAMPLE (${samplePct * 100} PERCENT)`; } - getLimitOffsetClause(limit: number, offset?: number): string { - return offset !== undefined && offset > 0 ? `OFFSET ${offset} ROWS FETCH NEXT ${limit} ROWS ONLY` : ''; + getLimitOffsetClause(_limit: number, _offset?: number): string { + return ''; } getTopClause(limit: number): string { - return `TOP ${limit}`; + return `TOP (${safeSqlLimit(limit)})`; } getNullCountExpression(column: string): string { @@ -127,6 +135,18 @@ export class KtxSqlServerDialect { return `COUNT(DISTINCT ${column})`; } + textLengthExpression(columnSql: string): string { + return `LEN(CAST(${columnSql} AS NVARCHAR(MAX)))`; + } + + castToText(columnSql: string): string { + return `CAST(${columnSql} AS NVARCHAR(MAX))`; + } + + getSampleValueAggregation(innerSql: string): string { + return `(SELECT STRING_AGG(CAST(value AS NVARCHAR(MAX)), CHAR(31)) FROM (${innerSql}) AS relationship_profile_values)`; + } + generateCardinalitySampleQuery(tableName: string, columnName: string, sampleSize: number): string { return ` WITH sampled AS ( @@ -167,35 +187,4 @@ export class KtxSqlServerDialect { FROM sampled `; } - - getTimeTruncExpression( - column: string, - granularity: 'day' | 'week' | 'month' | 'quarter' | 'year', - timezone?: string, - ): string { - const col = timezone ? `${column} AT TIME ZONE 'UTC' AT TIME ZONE '${timezone}'` : column; - switch (granularity) { - case 'day': - return `CAST(${col} AS DATE)`; - case 'week': - return `DATEADD(WEEK, DATEDIFF(WEEK, 0, ${col}), 0)`; - case 'month': - return `DATEFROMPARTS(YEAR(${col}), MONTH(${col}), 1)`; - case 'quarter': - return `DATEFROMPARTS(YEAR(${col}), (DATEPART(QUARTER, ${col}) - 1) * 3 + 1, 1)`; - case 'year': - return `DATEFROMPARTS(YEAR(${col}), 1, 1)`; - } - } - - getCustomTimeTruncExpression(column: string, interval: string, origin?: string, timezone?: string): string { - const col = timezone ? `${column} AT TIME ZONE 'UTC' AT TIME ZONE '${timezone}'` : column; - const [amount, unit] = interval.split(' '); - const originExpr = origin ? `'${origin}'` : `'1970-01-01'`; - return `DATEADD(${unit}, (DATEDIFF(${unit}, ${originExpr}, ${col}) / ${amount}) * ${amount}, ${originExpr})`; - } - - parseIntervalToSql(interval: string): string { - return `'${interval}'`; - } } diff --git a/packages/cli/src/context/connections/dialect-helpers.ts b/packages/cli/src/context/connections/dialect-helpers.ts new file mode 100644 index 00000000..04ed569b --- /dev/null +++ b/packages/cli/src/context/connections/dialect-helpers.ts @@ -0,0 +1,87 @@ +import type { KtxTableRef } from '../scan/types.js'; + +export type KtxDialectIdentifierShape = 'ansi' | 'sqlite' | 'three-part'; + +export type KtxDialectTableRef = Pick & Partial>; + +export function safeSqlLimit(limit: number): number { + return Math.max(1, Math.floor(limit)); +} + +function safeSqlOffset(offset: number | undefined): number | null { + if (offset === undefined) { + return null; + } + const normalized = Math.floor(offset); + return normalized > 0 ? normalized : null; +} + +function cleanIdentifierPart(part: string): string { + return part.trim().replace(/^["'`\[]|["'`\]]$/g, ''); +} + +function splitDisplay(display: string): string[] { + return display.trim().split('.').map(cleanIdentifierPart).filter(Boolean); +} + +function tableParts(table: KtxDialectTableRef, shape: KtxDialectIdentifierShape): string[] { + if (shape === 'sqlite') { + return [table.name]; + } + return [table.catalog ?? null, table.db ?? null, table.name].filter((part): part is string => Boolean(part)); +} + +function acceptedDisplayPartCounts(shape: KtxDialectIdentifierShape): readonly number[] { + if (shape === 'sqlite') { + return [1]; + } + if (shape === 'three-part') { + return [3]; + } + return [2, 3]; +} + +export function formatDialectTableName( + table: KtxDialectTableRef, + quoteIdentifier: (identifier: string) => string, + shape: KtxDialectIdentifierShape, +): string { + return tableParts(table, shape).map(quoteIdentifier).join('.'); +} + +export function formatDialectDisplayRef(table: KtxDialectTableRef, shape: KtxDialectIdentifierShape): string { + return tableParts(table, shape).join('.'); +} + +export function parseDialectDisplayRef(display: string, shape: KtxDialectIdentifierShape): KtxTableRef | null { + const parts = splitDisplay(display); + if (!acceptedDisplayPartCounts(shape).includes(parts.length)) { + return null; + } + if (parts.length === 1) { + return { catalog: null, db: null, name: parts[0]! }; + } + if (parts.length === 2) { + return { catalog: null, db: parts[0]!, name: parts[1]! }; + } + if (parts.length === 3) { + return { catalog: parts[0]!, db: parts[1]!, name: parts[2]! }; + } + return null; +} + +export function columnDisplayPartCount(shape: KtxDialectIdentifierShape): 1 | 2 | 3 { + if (shape === 'sqlite') { + return 1; + } + if (shape === 'three-part') { + return 3; + } + return 2; +} + +export function limitOffsetClause(limit: number, offset?: number): string { + const safeLimit = safeSqlLimit(limit); + const safeOffset = safeSqlOffset(offset); + return safeOffset === null ? `LIMIT ${safeLimit}` : `LIMIT ${safeLimit} OFFSET ${safeOffset}`; +} diff --git a/packages/cli/src/context/connections/dialects.test.ts b/packages/cli/src/context/connections/dialects.test.ts deleted file mode 100644 index d4f77997..00000000 --- a/packages/cli/src/context/connections/dialects.test.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { getDialectForDriver } from './dialects.js'; - -describe('getDialectForDriver', () => { - it.each([ - ['postgres', '"public"."orders"'], - ['mysql', '`public`.`orders`'], - ['clickhouse', '`public`.`orders`'], - ['sqlite', '"orders"'], - ['snowflake', '"analytics"."public"."orders"'], - ['bigquery', '`analytics`.`public`.`orders`'], - ['sqlserver', '[analytics].[public].[orders]'], - ] as const)('formats table names for %s', (driver, expected) => { - const dialect = getDialectForDriver(driver); - expect( - dialect.formatTableName({ - catalog: driver === 'snowflake' || driver === 'bigquery' || driver === 'sqlserver' ? 'analytics' : null, - db: driver === 'sqlite' ? null : 'public', - name: 'orders', - }), - ).toBe(expected); - }); - - it('throws with a supported-driver list for unknown drivers', () => { - expect(() => getDialectForDriver('oracle')).toThrow( - 'Unsupported warehouse driver "oracle". Supported drivers: bigquery, clickhouse, mysql, postgres, sqlite, snowflake, sqlserver', - ); - }); - - it('rejects legacy driver aliases', () => { - expect(() => getDialectForDriver('postgresql')).toThrow('Unsupported warehouse driver "postgresql"'); - expect(() => getDialectForDriver('sqlite3')).toThrow('Unsupported warehouse driver "sqlite3"'); - }); -}); diff --git a/packages/cli/src/context/connections/dialects.ts b/packages/cli/src/context/connections/dialects.ts index 5c6cc27f..c7929cea 100644 --- a/packages/cli/src/context/connections/dialects.ts +++ b/packages/cli/src/context/connections/dialects.ts @@ -1,22 +1,40 @@ -import type { KtxSchemaDimensionType, KtxTableRef } from '../scan/types.js'; - -type SupportedDriver = - | 'postgres' - | 'mysql' - | 'sqlserver' - | 'snowflake' - | 'bigquery' - | 'clickhouse' - | 'sqlite'; +import { KtxBigQueryDialect } from '../../connectors/bigquery/dialect.js'; +import { KtxClickHouseDialect } from '../../connectors/clickhouse/dialect.js'; +import { KtxMysqlDialect } from '../../connectors/mysql/dialect.js'; +import { KtxPostgresDialect } from '../../connectors/postgres/dialect.js'; +import { KtxSqliteDialect } from '../../connectors/sqlite/dialect.js'; +import { KtxSnowflakeDialect } from '../../connectors/snowflake/dialect.js'; +import { KtxSqlServerDialect } from '../../connectors/sqlserver/dialect.js'; +import type { KtxConnectionDriver, KtxSchemaDimensionType, KtxTableRef } from '../scan/types.js'; +import type { KtxDialectTableRef } from './dialect-helpers.js'; export interface KtxDialect { - readonly type: SupportedDriver; + readonly type: KtxConnectionDriver; quoteIdentifier(identifier: string): string; - formatTableName(table: KtxTableRef): string; + formatTableName(table: KtxDialectTableRef): string; + formatDisplayRef(table: KtxDialectTableRef): string; + parseDisplayRef(display: string): KtxTableRef | null; + columnDisplayTablePartCount(): 1 | 2 | 3; + getLimitOffsetClause(limit: number, offset?: number): string; + getTopClause(limit: number): string; + getRandomSampleFilter(samplePct: number): string; + getTableSampleClause(samplePct: number): string; + generateSampleQuery(tableName: string, limit: number, columns?: string[]): string; + generateColumnSampleQuery(tableName: string, columnName: string, limit: number): string; + getSampleValueAggregation(innerSql: string): string; + generateCardinalitySampleQuery(tableName: string, columnName: string, sampleSize: number): string; + generateRandomizedCardinalitySampleQuery(tableName: string, columnName: string, sampleSize: number): string; + generateDistinctValuesQuery(tableName: string, columnName: string, limit: number): string; + generateColumnStatisticsQuery(schemaName: string, tableName: string): string | null; + getNullCountExpression(column: string): string; + getDistinctCountExpression(column: string): string; + textLengthExpression(columnSql: string): string; + castToText(columnSql: string): string; mapToDimensionType(nativeType: string): KtxSchemaDimensionType; + mapDataType(nativeType: string): string; } -const supportedDrivers: SupportedDriver[] = [ +const supportedDrivers: KtxConnectionDriver[] = [ 'bigquery', 'clickhouse', 'mysql', @@ -26,71 +44,21 @@ const supportedDrivers: SupportedDriver[] = [ 'sqlserver', ]; -function doubleQuoted(identifier: string): string { - return `"${identifier.replace(/"/g, '""')}"`; -} - -function backtickQuoted(identifier: string): string { - return `\`${identifier.replace(/`/g, '``')}\``; -} - -function bigQueryQuoted(identifier: string): string { - return `\`${identifier.replace(/`/g, '\\`')}\``; -} - -function bracketQuoted(identifier: string): string { - return `[${identifier.replace(/\]/g, ']]')}]`; -} - -function inferDimensionType(nativeType: string): KtxSchemaDimensionType { - const normalized = nativeType.toLowerCase().trim(); - if (normalized.includes('date') || normalized.includes('time')) { - return 'time'; - } - if ( - normalized.includes('int') || - normalized.includes('num') || - normalized.includes('dec') || - normalized.includes('float') || - normalized.includes('double') || - normalized.includes('real') - ) { - return 'number'; - } - if (normalized.includes('bool') || normalized === 'bit') { - return 'boolean'; - } - return 'string'; -} - -function formatWithParts(table: KtxTableRef, quote: (identifier: string) => string, sqlite = false): string { - const parts = sqlite ? [table.name] : [table.catalog, table.db, table.name].filter((part): part is string => !!part); - return parts.map(quote).join('.'); -} - -function createDialect(type: SupportedDriver, quote: (identifier: string) => string, sqlite = false): KtxDialect { - return { - type, - quoteIdentifier: quote, - formatTableName: (table) => formatWithParts(table, quote, sqlite), - mapToDimensionType: inferDimensionType, - }; -} - -const dialects: Record = { - postgres: createDialect('postgres', doubleQuoted), - mysql: createDialect('mysql', backtickQuoted), - clickhouse: createDialect('clickhouse', backtickQuoted), - sqlite: createDialect('sqlite', doubleQuoted, true), - snowflake: createDialect('snowflake', doubleQuoted), - bigquery: createDialect('bigquery', bigQueryQuoted), - sqlserver: createDialect('sqlserver', bracketQuoted), +const dialectFactories: Record KtxDialect> = { + bigquery: () => new KtxBigQueryDialect(), + clickhouse: () => new KtxClickHouseDialect(), + mysql: () => new KtxMysqlDialect(), + postgres: () => new KtxPostgresDialect(), + sqlite: () => new KtxSqliteDialect(), + snowflake: () => new KtxSnowflakeDialect(), + sqlserver: () => new KtxSqlServerDialect(), }; export function getDialectForDriver(driver: string): KtxDialect { const normalized = driver.toLowerCase().trim(); - if (normalized in dialects) { - return dialects[normalized as SupportedDriver]; + const factory = dialectFactories[normalized as KtxConnectionDriver]; + if (factory) { + return factory(); } throw new Error(`Unsupported warehouse driver "${driver}". Supported drivers: ${supportedDrivers.join(', ')}`); } diff --git a/packages/cli/src/context/connections/drivers.ts b/packages/cli/src/context/connections/drivers.ts new file mode 100644 index 00000000..1b87984b --- /dev/null +++ b/packages/cli/src/context/connections/drivers.ts @@ -0,0 +1,199 @@ +import type { KtxConnectionDriver, KtxScanConnector } from '../scan/types.js'; + +/** @internal */ +export type KtxScopeConfigKey = 'dataset_ids' | 'databases' | 'schemas' | 'schema_names'; + +/** @internal */ +export interface KtxDriverConnectorModule { + isConnectionConfig(connection: unknown): boolean; + createScanConnector(args: { + connectionId: string; + connection: unknown; + projectDir: string; + }): KtxScanConnector; +} + +export interface KtxDriverRegistration { + readonly driver: KtxConnectionDriver; + readonly scopeConfigKey: KtxScopeConfigKey | null; + readonly hasHistoricSqlReader: boolean; + readonly hasLocalQueryExecutor: boolean; + load(): Promise; +} + +function invalidConnectionConfig(driver: KtxConnectionDriver): Error { + return new Error(`Connection config does not match warehouse driver "${driver}".`); +} + +/** @internal */ +export const driverRegistrations: Record = { + bigquery: { + driver: 'bigquery', + scopeConfigKey: 'dataset_ids', + hasHistoricSqlReader: true, + hasLocalQueryExecutor: false, + load: async () => { + const m = await import('../../connectors/bigquery/connector.js'); + return { + isConnectionConfig: (connection) => { + const typedConnection = connection as Parameters[0]; + return m.isKtxBigQueryConnectionConfig(typedConnection); + }, + createScanConnector: ({ connectionId, connection }) => { + const typedConnection = connection as Parameters[0]; + if (!m.isKtxBigQueryConnectionConfig(typedConnection)) { + throw invalidConnectionConfig('bigquery'); + } + return new m.KtxBigQueryScanConnector({ connectionId, connection: typedConnection }); + }, + }; + }, + }, + clickhouse: { + driver: 'clickhouse', + scopeConfigKey: 'databases', + hasHistoricSqlReader: false, + hasLocalQueryExecutor: false, + load: async () => { + const m = await import('../../connectors/clickhouse/connector.js'); + return { + isConnectionConfig: (connection) => { + const typedConnection = connection as Parameters[0]; + return m.isKtxClickHouseConnectionConfig(typedConnection); + }, + createScanConnector: ({ connectionId, connection }) => { + const typedConnection = connection as Parameters[0]; + if (!m.isKtxClickHouseConnectionConfig(typedConnection)) { + throw invalidConnectionConfig('clickhouse'); + } + return new m.KtxClickHouseScanConnector({ connectionId, connection: typedConnection }); + }, + }; + }, + }, + mysql: { + driver: 'mysql', + scopeConfigKey: 'schemas', + hasHistoricSqlReader: false, + hasLocalQueryExecutor: false, + load: async () => { + const m = await import('../../connectors/mysql/connector.js'); + return { + isConnectionConfig: (connection) => { + const typedConnection = connection as Parameters[0]; + return m.isKtxMysqlConnectionConfig(typedConnection); + }, + createScanConnector: ({ connectionId, connection }) => { + const typedConnection = connection as Parameters[0]; + if (!m.isKtxMysqlConnectionConfig(typedConnection)) { + throw invalidConnectionConfig('mysql'); + } + return new m.KtxMysqlScanConnector({ connectionId, connection: typedConnection }); + }, + }; + }, + }, + postgres: { + driver: 'postgres', + scopeConfigKey: 'schemas', + hasHistoricSqlReader: true, + hasLocalQueryExecutor: true, + load: async () => { + const m = await import('../../connectors/postgres/connector.js'); + return { + isConnectionConfig: (connection) => { + const typedConnection = connection as Parameters[0]; + return m.isKtxPostgresConnectionConfig(typedConnection); + }, + createScanConnector: ({ connectionId, connection }) => { + const typedConnection = connection as Parameters[0]; + if (!m.isKtxPostgresConnectionConfig(typedConnection)) { + throw invalidConnectionConfig('postgres'); + } + return new m.KtxPostgresScanConnector({ connectionId, connection: typedConnection }); + }, + }; + }, + }, + sqlite: { + driver: 'sqlite', + scopeConfigKey: null, + hasHistoricSqlReader: false, + hasLocalQueryExecutor: true, + load: async () => { + const m = await import('../../connectors/sqlite/connector.js'); + return { + isConnectionConfig: (connection) => { + const typedConnection = connection as Parameters[0]; + return m.isKtxSqliteConnectionConfig(typedConnection); + }, + createScanConnector: ({ connectionId, connection, projectDir }) => { + const typedConnection = connection as Parameters[0]; + if (!m.isKtxSqliteConnectionConfig(typedConnection)) { + throw invalidConnectionConfig('sqlite'); + } + return new m.KtxSqliteScanConnector({ connectionId, connection: typedConnection, projectDir }); + }, + }; + }, + }, + snowflake: { + driver: 'snowflake', + scopeConfigKey: 'schema_names', + hasHistoricSqlReader: true, + hasLocalQueryExecutor: false, + load: async () => { + const m = await import('../../connectors/snowflake/connector.js'); + return { + isConnectionConfig: (connection) => { + const typedConnection = connection as Parameters[0]; + return m.isKtxSnowflakeConnectionConfig(typedConnection); + }, + createScanConnector: ({ connectionId, connection, projectDir }) => { + const typedConnection = connection as Parameters[0]; + if (!m.isKtxSnowflakeConnectionConfig(typedConnection)) { + throw invalidConnectionConfig('snowflake'); + } + return new m.KtxSnowflakeScanConnector({ connectionId, connection: typedConnection, projectDir }); + }, + }; + }, + }, + sqlserver: { + driver: 'sqlserver', + scopeConfigKey: 'schemas', + hasHistoricSqlReader: false, + hasLocalQueryExecutor: false, + load: async () => { + const m = await import('../../connectors/sqlserver/connector.js'); + return { + isConnectionConfig: (connection) => { + const typedConnection = connection as Parameters[0]; + return m.isKtxSqlServerConnectionConfig(typedConnection); + }, + createScanConnector: ({ connectionId, connection }) => { + const typedConnection = connection as Parameters[0]; + if (!m.isKtxSqlServerConnectionConfig(typedConnection)) { + throw invalidConnectionConfig('sqlserver'); + } + return new m.KtxSqlServerScanConnector({ connectionId, connection: typedConnection }); + }, + }; + }, + }, +}; + +const supportedDrivers = Object.keys(driverRegistrations).sort() as KtxConnectionDriver[]; + +function isRegisteredDriver(driver: string): driver is KtxConnectionDriver { + return Object.prototype.hasOwnProperty.call(driverRegistrations, driver); +} + +export function getDriverRegistration(driver: string): KtxDriverRegistration | undefined { + const normalized = driver.toLowerCase().trim(); + return isRegisteredDriver(normalized) ? driverRegistrations[normalized] : undefined; +} + +export function listSupportedDrivers(): KtxConnectionDriver[] { + return [...supportedDrivers]; +} diff --git a/packages/cli/src/context/connections/local-query-executor.ts b/packages/cli/src/context/connections/local-query-executor.ts index 72cefe2a..3a2e34c9 100644 --- a/packages/cli/src/context/connections/local-query-executor.ts +++ b/packages/cli/src/context/connections/local-query-executor.ts @@ -1,3 +1,4 @@ +import { driverRegistrations, getDriverRegistration } from './drivers.js'; import { createPostgresQueryExecutor } from './postgres-query-executor.js'; import type { KtxSqlQueryExecutionInput, @@ -5,6 +6,7 @@ import type { KtxSqlQueryExecutorPort, } from './query-executor.js'; import { createSqliteQueryExecutor } from './sqlite-query-executor.js'; +import type { KtxConnectionDriver } from '../scan/types.js'; export interface DefaultLocalQueryExecutorOptions { postgres?: KtxSqlQueryExecutorPort; @@ -15,20 +17,43 @@ function driverFor(input: KtxSqlQueryExecutionInput): string { return String(input.connection?.driver ?? '').toLowerCase(); } +function localExecutorMap( + options: DefaultLocalQueryExecutorOptions, +): Partial> { + const wiredExecutors: Partial> = { + postgres: options.postgres ?? createPostgresQueryExecutor(), + sqlite: options.sqlite ?? createSqliteQueryExecutor(), + }; + + const executors: Partial> = {}; + for (const registration of Object.values(driverRegistrations)) { + if (!registration.hasLocalQueryExecutor) continue; + const executor = wiredExecutors[registration.driver]; + if (executor) { + executors[registration.driver] = executor; + } + } + return executors; +} + export function createDefaultLocalQueryExecutor(options: DefaultLocalQueryExecutorOptions = {}): KtxSqlQueryExecutorPort { - const postgres = options.postgres ?? createPostgresQueryExecutor(); - const sqlite = options.sqlite ?? createSqliteQueryExecutor(); + const executors = localExecutorMap(options); return { async execute(input: KtxSqlQueryExecutionInput): Promise { const driver = driverFor(input); - if (driver === 'postgres') { - return postgres.execute(input); + const registration = getDriverRegistration(driver); + if (!registration?.hasLocalQueryExecutor) { + throw new Error(`No local query executor is configured for driver "${input.connection?.driver ?? 'unknown'}".`); } - if (driver === 'sqlite') { - return sqlite.execute(input); + + const executor = executors[registration.driver]; + if (!executor) { + throw new Error( + `Local query executor flag is enabled for driver "${registration.driver}", but no executor factory is wired.`, + ); } - throw new Error(`No local query executor is configured for driver "${input.connection?.driver ?? 'unknown'}".`); + return executor.execute(input); }, }; } diff --git a/packages/cli/src/context/ingest/adapters/historic-sql/connection-dialect.ts b/packages/cli/src/context/ingest/adapters/historic-sql/connection-dialect.ts index c6b4c53b..dd95f87a 100644 --- a/packages/cli/src/context/ingest/adapters/historic-sql/connection-dialect.ts +++ b/packages/cli/src/context/ingest/adapters/historic-sql/connection-dialect.ts @@ -1,5 +1,9 @@ +import { getDriverRegistration } from '../../../connections/drivers.js'; +import type { KtxConnectionDriver } from '../../../scan/types.js'; import type { HistoricSqlDialect } from './types.js'; +const historicSqlDialects: readonly HistoricSqlDialect[] = ['postgres', 'bigquery', 'snowflake']; + function recordOrNull(value: unknown): Record | null { return value && typeof value === 'object' && !Array.isArray(value) ? (value as Record) : null; } @@ -10,6 +14,14 @@ function queryHistoryRecord(connection: unknown): Record | null return context ? recordOrNull(context.queryHistory) : null; } +function historicSqlDialectForDriver(driver: KtxConnectionDriver): HistoricSqlDialect { + const dialect = historicSqlDialects.find((candidate) => candidate === driver); + if (!dialect) { + throw new Error(`Driver "${driver}" is marked as historic-SQL capable but has no HistoricSqlDialect mapping.`); + } + return dialect; +} + export function isQueryHistoryEnabled(connection: unknown): boolean { return queryHistoryRecord(connection)?.enabled === true; } @@ -25,8 +37,6 @@ export function queryHistoryDialectForConnection(connection: unknown): HistoricS } const conn = recordOrNull(connection); const driver = String(conn?.driver ?? '').toLowerCase(); - if (driver === 'postgres') return 'postgres'; - if (driver === 'bigquery') return 'bigquery'; - if (driver === 'snowflake') return 'snowflake'; - return null; + const registration = getDriverRegistration(driver); + return registration?.hasHistoricSqlReader ? historicSqlDialectForDriver(registration.driver) : null; } diff --git a/packages/cli/src/context/scan/enabled-tables.ts b/packages/cli/src/context/scan/enabled-tables.ts index d4f1009c..96c94afd 100644 --- a/packages/cli/src/context/scan/enabled-tables.ts +++ b/packages/cli/src/context/scan/enabled-tables.ts @@ -27,12 +27,13 @@ export function resolveEnabledTables( function parseEnabledTableEntry(value: unknown): KtxTableRef | null { if (typeof value === 'string') { - return parseDottedEntry(value); + return parseDottedTableEntry(value); } return null; } -function parseDottedEntry(value: string): KtxTableRef | null { +/** @internal */ +export function parseDottedTableEntry(value: string): KtxTableRef | null { const trimmed = value.trim(); if (trimmed.length === 0) return null; const parts = trimmed.split('.'); diff --git a/packages/cli/src/context/scan/entity-details.ts b/packages/cli/src/context/scan/entity-details.ts index 37e766b6..731eea8f 100644 --- a/packages/cli/src/context/scan/entity-details.ts +++ b/packages/cli/src/context/scan/entity-details.ts @@ -1,7 +1,7 @@ import type { KtxLocalProject } from '../../context/project/project.js'; +import { getDialectForDriver, type KtxDialect } from '../connections/dialects.js'; import { readLocalScanStructuralSnapshot } from './local-structural-artifacts.js'; import type { - KtxConnectionDriver, KtxScanReport, KtxSchemaColumn, KtxSchemaSnapshot, @@ -88,59 +88,23 @@ function refsEqual(left: KtxTableRef, right: KtxTableRef): boolean { ); } -function cleanIdentifierPart(part: string): string { - return part.trim().replace(/^["'`\[]|["'`\]]$/g, ''); -} - -function splitDisplay(display: string): string[] { - return display - .trim() - .split('.') - .map(cleanIdentifierPart) - .filter(Boolean); -} - -function displayForTable(driver: KtxConnectionDriver, table: KtxTableRef): string { - if (driver === 'sqlite') { - return table.name; - } - return [table.catalog, table.db, table.name].filter((part): part is string => Boolean(part)).join('.'); -} - function tableRef(table: KtxSchemaTable): KtxTableRef { return { catalog: table.catalog, db: table.db, name: table.name }; } function candidateList( - driver: KtxConnectionDriver, + dialect: KtxDialect, tables: KtxSchemaTable[], ): Array<{ tableRef: KtxTableRef; display: string }> { return tables .map((table) => ({ tableRef: tableRef(table), - display: displayForTable(driver, table), + display: dialect.formatDisplayRef(table), })) .sort((left, right) => left.display.localeCompare(right.display)); } -function parseDisplayRef(driver: KtxConnectionDriver, display: string): KtxTableRef | null { - const parts = splitDisplay(display); - if (driver === 'sqlite') { - return parts.length === 1 ? { catalog: null, db: null, name: parts[0]! } : null; - } - if (driver === 'bigquery' || driver === 'snowflake' || driver === 'sqlserver') { - return parts.length === 3 ? { catalog: parts[0]!, db: parts[1]!, name: parts[2]! } : null; - } - if (parts.length === 2) { - return { catalog: null, db: parts[0]!, name: parts[1]! }; - } - if (parts.length === 3) { - return { catalog: parts[0]!, db: parts[1]!, name: parts[2]! }; - } - return null; -} - -function resolveTable(snapshot: KtxSchemaSnapshot, input: KtxEntityDetailsTableInput): ResolveResult { +function resolveTable(snapshot: KtxSchemaSnapshot, input: KtxEntityDetailsTableInput, dialect: KtxDialect): ResolveResult { if (typeof input !== 'string') { const table = snapshot.tables.find((candidate) => refsEqual(candidate, input)) ?? null; return table @@ -149,13 +113,13 @@ function resolveTable(snapshot: KtxSchemaSnapshot, input: KtxEntityDetailsTableI table: null, error: { code: 'table_not_found', - message: `Table not found in latest scan: ${displayForTable(snapshot.driver, input)}`, - candidates: candidateList(snapshot.driver, snapshot.tables), + message: `Table not found in latest scan: ${dialect.formatDisplayRef(input)}`, + candidates: candidateList(dialect, snapshot.tables), }, }; } - const parsed = parseDisplayRef(snapshot.driver, input); + const parsed = dialect.parseDisplayRef(input); if (parsed) { const table = snapshot.tables.find((candidate) => refsEqual(candidate, parsed)) ?? null; return table @@ -165,7 +129,7 @@ function resolveTable(snapshot: KtxSchemaSnapshot, input: KtxEntityDetailsTableI error: { code: 'table_not_found', message: `Table not found in latest scan: ${input}`, - candidates: candidateList(snapshot.driver, snapshot.tables), + candidates: candidateList(dialect, snapshot.tables), }, }; } @@ -180,7 +144,7 @@ function resolveTable(snapshot: KtxSchemaSnapshot, input: KtxEntityDetailsTableI error: { code: 'ambiguous_table', message: `Table name "${input}" is ambiguous across schemas/catalogs; pass a structured table ref.`, - candidates: candidateList(snapshot.driver, byName), + candidates: candidateList(dialect, byName), }, }; } @@ -189,7 +153,7 @@ function resolveTable(snapshot: KtxSchemaSnapshot, input: KtxEntityDetailsTableI error: { code: 'table_not_found', message: `Table not found in latest scan: ${input}`, - candidates: candidateList(snapshot.driver, snapshot.tables), + candidates: candidateList(dialect, snapshot.tables), }, }; } @@ -261,9 +225,10 @@ export function createKtxEntityDetailsService(project: KtxLocalProject) { } const info = snapshotInfo(scan.report, scan.snapshot); + const dialect = getDialectForDriver(scan.snapshot.driver); const results: KtxEntityDetailsResponse['results'] = []; for (const entity of input.entities) { - const resolved = resolveTable(scan.snapshot, entity.table); + const resolved = resolveTable(scan.snapshot, entity.table, dialect); if (!resolved.table) { results.push({ ok: false, @@ -289,7 +254,7 @@ export function createKtxEntityDetailsService(project: KtxLocalProject) { snapshot: info, error: { code: 'column_not_found', - message: `Column(s) not found on ${displayForTable(scan.snapshot.driver, resolved.table)}: ${missing.join(', ')}`, + message: `Column(s) not found on ${dialect.formatDisplayRef(resolved.table)}: ${missing.join(', ')}`, candidates: resolved.table.columns.map((column) => column.name), }, }); @@ -300,7 +265,7 @@ export function createKtxEntityDetailsService(project: KtxLocalProject) { ok: true, connectionId: input.connectionId, tableRef: tableRef(resolved.table), - display: displayForTable(scan.snapshot.driver, resolved.table), + display: dialect.formatDisplayRef(resolved.table), kind: resolved.table.kind, comment: resolved.table.comment, estimatedRows: resolved.table.estimatedRows, diff --git a/packages/cli/src/context/scan/local-enrichment.ts b/packages/cli/src/context/scan/local-enrichment.ts index 545b2ad6..833cb5b1 100644 --- a/packages/cli/src/context/scan/local-enrichment.ts +++ b/packages/cli/src/context/scan/local-enrichment.ts @@ -1,5 +1,6 @@ import pLimit from 'p-limit'; import type { KtxLlmRuntimePort } from '../../context/llm/runtime-port.js'; +import { getDialectForDriver } from '../connections/dialects.js'; import { buildDefaultKtxProjectConfig, type KtxScanRelationshipConfig } from '../project/config.js'; import { KtxDescriptionGenerator } from './description-generation.js'; import { buildKtxColumnEmbeddingText } from './embedding-text.js'; @@ -118,6 +119,18 @@ function targetMatchesForeignKey(table: KtxEnrichedTable, foreignKey: KtxSchemaF ); } +function assertConnectorDriverMatchesSnapshot(input: { + connector: KtxScanConnector; + snapshot: KtxSchemaSnapshot; + connectionId: string; +}): void { + if (input.connector.driver !== input.snapshot.driver) { + throw new Error( + `ktx scan connector driver "${input.connector.driver}" does not match snapshot driver "${input.snapshot.driver}" for connection "${input.connectionId}"`, + ); + } +} + function formalRelationshipsFromSnapshot( snapshot: KtxSchemaSnapshot, tables: readonly KtxEnrichedTable[], @@ -468,6 +481,12 @@ export async function runLocalScanEnrichment( )); await progress?.update(0.05, `Loaded schema snapshot with ${snapshot.tables.length} tables`); + assertConnectorDriverMatchesSnapshot({ + connector: input.connector, + snapshot, + connectionId: input.connectionId, + }); + const dialect = getDialectForDriver(snapshot.driver); const now = input.now ?? (() => new Date()); const state = completedKtxScanEnrichmentStateSummary(); const syncId = input.syncId ?? input.context.runId; @@ -575,7 +594,7 @@ export async function runLocalScanEnrichment( await relationshipProgress?.update(0, 'Detecting relationships'); const detection = await discoverKtxRelationships({ connectionId: input.connectionId, - driver: snapshot.driver, + dialect, connector: input.connector, schema, context: input.context, diff --git a/packages/cli/src/context/scan/relationship-benchmarks.ts b/packages/cli/src/context/scan/relationship-benchmarks.ts index f4367b5a..a07221a3 100644 --- a/packages/cli/src/context/scan/relationship-benchmarks.ts +++ b/packages/cli/src/context/scan/relationship-benchmarks.ts @@ -6,6 +6,7 @@ import { gunzipSync } from 'node:zlib'; import Database from 'better-sqlite3'; import YAML from 'yaml'; import { z } from 'zod'; +import { getDialectForDriver } from '../connections/dialects.js'; import type { KtxLlmRuntimePort } from '../llm/runtime-port.js'; import type { KtxEnrichedRelationship, KtxEnrichedSchema, KtxRelationshipType } from './enrichment-types.js'; import { snapshotToKtxEnrichedSchema } from './local-enrichment.js'; @@ -536,6 +537,7 @@ export function ktxRelationshipBenchmarkDetectorWithLlm( const formalLinks = formalMetadata.accepted.map((relationship) => relationshipToBenchmarkLink(relationship)); const acceptedKeys = new Set(formalLinks.map(fkKey)); const sqliteDataAvailable = Boolean(input.dataPath && input.snapshot.driver === 'sqlite'); + const dialect = getDialectForDriver(input.snapshot.driver); const profilingExecutor = sqliteDataAvailable && input.mode !== 'profiling_disabled' ? new KtxRelationshipBenchmarkSqliteExecutor(input.dataPath as string) @@ -550,7 +552,7 @@ export function ktxRelationshipBenchmarkDetectorWithLlm( }) : await profileKtxRelationshipSchema({ connectionId: input.snapshot.connectionId, - driver: input.snapshot.driver, + dialect, schema: input.schema, executor: profilingExecutor, ctx: { runId: `relationship-benchmark:${input.fixtureId}:${input.mode}:profile` }, @@ -580,7 +582,7 @@ export function ktxRelationshipBenchmarkDetectorWithLlm( : Math.max(0, input.validationBudget - profiles.queryCount); const validatedBroadCandidates = await validateKtxRelationshipDiscoveryCandidates({ connectionId: input.snapshot.connectionId, - driver: input.snapshot.driver, + dialect, candidates, profiles, executor: validationExecutor, @@ -597,7 +599,7 @@ export function ktxRelationshipBenchmarkDetectorWithLlm( input.mode !== 'validation_disabled' ? await discoverKtxCompositeRelationships({ connectionId: input.snapshot.connectionId, - driver: input.snapshot.driver, + dialect, schema: input.schema, profiles, executor: validationExecutor, @@ -671,6 +673,7 @@ export function currentKtxRelationshipBenchmarkDetector(): KtxRelationshipBenchm const formalLinks = formalMetadata.accepted.map((relationship) => relationshipToBenchmarkLink(relationship)); const acceptedKeys = new Set(formalLinks.map(fkKey)); const sqliteDataAvailable = Boolean(input.dataPath && input.snapshot.driver === 'sqlite'); + const dialect = getDialectForDriver(input.snapshot.driver); const profilingExecutor = sqliteDataAvailable && input.mode !== 'profiling_disabled' ? new KtxRelationshipBenchmarkSqliteExecutor(input.dataPath as string) @@ -685,7 +688,7 @@ export function currentKtxRelationshipBenchmarkDetector(): KtxRelationshipBenchm }) : await profileKtxRelationshipSchema({ connectionId: input.snapshot.connectionId, - driver: input.snapshot.driver, + dialect, schema: input.schema, executor: profilingExecutor, ctx: { runId: `relationship-benchmark:${input.fixtureId}:${input.mode}:profile` }, @@ -702,7 +705,7 @@ export function currentKtxRelationshipBenchmarkDetector(): KtxRelationshipBenchm : Math.max(0, input.validationBudget - profiles.queryCount); const validatedBroadCandidates = await validateKtxRelationshipDiscoveryCandidates({ connectionId: input.snapshot.connectionId, - driver: input.snapshot.driver, + dialect, candidates: broadRelationshipCandidates, profiles, executor: validationExecutor, @@ -719,7 +722,7 @@ export function currentKtxRelationshipBenchmarkDetector(): KtxRelationshipBenchm input.mode !== 'validation_disabled' ? await discoverKtxCompositeRelationships({ connectionId: input.snapshot.connectionId, - driver: input.snapshot.driver, + dialect, schema: input.schema, profiles, executor: validationExecutor, diff --git a/packages/cli/src/context/scan/relationship-composite-candidates.ts b/packages/cli/src/context/scan/relationship-composite-candidates.ts index d8ee650d..28263a15 100644 --- a/packages/cli/src/context/scan/relationship-composite-candidates.ts +++ b/packages/cli/src/context/scan/relationship-composite-candidates.ts @@ -1,11 +1,10 @@ +import type { KtxDialect } from '../connections/dialects.js'; import type { KtxEnrichedColumn, KtxEnrichedSchema, KtxEnrichedTable, KtxRelationshipType } from './enrichment-types.js'; import { - formatKtxRelationshipTableRef, - quoteKtxRelationshipIdentifier, type KtxRelationshipProfileArtifact, type KtxRelationshipReadOnlyExecutor, } from './relationship-profiling.js'; -import type { KtxConnectionDriver, KtxQueryResult, KtxScanContext, KtxTableRef } from './types.js'; +import type { KtxQueryResult, KtxScanContext, KtxTableRef } from './types.js'; type KtxCompositeRelationshipStatus = 'accepted' | 'review' | 'rejected'; @@ -57,7 +56,7 @@ export interface KtxCompositeRelationshipCandidate { export interface DiscoverKtxCompositeRelationshipsInput { connectionId: string; - driver: KtxConnectionDriver; + dialect: KtxDialect; schema: KtxEnrichedSchema; profiles: KtxRelationshipProfileArtifact; executor: KtxRelationshipReadOnlyExecutor | null; @@ -224,28 +223,16 @@ function numberAt(result: KtxQueryResult, header: string): number { return 0; } -function topSql(driver: KtxConnectionDriver, limit: number): string { - if (driver === 'sqlserver') { - return ` TOP (${Math.max(1, Math.floor(limit))})`; - } - return ''; +function sqlSuffix(fragment: string): string { + return fragment ? ` ${fragment}` : ''; } -function limitSql(driver: KtxConnectionDriver, limit: number): string { - if (driver === 'sqlserver') { - return ''; - } - return ` LIMIT ${Math.max(1, Math.floor(limit))}`; +function aliasedTupleSelect(dialect: KtxDialect, columns: readonly string[]): string { + return columns.map((column, index) => `${dialect.quoteIdentifier(column)} AS c${index}`).join(', '); } -function aliasedTupleSelect(driver: KtxConnectionDriver, columns: readonly string[]): string { - return columns - .map((column, index) => `${quoteKtxRelationshipIdentifier(driver, column)} AS c${index}`) - .join(', '); -} - -function nonNullPredicate(driver: KtxConnectionDriver, columns: readonly string[]): string { - return columns.map((column) => `${quoteKtxRelationshipIdentifier(driver, column)} IS NOT NULL`).join(' AND '); +function nonNullPredicate(dialect: KtxDialect, columns: readonly string[]): string { + return columns.map((column) => `${dialect.quoteIdentifier(column)} IS NOT NULL`).join(' AND '); } function tupleEquality(columns: number): string { @@ -255,39 +242,39 @@ function tupleEquality(columns: number): string { } function buildTupleDistinctSql(input: { - driver: KtxConnectionDriver; + dialect: KtxDialect; table: KtxTableRef; columns: readonly string[]; }): string { - const tableSql = formatKtxRelationshipTableRef(input.driver, input.table); + const tableSql = input.dialect.formatTableName(input.table); return [ 'WITH tuple_values AS (', - `SELECT DISTINCT ${aliasedTupleSelect(input.driver, input.columns)} FROM ${tableSql}`, - `WHERE ${nonNullPredicate(input.driver, input.columns)}`, + `SELECT DISTINCT ${aliasedTupleSelect(input.dialect, input.columns)} FROM ${tableSql}`, + `WHERE ${nonNullPredicate(input.dialect, input.columns)}`, ')', 'SELECT COUNT(*) AS distinct_count FROM tuple_values', ].join(' '); } function buildCompositeCoverageSql(input: { - driver: KtxConnectionDriver; + dialect: KtxDialect; childTable: KtxTableRef; childColumns: readonly string[]; parentTable: KtxTableRef; parentColumns: readonly string[]; maxDistinctSourceValues: number; }): string { - const childTableSql = formatKtxRelationshipTableRef(input.driver, input.childTable); - const parentTableSql = formatKtxRelationshipTableRef(input.driver, input.parentTable); - const top = topSql(input.driver, input.maxDistinctSourceValues); - const limit = limitSql(input.driver, input.maxDistinctSourceValues); + const childTableSql = input.dialect.formatTableName(input.childTable); + const parentTableSql = input.dialect.formatTableName(input.parentTable); + const top = input.dialect.getTopClause(input.maxDistinctSourceValues); + const limit = sqlSuffix(input.dialect.getLimitOffsetClause(input.maxDistinctSourceValues)); return [ 'WITH child_values AS (', - `SELECT DISTINCT${top} ${aliasedTupleSelect(input.driver, input.childColumns)} FROM ${childTableSql}`, - `WHERE ${nonNullPredicate(input.driver, input.childColumns)}${limit}`, + `SELECT DISTINCT${top ? ` ${top}` : ''} ${aliasedTupleSelect(input.dialect, input.childColumns)} FROM ${childTableSql}`, + `WHERE ${nonNullPredicate(input.dialect, input.childColumns)}${limit}`, '), parent_values AS (', - `SELECT DISTINCT ${aliasedTupleSelect(input.driver, input.parentColumns)} FROM ${parentTableSql}`, - `WHERE ${nonNullPredicate(input.driver, input.parentColumns)}`, + `SELECT DISTINCT ${aliasedTupleSelect(input.dialect, input.parentColumns)} FROM ${parentTableSql}`, + `WHERE ${nonNullPredicate(input.dialect, input.parentColumns)}`, ')', 'SELECT', '(SELECT COUNT(*) FROM child_values) AS child_distinct,', @@ -335,7 +322,7 @@ function hasAcceptedSubset( async function detectCompositePrimaryKeys(input: { connectionId: string; - driver: KtxConnectionDriver; + dialect: KtxDialect; table: KtxEnrichedTable; profiles: KtxRelationshipProfileArtifact; executor: KtxRelationshipReadOnlyExecutor; @@ -379,7 +366,7 @@ async function detectCompositePrimaryKeys(input: { { connectionId: input.connectionId, sql: buildTupleDistinctSql({ - driver: input.driver, + dialect: input.dialect, table: input.table.ref, columns: columnNames, }), @@ -439,7 +426,7 @@ function compatibleTuple(sourceColumns: readonly KtxEnrichedColumn[], targetColu async function validateCompositeRelationship(input: { connectionId: string; - driver: KtxConnectionDriver; + dialect: KtxDialect; sourceTable: KtxEnrichedTable; sourceColumns: readonly KtxEnrichedColumn[]; targetKey: KtxCompositePrimaryKeyCandidate; @@ -454,7 +441,7 @@ async function validateCompositeRelationship(input: { { connectionId: input.connectionId, sql: buildCompositeCoverageSql({ - driver: input.driver, + dialect: input.dialect, childTable: input.sourceTable.ref, childColumns: input.sourceColumns.map((column) => column.name), parentTable: input.targetTable.ref, @@ -552,7 +539,7 @@ export async function discoverKtxCompositeRelationships( for (const table of tables) { const result = await detectCompositePrimaryKeys({ connectionId: input.connectionId, - driver: input.driver, + dialect: input.dialect, table, profiles: input.profiles, executor: input.executor, @@ -595,7 +582,7 @@ export async function discoverKtxCompositeRelationships( const result = await validateCompositeRelationship({ connectionId: input.connectionId, - driver: input.driver, + dialect: input.dialect, sourceTable, sourceColumns, targetKey, diff --git a/packages/cli/src/context/scan/relationship-discovery.ts b/packages/cli/src/context/scan/relationship-discovery.ts index 66a47395..fc755536 100644 --- a/packages/cli/src/context/scan/relationship-discovery.ts +++ b/packages/cli/src/context/scan/relationship-discovery.ts @@ -1,4 +1,5 @@ import type { KtxLlmRuntimePort } from '../../context/llm/runtime-port.js'; +import type { KtxDialect } from '../connections/dialects.js'; import type { KtxScanRelationshipConfig } from '../project/config.js'; import type { KtxEnrichedRelationship, KtxEnrichedSchema, KtxRelationshipUpdate } from './enrichment-types.js'; import { @@ -24,7 +25,6 @@ import { } from './relationship-profiling.js'; import { validateKtxRelationshipDiscoveryCandidates } from './relationship-validation.js'; import type { - KtxConnectionDriver, KtxScanConnector, KtxScanContext, KtxScanEnrichmentSummary, @@ -34,7 +34,7 @@ import type { export interface DiscoverKtxRelationshipsInput { connectionId: string; - driver: KtxConnectionDriver; + dialect: KtxDialect; connector: KtxScanConnector; schema: KtxEnrichedSchema; context: KtxScanContext; @@ -122,7 +122,7 @@ function compositeSummary(relationships: readonly KtxCompositeRelationshipCandid async function detectCompositeRelationships(input: { connectionId: string; - driver: DiscoverKtxRelationshipsInput['driver']; + dialect: KtxDialect; schema: KtxEnrichedSchema; profile: KtxRelationshipProfileArtifact; executor: KtxRelationshipReadOnlyExecutor | null; @@ -135,7 +135,7 @@ async function detectCompositeRelationships(input: { try { const compositeDetection = await discoverKtxCompositeRelationships({ connectionId: input.connectionId, - driver: input.driver, + dialect: input.dialect, schema: input.schema, profiles: input.profile, executor: input.executor, @@ -223,7 +223,7 @@ export async function discoverKtxRelationships( const profileCache = createKtxRelationshipProfileCache(); const profile = await profileKtxRelationshipSchema({ connectionId: input.connectionId, - driver: input.driver, + dialect: input.dialect, schema: input.schema, executor, ctx: input.context, @@ -256,7 +256,7 @@ export async function discoverKtxRelationships( warnings.push(...llmProposalResult.warnings); const validated = await validateKtxRelationshipDiscoveryCandidates({ connectionId: input.connectionId, - driver: input.driver, + dialect: input.dialect, candidates, profiles: profile, executor, @@ -282,7 +282,7 @@ export async function discoverKtxRelationships( }); const compositeRelationships = await detectCompositeRelationships({ connectionId: input.connectionId, - driver: input.driver, + dialect: input.dialect, schema: input.schema, profile, executor, diff --git a/packages/cli/src/context/scan/relationship-profiling.ts b/packages/cli/src/context/scan/relationship-profiling.ts index 1824d263..0f22c21c 100644 --- a/packages/cli/src/context/scan/relationship-profiling.ts +++ b/packages/cli/src/context/scan/relationship-profiling.ts @@ -1,3 +1,4 @@ +import type { KtxDialect } from '../connections/dialects.js'; import type { KtxEnrichedColumn, KtxEnrichedSchema, KtxEnrichedTable } from './enrichment-types.js'; import { mapWithConcurrency } from './relationship-validation.js'; import type { @@ -55,7 +56,7 @@ export interface KtxRelationshipProfileCache { export interface ProfileKtxRelationshipSchemaInput { connectionId: string; - driver: KtxConnectionDriver; + dialect: KtxDialect; schema: KtxEnrichedSchema; executor: KtxRelationshipReadOnlyExecutor | null; ctx: KtxScanContext; @@ -71,75 +72,6 @@ export function createKtxRelationshipProfileCache(): KtxRelationshipProfileCache const SAMPLE_VALUE_DELIMITER = '\u001f'; -type QuoteStyle = 'double' | 'backtick' | 'bracket'; - -function quoteStyle(driver: KtxConnectionDriver): QuoteStyle { - if (driver === 'mysql' || driver === 'clickhouse') { - return 'backtick'; - } - if (driver === 'sqlserver') { - return 'bracket'; - } - return 'double'; -} - -export function quoteKtxRelationshipIdentifier(driver: KtxConnectionDriver, identifier: string): string { - switch (quoteStyle(driver)) { - case 'backtick': - return `\`${identifier.replace(/`/g, '``')}\``; - case 'bracket': - return `[${identifier.replace(/\]/g, ']]')}]`; - case 'double': - return `"${identifier.replace(/"/g, '""')}"`; - } -} - -export function formatKtxRelationshipTableRef(driver: KtxConnectionDriver, table: KtxTableRef): string { - const parts = - driver === 'sqlite' - ? [table.name] - : [table.catalog, table.db, table.name].filter((value): value is string => Boolean(value)); - return parts.map((part) => quoteKtxRelationshipIdentifier(driver, part)).join('.'); -} - -function textLengthExpression(driver: KtxConnectionDriver, columnSql: string): string { - if (driver === 'mysql') { - return `CHAR_LENGTH(CAST(${columnSql} AS CHAR))`; - } - if (driver === 'sqlserver') { - return `LEN(CAST(${columnSql} AS NVARCHAR(MAX)))`; - } - if (driver === 'bigquery') { - return `LENGTH(CAST(${columnSql} AS STRING))`; - } - if (driver === 'clickhouse') { - return `length(toString(${columnSql}))`; - } - return `LENGTH(CAST(${columnSql} AS TEXT))`; -} - -function limitSql(driver: KtxConnectionDriver, limit: number): string { - if (driver === 'sqlserver') { - return ''; - } - return ` LIMIT ${Math.max(1, Math.floor(limit))}`; -} - -function topSql(driver: KtxConnectionDriver, limit: number): string { - if (driver === 'sqlserver') { - return ` TOP (${Math.max(1, Math.floor(limit))})`; - } - return ''; -} - -function sampledTableSql(driver: KtxConnectionDriver, tableSql: string, limit: number): string { - const safeLimit = Math.max(1, Math.floor(limit)); - if (driver === 'sqlserver') { - return `(SELECT TOP (${safeLimit}) * FROM ${tableSql}) AS relationship_profile_sample`; - } - return `(SELECT * FROM ${tableSql}${limitSql(driver, safeLimit)}) AS relationship_profile_sample`; -} - function firstRow(result: KtxQueryResult): unknown[] { return result.rows[0] ?? []; } @@ -191,7 +123,7 @@ function columnKey(table: KtxEnrichedTable, column: KtxEnrichedColumn): string { function tableProfileCacheKey(input: { connectionId: string; - driver: KtxConnectionDriver; + dialect: KtxDialect; ctx: KtxScanContext; table: KtxTableRef; sampleValuesPerColumn: number; @@ -200,7 +132,7 @@ function tableProfileCacheKey(input: { return [ input.ctx.runId, input.connectionId, - input.driver, + input.dialect.type, input.table.catalog ?? '', input.table.db ?? '', input.table.name, @@ -213,57 +145,47 @@ function sqlStringLiteral(value: string): string { return `'${value.replace(/'/g, "''")}'`; } -function sampleAggregateSql(driver: KtxConnectionDriver, innerSql: string): string { - if (driver === 'postgres') { - return `(SELECT STRING_AGG(CAST(value AS TEXT), CHR(31)) FROM (${innerSql}) AS relationship_profile_values)`; +function sqlSuffix(fragment: string): string { + return fragment ? ` ${fragment}` : ''; +} + +function sampledTableSql(dialect: KtxDialect, tableSql: string, limit: number): string { + const top = dialect.getTopClause(limit); + if (top) { + return `(SELECT ${top} * FROM ${tableSql}) AS relationship_profile_sample`; } - if (driver === 'bigquery') { - return `(SELECT STRING_AGG(CAST(value AS STRING), '\\u001F') FROM (${innerSql}) AS relationship_profile_values)`; - } - if (driver === 'mysql') { - return `(SELECT GROUP_CONCAT(CAST(value AS CHAR) SEPARATOR CHAR(31)) FROM (${innerSql}) AS relationship_profile_values)`; - } - if (driver === 'sqlserver') { - return `(SELECT STRING_AGG(CAST(value AS NVARCHAR(MAX)), CHAR(31)) FROM (${innerSql}) AS relationship_profile_values)`; - } - if (driver === 'clickhouse') { - return `(SELECT arrayStringConcat(groupArray(toString(value)), '\\x1F') FROM (${innerSql}) AS relationship_profile_values)`; - } - if (driver === 'snowflake') { - return `(SELECT LISTAGG(CAST(value AS VARCHAR), '\\x1f') FROM (${innerSql}) AS relationship_profile_values)`; - } - return `(SELECT GROUP_CONCAT(CAST(value AS TEXT), char(31)) FROM (${innerSql}) AS relationship_profile_values)`; + return `(SELECT * FROM ${tableSql}${sqlSuffix(dialect.getLimitOffsetClause(limit))}) AS relationship_profile_sample`; } function sampleValuesSql(input: { - driver: KtxConnectionDriver; + dialect: KtxDialect; tableSql: string; columnSql: string; limit: number; }): string { + const top = input.dialect.getTopClause(input.limit); return [ - `SELECT${topSql(input.driver, input.limit)} ${input.columnSql} AS value`, + `SELECT${top ? ` ${top}` : ''} ${input.columnSql} AS value`, `FROM ${input.tableSql}`, `WHERE ${input.columnSql} IS NOT NULL`, `GROUP BY ${input.columnSql}`, `ORDER BY COUNT(*) DESC, ${input.columnSql} ASC`, - limitSql(input.driver, input.limit), + sqlSuffix(input.dialect.getLimitOffsetClause(input.limit)), ].join(' '); } function columnProfileSelectSql(input: { - connectionDriver: KtxConnectionDriver; + dialect: KtxDialect; tableSql: string; profileTableSql: string; column: KtxEnrichedColumn; sampleValuesPerColumn: number; }): string { - const columnSql = quoteKtxRelationshipIdentifier(input.connectionDriver, input.column.name); - const textLengthSql = textLengthExpression(input.connectionDriver, columnSql); - const samplesSql = sampleAggregateSql( - input.connectionDriver, + const columnSql = input.dialect.quoteIdentifier(input.column.name); + const textLengthSql = input.dialect.textLengthExpression(columnSql); + const samplesSql = input.dialect.getSampleValueAggregation( sampleValuesSql({ - driver: input.connectionDriver, + dialect: input.dialect, tableSql: input.profileTableSql, columnSql, limit: input.sampleValuesPerColumn, @@ -296,12 +218,12 @@ function splitSampleValues(value: unknown): string[] { async function queryCount(input: { connectionId: string; - driver: KtxConnectionDriver; + dialect: KtxDialect; table: KtxTableRef; executor: KtxRelationshipReadOnlyExecutor; ctx: KtxScanContext; }): Promise<{ rowCount: number; queryCount: number }> { - const tableSql = formatKtxRelationshipTableRef(input.driver, input.table); + const tableSql = input.dialect.formatTableName(input.table); const result = await input.executor.executeReadOnly( { connectionId: input.connectionId, sql: `SELECT COUNT(*) AS row_count FROM ${tableSql}`, maxRows: 1 }, input.ctx, @@ -311,7 +233,7 @@ async function queryCount(input: { async function queryTableProfile(input: { connectionId: string; - driver: KtxConnectionDriver; + dialect: KtxDialect; table: KtxEnrichedTable; executor: KtxRelationshipReadOnlyExecutor; ctx: KtxScanContext; @@ -325,7 +247,7 @@ async function queryTableProfile(input: { if (input.table.columns.length === 0) { const rowCount = await queryCount({ connectionId: input.connectionId, - driver: input.driver, + dialect: input.dialect, table: input.table.ref, executor: input.executor, ctx: input.ctx, @@ -337,12 +259,12 @@ async function queryTableProfile(input: { }; } - const tableSql = formatKtxRelationshipTableRef(input.driver, input.table.ref); - const profileTableSql = sampledTableSql(input.driver, tableSql, input.profileSampleRows); + const tableSql = input.dialect.formatTableName(input.table.ref); + const profileTableSql = sampledTableSql(input.dialect, tableSql, input.profileSampleRows); const sql = input.table.columns .map((column) => columnProfileSelectSql({ - connectionDriver: input.driver, + dialect: input.dialect, tableSql, profileTableSql, column, @@ -401,7 +323,7 @@ export async function profileKtxRelationshipSchema( if (!input.executor) { return { connectionId: input.connectionId, - driver: input.driver, + driver: input.dialect.type, sqlAvailable: false, queryCount: 0, tables: [], @@ -425,7 +347,7 @@ export async function profileKtxRelationshipSchema( const profileSampleRows = input.profileSampleRows ?? 10000; const cacheKey = tableProfileCacheKey({ connectionId: input.connectionId, - driver: input.driver, + dialect: input.dialect, ctx: input.ctx, table: table.ref, sampleValuesPerColumn, @@ -439,7 +361,7 @@ export async function profileKtxRelationshipSchema( try { const tableProfile = await queryTableProfile({ connectionId: input.connectionId, - driver: input.driver, + dialect: input.dialect, table, executor, ctx: input.ctx, @@ -481,7 +403,7 @@ export async function profileKtxRelationshipSchema( return { connectionId: input.connectionId, - driver: input.driver, + driver: input.dialect.type, sqlAvailable: true, queryCount: queryTotal, tables, diff --git a/packages/cli/src/context/scan/relationship-validation.ts b/packages/cli/src/context/scan/relationship-validation.ts index 685d1ea9..5fc0f3fb 100644 --- a/packages/cli/src/context/scan/relationship-validation.ts +++ b/packages/cli/src/context/scan/relationship-validation.ts @@ -1,13 +1,12 @@ +import type { KtxDialect } from '../connections/dialects.js'; import type { KtxRelationshipEndpoint } from './enrichment-types.js'; import { applyKtxRelationshipValidationBudget, type KtxRelationshipValidationBudget } from './relationship-budget.js'; import type { KtxRelationshipDiscoveryCandidate } from './relationship-candidates.js'; import { - formatKtxRelationshipTableRef, type KtxRelationshipProfileArtifact, type KtxRelationshipReadOnlyExecutor, - quoteKtxRelationshipIdentifier, } from './relationship-profiling.js'; -import type { KtxConnectionDriver, KtxQueryResult, KtxScanContext } from './types.js'; +import type { KtxQueryResult, KtxScanContext, KtxTableRef } from './types.js'; type KtxValidatedRelationshipStatus = 'accepted' | 'review' | 'rejected'; @@ -45,7 +44,7 @@ export interface KtxValidatedRelationshipDiscoveryCandidate export interface ValidateKtxRelationshipDiscoveryCandidatesInput { connectionId: string; - driver: KtxConnectionDriver; + dialect: KtxDialect; candidates: readonly KtxRelationshipDiscoveryCandidate[]; profiles: KtxRelationshipProfileArtifact; executor: KtxRelationshipReadOnlyExecutor | null; @@ -104,38 +103,28 @@ function numberAt(result: KtxQueryResult, header: string): number { return 0; } -function limitSql(driver: KtxConnectionDriver, limit: number): string { - if (driver === 'sqlserver') { - return ''; - } - return ` LIMIT ${Math.max(1, Math.floor(limit))}`; -} - -function topSql(driver: KtxConnectionDriver, limit: number): string { - if (driver === 'sqlserver') { - return ` TOP (${Math.max(1, Math.floor(limit))})`; - } - return ''; +function sqlSuffix(fragment: string): string { + return fragment ? ` ${fragment}` : ''; } function buildCoverageSql(input: { - driver: KtxConnectionDriver; - childTable: string; + dialect: KtxDialect; + childTable: KtxTableRef; childColumn: string; - parentTable: string; + parentTable: KtxTableRef; parentColumn: string; maxDistinctSourceValues: number; }): string { - const childTable = formatKtxRelationshipTableRef(input.driver, { catalog: null, db: null, name: input.childTable }); - const parentTable = formatKtxRelationshipTableRef(input.driver, { catalog: null, db: null, name: input.parentTable }); - const childColumn = quoteKtxRelationshipIdentifier(input.driver, input.childColumn); - const parentColumn = quoteKtxRelationshipIdentifier(input.driver, input.parentColumn); - const limit = limitSql(input.driver, input.maxDistinctSourceValues); - const top = topSql(input.driver, input.maxDistinctSourceValues); + const childTable = input.dialect.formatTableName(input.childTable); + const parentTable = input.dialect.formatTableName(input.parentTable); + const childColumn = input.dialect.quoteIdentifier(input.childColumn); + const parentColumn = input.dialect.quoteIdentifier(input.parentColumn); + const limit = sqlSuffix(input.dialect.getLimitOffsetClause(input.maxDistinctSourceValues)); + const top = input.dialect.getTopClause(input.maxDistinctSourceValues); return [ 'WITH child_values AS (', - `SELECT DISTINCT${top} ${childColumn} AS value FROM ${childTable} WHERE ${childColumn} IS NOT NULL${limit}`, + `SELECT DISTINCT${top ? ` ${top}` : ''} ${childColumn} AS value FROM ${childTable} WHERE ${childColumn} IS NOT NULL${limit}`, '), parent_values AS (', `SELECT DISTINCT ${parentColumn} AS value FROM ${parentTable} WHERE ${parentColumn} IS NOT NULL`, ')', @@ -271,10 +260,10 @@ export async function validateKtxRelationshipDiscoveryCandidates( { connectionId: input.connectionId, sql: buildCoverageSql({ - driver: input.driver, - childTable: candidate.from.table.name, + dialect: input.dialect, + childTable: candidate.from.table, childColumn: sourceColumn, - parentTable: candidate.to.table.name, + parentTable: candidate.to.table, parentColumn: targetColumn, maxDistinctSourceValues: settings.maxDistinctSourceValues, }), diff --git a/packages/cli/src/context/scan/types.ts b/packages/cli/src/context/scan/types.ts index 5590b465..1d9e6d6a 100644 --- a/packages/cli/src/context/scan/types.ts +++ b/packages/cli/src/context/scan/types.ts @@ -297,6 +297,7 @@ export interface KtxQueryResult { } export interface KtxTableListEntry { + catalog: string | null; schema: string; name: string; kind: 'table' | 'view'; @@ -313,6 +314,8 @@ export interface KtxScanConnector { capabilities: KtxConnectorCapabilities; eventStreamDiscovery?: KtxEventStreamDiscoveryPort; introspect(input: KtxScanInput, ctx: KtxScanContext): Promise; + listSchemas(): Promise; + listTables(schemas?: string[]): Promise; testConnection?(): Promise; sampleColumn?(input: KtxColumnSampleInput, ctx: KtxScanContext): Promise; sampleTable?(input: KtxTableSampleInput, ctx: KtxScanContext): Promise; diff --git a/packages/cli/src/context/scan/warehouse-catalog.ts b/packages/cli/src/context/scan/warehouse-catalog.ts index b8e91492..f224432b 100644 --- a/packages/cli/src/context/scan/warehouse-catalog.ts +++ b/packages/cli/src/context/scan/warehouse-catalog.ts @@ -1,4 +1,4 @@ -import { getDialectForDriver } from '../../context/connections/dialects.js'; +import { getDialectForDriver, type KtxDialect } from '../connections/dialects.js'; import type { KtxFileStorePort } from '../../context/core/file-store.js'; import type { KtxConnectionDriver, @@ -128,46 +128,22 @@ function splitDisplay(display: string): string[] { .filter(Boolean); } -function formatDisplay(driver: CatalogDriver, table: KtxTableRef): string { - if (driver === 'sqlite') { - return table.name; - } - return [table.catalog, table.db, table.name].filter((part): part is string => Boolean(part)).join('.'); +function formatDisplay(dialect: KtxDialect, table: KtxTableRef): string { + return dialect.formatDisplayRef(table); } -function parseDisplay(driver: CatalogDriver, display: string): KtxTableRef | null { +function parseDisplay(dialect: KtxDialect, display: string): KtxTableRef | null { + const parsed = dialect.parseDisplayRef(display); + if (parsed) { + return parsed; + } const parts = splitDisplay(display); - if (driver === 'sqlite') { - return parts.length === 1 ? { catalog: null, db: null, name: parts[0]! } : null; - } - if (driver === 'bigquery' || driver === 'snowflake' || driver === 'sqlserver') { - if (parts.length !== 3) { - return null; - } - return { catalog: parts[0]!, db: parts[1]!, name: parts[2]! }; - } - if (parts.length === 2) { - return { catalog: null, db: parts[0]!, name: parts[1]! }; - } - if (parts.length === 3) { - return { catalog: parts[0]!, db: parts[1]!, name: parts[2]! }; - } return parts.length === 1 ? { catalog: null, db: null, name: parts[0]! } : null; } -function expectedDisplayPartCount(driver: CatalogDriver): number { - if (driver === 'sqlite') { - return 1; - } - if (driver === 'bigquery' || driver === 'snowflake' || driver === 'sqlserver') { - return 3; - } - return 2; -} - -function parseColumnDisplay(driver: CatalogDriver, display: string): (KtxTableRef & { column: string }) | null { +function parseColumnDisplay(dialect: KtxDialect, display: string): (KtxTableRef & { column: string }) | null { const parts = splitDisplay(display); - const tablePartCount = expectedDisplayPartCount(driver); + const tablePartCount = dialect.columnDisplayTablePartCount(); if (parts.length !== tablePartCount + 1) { return null; } @@ -175,7 +151,7 @@ function parseColumnDisplay(driver: CatalogDriver, display: string): (KtxTableRe if (!column) { return null; } - const table = parseDisplay(driver, parts.slice(0, -1).join('.')); + const table = dialect.parseDisplayRef(parts.slice(0, -1).join('.')); return table ? { ...table, column } : null; } @@ -272,6 +248,7 @@ export class WarehouseCatalogService { if (!table) { return null; } + const dialect = getDialectForDriver(catalog.driver); const profileTables = catalog.profile?.tables ?? []; const profileTable = profileTables.find((candidate) => candidate.table && refsEqual(candidate.table, table)); const profileColumns = catalog.profile?.columns ?? {}; @@ -281,7 +258,7 @@ export class WarehouseCatalogService { catalog: table.catalog, db: table.db, name: table.name, - display: formatDisplay(catalog.driver, table), + display: formatDisplay(dialect, table), kind: table.kind, comment: table.comment, description: firstDescription(table.descriptions), @@ -321,16 +298,21 @@ export class WarehouseCatalogService { if (!catalog) { return { resolved: null, candidates: [], dialect: 'unknown' }; } - const dialect = getDialectForDriver(catalog.driver).type; - const parsed = parseDisplay(catalog.driver, display); + const dialect = getDialectForDriver(catalog.driver); + const parsed = parseDisplay(dialect, display); if (!parsed) { - return { resolved: null, candidates: bestCandidates(catalog.tables, display), dialect }; + return { resolved: null, candidates: bestCandidates(catalog.tables, display), dialect: dialect.type }; } - const table = catalog.tables.find((candidate) => refsEqual(candidate, parsed)); + const exactTable = catalog.tables.find((candidate) => refsEqual(candidate, parsed)); + const looseNameMatches = + parsed.catalog === null && parsed.db === null + ? catalog.tables.filter((candidate) => normalize(candidate.name) === normalize(parsed.name)) + : []; + const table = exactTable ?? (looseNameMatches.length === 1 ? looseNameMatches[0] : undefined); if (!table) { - return { resolved: null, candidates: bestCandidates(catalog.tables, display), dialect }; + return { resolved: null, candidates: bestCandidates(catalog.tables, display), dialect: dialect.type }; } - return { resolved: { catalog: table.catalog, db: table.db, name: table.name }, candidates: [], dialect }; + return { resolved: { catalog: table.catalog, db: table.db, name: table.name }, candidates: [], dialect: dialect.type }; } async resolveDisplayTarget(connectionId: string, display: string): Promise { @@ -339,20 +321,20 @@ export class WarehouseCatalogService { return { resolved: null, candidates: [], dialect: 'unknown' }; } - const dialect = getDialectForDriver(catalog.driver).type; + const dialect = getDialectForDriver(catalog.driver); const tableResolution = await this.resolveDisplay(connectionId, display); if (tableResolution.resolved) { return tableResolution; } - const parsedColumn = parseColumnDisplay(catalog.driver, display); + const parsedColumn = parseColumnDisplay(dialect, display); if (!parsedColumn) { - return { resolved: null, candidates: bestCandidates(catalog.tables, display), dialect }; + return { resolved: null, candidates: bestCandidates(catalog.tables, display), dialect: dialect.type }; } const table = catalog.tables.find((candidate) => refsEqual(candidate, parsedColumn)); if (!table) { - return { resolved: null, candidates: bestCandidates(catalog.tables, display), dialect }; + return { resolved: null, candidates: bestCandidates(catalog.tables, display), dialect: dialect.type }; } return { @@ -363,7 +345,7 @@ export class WarehouseCatalogService { column: parsedColumn.column, }, candidates: [], - dialect, + dialect: dialect.type, }; } @@ -372,6 +354,7 @@ export class WarehouseCatalogService { if (!catalog) { return []; } + const dialect = getDialectForDriver(catalog.driver); const hits: RawSchemaHit[] = []; for (const table of catalog.tables as TableWithDescriptions[]) { const tableMatch = matchedOnTable(table, query); @@ -380,7 +363,7 @@ export class WarehouseCatalogService { kind: 'table', connectionId, ref: { catalog: table.catalog, db: table.db, name: table.name }, - display: formatDisplay(catalog.driver, table), + display: formatDisplay(dialect, table), matchedOn: tableMatch, }); } @@ -393,7 +376,7 @@ export class WarehouseCatalogService { kind: 'column', connectionId, ref: { catalog: table.catalog, db: table.db, name: table.name, column: column.name }, - display: `${formatDisplay(catalog.driver, table)}.${column.name}`, + display: `${formatDisplay(dialect, table)}.${column.name}`, matchedOn: columnMatch, }); } diff --git a/packages/cli/src/database-tree-picker.ts b/packages/cli/src/database-tree-picker.ts index 6b357de6..6698a0d2 100644 --- a/packages/cli/src/database-tree-picker.ts +++ b/packages/cli/src/database-tree-picker.ts @@ -1,3 +1,4 @@ +import { parseDottedTableEntry } from './context/scan/enabled-tables.js'; import type { KtxTableListEntry } from './context/scan/types.js'; import type { KtxCliIo } from './cli-runtime.js'; import { profileMark } from './startup-profile.js'; @@ -73,7 +74,9 @@ export interface PickDatabaseScopeArgs { } function qualifiedTableId(entry: KtxTableListEntry): string { - return `${entry.schema}.${entry.name}`; + return entry.catalog !== null + ? `${entry.catalog}.${entry.schema}.${entry.name}` + : `${entry.schema}.${entry.name}`; } function tableTitle(entry: KtxTableListEntry): string { @@ -177,7 +180,8 @@ function schemasFromEnabledTables(enabledTables: readonly string[]): string[] { const seen = new Set(); const result: string[] = []; for (const qualified of enabledTables) { - const schema = qualified.split('.')[0] ?? ''; + const ref = parseDottedTableEntry(qualified); + const schema = ref?.db ?? ''; if (schema.length === 0 || seen.has(schema)) continue; seen.add(schema); result.push(schema); @@ -228,11 +232,14 @@ async function runStageTwoTreePicker(input: { ? initialSelectionForExisting(args.existing.enabledTables, byId) : initialSelectionFromDefaults(selectedSchemas, schemaIds); - const initialState = buildInitialState({ - tree, - existingSelectedIds: initialSelection, - skipEmptyAction: 'save-empty', - }); + const initialState = { + ...buildInitialState({ + tree, + existingSelectedIds: initialSelection, + skipEmptyAction: 'save-empty', + }), + expanded: new Set(schemaIds), + }; const schemaWordPlural = schemaCount === 1 ? args.schemaNoun : args.schemaNounPlural; const subtitleLines = [ diff --git a/packages/cli/src/error-message.ts b/packages/cli/src/error-message.ts new file mode 100644 index 00000000..8ec1355a --- /dev/null +++ b/packages/cli/src/error-message.ts @@ -0,0 +1,28 @@ +export function describeError(error: unknown): string { + if (!(error instanceof Error)) { + const text = String(error); + return text.length > 0 ? text : 'unknown error'; + } + const parts: string[] = []; + if (error.message.length > 0) { + parts.push(error.message); + } + const seen = new Set([error]); + let cause: unknown = error.cause; + while (cause && !seen.has(cause)) { + seen.add(cause); + if (cause instanceof Error) { + if (cause.message.length > 0) { + parts.push(cause.message); + } + cause = cause.cause; + } else { + const text = String(cause); + if (text.length > 0) { + parts.push(text); + } + break; + } + } + return parts.length > 0 ? parts.join(': ') : 'unknown error'; +} diff --git a/packages/cli/src/llm/embedding-health.ts b/packages/cli/src/llm/embedding-health.ts index 2c8ac53d..47d3f14f 100644 --- a/packages/cli/src/llm/embedding-health.ts +++ b/packages/cli/src/llm/embedding-health.ts @@ -1,3 +1,4 @@ +import { describeError } from '../error-message.js'; import { createKtxEmbeddingProvider, type KtxEmbeddingProviderDeps } from './embedding-provider.js'; import type { KtxEmbeddingConfig } from './types.js'; @@ -48,7 +49,6 @@ export async function runKtxEmbeddingHealthCheck( } return { ok: true }; } catch (error) { - const message = error instanceof Error ? error.message : String(error); - return { ok: false, message: redactHealthCheckMessage(message, config) }; + return { ok: false, message: redactHealthCheckMessage(describeError(error), config) }; } } diff --git a/packages/cli/src/local-scan-connectors.ts b/packages/cli/src/local-scan-connectors.ts index 31fc158e..4d98bc0c 100644 --- a/packages/cli/src/local-scan-connectors.ts +++ b/packages/cli/src/local-scan-connectors.ts @@ -1,7 +1,11 @@ +import { + getDriverRegistration, + listSupportedDrivers, +} from './context/connections/drivers.js'; import type { KtxLocalProject } from './context/project/project.js'; import type { KtxScanConnector } from './context/scan/types.js'; -const SUPPORTED_DRIVERS = 'sqlite, postgres, mysql, clickhouse, sqlserver, bigquery, snowflake'; +const SUPPORTED_DRIVERS = listSupportedDrivers().join(', '); export async function createKtxCliScanConnector( project: KtxLocalProject, @@ -17,58 +21,23 @@ export async function createKtxCliScanConnector( `Connection "${connectionId}" has no \`driver\` field in ktx.yaml. Supported drivers: ${SUPPORTED_DRIVERS}.`, ); } - if (driver === 'sqlite') { - const { KtxSqliteScanConnector, isKtxSqliteConnectionConfig } = await import('./connectors/sqlite/connector.js');; - if (!isKtxSqliteConnectionConfig(connection)) { - throw invalidConnectionConfigError(connectionId, driver); - } - return new KtxSqliteScanConnector({ connectionId, connection, projectDir: project.projectDir }); + + const registration = getDriverRegistration(driver); + if (!registration) { + throw new Error( + `Connection "${connectionId}" uses driver "${driver}", which has no native standalone KTX scan connector. Supported drivers: ${SUPPORTED_DRIVERS}.`, + ); } - if (driver === 'postgres') { - const { KtxPostgresScanConnector, isKtxPostgresConnectionConfig } = await import('./connectors/postgres/connector.js');; - if (!isKtxPostgresConnectionConfig(connection)) { - throw invalidConnectionConfigError(connectionId, driver); - } - return new KtxPostgresScanConnector({ connectionId, connection }); + + const connectorModule = await registration.load(); + if (!connectorModule.isConnectionConfig(connection)) { + throw invalidConnectionConfigError(connectionId, driver); } - if (driver === 'mysql') { - const { KtxMysqlScanConnector, isKtxMysqlConnectionConfig } = await import('./connectors/mysql/connector.js');; - if (!isKtxMysqlConnectionConfig(connection)) { - throw invalidConnectionConfigError(connectionId, driver); - } - return new KtxMysqlScanConnector({ connectionId, connection }); - } - if (driver === 'clickhouse') { - const { KtxClickHouseScanConnector, isKtxClickHouseConnectionConfig } = await import('./connectors/clickhouse/connector.js');; - if (!isKtxClickHouseConnectionConfig(connection)) { - throw invalidConnectionConfigError(connectionId, driver); - } - return new KtxClickHouseScanConnector({ connectionId, connection }); - } - if (driver === 'sqlserver') { - const { KtxSqlServerScanConnector, isKtxSqlServerConnectionConfig } = await import('./connectors/sqlserver/connector.js');; - if (!isKtxSqlServerConnectionConfig(connection)) { - throw invalidConnectionConfigError(connectionId, driver); - } - return new KtxSqlServerScanConnector({ connectionId, connection }); - } - if (driver === 'bigquery') { - const { KtxBigQueryScanConnector, isKtxBigQueryConnectionConfig } = await import('./connectors/bigquery/connector.js');; - if (!isKtxBigQueryConnectionConfig(connection)) { - throw invalidConnectionConfigError(connectionId, driver); - } - return new KtxBigQueryScanConnector({ connectionId, connection }); - } - if (driver === 'snowflake') { - const { KtxSnowflakeScanConnector, isKtxSnowflakeConnectionConfig } = await import('./connectors/snowflake/connector.js');; - if (!isKtxSnowflakeConnectionConfig(connection)) { - throw invalidConnectionConfigError(connectionId, driver); - } - return new KtxSnowflakeScanConnector({ connectionId, connection, projectDir: project.projectDir }); - } - throw new Error( - `Connection "${connectionId}" uses driver "${driver}", which has no native standalone KTX scan connector. Supported drivers: ${SUPPORTED_DRIVERS}.`, - ); + return connectorModule.createScanConnector({ + connectionId, + connection, + projectDir: project.projectDir, + }); } function invalidConnectionConfigError(connectionId: string, driver: string): Error { diff --git a/packages/cli/src/managed-local-embeddings.ts b/packages/cli/src/managed-local-embeddings.ts index b178be47..768648c1 100644 --- a/packages/cli/src/managed-local-embeddings.ts +++ b/packages/cli/src/managed-local-embeddings.ts @@ -1,5 +1,6 @@ import type { KtxEmbeddingConfig } from './llm/types.js'; import type { KtxCliIo } from './cli-runtime.js'; +import { writePrefixedLines } from './clack.js'; import { ensureManagedPythonCommandRuntime, type KtxManagedPythonInstallPolicy, @@ -73,7 +74,7 @@ export async function ensureManagedLocalEmbeddingsDaemon( }); const verb = daemon.status === 'started' ? 'Started' : 'Using'; - options.io.stderr.write(`${verb} KTX daemon: ${daemon.baseUrl}\n`); + writePrefixedLines((chunk) => options.io.stderr.write(chunk), `${verb} KTX daemon: ${daemon.baseUrl}`); return { baseUrl: daemon.baseUrl, diff --git a/packages/cli/src/managed-python-daemon.ts b/packages/cli/src/managed-python-daemon.ts index 7bc92e14..4e56ca47 100644 --- a/packages/cli/src/managed-python-daemon.ts +++ b/packages/cli/src/managed-python-daemon.ts @@ -4,6 +4,7 @@ import { createServer } from 'node:net'; import { setTimeout as delay } from 'node:timers/promises'; import { promisify } from 'node:util'; import { z } from 'zod'; +import { describeError } from './error-message.js'; import { installManagedPythonRuntime, managedPythonDaemonLayout, @@ -16,6 +17,17 @@ import { } from './managed-python-runtime.js'; import { sanitizeChildProxyEnv } from './proxy-env.js'; +export class ManagedPythonDaemonStartError extends Error { + readonly detail: string; + readonly stderrLog: string; + constructor(detail: string, stderrLog: string) { + super(`KTX daemon failed to start: ${detail}. stderr: ${stderrLog}`); + this.name = 'ManagedPythonDaemonStartError'; + this.detail = detail; + this.stderrLog = stderrLog; + } +} + export interface ManagedPythonDaemonState { schemaVersion: 1; pid: number; @@ -237,7 +249,7 @@ async function healthOk(input: { } return { ok: true }; } catch (error) { - return { ok: false, detail: error instanceof Error ? error.message : String(error) }; + return { ok: false, detail: describeError(error) }; } } @@ -328,7 +340,7 @@ async function waitForHealth(input: { return; } lastDetail = finalHealth.detail; - throw new Error(`KTX daemon failed to start: ${lastDetail}. stderr: ${input.state.stderrLog}`); + throw new ManagedPythonDaemonStartError(lastDetail, input.state.stderrLog); } async function removeState(layout: ManagedPythonDaemonLayout): Promise { @@ -721,13 +733,21 @@ export async function startManagedPythonDaemon( stdoutLog: layout.daemonStdoutPath, stderrLog: layout.daemonStderrPath, }; - await waitForHealth({ - state, - cliVersion: options.cliVersion, - fetch: fetchImpl, - timeoutMs: options.startupTimeoutMs ?? 10_000, - pollIntervalMs: options.pollIntervalMs ?? 100, - }); + try { + await waitForHealth({ + state, + cliVersion: options.cliVersion, + fetch: fetchImpl, + timeoutMs: options.startupTimeoutMs ?? 30_000, + pollIntervalMs: options.pollIntervalMs ?? 100, + }); + } catch (error) { + if (processAlive(state.pid)) { + killProcess(state.pid); + } + await removeState(layout); + throw error; + } await writeState(layout.daemonStatePath, state); return { status: 'started', layout, state, baseUrl: baseUrl(state) }; } finally { diff --git a/packages/cli/src/managed-python-http.ts b/packages/cli/src/managed-python-http.ts index 0c9b24b3..728aa3ca 100644 --- a/packages/cli/src/managed-python-http.ts +++ b/packages/cli/src/managed-python-http.ts @@ -7,6 +7,7 @@ import type { LookerTableIdentifierParser } from './context/ingest/adapters/look import { createHttpSqlAnalysisPort, type KtxSqlAnalysisHttpJsonRunner } from './context/sql-analysis/http-sql-analysis-port.js'; import type { SqlAnalysisPort } from './context/sql-analysis/ports.js'; import type { KtxCliIo } from './cli-runtime.js'; +import { writePrefixedLines } from './clack.js'; import { ensureManagedPythonCommandRuntime, type KtxManagedPythonInstallPolicy, @@ -137,7 +138,7 @@ export function createManagedPythonDaemonBaseUrlResolver( force: false, }); const verb = daemon.status === 'started' ? 'Started' : 'Using existing'; - options.io.stderr.write(`${verb} KTX daemon: ${daemon.baseUrl}\n`); + writePrefixedLines((chunk) => options.io.stderr.write(chunk), `${verb} KTX daemon: ${daemon.baseUrl}`); cachedBaseUrl = daemon.baseUrl; return cachedBaseUrl; }; diff --git a/packages/cli/src/setup-agents.ts b/packages/cli/src/setup-agents.ts index 99d510c5..a671ba4b 100644 --- a/packages/cli/src/setup-agents.ts +++ b/packages/cli/src/setup-agents.ts @@ -10,6 +10,7 @@ import { markKtxSetupStateStepComplete } from './context/project/setup-config.js import { serializeKtxProjectConfig } from './context/project/config.js'; import { strToU8, zipSync } from 'fflate'; import type { KtxCliIo } from './cli-runtime.js'; +import { errorMessage, writePrefixedLines } from './clack.js'; import { createKtxSetupPromptAdapter, createKtxSetupUiAdapter, @@ -1230,7 +1231,7 @@ export async function runKtxSetupAgentsStep( } return { status: 'ready', projectDir: args.projectDir, installs, nextActions }; } catch (error) { - io.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`); + writePrefixedLines((chunk) => io.stderr.write(chunk), errorMessage(error)); return { status: 'failed', projectDir: args.projectDir }; } } diff --git a/packages/cli/src/setup-context.ts b/packages/cli/src/setup-context.ts index aa519111..dc289278 100644 --- a/packages/cli/src/setup-context.ts +++ b/packages/cli/src/setup-context.ts @@ -5,6 +5,7 @@ import { type KtxLocalProject, loadKtxProject } from './context/project/project. import { markKtxSetupStateStepComplete, readKtxSetupState } from './context/project/setup-config.js'; import { serializeKtxProjectConfig } from './context/project/config.js'; import type { KtxCliIo } from './cli-runtime.js'; +import { errorMessage, writePrefixedLines } from './clack.js'; import { buildPublicIngestPlan } from './public-ingest.js'; import { type KtxDatabaseContextDepth, @@ -745,7 +746,7 @@ export async function runKtxSetupContextStep( return await runBuild(args, io, deps, project, targets); } catch (error) { - io.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`); + writePrefixedLines((chunk) => io.stderr.write(chunk), errorMessage(error)); return { status: 'failed', projectDir: args.projectDir }; } } diff --git a/packages/cli/src/setup-databases.ts b/packages/cli/src/setup-databases.ts index ec2f017f..0704ecd2 100644 --- a/packages/cli/src/setup-databases.ts +++ b/packages/cli/src/setup-databases.ts @@ -3,6 +3,7 @@ import { readFile, writeFile } from 'node:fs/promises'; import { delimiter, dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; import { promisify } from 'node:util'; +import { getDriverRegistration } from './context/connections/drivers.js'; import { queryHistoryDialectForConnection } from './context/ingest/adapters/historic-sql/connection-dialect.js'; import type { HistoricSqlDialect } from './context/ingest/adapters/historic-sql/types.js'; import { @@ -15,6 +16,11 @@ import { loadKtxProject } from './context/project/project.js'; import { markKtxSetupStateStepComplete, setKtxSetupDatabaseConnectionIds } from './context/project/setup-config.js'; import type { KtxTableListEntry } from './context/scan/types.js'; import type { KtxCliIo } from './cli-runtime.js'; +import { + errorMessage, + flushPrefixedBufferedCommandOutput, + writePrefixedLines, +} from './clack.js'; import { runKtxConnection } from './connection.js'; import { pickDatabaseScope as defaultPickDatabaseScope, @@ -112,13 +118,13 @@ export interface KtxSetupDatabasesDeps { } const DRIVER_OPTIONS: Array<{ value: KtxSetupDatabaseDriver; label: string }> = [ - { value: 'sqlite', label: 'SQLite' }, { value: 'postgres', label: 'PostgreSQL' }, + { value: 'bigquery', label: 'BigQuery' }, + { value: 'snowflake', label: 'Snowflake' }, { value: 'mysql', label: 'MySQL' }, { value: 'clickhouse', label: 'ClickHouse' }, { value: 'sqlserver', label: 'SQL Server' }, - { value: 'bigquery', label: 'BigQuery' }, - { value: 'snowflake', label: 'Snowflake' }, + { value: 'sqlite', label: 'SQLite' }, ]; const DRIVER_LABELS = Object.fromEntries(DRIVER_OPTIONS.map((option) => [option.value, option.label])) as Record< @@ -220,7 +226,7 @@ const SCOPE_DISCOVERY_SPECS: Partial; -type ConnectionSetupStatus = 'ready' | 'back' | 'failed'; +type ConnectionSetupStatus = 'ready' | 'back' | 'failed' | 'failed-query-history-unavailable'; const DRIVER_CONNECTION_DEFAULTS: Record = { postgres: { port: '5432' }, @@ -361,74 +367,18 @@ async function defaultListSchemas(projectDir: string, connectionId: string): Pro const project = await loadKtxProject({ projectDir }); const connection = project.config.connections[connectionId]; const driver = normalizeDriver(connection?.driver); + const registration = driver ? getDriverRegistration(driver) : undefined; + if (!registration) return []; - if (driver === 'postgres') { - const { KtxPostgresScanConnector, isKtxPostgresConnectionConfig } = await import('./connectors/postgres/connector.js');; - if (!isKtxPostgresConnectionConfig(connection)) return []; - const connector = new KtxPostgresScanConnector({ connectionId, connection }); - try { - return await connector.listSchemas(); - } finally { - await connector.cleanup(); - } + const connectorModule = await registration.load(); + if (!connectorModule.isConnectionConfig(connection)) return []; + + const connector = connectorModule.createScanConnector({ connectionId, connection, projectDir }); + try { + return await connector.listSchemas(); + } finally { + await connector.cleanup?.(); } - - if (driver === 'sqlserver') { - const { KtxSqlServerScanConnector, isKtxSqlServerConnectionConfig } = await import('./connectors/sqlserver/connector.js');; - if (!isKtxSqlServerConnectionConfig(connection)) return []; - const connector = new KtxSqlServerScanConnector({ connectionId, connection }); - try { - return await connector.listSchemas(); - } finally { - await connector.cleanup(); - } - } - - if (driver === 'mysql') { - const { KtxMysqlScanConnector, isKtxMysqlConnectionConfig } = await import('./connectors/mysql/connector.js');; - if (!isKtxMysqlConnectionConfig(connection)) return []; - const connector = new KtxMysqlScanConnector({ connectionId, connection }); - try { - return await connector.listSchemas(); - } finally { - await connector.cleanup(); - } - } - - if (driver === 'clickhouse') { - const { KtxClickHouseScanConnector, isKtxClickHouseConnectionConfig } = await import('./connectors/clickhouse/connector.js');; - if (!isKtxClickHouseConnectionConfig(connection)) return []; - const connector = new KtxClickHouseScanConnector({ connectionId, connection }); - try { - return await connector.listSchemas(); - } finally { - await connector.cleanup(); - } - } - - if (driver === 'bigquery') { - const { KtxBigQueryScanConnector, isKtxBigQueryConnectionConfig } = await import('./connectors/bigquery/connector.js');; - if (!isKtxBigQueryConnectionConfig(connection)) return []; - const connector = new KtxBigQueryScanConnector({ connectionId, connection }); - try { - return await connector.listDatasets(); - } finally { - await connector.cleanup(); - } - } - - if (driver === 'snowflake') { - const { KtxSnowflakeScanConnector, isKtxSnowflakeConnectionConfig } = await import('./connectors/snowflake/connector.js');; - if (!isKtxSnowflakeConnectionConfig(connection)) return []; - const connector = new KtxSnowflakeScanConnector({ connectionId, connection, projectDir }); - try { - return await connector.listSchemas(); - } finally { - await connector.cleanup(); - } - } - - return []; } function configuredSchemas(connection: KtxProjectConnectionConfig | undefined, driver: KtxSetupDatabaseDriver): string[] | undefined { @@ -448,74 +398,18 @@ async function defaultListTables( const connection = project.config.connections[connectionId]; const driver = normalizeDriver(connection?.driver); const schemas = schemasOverride ?? (driver ? configuredSchemas(connection, driver) : undefined); + const registration = driver ? getDriverRegistration(driver) : undefined; + if (!registration) return []; - if (driver === 'postgres') { - const { KtxPostgresScanConnector, isKtxPostgresConnectionConfig } = await import('./connectors/postgres/connector.js');; - if (!isKtxPostgresConnectionConfig(connection)) return []; - const connector = new KtxPostgresScanConnector({ connectionId, connection }); - try { - return await connector.listTables(schemas); - } finally { - await connector.cleanup(); - } + const connectorModule = await registration.load(); + if (!connectorModule.isConnectionConfig(connection)) return []; + + const connector = connectorModule.createScanConnector({ connectionId, connection, projectDir }); + try { + return await connector.listTables(schemas); + } finally { + await connector.cleanup?.(); } - - if (driver === 'mysql') { - const { KtxMysqlScanConnector, isKtxMysqlConnectionConfig } = await import('./connectors/mysql/connector.js');; - if (!isKtxMysqlConnectionConfig(connection)) return []; - const connector = new KtxMysqlScanConnector({ connectionId, connection }); - try { - return await connector.listTables(schemas); - } finally { - await connector.cleanup(); - } - } - - if (driver === 'sqlserver') { - const { KtxSqlServerScanConnector, isKtxSqlServerConnectionConfig } = await import('./connectors/sqlserver/connector.js');; - if (!isKtxSqlServerConnectionConfig(connection)) return []; - const connector = new KtxSqlServerScanConnector({ connectionId, connection }); - try { - return await connector.listTables(schemas); - } finally { - await connector.cleanup(); - } - } - - if (driver === 'bigquery') { - const { KtxBigQueryScanConnector, isKtxBigQueryConnectionConfig } = await import('./connectors/bigquery/connector.js');; - if (!isKtxBigQueryConnectionConfig(connection)) return []; - const connector = new KtxBigQueryScanConnector({ connectionId, connection }); - try { - return await connector.listTables(schemas); - } finally { - await connector.cleanup(); - } - } - - if (driver === 'snowflake') { - const { KtxSnowflakeScanConnector, isKtxSnowflakeConnectionConfig } = await import('./connectors/snowflake/connector.js');; - if (!isKtxSnowflakeConnectionConfig(connection)) return []; - const connector = new KtxSnowflakeScanConnector({ connectionId, connection, projectDir }); - try { - return await connector.listTables(schemas); - } finally { - await connector.cleanup(); - } - } - - if (driver === 'clickhouse') { - const { KtxClickHouseScanConnector, isKtxClickHouseConnectionConfig } = await import('./connectors/clickhouse/connector.js');; - if (!isKtxClickHouseConnectionConfig(connection)) return []; - const connector = new KtxClickHouseScanConnector({ connectionId, connection }); - try { - return await connector.listTables(schemas); - } finally { - await connector.cleanup(); - } - } - - return []; } function existingConnectionIdsByDriver( @@ -638,9 +532,9 @@ function scriptedScopeConfigForDriver( databaseSchemas: string[], ): Record { if (databaseSchemas.length === 0) return {}; - if (driver === 'bigquery') return { dataset_ids: databaseSchemas }; - if (driver === 'clickhouse') return { databases: databaseSchemas }; - return { schemas: databaseSchemas }; + const registration = getDriverRegistration(driver); + if (!registration?.scopeConfigKey) return {}; + return { [registration.scopeConfigKey]: databaseSchemas }; } function databaseNameFromLiteralUrl(url: string): string | undefined { @@ -1128,25 +1022,6 @@ function createBufferedCommandIo(): BufferedCommandIo { }; } -function flushBufferedCommandOutput(io: KtxCliIo, bufferedIo: BufferedCommandIo): void { - const stdout = bufferedIo.stdoutText(); - const stderr = bufferedIo.stderrText(); - if (stdout.length > 0) { - io.stdout.write(stdout); - } - if (stderr.length > 0) { - io.stderr.write(stderr); - } -} - -function writePrefixedLines(write: (chunk: string) => void, output: string): void { - for (const line of output.split(/\r?\n/)) { - if (line.length > 0) { - write(`│ ${line}\n`); - } - } -} - function envWithCurrentNodeFirst(env: NodeJS.ProcessEnv = process.env): NodeJS.ProcessEnv { return { ...env, @@ -1222,11 +1097,6 @@ async function defaultRebuildNativeSqlite(io: KtxCliIo): Promise { } } -function flushPrefixedBufferedCommandOutput(io: KtxCliIo, bufferedIo: BufferedCommandIo): void { - writePrefixedLines((chunk) => io.stdout.write(chunk), bufferedIo.stdoutText()); - writePrefixedLines((chunk) => io.stderr.write(chunk), bufferedIo.stderrText()); -} - function nativeSqliteAbiMismatchDetail(output: string): string | null { const mentionsBetterSqlite = /\bbetter-sqlite3\b|better_sqlite3/i.test(output); const mentionsAbiMismatch = /compiled against a different Node\.js version|NODE_MODULE_VERSION/i.test(output); @@ -1318,6 +1188,20 @@ async function writeConnectionConfig(input: { } } +async function disableConnectionQueryHistory(projectDir: string, connectionId: string): Promise { + const project = await loadKtxProject({ projectDir }); + const connection = project.config.connections[connectionId]; + if (!connection) { + return; + } + const existing = queryHistoryConfigRecord(connection) ?? historicSqlConfigRecord(connection) ?? {}; + await writeConnectionConfig({ + projectDir, + connectionId, + connection: withQueryHistoryConfig(connection, { ...existing, enabled: false }), + }); +} + async function createConnectionConfigRollback(projectDir: string, connectionId: string): Promise<() => Promise> { const project = await loadKtxProject({ projectDir }); const previousConnection = project.config.connections[connectionId]; @@ -1519,9 +1403,9 @@ async function maybeConfigureDatabaseScope(input: { input.connectionId, ); } catch (error) { - const detail = error instanceof Error ? error.message : String(error); - input.io.stderr.write( - `Could not discover ${spec.promptLabel.toLowerCase()} for ${input.connectionId}; ${detail}\n`, + writePrefixedLines( + (chunk) => input.io.stderr.write(chunk), + `Could not discover ${spec.promptLabel.toLowerCase()} for ${input.connectionId}; ${errorMessage(error)}`, ); const typed = await promptCommaSeparatedScope({ prompts: input.prompts, @@ -1573,11 +1457,12 @@ async function maybeConfigureDatabaseScope(input: { input.io, ); } catch (error) { - const detail = error instanceof Error ? error.message : String(error); - input.io.stderr.write( + const detail = errorMessage(error); + writePrefixedLines( + (chunk) => input.io.stderr.write(chunk), input.forcePrompt === true - ? `Could not discover tables for ${input.connectionId}; edit was not saved. ${detail}\n` - : `Could not discover tables for ${input.connectionId}; continuing without table filter. ${detail}\n`, + ? `Could not discover tables for ${input.connectionId}; edit was not saved. ${detail}` + : `Could not discover tables for ${input.connectionId}; continuing without table filter. ${detail}`, ); return input.forcePrompt === true ? 'failed' : 'ready'; } @@ -1665,19 +1550,19 @@ async function maybeRunHistoricSqlSetupProbe(input: { connectionId: string; io: KtxCliIo; deps: KtxSetupDatabasesDeps; -}): Promise { +}): Promise { const project = await loadKtxProject({ projectDir: input.projectDir }); const connection = project.config.connections[input.connectionId]; const queryHistory = queryHistoryConfigRecord(connection) ?? historicSqlConfigRecord(connection); if (queryHistory?.enabled !== true) { - return; + return true; } if (!connection) { - return; + return true; } const dialect = queryHistoryDialectForConnection(connection); if (!dialect) { - return; + return true; } input.io.stdout.write('│ Query history probe...\n'); @@ -1696,6 +1581,7 @@ async function maybeRunHistoricSqlSetupProbe(input: { if (!result.ok) { input.io.stdout.write('│ Setup written; query history will be skipped until fixed.\n'); } + return result.ok; } async function applyHistoricSqlConfigToExistingConnection(input: { @@ -1785,8 +1671,11 @@ async function validateAndScanConnection(input: { const testIo = createBufferedCommandIo(); const testCode = await testConnection(input.projectDir, input.connectionId, testIo); if (testCode !== 0) { - flushBufferedCommandOutput(input.io, testIo); - input.io.stderr.write(`Connection test failed for ${input.connectionId}.\n`); + flushPrefixedBufferedCommandOutput(input.io, testIo); + writePrefixedLines( + (chunk) => input.io.stderr.write(chunk), + `Connection test failed for ${input.connectionId}.`, + ); return 'failed'; } const testOutput = testIo.stdoutText(); @@ -1800,7 +1689,7 @@ async function validateAndScanConnection(input: { return scopeStatus; } - await maybeRunHistoricSqlSetupProbe({ + const queryHistoryAvailable = await maybeRunHistoricSqlSetupProbe({ projectDir: input.projectDir, connectionId: input.connectionId, io: input.io, @@ -1857,7 +1746,7 @@ async function validateAndScanConnection(input: { ); } if (scanCode !== 0) { - return 'failed'; + return queryHistoryAvailable ? 'failed' : 'failed-query-history-unavailable'; } } const scanOutput = scanIo.stdoutText(); @@ -1999,7 +1888,10 @@ async function runPrimarySourceFullEdit(input: { const existing = project.config.connections[input.connectionId]; const driver = normalizeDriver(existing?.driver); if (!existing || !driver) { - input.io.stderr.write(`Connection "${input.connectionId}" is not a configured database.\n`); + writePrefixedLines( + (chunk) => input.io.stderr.write(chunk), + `Connection "${input.connectionId}" is not a configured database.`, + ); return 'failed'; } @@ -2053,7 +1945,7 @@ async function runPrimarySourceFullEdit(input: { }); if (validated !== 'ready') { await rollback(); - return validated; + return validated === 'failed-query-history-unavailable' ? 'failed' : validated; } return 'ready'; } @@ -2188,7 +2080,7 @@ export async function runKtxSetupDatabasesStep( prompts, }); } catch (error) { - io.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`); + writePrefixedLines((chunk) => io.stderr.write(chunk), errorMessage(error)); return { status: 'failed', projectDir: args.projectDir }; } if (connectionChoice === 'back') { @@ -2332,14 +2224,18 @@ export async function runKtxSetupDatabasesStep( break; } if (args.inputMode === 'disabled') return { status: 'failed', projectDir: args.projectDir }; + const failureOptions = [ + { value: 'retry', label: 'Retry connection test' }, + { value: 're-enter', label: 'Re-enter connection details' }, + ...(setupStatus === 'failed-query-history-unavailable' + ? [{ value: 'disable-query-history', label: 'Disable query history and retry' }] + : []), + { value: 'skip', label: 'Skip this database' }, + { value: 'back', label: 'Back' }, + ]; const action = await prompts.select({ message: `Database setup failed for ${connectionChoice.connectionId}`, - options: [ - { value: 'retry', label: 'Retry connection test' }, - { value: 're-enter', label: 'Re-enter connection details' }, - { value: 'skip', label: 'Skip this database' }, - { value: 'back', label: 'Back' }, - ], + options: failureOptions, }); if (action === 'back') { if (!canReturnToDriverSelection) return { status: 'back', projectDir: args.projectDir }; @@ -2359,6 +2255,16 @@ export async function runKtxSetupDatabasesStep( args, prompts, }); + } else if (action === 'disable-query-history') { + await disableConnectionQueryHistory(args.projectDir, connectionChoice.connectionId); + setupStatus = await validateAndScanConnection({ + projectDir: args.projectDir, + connectionId: connectionChoice.connectionId, + io, + deps, + args, + prompts, + }); } else if (action === 're-enter') { const connection = await buildConnectionConfig({ driver, diff --git a/packages/cli/src/setup-embeddings.ts b/packages/cli/src/setup-embeddings.ts index 0aedd264..8f49bcf1 100644 --- a/packages/cli/src/setup-embeddings.ts +++ b/packages/cli/src/setup-embeddings.ts @@ -6,12 +6,13 @@ import { markKtxSetupStateStepComplete, readKtxSetupState } from './context/proj import type { KtxEmbeddingConfig } from './llm/types.js'; import { type KtxEmbeddingHealthCheckResult, runKtxEmbeddingHealthCheck } from './llm/embedding-health.js'; import type { KtxCliIo } from './cli-runtime.js'; -import { createStaticCliSpinner, type KtxCliSpinner } from './clack.js'; +import { createStaticCliSpinner, errorMessage, writePrefixedLines, type KtxCliSpinner } from './clack.js'; import { ensureManagedLocalEmbeddingsDaemon, managedLocalEmbeddingHealthConfig, type ManagedLocalEmbeddingsDaemon, } from './managed-local-embeddings.js'; +import { ManagedPythonDaemonStartError } from './managed-python-daemon.js'; import type { KtxManagedPythonInstallPolicy } from './managed-python-command.js'; import { withTextInputNavigation } from './prompt-navigation.js'; import { envCredentialReference, writeProjectLocalSecretReference } from './setup-secrets.js'; @@ -419,7 +420,13 @@ export async function runKtxSetupEmbeddingsStep( io, }); } catch (error) { - io.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`); + const write = (chunk: string) => io.stderr.write(chunk); + if (error instanceof ManagedPythonDaemonStartError) { + const tail = await readLocalEmbeddingDaemonStderrTail(error.stderrLog); + writePrefixedLines(write, localEmbeddingSetupMessage(error.detail, tail)); + } else { + writePrefixedLines(write, errorMessage(error)); + } return { status: 'failed', projectDir: args.projectDir }; } } diff --git a/packages/cli/src/setup-runtime.ts b/packages/cli/src/setup-runtime.ts index 25612065..19f09a53 100644 --- a/packages/cli/src/setup-runtime.ts +++ b/packages/cli/src/setup-runtime.ts @@ -1,6 +1,7 @@ import { loadKtxProject, type KtxLocalProject } from './context/project/project.js'; import { markKtxSetupStateStepComplete } from './context/project/setup-config.js'; import type { KtxCliIo } from './cli-runtime.js'; +import { errorMessage, writePrefixedLines } from './clack.js'; import { ensureManagedLocalEmbeddingsDaemon, type ManagedLocalEmbeddingsDaemon, @@ -88,7 +89,7 @@ export async function runKtxSetupRuntimeStep( }); } } catch (error) { - io.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`); + writePrefixedLines((chunk) => io.stderr.write(chunk), errorMessage(error)); return { status: 'failed', projectDir: args.projectDir, requirements }; } diff --git a/packages/cli/src/setup-sources.ts b/packages/cli/src/setup-sources.ts index 410de812..dea1cd43 100644 --- a/packages/cli/src/setup-sources.ts +++ b/packages/cli/src/setup-sources.ts @@ -17,6 +17,7 @@ import { type KtxProjectConfig, type KtxProjectConnectionConfig, serializeKtxPro import { loadKtxProject } from './context/project/project.js'; import { markKtxSetupStateStepComplete } from './context/project/setup-config.js'; import type { KtxCliIo } from './cli-runtime.js'; +import { errorMessage, writePrefixedLines } from './clack.js'; import { pickNotionRootPages } from './notion-page-picker.js'; import { runKtxSourceMapping } from './source-mapping.js'; import { withMultiselectNavigation, withTextInputNavigation } from './prompt-navigation.js'; @@ -1983,7 +1984,7 @@ export async function runKtxSetupSourcesStep( return { status: 'ready', projectDir: args.projectDir, connectionIds: readyConnectionIds }; } } catch (error) { - io.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`); + writePrefixedLines((chunk) => io.stderr.write(chunk), errorMessage(error)); return { status: 'failed', projectDir: args.projectDir }; } } diff --git a/packages/cli/src/telemetry/index.ts b/packages/cli/src/telemetry/index.ts index 10637a3d..27c5004a 100644 --- a/packages/cli/src/telemetry/index.ts +++ b/packages/cli/src/telemetry/index.ts @@ -55,6 +55,10 @@ const emittedProjectSnapshots = new Set(); const MCP_SAMPLE_RATE = 0.1 as const; let mcpSampled: boolean | undefined; +function telemetryDebugEnabled(): boolean { + return process.env.KTX_TELEMETRY_DEBUG === '1'; +} + export function shouldEmitMcpTelemetry(): boolean { mcpSampled ??= Math.random() < MCP_SAMPLE_RATE; return mcpSampled; @@ -71,19 +75,21 @@ export async function emitTelemetryEvent(input: packageInfo?: KtxCliPackageInfo; projectDir?: string; }): Promise { + const debug = telemetryDebugEnabled(); const identity = await loadTelemetryIdentity({ stdoutIsTTY: input.io.stdout.isTTY === true, stderr: input.io.stderr, env: process.env, }); - if (!identity.enabled || !identity.installId) { + if ((!identity.enabled || !identity.installId) && !debug) { return; } const packageInfo = input.packageInfo ?? getKtxCliPackageInfo(); + const installId = identity.installId ?? 'debug'; - const projectId = input.projectDir ? computeTelemetryProjectId(identity.installId, input.projectDir) : undefined; + const projectId = input.projectDir ? computeTelemetryProjectId(installId, input.projectDir) : undefined; await trackTelemetryEvent({ event: buildTelemetryEvent( input.name, @@ -93,7 +99,7 @@ export async function emitTelemetryEvent(input: }), input.fields, ), - distinctId: identity.installId, + distinctId: installId, projectId, env: process.env, stderr: input.io.stderr, diff --git a/packages/cli/src/admin-reindex.test.ts b/packages/cli/test/admin-reindex.test.ts similarity index 96% rename from packages/cli/src/admin-reindex.test.ts rename to packages/cli/test/admin-reindex.test.ts index dace420f..bdfc5a09 100644 --- a/packages/cli/src/admin-reindex.test.ts +++ b/packages/cli/test/admin-reindex.test.ts @@ -1,9 +1,9 @@ import { createRequire } from 'node:module'; -import type { ReindexSummary } from './context/index-sync/types.js'; +import type { ReindexSummary } from '../src/context/index-sync/types.js'; import { describe, expect, it, vi } from 'vitest'; -import { renderReindexJson, renderReindexPlain, reindexHasErrors } from './admin-reindex.js'; -import { runKtxCli } from './index.js'; +import { renderReindexJson, renderReindexPlain, reindexHasErrors } from '../src/admin-reindex.js'; +import { runKtxCli } from '../src/index.js'; const cliVersion = (createRequire(import.meta.url)('@kaelio/ktx/package.json') as { version: string }) .version; diff --git a/packages/cli/src/admin.test.ts b/packages/cli/test/admin.test.ts similarity index 99% rename from packages/cli/src/admin.test.ts rename to packages/cli/test/admin.test.ts index 15f4179e..4f425e14 100644 --- a/packages/cli/src/admin.test.ts +++ b/packages/cli/test/admin.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it, vi } from 'vitest'; -import { runKtxCli } from './index.js'; +import { runKtxCli } from '../src/index.js'; function makeIo() { let stdout = ''; diff --git a/packages/cli/src/cli-program-telemetry.test.ts b/packages/cli/test/cli-program-telemetry.test.ts similarity index 96% rename from packages/cli/src/cli-program-telemetry.test.ts rename to packages/cli/test/cli-program-telemetry.test.ts index db905442..8088e7f2 100644 --- a/packages/cli/src/cli-program-telemetry.test.ts +++ b/packages/cli/test/cli-program-telemetry.test.ts @@ -3,8 +3,8 @@ import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { runCommanderKtxCli } from './cli-program.js'; -import type { KtxCliDeps, KtxCliIo, KtxCliPackageInfo } from './cli-runtime.js'; +import { runCommanderKtxCli } from '../src/cli-program.js'; +import type { KtxCliDeps, KtxCliIo, KtxCliPackageInfo } from '../src/cli-runtime.js'; function makeIo(stdoutIsTTY = true): { io: KtxCliIo; stdout: () => string; stderr: () => string } { let stdout = ''; diff --git a/packages/cli/src/cli-program.test.ts b/packages/cli/test/cli-program.test.ts similarity index 93% rename from packages/cli/src/cli-program.test.ts rename to packages/cli/test/cli-program.test.ts index 009dfb8a..332645aa 100644 --- a/packages/cli/src/cli-program.test.ts +++ b/packages/cli/test/cli-program.test.ts @@ -1,7 +1,7 @@ import { Command, type CommandUnknownOpts } from '@commander-js/extra-typings'; import { describe, expect, it } from 'vitest'; -import { buildKtxProgram, collectCommandFlagsPresent } from './cli-program.js'; -import type { KtxCliIo, KtxCliPackageInfo } from './cli-runtime.js'; +import { buildKtxProgram, collectCommandFlagsPresent } from '../src/cli-program.js'; +import type { KtxCliIo, KtxCliPackageInfo } from '../src/cli-runtime.js'; function stubIo(): KtxCliIo { return { diff --git a/packages/cli/src/command-tree.test.ts b/packages/cli/test/command-tree.test.ts similarity index 98% rename from packages/cli/src/command-tree.test.ts rename to packages/cli/test/command-tree.test.ts index 181fac77..2a9d4f87 100644 --- a/packages/cli/src/command-tree.test.ts +++ b/packages/cli/test/command-tree.test.ts @@ -1,6 +1,6 @@ import { Command } from '@commander-js/extra-typings'; import { describe, expect, it } from 'vitest'; -import { formatCommandTree, walkCommandTree } from './command-tree.js'; +import { formatCommandTree, walkCommandTree } from '../src/command-tree.js'; describe('walkCommandTree', () => { it('captures name, description, aliases, and nested children', () => { diff --git a/packages/cli/src/commands/mcp-commands.test.ts b/packages/cli/test/commands/mcp-commands.test.ts similarity index 97% rename from packages/cli/src/commands/mcp-commands.test.ts rename to packages/cli/test/commands/mcp-commands.test.ts index dfcd1946..cf9c0cd5 100644 --- a/packages/cli/src/commands/mcp-commands.test.ts +++ b/packages/cli/test/commands/mcp-commands.test.ts @@ -1,7 +1,7 @@ import { Command } from '@commander-js/extra-typings'; import { describe, expect, it, vi } from 'vitest'; -import type { KtxCliCommandContext } from '../cli-program.js'; -import { registerMcpCommands } from './mcp-commands.js'; +import type { KtxCliCommandContext } from '../../src/cli-program.js'; +import { registerMcpCommands } from '../../src/commands/mcp-commands.js'; function makeContext(overrides: Partial = {}): KtxCliCommandContext { let exitCode = 0; diff --git a/packages/cli/src/commands/sql-commands.test.ts b/packages/cli/test/commands/sql-commands.test.ts similarity index 95% rename from packages/cli/src/commands/sql-commands.test.ts rename to packages/cli/test/commands/sql-commands.test.ts index 4f2c0277..9db7e8bf 100644 --- a/packages/cli/src/commands/sql-commands.test.ts +++ b/packages/cli/test/commands/sql-commands.test.ts @@ -1,7 +1,7 @@ import { Command } from '@commander-js/extra-typings'; import { describe, expect, it, vi } from 'vitest'; -import type { KtxCliCommandContext } from '../cli-program.js'; -import { registerSqlCommands } from './sql-commands.js'; +import type { KtxCliCommandContext } from '../../src/cli-program.js'; +import { registerSqlCommands } from '../../src/commands/sql-commands.js'; function makeContext(overrides: Partial = {}): KtxCliCommandContext { let exitCode = 0; diff --git a/packages/cli/src/connection.test.ts b/packages/cli/test/connection.test.ts similarity index 97% rename from packages/cli/src/connection.test.ts rename to packages/cli/test/connection.test.ts index 7cfc5b93..59ead362 100644 --- a/packages/cli/src/connection.test.ts +++ b/packages/cli/test/connection.test.ts @@ -1,14 +1,14 @@ import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; -import type { LookerClient } from './context/ingest/adapters/looker/client.js'; -import type { MetabaseRuntimeClient } from './context/ingest/adapters/metabase/client-port.js'; -import type { NotionClient } from './context/ingest/adapters/notion/notion-client.js'; -import { initKtxProject } from './context/project/project.js'; -import { parseKtxProjectConfig, serializeKtxProjectConfig } from './context/project/config.js'; -import type { KtxConnectionDriver, KtxScanConnector } from './context/scan/types.js'; +import type { LookerClient } from '../src/context/ingest/adapters/looker/client.js'; +import type { MetabaseRuntimeClient } from '../src/context/ingest/adapters/metabase/client-port.js'; +import type { NotionClient } from '../src/context/ingest/adapters/notion/notion-client.js'; +import { initKtxProject } from '../src/context/project/project.js'; +import { parseKtxProjectConfig, serializeKtxProjectConfig } from '../src/context/project/config.js'; +import type { KtxConnectionDriver, KtxScanConnector } from '../src/context/scan/types.js'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { runKtxConnection } from './connection.js'; +import { runKtxConnection } from '../src/connection.js'; function stripAnsi(s: string): string { return s.replace(/\[[0-9;]*m/g, ''); @@ -59,6 +59,8 @@ function nativeConnector( introspect: vi.fn(async () => { throw new Error('introspect should not be called from connection test'); }), + listSchemas: vi.fn(async () => []), + listTables: vi.fn(async () => []), testConnection, cleanup, }; diff --git a/packages/cli/src/connectors/bigquery/connector.test.ts b/packages/cli/test/connectors/bigquery/connector.test.ts similarity index 93% rename from packages/cli/src/connectors/bigquery/connector.test.ts rename to packages/cli/test/connectors/bigquery/connector.test.ts index b9893ccf..11ad69d8 100644 --- a/packages/cli/src/connectors/bigquery/connector.test.ts +++ b/packages/cli/test/connectors/bigquery/connector.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it, vi } from 'vitest'; -import { bigQueryConnectionConfigFromConfig, isKtxBigQueryConnectionConfig, type KtxBigQueryClient, KtxBigQueryScanConnector, type KtxBigQueryClientFactory, type KtxBigQueryDataset, type KtxBigQueryQueryJob, type KtxBigQueryTableRef } from '../../connectors/bigquery/connector.js'; -import { createBigQueryLiveDatabaseIntrospection } from '../../connectors/bigquery/live-database-introspection.js'; -import { tableRefSet } from '../../context/scan/table-ref.js'; +import { bigQueryConnectionConfigFromConfig, isKtxBigQueryConnectionConfig, type KtxBigQueryClient, KtxBigQueryScanConnector, type KtxBigQueryClientFactory, type KtxBigQueryDataset, type KtxBigQueryQueryJob, type KtxBigQueryTableRef, prepareBigQueryReadOnlyQuery } from '../../../src/connectors/bigquery/connector.js'; +import { createBigQueryLiveDatabaseIntrospection } from '../../../src/connectors/bigquery/live-database-introspection.js'; +import { tableRefSet } from '../../../src/context/scan/table-ref.js'; function fakeClientFactory(options: { primaryKeyError?: Error } = {}): KtxBigQueryClientFactory { const queryResults = vi.fn(async (): ReturnType => [ @@ -98,6 +98,17 @@ const connection = { } as const; describe('KtxBigQueryScanConnector', () => { + it('prepares read-only SQL parameters with BigQuery named placeholders', () => { + expect(prepareBigQueryReadOnlyQuery('SELECT * FROM orders WHERE id = :id AND id_2 = :id_2', { id: 1, id_2: 2 })).toEqual({ + sql: 'SELECT * FROM orders WHERE id = @id AND id_2 = @id_2', + params: { id: 1, id_2: 2 }, + }); + expect(prepareBigQueryReadOnlyQuery('SELECT * FROM orders')).toEqual({ + sql: 'SELECT * FROM orders', + params: undefined, + }); + }); + it('resolves configuration safely', () => { expect(isKtxBigQueryConnectionConfig(connection)).toBe(true); expect(isKtxBigQueryConnectionConfig({ driver: 'mysql' })).toBe(false); @@ -256,7 +267,7 @@ describe('KtxBigQueryScanConnector', () => { ), ).resolves.toEqual({ values: ['open', 'paid'], cardinality: 2 }); await expect(connector.getTableRowCount('orders')).resolves.toBe(12); - await expect(connector.listDatasets()).resolves.toEqual(['analytics', 'staging']); + await expect(connector.listSchemas()).resolves.toEqual(['analytics', 'staging']); await expect( connector.columnStats( { connectionId: 'warehouse', table: { catalog: 'project-1', db: 'analytics', name: 'orders' }, column: 'status' }, @@ -366,9 +377,9 @@ describe('KtxBigQueryScanConnector', () => { }); await expect(connector.listTables(['analytics', 'mart'])).resolves.toEqual([ - { schema: 'analytics', name: 'orders', kind: 'table' }, - { schema: 'analytics', name: 'order_clone', kind: 'table' }, - { schema: 'mart', name: 'orders_mv', kind: 'view' }, + { catalog: 'project-1', schema: 'analytics', name: 'orders', kind: 'table' }, + { catalog: 'project-1', schema: 'analytics', name: 'order_clone', kind: 'table' }, + { catalog: 'project-1', schema: 'mart', name: 'orders_mv', kind: 'view' }, ]); expect(createQueryJob).toHaveBeenCalledTimes(1); diff --git a/packages/cli/src/connectors/bigquery/dialect.test.ts b/packages/cli/test/connectors/bigquery/dialect.test.ts similarity index 81% rename from packages/cli/src/connectors/bigquery/dialect.test.ts rename to packages/cli/test/connectors/bigquery/dialect.test.ts index d2033bd9..171617ce 100644 --- a/packages/cli/src/connectors/bigquery/dialect.test.ts +++ b/packages/cli/test/connectors/bigquery/dialect.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { KtxBigQueryDialect } from './dialect.js'; +import { KtxBigQueryDialect } from '../../../src/connectors/bigquery/dialect.js'; describe('KtxBigQueryDialect', () => { const dialect = new KtxBigQueryDialect(); @@ -38,14 +38,6 @@ describe('KtxBigQueryDialect', () => { ); }); - it('rewrites colon parameters to BigQuery named parameters', () => { - expect(dialect.prepareQuery('SELECT * FROM orders WHERE id = :id AND id_2 = :id_2', { id: 1, id_2: 2 })).toEqual({ - sql: 'SELECT * FROM orders WHERE id = @id AND id_2 = @id_2', - params: { id: 1, id_2: 2 }, - }); - expect(dialect.prepareQuery('SELECT * FROM orders')).toEqual({ sql: 'SELECT * FROM orders', params: undefined }); - }); - it('keeps unsupported statistics explicit', () => { expect(dialect.generateColumnStatisticsQuery('analytics', 'orders')).toBeNull(); }); diff --git a/packages/cli/src/connectors/clickhouse/connector.test.ts b/packages/cli/test/connectors/clickhouse/connector.test.ts similarity index 88% rename from packages/cli/src/connectors/clickhouse/connector.test.ts rename to packages/cli/test/connectors/clickhouse/connector.test.ts index abc7cad5..aba3143f 100644 --- a/packages/cli/src/connectors/clickhouse/connector.test.ts +++ b/packages/cli/test/connectors/clickhouse/connector.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it, vi } from 'vitest'; -import { clickHouseClientConfigFromConfig, isKtxClickHouseConnectionConfig, KtxClickHouseScanConnector, type KtxClickHouseClientFactory } from '../../connectors/clickhouse/connector.js'; -import { createClickHouseLiveDatabaseIntrospection } from '../../connectors/clickhouse/live-database-introspection.js'; -import { tableRefSet } from '../../context/scan/table-ref.js'; +import { clickHouseClientConfigFromConfig, isKtxClickHouseConnectionConfig, KtxClickHouseScanConnector, prepareClickHouseReadOnlyQuery, type KtxClickHouseClientFactory } from '../../../src/connectors/clickhouse/connector.js'; +import { createClickHouseLiveDatabaseIntrospection } from '../../../src/connectors/clickhouse/live-database-introspection.js'; +import { tableRefSet } from '../../../src/context/scan/table-ref.js'; function result(payload: T) { return { @@ -15,8 +15,8 @@ function fakeClientFactory(): KtxClickHouseClientFactory { const query = vi.fn(async (input: { query: string; format: string; query_params?: Record }) => { if (input.query.includes('FROM system.tables')) { return result([ - { name: 'events', engine: 'MergeTree', comment: 'Event stream' }, - { name: 'event_summary', engine: 'View', comment: '' }, + { database: 'analytics', name: 'event_summary', engine: 'View', comment: '' }, + { database: 'analytics', name: 'events', engine: 'MergeTree', comment: 'Event stream' }, ]); } if (input.query.includes('FROM system.columns')) { @@ -136,6 +136,33 @@ function multiDatabaseClickHouseClientFactory(): KtxClickHouseClientFactory { } describe('KtxClickHouseScanConnector', () => { + it('prepares read-only SQL parameters with ClickHouse typed placeholders', () => { + expect( + prepareClickHouseReadOnlyQuery('select * from events where id = :id and event_name = :name', { + id: 10, + name: 'signup', + }), + ).toEqual({ + sql: 'select * from events where id = {id:Int64} and event_name = {name:String}', + params: { id: 10, name: 'signup' }, + }); + expect( + prepareClickHouseReadOnlyQuery('select * from events where enabled = :enabled and ratio = :ratio and created_at = :created_at', { + enabled: true, + ratio: 1.5, + created_at: new Date('2026-05-25T00:00:00.000Z'), + }), + ).toEqual({ + sql: 'select * from events where enabled = {enabled:Bool} and ratio = {ratio:Float64} and created_at = {created_at:DateTime}', + params: { + enabled: true, + ratio: 1.5, + created_at: new Date('2026-05-25T00:00:00.000Z'), + }, + }); + expect(prepareClickHouseReadOnlyQuery('select 1')).toEqual({ sql: 'select 1', params: undefined }); + }); + it('resolves ClickHouse connection configuration safely', () => { expect(isKtxClickHouseConnectionConfig({ driver: 'clickhouse', host: 'localhost', database: 'analytics' })).toBe( true, @@ -196,8 +223,8 @@ describe('KtxClickHouseScanConnector', () => { }, }); expect(snapshot.tables.map((table) => [table.name, table.kind, table.estimatedRows, table.comment])).toEqual([ - ['events', 'table', 2, 'Event stream'], ['event_summary', 'view', null, null], + ['events', 'table', 2, 'Event stream'], ]); expect(snapshot.tables.find((table) => table.name === 'events')?.columns[0]).toMatchObject({ name: 'id', @@ -344,6 +371,10 @@ describe('KtxClickHouseScanConnector', () => { await expect(connector.getTableRowCount('events')).resolves.toBe(2); await expect(connector.listSchemas()).resolves.toEqual(['analytics', 'warehouse']); + await expect(connector.listTables(['analytics'])).resolves.toEqual([ + { catalog: null, schema: 'analytics', name: 'event_summary', kind: 'view' }, + { catalog: null, schema: 'analytics', name: 'events', kind: 'table' }, + ]); await expect( connector.columnStats( { connectionId: 'warehouse', table: { catalog: null, db: 'analytics', name: 'events' }, column: 'event_name' }, diff --git a/packages/cli/src/connectors/clickhouse/dialect.test.ts b/packages/cli/test/connectors/clickhouse/dialect.test.ts similarity index 75% rename from packages/cli/src/connectors/clickhouse/dialect.test.ts rename to packages/cli/test/connectors/clickhouse/dialect.test.ts index 14a1032c..809b1304 100644 --- a/packages/cli/src/connectors/clickhouse/dialect.test.ts +++ b/packages/cli/test/connectors/clickhouse/dialect.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { KtxClickHouseDialect } from './dialect.js'; +import { KtxClickHouseDialect } from '../../../src/connectors/clickhouse/dialect.js'; describe('KtxClickHouseDialect', () => { const dialect = new KtxClickHouseDialect(); @@ -23,7 +23,7 @@ describe('KtxClickHouseDialect', () => { expect(dialect.mapToDimensionType('')).toBe('string'); }); - it('builds sampling, distinct-value, pagination, and time SQL', () => { + it('builds sampling, distinct-value, and pagination SQL', () => { expect(dialect.generateSampleQuery('`analytics`.`events`', 25, ['id', 'event_name'])).toBe( 'SELECT `id`, `event_name` FROM `analytics`.`events` LIMIT 25', ); @@ -34,16 +34,6 @@ describe('KtxClickHouseDialect', () => { 'SELECT DISTINCT toString(`event_name`) AS val', ); expect(dialect.getLimitOffsetClause(10, 20)).toBe('LIMIT 10 OFFSET 20'); - expect(dialect.getTimeTruncExpression('created_at', 'week')).toBe('toStartOfWeek(created_at, 1)'); }); - it('prepares named parameters using ClickHouse typed placeholders', () => { - expect(dialect.prepareQuery('select * from events where id = :id and event_name = :name', { - id: 10, - name: 'signup', - })).toEqual({ - sql: 'select * from events where id = {id:Int64} and event_name = {name:String}', - params: { id: 10, name: 'signup' }, - }); - }); }); diff --git a/packages/cli/src/connectors/mysql/connector.test.ts b/packages/cli/test/connectors/mysql/connector.test.ts similarity index 92% rename from packages/cli/src/connectors/mysql/connector.test.ts rename to packages/cli/test/connectors/mysql/connector.test.ts index 6c69ea3d..c8334164 100644 --- a/packages/cli/src/connectors/mysql/connector.test.ts +++ b/packages/cli/test/connectors/mysql/connector.test.ts @@ -1,8 +1,8 @@ import { describe, expect, it, vi } from 'vitest'; import type { FieldPacket, RowDataPacket } from 'mysql2/promise'; -import { createMysqlLiveDatabaseIntrospection } from '../../connectors/mysql/live-database-introspection.js'; -import { isKtxMysqlConnectionConfig, KtxMysqlScanConnector, mysqlConnectionPoolConfigFromConfig, type KtxMysqlConnectionConfig, type KtxMysqlPoolFactory } from '../../connectors/mysql/connector.js'; -import { tableRefSet } from '../../context/scan/table-ref.js'; +import { createMysqlLiveDatabaseIntrospection } from '../../../src/connectors/mysql/live-database-introspection.js'; +import { isKtxMysqlConnectionConfig, KtxMysqlScanConnector, mysqlConnectionPoolConfigFromConfig, prepareMysqlReadOnlyQuery, type KtxMysqlConnectionConfig, type KtxMysqlPoolFactory } from '../../../src/connectors/mysql/connector.js'; +import { tableRefSet } from '../../../src/context/scan/table-ref.js'; function mysqlResult(rows: Record[], fields: Array<{ name: string; type?: number }>): [RowDataPacket[], FieldPacket[]] { return [rows as RowDataPacket[], fields as FieldPacket[]]; @@ -13,9 +13,9 @@ function fakePoolFactory(): KtxMysqlPoolFactory { if (sql.includes('INFORMATION_SCHEMA.TABLES')) { return mysqlResult( [ - { TABLE_NAME: 'customers', TABLE_TYPE: 'BASE TABLE', TABLE_COMMENT: 'Customer table', TABLE_ROWS: 2 }, - { TABLE_NAME: 'orders', TABLE_TYPE: 'BASE TABLE', TABLE_COMMENT: 'InnoDB free: 1 kB; Order table', TABLE_ROWS: 2 }, - { TABLE_NAME: 'order_summary', TABLE_TYPE: 'VIEW', TABLE_COMMENT: '', TABLE_ROWS: null }, + { TABLE_SCHEMA: 'analytics', TABLE_NAME: 'customers', TABLE_TYPE: 'BASE TABLE', TABLE_COMMENT: 'Customer table', TABLE_ROWS: 2 }, + { TABLE_SCHEMA: 'analytics', TABLE_NAME: 'orders', TABLE_TYPE: 'BASE TABLE', TABLE_COMMENT: 'InnoDB free: 1 kB; Order table', TABLE_ROWS: 2 }, + { TABLE_SCHEMA: 'analytics', TABLE_NAME: 'order_summary', TABLE_TYPE: 'VIEW', TABLE_COMMENT: '', TABLE_ROWS: null }, ], [{ name: 'TABLE_NAME' }, { name: 'TABLE_TYPE' }, { name: 'TABLE_COMMENT' }, { name: 'TABLE_ROWS' }], ); @@ -173,6 +173,19 @@ function multiSchemaMysqlPoolFactory( } describe('KtxMysqlScanConnector', () => { + it('prepares read-only SQL parameters with MySQL positional placeholders', () => { + expect( + prepareMysqlReadOnlyQuery('select * from orders where id = :id and status = :status', { + status: 'paid', + id: 10, + }), + ).toEqual({ + sql: 'select * from orders where id = ? and status = ?', + params: [10, 'paid'], + }); + expect(prepareMysqlReadOnlyQuery('select 1')).toEqual({ sql: 'select 1', params: undefined }); + }); + it('resolves MySQL connection configuration safely', () => { expect(isKtxMysqlConnectionConfig({ driver: 'mysql', host: 'localhost', database: 'analytics' })).toBe(true); expect(isKtxMysqlConnectionConfig({ driver: 'postgres', host: 'localhost', database: 'analytics' })).toBe(false); @@ -497,6 +510,11 @@ describe('KtxMysqlScanConnector', () => { await expect(connector.getTableRowCount('orders')).resolves.toBe(2); await expect(connector.listSchemas()).resolves.toEqual(['analytics', 'warehouse']); + await expect(connector.listTables(['analytics'])).resolves.toEqual([ + { catalog: null, schema: 'analytics', name: 'customers', kind: 'table' }, + { catalog: null, schema: 'analytics', name: 'orders', kind: 'table' }, + { catalog: null, schema: 'analytics', name: 'order_summary', kind: 'view' }, + ]); await expect(connector.columnStats( { connectionId: 'warehouse', table: { catalog: null, db: 'analytics', name: 'orders' }, column: 'status' }, { runId: 'scan-run-1' }, diff --git a/packages/cli/src/connectors/mysql/dialect.test.ts b/packages/cli/test/connectors/mysql/dialect.test.ts similarity index 75% rename from packages/cli/src/connectors/mysql/dialect.test.ts rename to packages/cli/test/connectors/mysql/dialect.test.ts index cf15527b..a00d6188 100644 --- a/packages/cli/src/connectors/mysql/dialect.test.ts +++ b/packages/cli/test/connectors/mysql/dialect.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { KtxMysqlDialect } from './dialect.js'; +import { KtxMysqlDialect } from '../../../src/connectors/mysql/dialect.js'; describe('KtxMysqlDialect', () => { const dialect = new KtxMysqlDialect(); @@ -23,7 +23,7 @@ describe('KtxMysqlDialect', () => { expect(dialect.mapToDimensionType('')).toBe('string'); }); - it('builds sampling, distinct-value, pagination, and time SQL', () => { + it('builds sampling, distinct-value, and pagination SQL', () => { expect(dialect.generateSampleQuery('`analytics`.`orders`', 25, ['id', 'status'])).toBe( 'SELECT `id`, `status` FROM `analytics`.`orders` LIMIT 25', ); @@ -34,16 +34,6 @@ describe('KtxMysqlDialect', () => { 'SELECT DISTINCT CAST(`status` AS CHAR) AS val', ); expect(dialect.getLimitOffsetClause(10, 20)).toBe('LIMIT 10 OFFSET 20'); - expect(dialect.getTimeTruncExpression('created_at', 'month')).toBe("DATE_FORMAT(created_at, '%Y-%m-01')"); }); - it('prepares named parameters in deterministic SQL placeholder order', () => { - expect(dialect.prepareQuery('select * from orders where id = :id and status = :status', { - status: 'paid', - id: 10, - })).toEqual({ - sql: 'select * from orders where id = ? and status = ?', - params: [10, 'paid'], - }); - }); }); diff --git a/packages/cli/src/connectors/postgres/connector.test.ts b/packages/cli/test/connectors/postgres/connector.test.ts similarity index 91% rename from packages/cli/src/connectors/postgres/connector.test.ts rename to packages/cli/test/connectors/postgres/connector.test.ts index d9fa45cf..e43e05a4 100644 --- a/packages/cli/src/connectors/postgres/connector.test.ts +++ b/packages/cli/test/connectors/postgres/connector.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it, vi } from 'vitest'; -import { createPostgresLiveDatabaseIntrospection } from '../../connectors/postgres/live-database-introspection.js'; -import { isKtxPostgresConnectionConfig, KtxPostgresScanConnector, postgresPoolConfigFromConfig, type KtxPostgresConnectionConfig, type KtxPostgresPoolFactory } from '../../connectors/postgres/connector.js'; -import { tableRefSet } from '../../context/scan/table-ref.js'; +import { createPostgresLiveDatabaseIntrospection } from '../../../src/connectors/postgres/live-database-introspection.js'; +import { isKtxPostgresConnectionConfig, KtxPostgresScanConnector, postgresPoolConfigFromConfig, preparePostgresReadOnlyQuery, type KtxPostgresConnectionConfig, type KtxPostgresPoolFactory } from '../../../src/connectors/postgres/connector.js'; +import { tableRefSet } from '../../../src/context/scan/table-ref.js'; interface FakeQueryResult { rows: Record[]; @@ -44,9 +44,9 @@ function metadataResults(): Map { 'FROM pg_catalog.pg_class c JOIN pg_catalog.pg_namespace n', { rows: [ - { table_name: 'customers', table_kind: 'r', row_count: '2', table_comment: 'Customers' }, - { table_name: 'orders', table_kind: 'r', row_count: '3', table_comment: null }, - { table_name: 'recent_orders', table_kind: 'v', row_count: '0', table_comment: 'Recent orders' }, + { schema_name: 'public', table_name: 'customers', table_kind: 'r', row_count: '2', table_comment: 'Customers' }, + { schema_name: 'public', table_name: 'orders', table_kind: 'r', row_count: '3', table_comment: null }, + { schema_name: 'public', table_name: 'recent_orders', table_kind: 'v', row_count: '0', table_comment: 'Recent orders' }, ], }, ], @@ -102,6 +102,28 @@ function metadataResults(): Map { } describe('KtxPostgresScanConnector', () => { + it('prepares read-only SQL parameters with PostgreSQL positional placeholders', () => { + expect( + preparePostgresReadOnlyQuery('select * from orders where id = :id and status = :status', { + id: 1, + status: 'paid', + }), + ).toEqual({ + sql: 'select * from orders where id = $1 and status = $2', + params: [1, 'paid'], + }); + expect( + preparePostgresReadOnlyQuery('select :Client_Name_10, :Client_Name_1', { + Client_Name_1: 'short', + Client_Name_10: 'long', + }), + ).toEqual({ + sql: 'select $2, $1', + params: ['short', 'long'], + }); + expect(preparePostgresReadOnlyQuery('select 1')).toEqual({ sql: 'select 1', params: undefined }); + }); + it('resolves configuration safely', () => { expect(isKtxPostgresConnectionConfig({ driver: 'postgres', url: 'env:DATABASE_URL' })).toBe(true); expect(isKtxPostgresConnectionConfig({ driver: 'postgresql', host: 'db', database: 'analytics' })).toBe(false); @@ -367,6 +389,11 @@ describe('KtxPostgresScanConnector', () => { }); await expect(connector.getTableRowCount({ db: 'public', name: 'orders' })).resolves.toBe(3); await expect(connector.listSchemas()).resolves.toEqual(['public']); + await expect(connector.listTables(['public'])).resolves.toEqual([ + { catalog: null, schema: 'public', name: 'customers', kind: 'table' }, + { catalog: null, schema: 'public', name: 'orders', kind: 'table' }, + { catalog: null, schema: 'public', name: 'recent_orders', kind: 'view' }, + ]); await expect(connector.testConnection()).resolves.toEqual({ success: true }); await expect( diff --git a/packages/cli/src/connectors/postgres/dialect.test.ts b/packages/cli/test/connectors/postgres/dialect.test.ts similarity index 64% rename from packages/cli/src/connectors/postgres/dialect.test.ts rename to packages/cli/test/connectors/postgres/dialect.test.ts index ffe85497..1a1d4768 100644 --- a/packages/cli/src/connectors/postgres/dialect.test.ts +++ b/packages/cli/test/connectors/postgres/dialect.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { KtxPostgresDialect } from './dialect.js'; +import { KtxPostgresDialect } from '../../../src/connectors/postgres/dialect.js'; describe('KtxPostgresDialect', () => { const dialect = new KtxPostgresDialect(); @@ -18,7 +18,7 @@ describe('KtxPostgresDialect', () => { expect(dialect.mapToDimensionType('jsonb')).toBe('string'); }); - it('generates sample, distinct-value, statistics, and time SQL', () => { + it('generates sample, distinct-value, and statistics SQL', () => { expect(dialect.generateSampleQuery('"public"."orders"', 5, ['id', 'status'])).toBe( 'SELECT "id", "status" FROM "public"."orders" LIMIT 5', ); @@ -29,24 +29,6 @@ describe('KtxPostgresDialect', () => { 'SELECT DISTINCT "status"::text AS val', ); expect(dialect.generateColumnStatisticsQuery('public', 'orders')).toContain('FROM pg_stats s'); - expect(dialect.getTimeTruncExpression('"created_at"', 'month')).toBe('DATE_TRUNC(\'month\', "created_at")'); }); - it('prepares named parameters with PostgreSQL positional parameters', () => { - expect( - dialect.prepareQuery('select * from orders where id = :id and status = :status', { id: 1, status: 'paid' }), - ).toEqual({ - sql: 'select * from orders where id = $1 and status = $2', - params: [1, 'paid'], - }); - expect( - dialect.prepareQuery('select :Client_Name_10, :Client_Name_1', { - Client_Name_1: 'short', - Client_Name_10: 'long', - }), - ).toEqual({ - sql: 'select $2, $1', - params: ['short', 'long'], - }); - }); }); diff --git a/packages/cli/src/connectors/postgres/historic-sql-query-client.test.ts b/packages/cli/test/connectors/postgres/historic-sql-query-client.test.ts similarity index 90% rename from packages/cli/src/connectors/postgres/historic-sql-query-client.test.ts rename to packages/cli/test/connectors/postgres/historic-sql-query-client.test.ts index b9c9fd40..0ec03fe1 100644 --- a/packages/cli/src/connectors/postgres/historic-sql-query-client.test.ts +++ b/packages/cli/test/connectors/postgres/historic-sql-query-client.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it, vi } from 'vitest'; -import { KtxPostgresHistoricSqlQueryClient } from './historic-sql-query-client.js'; -import type { KtxPostgresPoolConfig, KtxPostgresPoolFactory } from './connector.js'; +import { KtxPostgresHistoricSqlQueryClient } from '../../../src/connectors/postgres/historic-sql-query-client.js'; +import type { KtxPostgresPoolConfig, KtxPostgresPoolFactory } from '../../../src/connectors/postgres/connector.js'; describe('KtxPostgresHistoricSqlQueryClient', () => { it('executes parameterized read-only SQL through the native Postgres connector pool', async () => { diff --git a/packages/cli/src/connectors/snowflake/connector.test.ts b/packages/cli/test/connectors/snowflake/connector.test.ts similarity index 94% rename from packages/cli/src/connectors/snowflake/connector.test.ts rename to packages/cli/test/connectors/snowflake/connector.test.ts index 657dbaf1..1b00061b 100644 --- a/packages/cli/src/connectors/snowflake/connector.test.ts +++ b/packages/cli/test/connectors/snowflake/connector.test.ts @@ -7,9 +7,9 @@ vi.mock('snowflake-sdk', () => ({ createPool, })); -import { createSnowflakeLiveDatabaseIntrospection } from '../../connectors/snowflake/live-database-introspection.js'; -import { isKtxSnowflakeConnectionConfig, KtxSnowflakeScanConnector, snowflakeConnectionConfigFromConfig, type KtxSnowflakeConnectionConfig, type KtxSnowflakeDriver, type KtxSnowflakeDriverFactory } from '../../connectors/snowflake/connector.js'; -import { tableRefSet } from '../../context/scan/table-ref.js'; +import { createSnowflakeLiveDatabaseIntrospection } from '../../../src/connectors/snowflake/live-database-introspection.js'; +import { isKtxSnowflakeConnectionConfig, KtxSnowflakeScanConnector, prepareSnowflakeReadOnlyQuery, snowflakeConnectionConfigFromConfig, type KtxSnowflakeConnectionConfig, type KtxSnowflakeDriver, type KtxSnowflakeDriverFactory } from '../../../src/connectors/snowflake/connector.js'; +import { tableRefSet } from '../../../src/context/scan/table-ref.js'; function fakeDriverFactory(): KtxSnowflakeDriverFactory { const driver: KtxSnowflakeDriver = { @@ -64,8 +64,8 @@ function fakeDriverFactory(): KtxSnowflakeDriverFactory { ]), listSchemas: vi.fn(async () => ['PUBLIC', 'MART']), listTables: vi.fn(async () => [ - { schema: 'PUBLIC', name: 'ORDERS', kind: 'table' as const }, - { schema: 'PUBLIC', name: 'ORDER_SUMMARY', kind: 'view' as const }, + { catalog: 'ANALYTICS', schema: 'PUBLIC', name: 'ORDERS', kind: 'table' as const }, + { catalog: 'ANALYTICS', schema: 'PUBLIC', name: 'ORDER_SUMMARY', kind: 'view' as const }, ]), cleanup: vi.fn(async () => undefined), }; @@ -105,6 +105,17 @@ function installSnowflakePoolMock() { } describe('KtxSnowflakeScanConnector', () => { + it('prepares read-only SQL parameters with Snowflake bind arrays', () => { + expect(prepareSnowflakeReadOnlyQuery('SELECT * FROM ORDERS WHERE ID = ? AND STATUS = ?', { id: 1, status: 'paid' })).toEqual({ + sql: 'SELECT * FROM ORDERS WHERE ID = ? AND STATUS = ?', + params: [1, 'paid'], + }); + expect(prepareSnowflakeReadOnlyQuery('SELECT * FROM ORDERS')).toEqual({ + sql: 'SELECT * FROM ORDERS', + params: undefined, + }); + }); + it('resolves Snowflake connection configuration safely', () => { expect( isKtxSnowflakeConnectionConfig({ @@ -561,8 +572,8 @@ describe('KtxSnowflakeScanConnector', () => { }); await expect(connector.listTables(['MART', 'PUBLIC'])).resolves.toEqual([ - { schema: 'MART', name: 'ORDERS', kind: 'table' }, - { schema: 'PUBLIC', name: 'ORDER_SUMMARY', kind: 'view' }, + { catalog: 'ANALYTICS', schema: 'MART', name: 'ORDERS', kind: 'table' }, + { catalog: 'ANALYTICS', schema: 'PUBLIC', name: 'ORDER_SUMMARY', kind: 'view' }, ]); expect(queries).toHaveLength(1); diff --git a/packages/cli/src/connectors/snowflake/dialect.test.ts b/packages/cli/test/connectors/snowflake/dialect.test.ts similarity index 80% rename from packages/cli/src/connectors/snowflake/dialect.test.ts rename to packages/cli/test/connectors/snowflake/dialect.test.ts index 991a30b5..4f966ffb 100644 --- a/packages/cli/src/connectors/snowflake/dialect.test.ts +++ b/packages/cli/test/connectors/snowflake/dialect.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { KtxSnowflakeDialect } from './dialect.js'; +import { KtxSnowflakeDialect } from '../../../src/connectors/snowflake/dialect.js'; describe('KtxSnowflakeDialect', () => { const dialect = new KtxSnowflakeDialect(); @@ -36,14 +36,6 @@ describe('KtxSnowflakeDialect', () => { ); }); - it('passes Snowflake positional parameters as bind arrays', () => { - expect(dialect.prepareQuery('SELECT * FROM ORDERS WHERE ID = ? AND STATUS = ?', { id: 1, status: 'paid' })).toEqual({ - sql: 'SELECT * FROM ORDERS WHERE ID = ? AND STATUS = ?', - params: [1, 'paid'], - }); - expect(dialect.prepareQuery('SELECT * FROM ORDERS')).toEqual({ sql: 'SELECT * FROM ORDERS', params: undefined }); - }); - it('keeps unsupported statistics explicit', () => { expect(dialect.generateColumnStatisticsQuery('PUBLIC', 'ORDERS')).toBeNull(); }); diff --git a/packages/cli/src/connectors/snowflake/identifiers.test.ts b/packages/cli/test/connectors/snowflake/identifiers.test.ts similarity index 93% rename from packages/cli/src/connectors/snowflake/identifiers.test.ts rename to packages/cli/test/connectors/snowflake/identifiers.test.ts index d2c3e448..0a3b4cb8 100644 --- a/packages/cli/src/connectors/snowflake/identifiers.test.ts +++ b/packages/cli/test/connectors/snowflake/identifiers.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { assertSafeSnowflakeIdentifier, quoteSnowflakeIdentifier } from './identifiers.js'; +import { assertSafeSnowflakeIdentifier, quoteSnowflakeIdentifier } from '../../../src/connectors/snowflake/identifiers.js'; describe('Snowflake identifier guards', () => { it('quotes simple Snowflake identifiers', () => { diff --git a/packages/cli/src/connectors/snowflake/sdk-logger.test.ts b/packages/cli/test/connectors/snowflake/sdk-logger.test.ts similarity index 96% rename from packages/cli/src/connectors/snowflake/sdk-logger.test.ts rename to packages/cli/test/connectors/snowflake/sdk-logger.test.ts index 73bf0c76..1217b142 100644 --- a/packages/cli/src/connectors/snowflake/sdk-logger.test.ts +++ b/packages/cli/test/connectors/snowflake/sdk-logger.test.ts @@ -11,7 +11,7 @@ vi.mock('snowflake-sdk', () => ({ import { configureSnowflakeSdkLogger, resetSnowflakeSdkLoggerConfigurationForTests, -} from './sdk-logger.js'; +} from '../../../src/connectors/snowflake/sdk-logger.js'; describe('configureSnowflakeSdkLogger', () => { let projectDir: string; diff --git a/packages/cli/src/connectors/sqlite/connector.test.ts b/packages/cli/test/connectors/sqlite/connector.test.ts similarity index 91% rename from packages/cli/src/connectors/sqlite/connector.test.ts rename to packages/cli/test/connectors/sqlite/connector.test.ts index ecd283b7..27b00c57 100644 --- a/packages/cli/src/connectors/sqlite/connector.test.ts +++ b/packages/cli/test/connectors/sqlite/connector.test.ts @@ -4,9 +4,9 @@ import { mkdtemp, rm } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; -import { createSqliteLiveDatabaseIntrospection } from '../../connectors/sqlite/live-database-introspection.js'; -import { isKtxSqliteConnectionConfig, KtxSqliteScanConnector, sqliteDatabasePathFromConfig } from '../../connectors/sqlite/connector.js'; -import { tableRefSet } from '../../context/scan/table-ref.js'; +import { createSqliteLiveDatabaseIntrospection } from '../../../src/connectors/sqlite/live-database-introspection.js'; +import { isKtxSqliteConnectionConfig, KtxSqliteScanConnector, sqliteDatabasePathFromConfig } from '../../../src/connectors/sqlite/connector.js'; +import { tableRefSet } from '../../../src/context/scan/table-ref.js'; describe('KtxSqliteScanConnector', () => { let tempDir: string; @@ -150,6 +150,20 @@ describe('KtxSqliteScanConnector', () => { ]); }); + it('lists schemaless tables and views for setup discovery', async () => { + const connector = new KtxSqliteScanConnector({ + connectionId: 'warehouse', + connection: { driver: 'sqlite', path: dbPath }, + }); + + await expect(connector.listSchemas()).resolves.toEqual([]); + await expect(connector.listTables(['ignored'])).resolves.toEqual([ + { catalog: null, schema: '', name: 'customers', kind: 'table' }, + { catalog: null, schema: '', name: 'orders', kind: 'table' }, + { catalog: null, schema: '', name: 'recent_orders', kind: 'view' }, + ]); + }); + it('runs samples, distinct values, statistics, and read-only SQL', async () => { const connector = new KtxSqliteScanConnector({ connectionId: 'warehouse', diff --git a/packages/cli/src/connectors/sqlite/dialect.test.ts b/packages/cli/test/connectors/sqlite/dialect.test.ts similarity index 95% rename from packages/cli/src/connectors/sqlite/dialect.test.ts rename to packages/cli/test/connectors/sqlite/dialect.test.ts index cefed21a..879d133c 100644 --- a/packages/cli/src/connectors/sqlite/dialect.test.ts +++ b/packages/cli/test/connectors/sqlite/dialect.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { KtxSqliteDialect } from './dialect.js'; +import { KtxSqliteDialect } from '../../../src/connectors/sqlite/dialect.js'; describe('KtxSqliteDialect', () => { const dialect = new KtxSqliteDialect(); diff --git a/packages/cli/src/connectors/sqlserver/connector.test.ts b/packages/cli/test/connectors/sqlserver/connector.test.ts similarity index 89% rename from packages/cli/src/connectors/sqlserver/connector.test.ts rename to packages/cli/test/connectors/sqlserver/connector.test.ts index 4e84ff9a..b7318ab5 100644 --- a/packages/cli/src/connectors/sqlserver/connector.test.ts +++ b/packages/cli/test/connectors/sqlserver/connector.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it, vi } from 'vitest'; -import { createSqlServerLiveDatabaseIntrospection } from '../../connectors/sqlserver/live-database-introspection.js'; -import { isKtxSqlServerConnectionConfig, KtxSqlServerScanConnector, sqlServerConnectionPoolConfigFromConfig, type KtxSqlServerConnectionConfig, type KtxSqlServerPoolFactory, type KtxSqlServerQueryResult } from '../../connectors/sqlserver/connector.js'; -import { tableRefSet } from '../../context/scan/table-ref.js'; +import { createSqlServerLiveDatabaseIntrospection } from '../../../src/connectors/sqlserver/live-database-introspection.js'; +import { isKtxSqlServerConnectionConfig, KtxSqlServerScanConnector, prepareSqlServerReadOnlyQuery, sqlServerConnectionPoolConfigFromConfig, type KtxSqlServerConnectionConfig, type KtxSqlServerPoolFactory, type KtxSqlServerQueryResult } from '../../../src/connectors/sqlserver/connector.js'; +import { tableRefSet } from '../../../src/context/scan/table-ref.js'; function recordset>( rows: T[], @@ -21,9 +21,9 @@ function fakePoolFactory(options: { primaryKeyError?: Error; foreignKeyError?: E if (sql.includes('INFORMATION_SCHEMA.TABLES')) { return result( [ - { table_name: 'customers', table_type: 'BASE TABLE' }, - { table_name: 'orders', table_type: 'BASE TABLE' }, - { table_name: 'order_summary', table_type: 'VIEW' }, + { schema_name: 'dbo', table_name: 'customers', table_type: 'BASE TABLE' }, + { schema_name: 'dbo', table_name: 'orders', table_type: 'BASE TABLE' }, + { schema_name: 'dbo', table_name: 'order_summary', table_type: 'VIEW' }, ], ['table_name', 'table_type'], ); @@ -100,13 +100,13 @@ function fakePoolFactory(options: { primaryKeyError?: Error; foreignKeyError?: E ['table_name', 'row_count'], ); } - if (sql.includes('SELECT TOP 1 [id], [status] FROM [dbo].[orders]')) { + if (sql.includes('SELECT TOP 1 [id], [status] FROM [analytics].[dbo].[orders]')) { return result([{ id: 10, status: 'paid' }], ['id', 'status']); } if (sql.includes('SELECT TOP 1 * FROM (select id, status from dbo.orders) AS ktx_query_result')) { return result([{ id: 10, status: 'paid' }], ['id', 'status']); } - if (sql.includes('SELECT TOP 5 [status] FROM [dbo].[orders]')) { + if (sql.includes('SELECT TOP 5 [status] FROM [analytics].[dbo].[orders]')) { return result([{ status: 'paid' }, { status: 'open' }], ['status']); } if (sql.includes('COUNT(DISTINCT val)')) { @@ -118,6 +118,16 @@ function fakePoolFactory(options: { primaryKeyError?: Error; foreignKeyError?: E if (sql.includes('SUM(p.rows) AS row_count') && sql.includes('t.name = @tableName')) { return result([{ row_count: 2 }], ['row_count']); } + if (sql.includes('FROM sys.objects o')) { + return result( + [ + { schema_name: 'dbo', table_name: 'customers', table_type: 'USER_TABLE' }, + { schema_name: 'dbo', table_name: 'order_summary', table_type: 'VIEW' }, + { schema_name: 'dbo', table_name: 'orders', table_type: 'USER_TABLE' }, + ], + ['schema_name', 'table_name', 'table_type'], + ); + } if (sql.includes('SELECT s.name AS schema_name')) { return result([{ schema_name: 'dbo' }, { schema_name: 'sales' }], ['schema_name']); } @@ -140,6 +150,19 @@ function fakePoolFactory(options: { primaryKeyError?: Error; foreignKeyError?: E } describe('KtxSqlServerScanConnector', () => { + it('prepares read-only SQL parameters with SQL Server named placeholders', () => { + expect( + prepareSqlServerReadOnlyQuery('select * from events where id = :id and name = :name', { + id: 10, + name: 'signup', + }), + ).toEqual({ + sql: 'select * from events where id = @id and name = @name', + params: { id: 10, name: 'signup' }, + }); + expect(prepareSqlServerReadOnlyQuery('select 1')).toEqual({ sql: 'select 1', params: undefined }); + }); + it('resolves SQL Server connection configuration safely', () => { expect( isKtxSqlServerConnectionConfig({ @@ -366,6 +389,11 @@ describe('KtxSqlServerScanConnector', () => { await expect(connector.getTableRowCount('orders')).resolves.toBe(2); await expect(connector.listSchemas()).resolves.toEqual(['dbo', 'sales']); + await expect(connector.listTables(['dbo'])).resolves.toEqual([ + { catalog: 'analytics', schema: 'dbo', name: 'customers', kind: 'table' }, + { catalog: 'analytics', schema: 'dbo', name: 'order_summary', kind: 'view' }, + { catalog: 'analytics', schema: 'dbo', name: 'orders', kind: 'table' }, + ]); await expect( connector.columnStats( { connectionId: 'warehouse', table: { catalog: 'analytics', db: 'dbo', name: 'orders' }, column: 'status' }, diff --git a/packages/cli/src/connectors/sqlserver/dialect.test.ts b/packages/cli/test/connectors/sqlserver/dialect.test.ts similarity index 64% rename from packages/cli/src/connectors/sqlserver/dialect.test.ts rename to packages/cli/test/connectors/sqlserver/dialect.test.ts index 5c855340..f019991c 100644 --- a/packages/cli/src/connectors/sqlserver/dialect.test.ts +++ b/packages/cli/test/connectors/sqlserver/dialect.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { KtxSqlServerDialect } from './dialect.js'; +import { KtxSqlServerDialect } from '../../../src/connectors/sqlserver/dialect.js'; describe('KtxSqlServerDialect', () => { const dialect = new KtxSqlServerDialect(); @@ -7,7 +7,9 @@ describe('KtxSqlServerDialect', () => { it('quotes identifiers and formats schema-qualified table names', () => { expect(dialect.quoteIdentifier('events')).toBe('[events]'); expect(dialect.quoteIdentifier('odd]name')).toBe('[odd]]name]'); - expect(dialect.formatTableName({ catalog: 'warehouse', db: 'dbo', name: 'events' })).toBe('[dbo].[events]'); + expect(dialect.formatTableName({ catalog: 'warehouse', db: 'dbo', name: 'events' })).toBe( + '[warehouse].[dbo].[events]', + ); expect(dialect.formatTableName({ catalog: null, db: null, name: 'events' })).toBe('[events]'); }); @@ -20,7 +22,7 @@ describe('KtxSqlServerDialect', () => { expect(dialect.mapToDimensionType('')).toBe('string'); }); - it('builds sampling, distinct-value, pagination, and time SQL', () => { + it('builds sampling, distinct-value, and pagination SQL', () => { expect(dialect.generateSampleQuery('[dbo].[events]', 25, ['id', 'event_name'])).toBe( 'SELECT TOP 25 [id], [event_name] FROM [dbo].[events]', ); @@ -28,22 +30,8 @@ describe('KtxSqlServerDialect', () => { "SELECT TOP 10 [event_name] FROM [dbo].[events] WHERE [event_name] IS NOT NULL AND LTRIM(RTRIM(CAST([event_name] AS NVARCHAR(MAX)))) != ''", ); expect(dialect.generateDistinctValuesQuery('[dbo].[events]', '[event_name]', 5)).toContain('SELECT TOP 5 val'); - expect(dialect.getTopClause(10)).toBe('TOP 10'); - expect(dialect.getLimitOffsetClause(10, 20)).toBe('OFFSET 20 ROWS FETCH NEXT 10 ROWS ONLY'); - expect(dialect.getTimeTruncExpression('created_at', 'month')).toBe( - 'DATEFROMPARTS(YEAR(created_at), MONTH(created_at), 1)', - ); + expect(dialect.getTopClause(10)).toBe('TOP (10)'); + expect(dialect.getLimitOffsetClause(10, 20)).toBe(''); }); - it('prepares named parameters using SQL Server @ parameters', () => { - expect( - dialect.prepareQuery('select * from events where id = :id and name = :name', { - id: 10, - name: 'signup', - }), - ).toEqual({ - sql: 'select * from events where id = @id and name = @name', - params: { id: 10, name: 'signup' }, - }); - }); }); diff --git a/packages/cli/src/context-build-view.test.ts b/packages/cli/test/context-build-view.test.ts similarity index 99% rename from packages/cli/src/context-build-view.test.ts rename to packages/cli/test/context-build-view.test.ts index 089124f5..5936afa9 100644 --- a/packages/cli/src/context-build-view.test.ts +++ b/packages/cli/test/context-build-view.test.ts @@ -1,6 +1,6 @@ -import { buildDefaultKtxProjectConfig, type KtxProjectConfig } from './context/project/config.js'; +import { buildDefaultKtxProjectConfig, type KtxProjectConfig } from '../src/context/project/config.js'; import { describe, expect, it, vi } from 'vitest'; -import type { KtxPublicIngestProject, KtxPublicIngestTargetResult } from './public-ingest.js'; +import type { KtxPublicIngestProject, KtxPublicIngestTargetResult } from '../src/public-ingest.js'; import { type ContextBuildTargetState, extractProgressMessage, @@ -11,7 +11,7 @@ import { renderContextBuildView, runContextBuild, viewStateFromSourceProgress, -} from './context-build-view.js'; +} from '../src/context-build-view.js'; function makeIo(options: { isTTY?: boolean; columns?: number } = {}) { let stdout = ''; diff --git a/packages/cli/src/context/connections/bigquery-identifiers.test.ts b/packages/cli/test/context/connections/bigquery-identifiers.test.ts similarity index 92% rename from packages/cli/src/context/connections/bigquery-identifiers.test.ts rename to packages/cli/test/context/connections/bigquery-identifiers.test.ts index a1fd2e09..abc59164 100644 --- a/packages/cli/src/context/connections/bigquery-identifiers.test.ts +++ b/packages/cli/test/context/connections/bigquery-identifiers.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { normalizeBigQueryProjectId, normalizeBigQueryRegion } from './bigquery-identifiers.js'; +import { normalizeBigQueryProjectId, normalizeBigQueryRegion } from '../../../src/context/connections/bigquery-identifiers.js'; describe('BigQuery identifier normalization', () => { it('normalizes project ids and regions for information schema paths', () => { diff --git a/packages/cli/test/context/connections/dialects.test.ts b/packages/cli/test/context/connections/dialects.test.ts new file mode 100644 index 00000000..0b72566e --- /dev/null +++ b/packages/cli/test/context/connections/dialects.test.ts @@ -0,0 +1,316 @@ +import { describe, expect, it } from 'vitest'; +import { getDialectForDriver } from '../../../src/context/connections/dialects.js'; +import type { KtxConnectionDriver, KtxTableRef } from '../../../src/context/scan/types.js'; + +interface DialectFixture { + driver: KtxConnectionDriver; + table: KtxTableRef; + quoteInput: string; + quotedIdentifier: string; + formattedTable: string; + display: string; + invalidDisplay: string; + columnDisplayTablePartCount: 1 | 2 | 3; + limitClause: string; + topClause: string; + randomFilter: string; + tableSampleClause: string; + sampleQuery: string; + columnSampleContains: string; + nullCountExpression: string; + distinctCountExpression: string; + textLengthExpression: string; + castToText: string; + sampleValueAggregation: string; + cardinalityContains: string; + randomizedCardinalityContains: string; + distinctValuesContains: string; + statisticsContains: string | null; + dimensionInput: string; + dimensionType: 'time' | 'string' | 'number' | 'boolean'; + nativeTypeInput: string; + normalizedType: string; +} + +const innerSampleSql = 'SELECT status AS value FROM orders'; + +const fixtures: DialectFixture[] = [ + { + driver: 'postgres', + table: { catalog: null, db: 'public', name: 'orders' }, + quoteInput: 'order"items', + quotedIdentifier: '"order""items"', + formattedTable: '"public"."orders"', + display: 'public.orders', + invalidDisplay: 'orders', + columnDisplayTablePartCount: 2, + limitClause: 'LIMIT 25 OFFSET 5', + topClause: '', + randomFilter: 'RANDOM() < 0.25', + tableSampleClause: 'TABLESAMPLE SYSTEM (25)', + sampleQuery: 'SELECT "id", "status" FROM "public"."orders" LIMIT 5', + columnSampleContains: 'TRIM(CAST("status" AS TEXT)) != \'\'', + nullCountExpression: 'COUNT(*) FILTER (WHERE "status" IS NULL)', + distinctCountExpression: 'COUNT(DISTINCT "status")', + textLengthExpression: 'LENGTH(CAST("status" AS TEXT))', + castToText: 'CAST("status" AS TEXT)', + sampleValueAggregation: + '(SELECT STRING_AGG(CAST(value AS TEXT), CHR(31)) FROM (SELECT status AS value FROM orders) AS relationship_profile_values)', + cardinalityContains: 'SELECT COUNT(DISTINCT val) AS cardinality', + randomizedCardinalityContains: 'ORDER BY RANDOM()', + distinctValuesContains: 'SELECT DISTINCT "status"::text AS val', + statisticsContains: 'FROM pg_stats s', + dimensionInput: 'timestamp with time zone', + dimensionType: 'time', + nativeTypeInput: 'numeric(12,2)', + normalizedType: 'numeric(12,2)', + }, + { + driver: 'mysql', + table: { catalog: null, db: 'analytics', name: 'orders' }, + quoteInput: 'order`items', + quotedIdentifier: '`order``items`', + formattedTable: '`analytics`.`orders`', + display: 'analytics.orders', + invalidDisplay: 'orders', + columnDisplayTablePartCount: 2, + limitClause: 'LIMIT 25 OFFSET 5', + topClause: '', + randomFilter: 'RAND() < 0.25', + tableSampleClause: '', + sampleQuery: 'SELECT `id`, `status` FROM `analytics`.`orders` LIMIT 5', + columnSampleContains: 'TRIM(CAST(`status` AS CHAR)) != \'\'', + nullCountExpression: 'SUM(CASE WHEN `status` IS NULL THEN 1 ELSE 0 END)', + distinctCountExpression: 'COUNT(DISTINCT `status`)', + textLengthExpression: 'CHAR_LENGTH(CAST(`status` AS CHAR))', + castToText: 'CAST(`status` AS CHAR)', + sampleValueAggregation: + '(SELECT GROUP_CONCAT(CAST(value AS CHAR) SEPARATOR CHAR(31)) FROM (SELECT status AS value FROM orders) AS relationship_profile_values)', + cardinalityContains: 'SELECT COUNT(DISTINCT val) AS cardinality', + randomizedCardinalityContains: 'ORDER BY RAND()', + distinctValuesContains: 'SELECT DISTINCT CAST(`status` AS CHAR) AS val', + statisticsContains: null, + dimensionInput: 'tinyint(1)', + dimensionType: 'boolean', + nativeTypeInput: 'varchar(255)', + normalizedType: 'varchar(255)', + }, + { + driver: 'clickhouse', + table: { catalog: null, db: 'analytics', name: 'events' }, + quoteInput: 'order`items', + quotedIdentifier: '`order``items`', + formattedTable: '`analytics`.`events`', + display: 'analytics.events', + invalidDisplay: 'events', + columnDisplayTablePartCount: 2, + limitClause: 'LIMIT 25 OFFSET 5', + topClause: '', + randomFilter: 'rand() / 4294967295.0 < 0.25', + tableSampleClause: '', + sampleQuery: 'SELECT `id`, `status` FROM `analytics`.`events` LIMIT 5', + columnSampleContains: 'trim(toString(`status`)) != \'\'', + nullCountExpression: 'countIf(`status` IS NULL)', + distinctCountExpression: 'COUNT(DISTINCT `status`)', + textLengthExpression: 'length(toString(`status`))', + castToText: 'toString(`status`)', + sampleValueAggregation: + '(SELECT arrayStringConcat(groupArray(toString(value)), \'\\x1F\') FROM (SELECT status AS value FROM orders) AS relationship_profile_values)', + cardinalityContains: 'SELECT COUNT(DISTINCT val) AS cardinality', + randomizedCardinalityContains: 'ORDER BY rand()', + distinctValuesContains: 'SELECT DISTINCT toString(`status`) AS val', + statisticsContains: null, + dimensionInput: 'Nullable(DateTime64(3))', + dimensionType: 'time', + nativeTypeInput: 'LowCardinality(String)', + normalizedType: 'LowCardinality(String)', + }, + { + driver: 'sqlite', + table: { catalog: null, db: null, name: 'orders' }, + quoteInput: 'order"items', + quotedIdentifier: '"order""items"', + formattedTable: '"orders"', + display: 'orders', + invalidDisplay: 'public.orders', + columnDisplayTablePartCount: 1, + limitClause: 'LIMIT 25 OFFSET 5', + topClause: '', + randomFilter: '(RANDOM() % 100) < 25', + tableSampleClause: '', + sampleQuery: 'SELECT "id", "status" FROM "orders" LIMIT 5', + columnSampleContains: 'TRIM(CAST("status" AS TEXT)) != \'\'', + nullCountExpression: 'SUM(CASE WHEN "status" IS NULL THEN 1 ELSE 0 END)', + distinctCountExpression: 'COUNT(DISTINCT "status")', + textLengthExpression: 'LENGTH(CAST("status" AS TEXT))', + castToText: 'CAST("status" AS TEXT)', + sampleValueAggregation: + '(SELECT GROUP_CONCAT(CAST(value AS TEXT), char(31)) FROM (SELECT status AS value FROM orders) AS relationship_profile_values)', + cardinalityContains: 'SELECT COUNT(DISTINCT val) AS cardinality', + randomizedCardinalityContains: 'ORDER BY RANDOM()', + distinctValuesContains: 'SELECT DISTINCT CAST("status" AS TEXT) AS val', + statisticsContains: null, + dimensionInput: 'INTEGER', + dimensionType: 'number', + nativeTypeInput: 'VARCHAR(255)', + normalizedType: 'VARCHAR(255)', + }, + { + driver: 'snowflake', + table: { catalog: 'ANALYTICS', db: 'PUBLIC', name: 'ORDERS' }, + quoteInput: 'order"items', + quotedIdentifier: '"order""items"', + formattedTable: '"ANALYTICS"."PUBLIC"."ORDERS"', + display: 'ANALYTICS.PUBLIC.ORDERS', + invalidDisplay: 'PUBLIC.ORDERS', + columnDisplayTablePartCount: 3, + limitClause: 'LIMIT 25 OFFSET 5', + topClause: '', + randomFilter: 'UNIFORM(0::FLOAT, 1::FLOAT, RANDOM()) < 0.25', + tableSampleClause: 'SAMPLE (25)', + sampleQuery: 'SELECT "id", "status" FROM "ANALYTICS"."PUBLIC"."ORDERS" SAMPLE ROW (5 ROWS)', + columnSampleContains: 'TRIM(CAST("status" AS STRING)) != \'\'', + nullCountExpression: 'COUNT_IF("status" IS NULL)', + distinctCountExpression: 'APPROX_COUNT_DISTINCT("status")', + textLengthExpression: 'LENGTH(CAST("status" AS TEXT))', + castToText: 'CAST("status" AS VARCHAR)', + sampleValueAggregation: + '(SELECT LISTAGG(CAST(value AS VARCHAR), \'\\x1f\') FROM (SELECT status AS value FROM orders) AS relationship_profile_values)', + cardinalityContains: 'SELECT COUNT(DISTINCT val) AS cardinality', + randomizedCardinalityContains: 'SAMPLE ROW (100 ROWS)', + distinctValuesContains: 'SELECT DISTINCT "status"::VARCHAR AS val', + statisticsContains: null, + dimensionInput: 'TIMESTAMP_NTZ', + dimensionType: 'time', + nativeTypeInput: 'NUMBER(38,0)', + normalizedType: 'NUMBER(38,0)', + }, + { + driver: 'bigquery', + table: { catalog: 'analytics-project', db: 'warehouse', name: 'orders' }, + quoteInput: 'order`items', + quotedIdentifier: '`order\\`items`', + formattedTable: '`analytics-project`.`warehouse`.`orders`', + display: 'analytics-project.warehouse.orders', + invalidDisplay: 'warehouse.orders', + columnDisplayTablePartCount: 3, + limitClause: 'LIMIT 25 OFFSET 5', + topClause: '', + randomFilter: 'RAND() < 0.25', + tableSampleClause: 'TABLESAMPLE SYSTEM (25 PERCENT)', + sampleQuery: 'SELECT `id`, `status` FROM `analytics-project`.`warehouse`.`orders` ORDER BY RAND() LIMIT 5', + columnSampleContains: 'TRIM(CAST(`status` AS STRING)) != \'\'', + nullCountExpression: 'COUNTIF(`status` IS NULL)', + distinctCountExpression: 'APPROX_COUNT_DISTINCT(`status`)', + textLengthExpression: 'LENGTH(CAST(`status` AS STRING))', + castToText: 'CAST(`status` AS STRING)', + sampleValueAggregation: + '(SELECT STRING_AGG(CAST(value AS STRING), \'\\u001F\') FROM (SELECT status AS value FROM orders) AS relationship_profile_values)', + cardinalityContains: 'SELECT APPROX_COUNT_DISTINCT(val) AS cardinality', + randomizedCardinalityContains: 'ORDER BY RAND()', + distinctValuesContains: 'SELECT DISTINCT CAST(`status` AS STRING) AS val', + statisticsContains: null, + dimensionInput: 'INT64', + dimensionType: 'number', + nativeTypeInput: 'INT64', + normalizedType: 'BIGINT', + }, + { + driver: 'sqlserver', + table: { catalog: 'warehouse', db: 'dbo', name: 'events' }, + quoteInput: 'odd]name', + quotedIdentifier: '[odd]]name]', + formattedTable: '[warehouse].[dbo].[events]', + display: 'warehouse.dbo.events', + invalidDisplay: 'dbo.events', + columnDisplayTablePartCount: 3, + limitClause: '', + topClause: 'TOP (25)', + randomFilter: 'ABS(CHECKSUM(NEWID())) % 100 < 25', + tableSampleClause: 'TABLESAMPLE (25 PERCENT)', + sampleQuery: 'SELECT TOP 5 [id], [status] FROM [warehouse].[dbo].[events]', + columnSampleContains: 'LTRIM(RTRIM(CAST([status] AS NVARCHAR(MAX)))) != \'\'', + nullCountExpression: 'SUM(CASE WHEN [status] IS NULL THEN 1 ELSE 0 END)', + distinctCountExpression: 'COUNT(DISTINCT [status])', + textLengthExpression: 'LEN(CAST([status] AS NVARCHAR(MAX)))', + castToText: 'CAST([status] AS NVARCHAR(MAX))', + sampleValueAggregation: + '(SELECT STRING_AGG(CAST(value AS NVARCHAR(MAX)), CHAR(31)) FROM (SELECT status AS value FROM orders) AS relationship_profile_values)', + cardinalityContains: 'SELECT COUNT(DISTINCT val) AS cardinality', + randomizedCardinalityContains: 'ORDER BY NEWID()', + distinctValuesContains: 'SELECT TOP 20 val', + statisticsContains: null, + dimensionInput: 'datetime2', + dimensionType: 'time', + nativeTypeInput: 'uniqueidentifier', + normalizedType: 'uniqueidentifier', + }, +]; + +describe('getDialectForDriver', () => { + it.each(fixtures)('returns a full KtxDialect for $driver', (fixture) => { + const dialect = getDialectForDriver(fixture.driver); + const column = dialect.quoteIdentifier('status'); + + expect(dialect.type).toBe(fixture.driver); + expect(dialect.quoteIdentifier(fixture.quoteInput)).toBe(fixture.quotedIdentifier); + expect(dialect.formatTableName(fixture.table)).toBe(fixture.formattedTable); + expect(dialect.formatDisplayRef(fixture.table)).toBe(fixture.display); + expect(dialect.parseDisplayRef(fixture.display)).toEqual(fixture.table); + expect(dialect.parseDisplayRef(fixture.invalidDisplay)).toBeNull(); + expect(dialect.columnDisplayTablePartCount()).toBe(fixture.columnDisplayTablePartCount); + expect(dialect.getLimitOffsetClause(25, 5)).toBe(fixture.limitClause); + expect(dialect.getTopClause(25)).toBe(fixture.topClause); + expect(dialect.getRandomSampleFilter(0.25)).toBe(fixture.randomFilter); + expect(dialect.getTableSampleClause(0.25)).toBe(fixture.tableSampleClause); + expect(dialect.generateSampleQuery(fixture.formattedTable, 5, ['id', 'status'])).toBe(fixture.sampleQuery); + expect(dialect.generateColumnSampleQuery(fixture.formattedTable, 'status', 10)).toContain( + fixture.columnSampleContains, + ); + expect(dialect.getNullCountExpression(column)).toBe(fixture.nullCountExpression); + expect(dialect.getDistinctCountExpression(column)).toBe(fixture.distinctCountExpression); + expect(dialect.textLengthExpression(column)).toBe(fixture.textLengthExpression); + expect(dialect.castToText(column)).toBe(fixture.castToText); + expect(dialect.getSampleValueAggregation(innerSampleSql)).toBe(fixture.sampleValueAggregation); + expect(dialect.generateCardinalitySampleQuery(fixture.formattedTable, column, 100)).toContain( + fixture.cardinalityContains, + ); + expect(dialect.generateRandomizedCardinalitySampleQuery(fixture.formattedTable, column, 100)).toContain( + fixture.randomizedCardinalityContains, + ); + expect(dialect.generateDistinctValuesQuery(fixture.formattedTable, column, 20)).toContain( + fixture.distinctValuesContains, + ); + const statistics = dialect.generateColumnStatisticsQuery(fixture.table.db ?? '', fixture.table.name); + if (fixture.statisticsContains) { + expect(statistics).toContain(fixture.statisticsContains); + } else { + expect(statistics).toBeNull(); + } + expect(dialect.mapToDimensionType(fixture.dimensionInput)).toBe(fixture.dimensionType); + expect(dialect.mapDataType(fixture.nativeTypeInput)).toBe(fixture.normalizedType); + }); + + it('accepts three-part ANSI display refs while keeping one-part names caller-owned', () => { + for (const driver of ['postgres', 'mysql', 'clickhouse'] as const) { + const dialect = getDialectForDriver(driver); + expect(dialect.parseDisplayRef('warehouse.public.orders')).toEqual({ + catalog: 'warehouse', + db: 'public', + name: 'orders', + }); + expect(dialect.parseDisplayRef('orders')).toBeNull(); + } + }); + + it('throws with a supported-driver list for unknown drivers', () => { + expect(() => getDialectForDriver('oracle')).toThrow( + 'Unsupported warehouse driver "oracle". Supported drivers: bigquery, clickhouse, mysql, postgres, sqlite, snowflake, sqlserver', + ); + }); + + it('rejects legacy driver aliases', () => { + expect(() => getDialectForDriver('postgresql')).toThrow('Unsupported warehouse driver "postgresql"'); + expect(() => getDialectForDriver('sqlite3')).toThrow('Unsupported warehouse driver "sqlite3"'); + }); +}); diff --git a/packages/cli/test/context/connections/drivers.test.ts b/packages/cli/test/context/connections/drivers.test.ts new file mode 100644 index 00000000..380b2265 --- /dev/null +++ b/packages/cli/test/context/connections/drivers.test.ts @@ -0,0 +1,145 @@ +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { + driverRegistrations, + getDriverRegistration, + listSupportedDrivers, +} from '../../../src/context/connections/drivers.js'; +import type { + KtxDriverConnectorModule, + KtxScopeConfigKey, +} from '../../../src/context/connections/drivers.js'; +import type { KtxConnectionDriver } from '../../../src/context/scan/types.js'; + +type FixtureFactory = (projectDir: string) => Record; + +const connectionFixtures: Record = { + postgres: () => ({ + driver: 'postgres', + url: 'postgresql://reader:secret@localhost:5432/analytics', // pragma: allowlist secret + schemas: ['public'], + }), + sqlite: () => ({ driver: 'sqlite', path: 'warehouse.db' }), + mysql: () => ({ + driver: 'mysql', + host: 'localhost', + database: 'analytics', + username: 'reader', + password: 'secret', // pragma: allowlist secret + schemas: ['analytics'], + }), + clickhouse: () => ({ + driver: 'clickhouse', + url: 'http://localhost:8123', + database: 'analytics', + username: 'reader', + password: 'secret', // pragma: allowlist secret + }), + sqlserver: () => ({ + driver: 'sqlserver', + host: 'localhost', + database: 'analytics', + username: 'reader', + password: 'secret', // pragma: allowlist secret + schemas: ['dbo'], + }), + bigquery: () => ({ + driver: 'bigquery', + dataset_id: 'analytics', + credentials_json: JSON.stringify({ + project_id: 'project-1', + client_email: 'reader@example.test', + private_key: '-----BEGIN PRIVATE KEY-----\nsecret\n-----END PRIVATE KEY-----\n', // pragma: allowlist secret + }), + location: 'US', + }), + snowflake: () => ({ + driver: 'snowflake', + account: 'example-account', + username: 'reader', + password: 'secret', // pragma: allowlist secret + warehouse: 'COMPUTE_WH', + database: 'ANALYTICS', + schema: 'PUBLIC', + }), +}; + +const allowedScopeKeys = new Set(['dataset_ids', 'databases', 'schemas', 'schema_names']); +const historicSqlReaderDrivers = new Set(['postgres', 'bigquery', 'snowflake']); +const localExecutorDrivers = new Set(['postgres', 'sqlite']); + +function assertExportedRegistryBoundaryTypes(input: { + scopeConfigKey: KtxScopeConfigKey; + connectorModule: KtxDriverConnectorModule; +}): { + scopeConfigKey: KtxScopeConfigKey; + connectorModule: KtxDriverConnectorModule; +} { + return input; +} + +describe('driverRegistrations', () => { + let projectDir: string; + + beforeEach(async () => { + projectDir = await mkdtemp(join(tmpdir(), 'ktx-driver-registry-')); + }); + + afterEach(async () => { + await rm(projectDir, { recursive: true, force: true }); + }); + + it('lists every supported warehouse driver', () => { + const registryDrivers = Object.keys(driverRegistrations).sort(); + expect(listSupportedDrivers()).toEqual(registryDrivers); + expect(listSupportedDrivers()).toEqual([ + 'bigquery', + 'clickhouse', + 'mysql', + 'postgres', + 'snowflake', + 'sqlite', + 'sqlserver', + ]); + }); + + it('resolves registered drivers case-insensitively', () => { + expect(getDriverRegistration(' Postgres ')?.driver).toBe('postgres'); + expect(getDriverRegistration('unknown')).toBeUndefined(); + }); + + it.each(Object.values(driverRegistrations))('adapts $driver connector exports', async (registration) => { + const connectorModule = await registration.load(); + const connection = connectionFixtures[registration.driver](projectDir); + const exportedBoundary = assertExportedRegistryBoundaryTypes({ + scopeConfigKey: registration.scopeConfigKey ?? 'schemas', + connectorModule, + }); + expect(exportedBoundary.connectorModule.createScanConnector).toEqual(expect.any(Function)); + + expect(connectorModule.isConnectionConfig(connection)).toBe(true); + expect(connectorModule.isConnectionConfig({})).toBe(false); + + const connector = connectorModule.createScanConnector({ + connectionId: 'warehouse', + connection, + projectDir, + }); + + expect(connector.driver).toBe(registration.driver); + expect(connector.listSchemas).toEqual(expect.any(Function)); + expect(connector.listTables).toEqual(expect.any(Function)); + await connector.cleanup?.(); + + if (registration.driver === 'sqlite') { + expect(registration.scopeConfigKey).toBeNull(); + } else { + expect(registration.scopeConfigKey).not.toBeNull(); + expect(allowedScopeKeys.has(registration.scopeConfigKey ?? '')).toBe(true); + } + expect(registration.hasHistoricSqlReader).toBe(historicSqlReaderDrivers.has(registration.driver)); + expect(registration.hasLocalQueryExecutor).toBe(localExecutorDrivers.has(registration.driver)); + }); +}); diff --git a/packages/cli/src/context/connections/local-query-executor.test.ts b/packages/cli/test/context/connections/local-query-executor.test.ts similarity index 93% rename from packages/cli/src/context/connections/local-query-executor.test.ts rename to packages/cli/test/context/connections/local-query-executor.test.ts index d2f77975..ca700b04 100644 --- a/packages/cli/src/context/connections/local-query-executor.test.ts +++ b/packages/cli/test/context/connections/local-query-executor.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it, vi } from 'vitest'; -import { createDefaultLocalQueryExecutor } from './local-query-executor.js'; +import { createDefaultLocalQueryExecutor } from '../../../src/context/connections/local-query-executor.js'; describe('createDefaultLocalQueryExecutor', () => { it('dispatches postgres and sqlite drivers to their executors', async () => { diff --git a/packages/cli/src/context/connections/local-warehouse-descriptor.test.ts b/packages/cli/test/context/connections/local-warehouse-descriptor.test.ts similarity index 97% rename from packages/cli/src/context/connections/local-warehouse-descriptor.test.ts rename to packages/cli/test/context/connections/local-warehouse-descriptor.test.ts index 7e62f1dc..e0a285a9 100644 --- a/packages/cli/src/context/connections/local-warehouse-descriptor.test.ts +++ b/packages/cli/test/context/connections/local-warehouse-descriptor.test.ts @@ -3,7 +3,7 @@ import { localConnectionInfoFromConfig, localConnectionToWarehouseDescriptor, localConnectionTypeForConfig, -} from './local-warehouse-descriptor.js'; +} from '../../../src/context/connections/local-warehouse-descriptor.js'; describe('localConnectionToWarehouseDescriptor', () => { it('maps local Postgres URLs to canonical warehouse descriptors', () => { diff --git a/packages/cli/src/context/connections/notion-config.test.ts b/packages/cli/test/context/connections/notion-config.test.ts similarity index 98% rename from packages/cli/src/context/connections/notion-config.test.ts rename to packages/cli/test/context/connections/notion-config.test.ts index 6416bf99..4b1cde96 100644 --- a/packages/cli/src/context/connections/notion-config.test.ts +++ b/packages/cli/test/context/connections/notion-config.test.ts @@ -7,7 +7,7 @@ import { parseNotionConnectionConfig, redactNotionConnectionConfig, resolveNotionAuthToken, -} from './notion-config.js'; +} from '../../../src/context/connections/notion-config.js'; describe('standalone Notion connection config', () => { let tempDir: string; diff --git a/packages/cli/src/context/connections/postgres-query-executor.test.ts b/packages/cli/test/context/connections/postgres-query-executor.test.ts similarity index 96% rename from packages/cli/src/context/connections/postgres-query-executor.test.ts rename to packages/cli/test/context/connections/postgres-query-executor.test.ts index 6bb522cf..fe4ab15c 100644 --- a/packages/cli/src/context/connections/postgres-query-executor.test.ts +++ b/packages/cli/test/context/connections/postgres-query-executor.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it, vi } from 'vitest'; -import { createPostgresQueryExecutor } from './postgres-query-executor.js'; +import { createPostgresQueryExecutor } from '../../../src/context/connections/postgres-query-executor.js'; function makeClient() { const calls: unknown[] = []; diff --git a/packages/cli/src/context/connections/read-only-sql.test.ts b/packages/cli/test/context/connections/read-only-sql.test.ts similarity index 91% rename from packages/cli/src/context/connections/read-only-sql.test.ts rename to packages/cli/test/context/connections/read-only-sql.test.ts index 217bf23d..90affa04 100644 --- a/packages/cli/src/context/connections/read-only-sql.test.ts +++ b/packages/cli/test/context/connections/read-only-sql.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { assertReadOnlySql, limitSqlForExecution } from './read-only-sql.js'; +import { assertReadOnlySql, limitSqlForExecution } from '../../../src/context/connections/read-only-sql.js'; describe('assertReadOnlySql', () => { it('allows select and with queries', () => { diff --git a/packages/cli/src/context/connections/sqlite-query-executor.test.ts b/packages/cli/test/context/connections/sqlite-query-executor.test.ts similarity index 98% rename from packages/cli/src/context/connections/sqlite-query-executor.test.ts rename to packages/cli/test/context/connections/sqlite-query-executor.test.ts index facb5139..a9e61ba5 100644 --- a/packages/cli/src/context/connections/sqlite-query-executor.test.ts +++ b/packages/cli/test/context/connections/sqlite-query-executor.test.ts @@ -4,7 +4,7 @@ import { tmpdir } from 'node:os'; import { join } from 'node:path'; import Database from 'better-sqlite3'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; -import { createSqliteQueryExecutor, sqliteDatabasePathFromConnection } from './sqlite-query-executor.js'; +import { createSqliteQueryExecutor, sqliteDatabasePathFromConnection } from '../../../src/context/connections/sqlite-query-executor.js'; describe('createSqliteQueryExecutor', () => { let tempDir: string; diff --git a/packages/cli/src/context/core/config-reference.test.ts b/packages/cli/test/context/core/config-reference.test.ts similarity index 96% rename from packages/cli/src/context/core/config-reference.test.ts rename to packages/cli/test/context/core/config-reference.test.ts index f12d0bd9..c4b7c848 100644 --- a/packages/cli/src/context/core/config-reference.test.ts +++ b/packages/cli/test/context/core/config-reference.test.ts @@ -2,7 +2,7 @@ import { mkdir, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { describe, expect, it } from 'vitest'; -import { resolveKtxConfigReference, resolveKtxHomePath } from './config-reference.js'; +import { resolveKtxConfigReference, resolveKtxHomePath } from '../../../src/context/core/config-reference.js'; describe('KTX config references', () => { it('resolves env references without returning empty values', () => { diff --git a/packages/cli/src/context/core/git.service.assert-worktree-clean.test.ts b/packages/cli/test/context/core/git.service.assert-worktree-clean.test.ts similarity index 92% rename from packages/cli/src/context/core/git.service.assert-worktree-clean.test.ts rename to packages/cli/test/context/core/git.service.assert-worktree-clean.test.ts index db7d7bd3..18ee0b74 100644 --- a/packages/cli/src/context/core/git.service.assert-worktree-clean.test.ts +++ b/packages/cli/test/context/core/git.service.assert-worktree-clean.test.ts @@ -3,9 +3,9 @@ import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import type { SimpleGit } from 'simple-git'; -import type { KtxCoreConfig } from './config.js'; -import { createSimpleGit } from './git-env.js'; -import { GitService } from './git.service.js'; +import type { KtxCoreConfig } from '../../../src/context/core/config.js'; +import { createSimpleGit } from '../../../src/context/core/git-env.js'; +import { GitService } from '../../../src/context/core/git.service.js'; describe('GitService.assertWorktreeClean', () => { let workdir: string; diff --git a/packages/cli/src/context/core/git.service.delete-directories.test.ts b/packages/cli/test/context/core/git.service.delete-directories.test.ts similarity index 92% rename from packages/cli/src/context/core/git.service.delete-directories.test.ts rename to packages/cli/test/context/core/git.service.delete-directories.test.ts index b6156349..1ccd6b95 100644 --- a/packages/cli/src/context/core/git.service.delete-directories.test.ts +++ b/packages/cli/test/context/core/git.service.delete-directories.test.ts @@ -3,9 +3,9 @@ import { mkdir, mkdtemp, readdir, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import type { SimpleGit } from 'simple-git'; -import type { KtxCoreConfig } from './config.js'; -import { createSimpleGit } from './git-env.js'; -import { GitService } from './git.service.js'; +import type { KtxCoreConfig } from '../../../src/context/core/config.js'; +import { createSimpleGit } from '../../../src/context/core/git-env.js'; +import { GitService } from '../../../src/context/core/git.service.js'; describe('GitService.deleteDirectories', () => { let workdir: string; diff --git a/packages/cli/src/context/core/git.service.patch.test.ts b/packages/cli/test/context/core/git.service.patch.test.ts similarity index 96% rename from packages/cli/src/context/core/git.service.patch.test.ts rename to packages/cli/test/context/core/git.service.patch.test.ts index de1ccb9f..160c64aa 100644 --- a/packages/cli/src/context/core/git.service.patch.test.ts +++ b/packages/cli/test/context/core/git.service.patch.test.ts @@ -2,7 +2,7 @@ import { mkdir, mkdtemp, readFile, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { describe, expect, it } from 'vitest'; -import { GitService } from './git.service.js'; +import { GitService } from '../../../src/context/core/git.service.js'; async function makeGit() { const homeDir = await mkdtemp(join(tmpdir(), 'ktx-git-patch-')); diff --git a/packages/cli/src/context/core/git.service.reset-hard.test.ts b/packages/cli/test/context/core/git.service.reset-hard.test.ts similarity index 90% rename from packages/cli/src/context/core/git.service.reset-hard.test.ts rename to packages/cli/test/context/core/git.service.reset-hard.test.ts index e688b8b3..e68a3dbe 100644 --- a/packages/cli/src/context/core/git.service.reset-hard.test.ts +++ b/packages/cli/test/context/core/git.service.reset-hard.test.ts @@ -3,9 +3,9 @@ import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import type { SimpleGit } from 'simple-git'; -import type { KtxCoreConfig } from './config.js'; -import { createSimpleGit } from './git-env.js'; -import { GitService } from './git.service.js'; +import type { KtxCoreConfig } from '../../../src/context/core/config.js'; +import { createSimpleGit } from '../../../src/context/core/git-env.js'; +import { GitService } from '../../../src/context/core/git.service.js'; describe('GitService.resetHardTo', () => { let workdir: string; diff --git a/packages/cli/src/context/core/git.service.test.ts b/packages/cli/test/context/core/git.service.test.ts similarity index 99% rename from packages/cli/src/context/core/git.service.test.ts rename to packages/cli/test/context/core/git.service.test.ts index e8a5aa73..8b19ed09 100644 --- a/packages/cli/src/context/core/git.service.test.ts +++ b/packages/cli/test/context/core/git.service.test.ts @@ -2,8 +2,8 @@ import { mkdtemp, readFile, realpath, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; -import type { KtxCoreConfig } from './config.js'; -import { GitService } from './git.service.js'; +import type { KtxCoreConfig } from '../../../src/context/core/config.js'; +import { GitService } from '../../../src/context/core/git.service.js'; // These tests drive a real git repo inside a temp directory — simple-git shells out to the // system `git` binary. They are fast enough to run as unit tests and catch real issues that diff --git a/packages/cli/src/context/core/session-worktree.service.test.ts b/packages/cli/test/context/core/session-worktree.service.test.ts similarity index 95% rename from packages/cli/src/context/core/session-worktree.service.test.ts rename to packages/cli/test/context/core/session-worktree.service.test.ts index 3cb66742..19f899e4 100644 --- a/packages/cli/src/context/core/session-worktree.service.test.ts +++ b/packages/cli/test/context/core/session-worktree.service.test.ts @@ -2,9 +2,9 @@ import { mkdtemp, realpath, rm, stat } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import type { KtxCoreConfig } from './config.js'; -import { GitService } from './git.service.js'; -import { SessionWorktreeService, type WorktreeConfigPort } from './session-worktree.service.js'; +import type { KtxCoreConfig } from '../../../src/context/core/config.js'; +import { GitService } from '../../../src/context/core/git.service.js'; +import { SessionWorktreeService, type WorktreeConfigPort } from '../../../src/context/core/session-worktree.service.js'; interface TestWorktreeConfig extends WorktreeConfigPort { workdir?: string; diff --git a/packages/cli/src/context/daemon/semantic-layer-compute.test.ts b/packages/cli/test/context/daemon/semantic-layer-compute.test.ts similarity index 99% rename from packages/cli/src/context/daemon/semantic-layer-compute.test.ts rename to packages/cli/test/context/daemon/semantic-layer-compute.test.ts index dac37ef4..e6bbddbc 100644 --- a/packages/cli/src/context/daemon/semantic-layer-compute.test.ts +++ b/packages/cli/test/context/daemon/semantic-layer-compute.test.ts @@ -1,7 +1,7 @@ import { once } from 'node:events'; import { createServer } from 'node:http'; import { describe, expect, it, vi } from 'vitest'; -import { createHttpSemanticLayerComputePort, createPythonSemanticLayerComputePort } from './semantic-layer-compute.js'; +import { createHttpSemanticLayerComputePort, createPythonSemanticLayerComputePort } from '../../../src/context/daemon/semantic-layer-compute.js'; const source = { name: 'orders', diff --git a/packages/cli/src/context/index-sync/reindex.test.ts b/packages/cli/test/context/index-sync/reindex.test.ts similarity index 95% rename from packages/cli/src/context/index-sync/reindex.test.ts rename to packages/cli/test/context/index-sync/reindex.test.ts index 90c6a178..fe7698ba 100644 --- a/packages/cli/src/context/index-sync/reindex.test.ts +++ b/packages/cli/test/context/index-sync/reindex.test.ts @@ -2,10 +2,10 @@ import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; -import type { KtxEmbeddingPort } from '../../context/core/embedding.js'; -import { initKtxProject, loadKtxProject, type KtxLocalProject } from '../../context/project/project.js'; -import { SqliteKnowledgeIndex } from '../wiki/sqlite-knowledge-index.js'; -import { reindexLocalIndexes } from './reindex.js'; +import type { KtxEmbeddingPort } from '../../../src/context/core/embedding.js'; +import { initKtxProject, loadKtxProject, type KtxLocalProject } from '../../../src/context/project/project.js'; +import { SqliteKnowledgeIndex } from '../../../src/context/wiki/sqlite-knowledge-index.js'; +import { reindexLocalIndexes } from '../../../src/context/index-sync/reindex.js'; class FakeEmbeddingPort implements KtxEmbeddingPort { readonly maxBatchSize = 8; diff --git a/packages/cli/src/context/ingest/action-identity.test.ts b/packages/cli/test/context/ingest/action-identity.test.ts similarity index 96% rename from packages/cli/src/context/ingest/action-identity.test.ts rename to packages/cli/test/context/ingest/action-identity.test.ts index 725a1d99..e4baaa70 100644 --- a/packages/cli/src/context/ingest/action-identity.test.ts +++ b/packages/cli/test/context/ingest/action-identity.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { actionTargetConnectionId, memoryActionIdentity } from './action-identity.js'; +import { actionTargetConnectionId, memoryActionIdentity } from '../../../src/context/ingest/action-identity.js'; describe('memory action target identity', () => { it('keys SL actions by target connection and wiki actions by run connection', () => { diff --git a/packages/cli/src/context/ingest/adapters/dbt-descriptions/parse-schema.test.ts b/packages/cli/test/context/ingest/adapters/dbt-descriptions/parse-schema.test.ts similarity index 97% rename from packages/cli/src/context/ingest/adapters/dbt-descriptions/parse-schema.test.ts rename to packages/cli/test/context/ingest/adapters/dbt-descriptions/parse-schema.test.ts index f29cab06..8de52354 100644 --- a/packages/cli/src/context/ingest/adapters/dbt-descriptions/parse-schema.test.ts +++ b/packages/cli/test/context/ingest/adapters/dbt-descriptions/parse-schema.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { parseDbtSchemaFile, parseDbtSchemaFiles } from './parse-schema.js'; +import { parseDbtSchemaFile, parseDbtSchemaFiles } from '../../../../../src/context/ingest/adapters/dbt-descriptions/parse-schema.js'; describe('dbt descriptions schema parser', () => { it('resolves shared dbt vars and defaults before parsing schema YAML', () => { diff --git a/packages/cli/src/context/ingest/adapters/dbt/chunk.test.ts b/packages/cli/test/context/ingest/adapters/dbt/chunk.test.ts similarity index 94% rename from packages/cli/src/context/ingest/adapters/dbt/chunk.test.ts rename to packages/cli/test/context/ingest/adapters/dbt/chunk.test.ts index 6eece2ac..ffda2887 100644 --- a/packages/cli/src/context/ingest/adapters/dbt/chunk.test.ts +++ b/packages/cli/test/context/ingest/adapters/dbt/chunk.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { chunkDbtProject } from './chunk.js'; +import { chunkDbtProject } from '../../../../../src/context/ingest/adapters/dbt/chunk.js'; describe('chunkDbtProject', () => { const diffSet = (modified: string[]) => ({ added: [], modified, deleted: [], unchanged: [] }); diff --git a/packages/cli/src/context/ingest/adapters/dbt/dbt.adapter.test.ts b/packages/cli/test/context/ingest/adapters/dbt/dbt.adapter.test.ts similarity index 92% rename from packages/cli/src/context/ingest/adapters/dbt/dbt.adapter.test.ts rename to packages/cli/test/context/ingest/adapters/dbt/dbt.adapter.test.ts index 2851318e..692e1e7f 100644 --- a/packages/cli/src/context/ingest/adapters/dbt/dbt.adapter.test.ts +++ b/packages/cli/test/context/ingest/adapters/dbt/dbt.adapter.test.ts @@ -2,8 +2,8 @@ import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; -import type { SourceAdapter } from '../../types.js'; -import { DbtSourceAdapter } from './dbt.adapter.js'; +import type { SourceAdapter } from '../../../../../src/context/ingest/types.js'; +import { DbtSourceAdapter } from '../../../../../src/context/ingest/adapters/dbt/dbt.adapter.js'; describe('DbtSourceAdapter', () => { let stagedDir: string; diff --git a/packages/cli/src/context/ingest/adapters/dbt/fetch.test.ts b/packages/cli/test/context/ingest/adapters/dbt/fetch.test.ts similarity index 94% rename from packages/cli/src/context/ingest/adapters/dbt/fetch.test.ts rename to packages/cli/test/context/ingest/adapters/dbt/fetch.test.ts index eebff7c6..98ed1804 100644 --- a/packages/cli/src/context/ingest/adapters/dbt/fetch.test.ts +++ b/packages/cli/test/context/ingest/adapters/dbt/fetch.test.ts @@ -2,7 +2,7 @@ import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { fetchDbtRepo } from './fetch.js'; +import { fetchDbtRepo } from '../../../../../src/context/ingest/adapters/dbt/fetch.js'; describe('fetchDbtRepo', () => { let tempDir: string; diff --git a/packages/cli/src/context/ingest/adapters/dbt/parse.test.ts b/packages/cli/test/context/ingest/adapters/dbt/parse.test.ts similarity index 73% rename from packages/cli/src/context/ingest/adapters/dbt/parse.test.ts rename to packages/cli/test/context/ingest/adapters/dbt/parse.test.ts index f373fd5b..33ff8b0e 100644 --- a/packages/cli/src/context/ingest/adapters/dbt/parse.test.ts +++ b/packages/cli/test/context/ingest/adapters/dbt/parse.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { normalizeDbtPath } from './parse.js'; +import { normalizeDbtPath } from '../../../../../src/context/ingest/adapters/dbt/parse.js'; describe('normalizeDbtPath', () => { it('normalizes Windows separators to POSIX separators', () => { diff --git a/packages/cli/src/context/ingest/adapters/historic-sql/bigquery-query-history-reader.test.ts b/packages/cli/test/context/ingest/adapters/historic-sql/bigquery-query-history-reader.test.ts similarity index 95% rename from packages/cli/src/context/ingest/adapters/historic-sql/bigquery-query-history-reader.test.ts rename to packages/cli/test/context/ingest/adapters/historic-sql/bigquery-query-history-reader.test.ts index b9ee73b3..74393e4b 100644 --- a/packages/cli/src/context/ingest/adapters/historic-sql/bigquery-query-history-reader.test.ts +++ b/packages/cli/test/context/ingest/adapters/historic-sql/bigquery-query-history-reader.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it, vi } from 'vitest'; -import { BigQueryHistoricSqlQueryHistoryReader } from './bigquery-query-history-reader.js'; -import { HistoricSqlGrantsMissingError } from './errors.js'; +import { BigQueryHistoricSqlQueryHistoryReader } from '../../../../../src/context/ingest/adapters/historic-sql/bigquery-query-history-reader.js'; +import { HistoricSqlGrantsMissingError } from '../../../../../src/context/ingest/adapters/historic-sql/errors.js'; interface FakeQueryResult { headers: string[]; diff --git a/packages/cli/src/context/ingest/adapters/historic-sql/buckets.test.ts b/packages/cli/test/context/ingest/adapters/historic-sql/buckets.test.ts similarity index 95% rename from packages/cli/src/context/ingest/adapters/historic-sql/buckets.test.ts rename to packages/cli/test/context/ingest/adapters/historic-sql/buckets.test.ts index 78dc2859..253add1e 100644 --- a/packages/cli/src/context/ingest/adapters/historic-sql/buckets.test.ts +++ b/packages/cli/test/context/ingest/adapters/historic-sql/buckets.test.ts @@ -6,7 +6,7 @@ import { bucketFrequency, bucketP95Runtime, bucketRecency, -} from './buckets.js'; +} from '../../../../../src/context/ingest/adapters/historic-sql/buckets.js'; describe('historic-sql bucket helpers', () => { it('uses stable execution buckets', () => { diff --git a/packages/cli/src/context/ingest/adapters/historic-sql/chunk-unified.test.ts b/packages/cli/test/context/ingest/adapters/historic-sql/chunk-unified.test.ts similarity index 98% rename from packages/cli/src/context/ingest/adapters/historic-sql/chunk-unified.test.ts rename to packages/cli/test/context/ingest/adapters/historic-sql/chunk-unified.test.ts index d8c0187f..a8e99c39 100644 --- a/packages/cli/src/context/ingest/adapters/historic-sql/chunk-unified.test.ts +++ b/packages/cli/test/context/ingest/adapters/historic-sql/chunk-unified.test.ts @@ -2,7 +2,7 @@ import { mkdir, mkdtemp, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { describe, expect, it } from 'vitest'; -import { chunkHistoricSqlUnifiedStagedDir, describeHistoricSqlUnifiedScope } from './chunk-unified.js'; +import { chunkHistoricSqlUnifiedStagedDir, describeHistoricSqlUnifiedScope } from '../../../../../src/context/ingest/adapters/historic-sql/chunk-unified.js'; async function tempDir(): Promise { return mkdtemp(join(tmpdir(), 'historic-sql-unified-chunk-')); diff --git a/packages/cli/test/context/ingest/adapters/historic-sql/connection-dialect.test.ts b/packages/cli/test/context/ingest/adapters/historic-sql/connection-dialect.test.ts new file mode 100644 index 00000000..8dc2ec88 --- /dev/null +++ b/packages/cli/test/context/ingest/adapters/historic-sql/connection-dialect.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from 'vitest'; +import { queryHistoryDialectForConnection } from '../../../../../src/context/ingest/adapters/historic-sql/connection-dialect.js'; + +describe('queryHistoryDialectForConnection', () => { + it.each([ + ['postgres', 'postgres'], + ['bigquery', 'bigquery'], + ['snowflake', 'snowflake'], + ] as const)('returns %s when query history is enabled', (driver, dialect) => { + expect(queryHistoryDialectForConnection({ driver, context: { queryHistory: { enabled: true } } })).toBe(dialect); + }); + + it.each(['sqlite', 'mysql', 'clickhouse', 'sqlserver'] as const)( + 'returns null for %s because no historic-SQL reader is registered', + (driver) => { + expect(queryHistoryDialectForConnection({ driver, context: { queryHistory: { enabled: true } } })).toBeNull(); + }, + ); + + it('returns null when query history is disabled', () => { + expect(queryHistoryDialectForConnection({ driver: 'postgres', context: { queryHistory: { enabled: false } } })).toBeNull(); + }); +}); diff --git a/packages/cli/src/context/ingest/adapters/historic-sql/detect.test.ts b/packages/cli/test/context/ingest/adapters/historic-sql/detect.test.ts similarity index 91% rename from packages/cli/src/context/ingest/adapters/historic-sql/detect.test.ts rename to packages/cli/test/context/ingest/adapters/historic-sql/detect.test.ts index 9ad3cf39..4baef647 100644 --- a/packages/cli/src/context/ingest/adapters/historic-sql/detect.test.ts +++ b/packages/cli/test/context/ingest/adapters/historic-sql/detect.test.ts @@ -2,8 +2,8 @@ import { mkdir, mkdtemp, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { describe, expect, it } from 'vitest'; -import { detectHistoricSqlStagedDir } from './detect.js'; -import { HISTORIC_SQL_SOURCE_KEY, stagedManifestSchema } from './types.js'; +import { detectHistoricSqlStagedDir } from '../../../../../src/context/ingest/adapters/historic-sql/detect.js'; +import { HISTORIC_SQL_SOURCE_KEY, stagedManifestSchema } from '../../../../../src/context/ingest/adapters/historic-sql/types.js'; async function tempDir(): Promise { return mkdtemp(join(tmpdir(), 'historic-sql-detect-')); diff --git a/packages/cli/src/context/ingest/adapters/historic-sql/evidence-tool.test.ts b/packages/cli/test/context/ingest/adapters/historic-sql/evidence-tool.test.ts similarity index 95% rename from packages/cli/src/context/ingest/adapters/historic-sql/evidence-tool.test.ts rename to packages/cli/test/context/ingest/adapters/historic-sql/evidence-tool.test.ts index ae16d105..0185798b 100644 --- a/packages/cli/src/context/ingest/adapters/historic-sql/evidence-tool.test.ts +++ b/packages/cli/test/context/ingest/adapters/historic-sql/evidence-tool.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it, vi } from 'vitest'; import { asSchema } from 'ai'; -import { createEmitHistoricSqlEvidenceTool } from './evidence-tool.js'; +import { createEmitHistoricSqlEvidenceTool } from '../../../../../src/context/ingest/adapters/historic-sql/evidence-tool.js'; describe('emit_historic_sql_evidence tool', () => { it('exposes an AI SDK v6 tool input schema with top-level object type', async () => { diff --git a/packages/cli/src/context/ingest/adapters/historic-sql/evidence.test.ts b/packages/cli/test/context/ingest/adapters/historic-sql/evidence.test.ts similarity index 96% rename from packages/cli/src/context/ingest/adapters/historic-sql/evidence.test.ts rename to packages/cli/test/context/ingest/adapters/historic-sql/evidence.test.ts index 8858ed37..1f188a95 100644 --- a/packages/cli/src/context/ingest/adapters/historic-sql/evidence.test.ts +++ b/packages/cli/test/context/ingest/adapters/historic-sql/evidence.test.ts @@ -4,7 +4,7 @@ import { historicSqlEvidencePath, historicSqlPatternEvidenceSchema, historicSqlTableUsageEvidenceSchema, -} from './evidence.js'; +} from '../../../../../src/context/ingest/adapters/historic-sql/evidence.js'; describe('historic-sql evidence contracts', () => { it('validates table usage evidence emitted by table digest WorkUnits', () => { diff --git a/packages/cli/src/context/ingest/adapters/historic-sql/historic-sql.adapter.test.ts b/packages/cli/test/context/ingest/adapters/historic-sql/historic-sql.adapter.test.ts similarity index 89% rename from packages/cli/src/context/ingest/adapters/historic-sql/historic-sql.adapter.test.ts rename to packages/cli/test/context/ingest/adapters/historic-sql/historic-sql.adapter.test.ts index 80df5c26..850be576 100644 --- a/packages/cli/src/context/ingest/adapters/historic-sql/historic-sql.adapter.test.ts +++ b/packages/cli/test/context/ingest/adapters/historic-sql/historic-sql.adapter.test.ts @@ -2,10 +2,10 @@ import { mkdtemp } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { describe, expect, it } from 'vitest'; -import type { SqlAnalysisPort } from '../../../../context/sql-analysis/ports.js'; -import type { SourceAdapter } from '../../types.js'; -import { HistoricSqlSourceAdapter } from './historic-sql.adapter.js'; -import type { HistoricSqlReader } from './types.js'; +import type { SqlAnalysisPort } from '../../../../../src/context/sql-analysis/ports.js'; +import type { SourceAdapter } from '../../../../../src/context/ingest/types.js'; +import { HistoricSqlSourceAdapter } from '../../../../../src/context/ingest/adapters/historic-sql/historic-sql.adapter.js'; +import type { HistoricSqlReader } from '../../../../../src/context/ingest/adapters/historic-sql/types.js'; async function tempDir(): Promise { return mkdtemp(join(tmpdir(), 'historic-sql-adapter-')); diff --git a/packages/cli/src/context/ingest/adapters/historic-sql/local-ingest-acceptance.test.ts b/packages/cli/test/context/ingest/adapters/historic-sql/local-ingest-acceptance.test.ts similarity index 93% rename from packages/cli/src/context/ingest/adapters/historic-sql/local-ingest-acceptance.test.ts rename to packages/cli/test/context/ingest/adapters/historic-sql/local-ingest-acceptance.test.ts index a443f995..1e626edd 100644 --- a/packages/cli/src/context/ingest/adapters/historic-sql/local-ingest-acceptance.test.ts +++ b/packages/cli/test/context/ingest/adapters/historic-sql/local-ingest-acceptance.test.ts @@ -2,15 +2,15 @@ import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import YAML from 'yaml'; -import type { AgentRunnerPort, RunLoopParams } from '../../../../context/llm/runtime-port.js'; -import { initKtxProject, loadKtxProject, type KtxLocalProject } from '../../../../context/project/project.js'; -import type { SqlAnalysisBatchItem, SqlAnalysisBatchResult, SqlAnalysisDialect, SqlAnalysisPort } from '../../../../context/sql-analysis/ports.js'; -import { searchLocalSlSources } from '../../../sl/local-sl.js'; -import { searchLocalKnowledgePages } from '../../../wiki/local-knowledge.js'; -import { runLocalIngest } from '../../local-ingest.js'; +import type { AgentRunnerPort, RunLoopParams } from '../../../../../src/context/llm/runtime-port.js'; +import { initKtxProject, loadKtxProject, type KtxLocalProject } from '../../../../../src/context/project/project.js'; +import type { SqlAnalysisBatchItem, SqlAnalysisBatchResult, SqlAnalysisDialect, SqlAnalysisPort } from '../../../../../src/context/sql-analysis/ports.js'; +import { searchLocalSlSources } from '../../../../../src/context/sl/local-sl.js'; +import { searchLocalKnowledgePages } from '../../../../../src/context/wiki/local-knowledge.js'; +import { runLocalIngest } from '../../../../../src/context/ingest/local-ingest.js'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { HistoricSqlSourceAdapter } from './historic-sql.adapter.js'; -import type { AggregatedTemplate, HistoricSqlReader, HistoricSqlUnifiedPullConfig } from './types.js'; +import { HistoricSqlSourceAdapter } from '../../../../../src/context/ingest/adapters/historic-sql/historic-sql.adapter.js'; +import type { AggregatedTemplate, HistoricSqlReader, HistoricSqlUnifiedPullConfig } from '../../../../../src/context/ingest/adapters/historic-sql/types.js'; class AcceptanceHistoricSqlReader implements HistoricSqlReader { async probe() { diff --git a/packages/cli/src/context/ingest/adapters/historic-sql/pattern-inputs.test.ts b/packages/cli/test/context/ingest/adapters/historic-sql/pattern-inputs.test.ts similarity index 95% rename from packages/cli/src/context/ingest/adapters/historic-sql/pattern-inputs.test.ts rename to packages/cli/test/context/ingest/adapters/historic-sql/pattern-inputs.test.ts index d37ed193..9fae5e08 100644 --- a/packages/cli/src/context/ingest/adapters/historic-sql/pattern-inputs.test.ts +++ b/packages/cli/test/context/ingest/adapters/historic-sql/pattern-inputs.test.ts @@ -4,8 +4,8 @@ import { isHistoricSqlPatternInputShardPath, serializedStagedPatternsInputByteLength, splitHistoricSqlPatternInputs, -} from './pattern-inputs.js'; -import type { StagedPatternsInput } from './types.js'; +} from '../../../../../src/context/ingest/adapters/historic-sql/pattern-inputs.js'; +import type { StagedPatternsInput } from '../../../../../src/context/ingest/adapters/historic-sql/types.js'; type PatternTemplate = StagedPatternsInput['templates'][number]; diff --git a/packages/cli/src/context/ingest/adapters/historic-sql/postgres-pgss-reader.test.ts b/packages/cli/test/context/ingest/adapters/historic-sql/postgres-pgss-reader.test.ts similarity index 97% rename from packages/cli/src/context/ingest/adapters/historic-sql/postgres-pgss-reader.test.ts rename to packages/cli/test/context/ingest/adapters/historic-sql/postgres-pgss-reader.test.ts index a91171cd..41baf331 100644 --- a/packages/cli/src/context/ingest/adapters/historic-sql/postgres-pgss-reader.test.ts +++ b/packages/cli/test/context/ingest/adapters/historic-sql/postgres-pgss-reader.test.ts @@ -3,8 +3,8 @@ import { HistoricSqlExtensionMissingError, HistoricSqlGrantsMissingError, HistoricSqlVersionUnsupportedError, -} from './errors.js'; -import { PostgresPgssReader } from './postgres-pgss-reader.js'; +} from '../../../../../src/context/ingest/adapters/historic-sql/errors.js'; +import { PostgresPgssReader } from '../../../../../src/context/ingest/adapters/historic-sql/postgres-pgss-reader.js'; interface FakeQueryResult { headers: string[]; diff --git a/packages/cli/src/context/ingest/adapters/historic-sql/projection.test.ts b/packages/cli/test/context/ingest/adapters/historic-sql/projection.test.ts similarity index 99% rename from packages/cli/src/context/ingest/adapters/historic-sql/projection.test.ts rename to packages/cli/test/context/ingest/adapters/historic-sql/projection.test.ts index 28ddf5f8..722d7156 100644 --- a/packages/cli/src/context/ingest/adapters/historic-sql/projection.test.ts +++ b/packages/cli/test/context/ingest/adapters/historic-sql/projection.test.ts @@ -3,7 +3,7 @@ import { tmpdir } from 'node:os'; import { join } from 'node:path'; import YAML from 'yaml'; import { describe, expect, it } from 'vitest'; -import { projectHistoricSqlEvidence } from './projection.js'; +import { projectHistoricSqlEvidence } from '../../../../../src/context/ingest/adapters/historic-sql/projection.js'; async function tempWorkdir(): Promise { return mkdtemp(join(tmpdir(), 'historic-sql-projection-')); diff --git a/packages/cli/src/context/ingest/adapters/historic-sql/redaction.test.ts b/packages/cli/test/context/ingest/adapters/historic-sql/redaction.test.ts similarity index 94% rename from packages/cli/src/context/ingest/adapters/historic-sql/redaction.test.ts rename to packages/cli/test/context/ingest/adapters/historic-sql/redaction.test.ts index d27015a6..262bc4ae 100644 --- a/packages/cli/src/context/ingest/adapters/historic-sql/redaction.test.ts +++ b/packages/cli/test/context/ingest/adapters/historic-sql/redaction.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { compileHistoricSqlRedactionPatterns, redactHistoricSqlText } from './redaction.js'; +import { compileHistoricSqlRedactionPatterns, redactHistoricSqlText } from '../../../../../src/context/ingest/adapters/historic-sql/redaction.js'; describe('historic-SQL redaction', () => { it('redacts regex matches and supports the (?i) case-insensitive prefix', () => { diff --git a/packages/cli/src/context/ingest/adapters/historic-sql/skill-schemas.test.ts b/packages/cli/test/context/ingest/adapters/historic-sql/skill-schemas.test.ts similarity index 96% rename from packages/cli/src/context/ingest/adapters/historic-sql/skill-schemas.test.ts rename to packages/cli/test/context/ingest/adapters/historic-sql/skill-schemas.test.ts index b384c0c0..2a6ac805 100644 --- a/packages/cli/src/context/ingest/adapters/historic-sql/skill-schemas.test.ts +++ b/packages/cli/test/context/ingest/adapters/historic-sql/skill-schemas.test.ts @@ -4,7 +4,7 @@ import { patternOutputSchema, patternsArraySchema, tableUsageOutputSchema, -} from './skill-schemas.js'; +} from '../../../../../src/context/ingest/adapters/historic-sql/skill-schemas.js'; describe('historic-sql skill schemas', () => { it('accepts table usage output and preserves future keys', () => { diff --git a/packages/cli/src/context/ingest/adapters/historic-sql/snowflake-query-history-reader.test.ts b/packages/cli/test/context/ingest/adapters/historic-sql/snowflake-query-history-reader.test.ts similarity index 94% rename from packages/cli/src/context/ingest/adapters/historic-sql/snowflake-query-history-reader.test.ts rename to packages/cli/test/context/ingest/adapters/historic-sql/snowflake-query-history-reader.test.ts index b33183d7..7307fcdd 100644 --- a/packages/cli/src/context/ingest/adapters/historic-sql/snowflake-query-history-reader.test.ts +++ b/packages/cli/test/context/ingest/adapters/historic-sql/snowflake-query-history-reader.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it, vi } from 'vitest'; -import { HistoricSqlGrantsMissingError } from './errors.js'; -import { SnowflakeHistoricSqlQueryHistoryReader } from './snowflake-query-history-reader.js'; +import { HistoricSqlGrantsMissingError } from '../../../../../src/context/ingest/adapters/historic-sql/errors.js'; +import { SnowflakeHistoricSqlQueryHistoryReader } from '../../../../../src/context/ingest/adapters/historic-sql/snowflake-query-history-reader.js'; interface FakeQueryResult { headers: string[]; diff --git a/packages/cli/src/context/ingest/adapters/historic-sql/stage-unified.test.ts b/packages/cli/test/context/ingest/adapters/historic-sql/stage-unified.test.ts similarity index 97% rename from packages/cli/src/context/ingest/adapters/historic-sql/stage-unified.test.ts rename to packages/cli/test/context/ingest/adapters/historic-sql/stage-unified.test.ts index d49c3a1d..b930d695 100644 --- a/packages/cli/src/context/ingest/adapters/historic-sql/stage-unified.test.ts +++ b/packages/cli/test/context/ingest/adapters/historic-sql/stage-unified.test.ts @@ -2,9 +2,9 @@ import { mkdtemp, readFile, readdir } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { describe, expect, it, vi } from 'vitest'; -import type { SqlAnalysisPort } from '../../../../context/sql-analysis/ports.js'; -import { stageHistoricSqlAggregatedSnapshot } from './stage-unified.js'; -import type { AggregatedTemplate, HistoricSqlReader } from './types.js'; +import type { SqlAnalysisPort } from '../../../../../src/context/sql-analysis/ports.js'; +import { stageHistoricSqlAggregatedSnapshot } from '../../../../../src/context/ingest/adapters/historic-sql/stage-unified.js'; +import type { AggregatedTemplate, HistoricSqlReader } from '../../../../../src/context/ingest/adapters/historic-sql/types.js'; async function tempDir(): Promise { return mkdtemp(join(tmpdir(), 'historic-sql-unified-stage-')); diff --git a/packages/cli/src/context/ingest/adapters/historic-sql/types.test.ts b/packages/cli/test/context/ingest/adapters/historic-sql/types.test.ts similarity index 97% rename from packages/cli/src/context/ingest/adapters/historic-sql/types.test.ts rename to packages/cli/test/context/ingest/adapters/historic-sql/types.test.ts index 95b253a8..d9417521 100644 --- a/packages/cli/src/context/ingest/adapters/historic-sql/types.test.ts +++ b/packages/cli/test/context/ingest/adapters/historic-sql/types.test.ts @@ -5,7 +5,7 @@ import { stagedManifestSchema, stagedPatternsInputSchema, stagedTableInputSchema, -} from './types.js'; +} from '../../../../../src/context/ingest/adapters/historic-sql/types.js'; describe('historic-sql unified contracts', () => { it('parses minExecutions and service-account filters', () => { diff --git a/packages/cli/src/context/ingest/adapters/live-database/chunk.test.ts b/packages/cli/test/context/ingest/adapters/live-database/chunk.test.ts similarity index 92% rename from packages/cli/src/context/ingest/adapters/live-database/chunk.test.ts rename to packages/cli/test/context/ingest/adapters/live-database/chunk.test.ts index 2e38be9a..d3c5207c 100644 --- a/packages/cli/src/context/ingest/adapters/live-database/chunk.test.ts +++ b/packages/cli/test/context/ingest/adapters/live-database/chunk.test.ts @@ -2,9 +2,9 @@ import { mkdtemp } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { describe, expect, it } from 'vitest'; -import type { KtxSchemaSnapshot } from '../../../scan/types.js'; -import { chunkLiveDatabaseStagedDir } from './chunk.js'; -import { liveDatabaseTablePath, writeLiveDatabaseSnapshot } from './stage.js'; +import type { KtxSchemaSnapshot } from '../../../../../src/context/scan/types.js'; +import { chunkLiveDatabaseStagedDir } from '../../../../../src/context/ingest/adapters/live-database/chunk.js'; +import { liveDatabaseTablePath, writeLiveDatabaseSnapshot } from '../../../../../src/context/ingest/adapters/live-database/stage.js'; function snapshot(): KtxSchemaSnapshot { return { diff --git a/packages/cli/src/context/ingest/adapters/live-database/daemon-introspection.test.ts b/packages/cli/test/context/ingest/adapters/live-database/daemon-introspection.test.ts similarity index 97% rename from packages/cli/src/context/ingest/adapters/live-database/daemon-introspection.test.ts rename to packages/cli/test/context/ingest/adapters/live-database/daemon-introspection.test.ts index ca62ec05..5cc6affb 100644 --- a/packages/cli/src/context/ingest/adapters/live-database/daemon-introspection.test.ts +++ b/packages/cli/test/context/ingest/adapters/live-database/daemon-introspection.test.ts @@ -1,8 +1,8 @@ import { once } from 'node:events'; import { createServer } from 'node:http'; import { describe, expect, it, vi } from 'vitest'; -import { tableRefSet } from '../../../scan/table-ref.js'; -import { createDaemonLiveDatabaseIntrospection } from './daemon-introspection.js'; +import { tableRefSet } from '../../../../../src/context/scan/table-ref.js'; +import { createDaemonLiveDatabaseIntrospection } from '../../../../../src/context/ingest/adapters/live-database/daemon-introspection.js'; const daemonResponse = { connection_id: 'warehouse', diff --git a/packages/cli/src/context/ingest/adapters/live-database/live-database.adapter.test.ts b/packages/cli/test/context/ingest/adapters/live-database/live-database.adapter.test.ts similarity index 94% rename from packages/cli/src/context/ingest/adapters/live-database/live-database.adapter.test.ts rename to packages/cli/test/context/ingest/adapters/live-database/live-database.adapter.test.ts index 6cd543e1..72c31446 100644 --- a/packages/cli/src/context/ingest/adapters/live-database/live-database.adapter.test.ts +++ b/packages/cli/test/context/ingest/adapters/live-database/live-database.adapter.test.ts @@ -2,8 +2,8 @@ import { mkdtemp, readdir, rm } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { describe, expect, it, vi } from 'vitest'; -import { tableRefSet, type KtxTableRefKey } from '../../../scan/table-ref.js'; -import { LiveDatabaseSourceAdapter } from './live-database.adapter.js'; +import { tableRefSet, type KtxTableRefKey } from '../../../../../src/context/scan/table-ref.js'; +import { LiveDatabaseSourceAdapter } from '../../../../../src/context/ingest/adapters/live-database/live-database.adapter.js'; describe('LiveDatabaseSourceAdapter', () => { it('fetches a schema snapshot through the introspection port', async () => { diff --git a/packages/cli/src/context/ingest/adapters/live-database/manifest.test.ts b/packages/cli/test/context/ingest/adapters/live-database/manifest.test.ts similarity index 99% rename from packages/cli/src/context/ingest/adapters/live-database/manifest.test.ts rename to packages/cli/test/context/ingest/adapters/live-database/manifest.test.ts index a97140a9..d32868ec 100644 --- a/packages/cli/src/context/ingest/adapters/live-database/manifest.test.ts +++ b/packages/cli/test/context/ingest/adapters/live-database/manifest.test.ts @@ -4,7 +4,7 @@ import { type LiveDatabaseManifestExistingDescriptions, type LiveDatabaseManifestJoinEntry, type LiveDatabaseManifestShard, -} from './manifest.js'; +} from '../../../../../src/context/ingest/adapters/live-database/manifest.js'; function shardObject(shards: Map): Record { return Object.fromEntries([...shards.entries()].sort(([a], [b]) => a.localeCompare(b))); diff --git a/packages/cli/src/context/ingest/adapters/live-database/stage.test.ts b/packages/cli/test/context/ingest/adapters/live-database/stage.test.ts similarity index 97% rename from packages/cli/src/context/ingest/adapters/live-database/stage.test.ts rename to packages/cli/test/context/ingest/adapters/live-database/stage.test.ts index 8fb675a2..b2382775 100644 --- a/packages/cli/src/context/ingest/adapters/live-database/stage.test.ts +++ b/packages/cli/test/context/ingest/adapters/live-database/stage.test.ts @@ -10,8 +10,8 @@ import { liveDatabaseTablePath, readLiveDatabaseTableFiles, writeLiveDatabaseSnapshot, -} from './stage.js'; -import type { KtxSchemaSnapshot } from '../../../scan/types.js'; +} from '../../../../../src/context/ingest/adapters/live-database/stage.js'; +import type { KtxSchemaSnapshot } from '../../../../../src/context/scan/types.js'; function snapshot(): KtxSchemaSnapshot { return { diff --git a/packages/cli/src/context/ingest/adapters/looker/chunk.test.ts b/packages/cli/test/context/ingest/adapters/looker/chunk.test.ts similarity index 96% rename from packages/cli/src/context/ingest/adapters/looker/chunk.test.ts rename to packages/cli/test/context/ingest/adapters/looker/chunk.test.ts index 9d41d37a..f55a734b 100644 --- a/packages/cli/src/context/ingest/adapters/looker/chunk.test.ts +++ b/packages/cli/test/context/ingest/adapters/looker/chunk.test.ts @@ -2,8 +2,8 @@ import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; -import { chunkLookerStagedDir } from './chunk.js'; -import { writeLookerEvidenceDocuments } from './evidence-documents.js'; +import { chunkLookerStagedDir } from '../../../../../src/context/ingest/adapters/looker/chunk.js'; +import { writeLookerEvidenceDocuments } from '../../../../../src/context/ingest/adapters/looker/evidence-documents.js'; async function writeJson(stagedDir: string, relPath: string, value: unknown): Promise { const abs = join(stagedDir, relPath); diff --git a/packages/cli/src/context/ingest/adapters/looker/client-boundary.test.ts b/packages/cli/test/context/ingest/adapters/looker/client-boundary.test.ts similarity index 77% rename from packages/cli/src/context/ingest/adapters/looker/client-boundary.test.ts rename to packages/cli/test/context/ingest/adapters/looker/client-boundary.test.ts index 9172e23f..48cd4e4b 100644 --- a/packages/cli/src/context/ingest/adapters/looker/client-boundary.test.ts +++ b/packages/cli/test/context/ingest/adapters/looker/client-boundary.test.ts @@ -3,7 +3,7 @@ import { describe, expect, it } from 'vitest'; describe('LookerClient boundary', () => { it('does not import server or NestJS modules', async () => { - const source = await readFile(new URL('./client.ts', import.meta.url), 'utf-8'); + const source = await readFile(new URL('../../../../../src/context/ingest/adapters/looker/client.ts', import.meta.url), 'utf-8'); expect(source).not.toMatch(/@nestjs\/common/); expect(source).not.toMatch(/DataSourceClient/); diff --git a/packages/cli/src/context/ingest/adapters/looker/client.test.ts b/packages/cli/test/context/ingest/adapters/looker/client.test.ts similarity index 99% rename from packages/cli/src/context/ingest/adapters/looker/client.test.ts rename to packages/cli/test/context/ingest/adapters/looker/client.test.ts index 3b1822e0..e9aacb11 100644 --- a/packages/cli/src/context/ingest/adapters/looker/client.test.ts +++ b/packages/cli/test/context/ingest/adapters/looker/client.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it, vi } from 'vitest'; -import { LookerClient, type LookerSdkPort } from './client.js'; +import { LookerClient, type LookerSdkPort } from '../../../../../src/context/ingest/adapters/looker/client.js'; const clientSecretParam = 'client_secret'; // pragma: allowlist secret diff --git a/packages/cli/src/context/ingest/adapters/looker/daemon-table-identifier-parser.test.ts b/packages/cli/test/context/ingest/adapters/looker/daemon-table-identifier-parser.test.ts similarity index 90% rename from packages/cli/src/context/ingest/adapters/looker/daemon-table-identifier-parser.test.ts rename to packages/cli/test/context/ingest/adapters/looker/daemon-table-identifier-parser.test.ts index 0da13d53..b8ae5b73 100644 --- a/packages/cli/src/context/ingest/adapters/looker/daemon-table-identifier-parser.test.ts +++ b/packages/cli/test/context/ingest/adapters/looker/daemon-table-identifier-parser.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it, vi } from 'vitest'; -import { createDaemonLookerTableIdentifierParser } from './daemon-table-identifier-parser.js'; +import { createDaemonLookerTableIdentifierParser } from '../../../../../src/context/ingest/adapters/looker/daemon-table-identifier-parser.js'; describe('createDaemonLookerTableIdentifierParser', () => { it('posts parse items to the daemon endpoint', async () => { diff --git a/packages/cli/src/context/ingest/adapters/looker/detect.test.ts b/packages/cli/test/context/ingest/adapters/looker/detect.test.ts similarity index 94% rename from packages/cli/src/context/ingest/adapters/looker/detect.test.ts rename to packages/cli/test/context/ingest/adapters/looker/detect.test.ts index 1490bcfa..08e8472b 100644 --- a/packages/cli/src/context/ingest/adapters/looker/detect.test.ts +++ b/packages/cli/test/context/ingest/adapters/looker/detect.test.ts @@ -2,7 +2,7 @@ import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; -import { detectLookerStagedDir } from './detect.js'; +import { detectLookerStagedDir } from '../../../../../src/context/ingest/adapters/looker/detect.js'; async function touch(stagedDir: string, relPath: string, body = '{}\n'): Promise { const abs = join(stagedDir, relPath); diff --git a/packages/cli/src/context/ingest/adapters/looker/evidence-documents.test.ts b/packages/cli/test/context/ingest/adapters/looker/evidence-documents.test.ts similarity index 98% rename from packages/cli/src/context/ingest/adapters/looker/evidence-documents.test.ts rename to packages/cli/test/context/ingest/adapters/looker/evidence-documents.test.ts index 6d4545ca..55da5fc9 100644 --- a/packages/cli/src/context/ingest/adapters/looker/evidence-documents.test.ts +++ b/packages/cli/test/context/ingest/adapters/looker/evidence-documents.test.ts @@ -2,7 +2,7 @@ import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { dirname, join } from 'node:path'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; -import { getLookerTriageSignals, writeLookerEvidenceDocuments } from './evidence-documents.js'; +import { getLookerTriageSignals, writeLookerEvidenceDocuments } from '../../../../../src/context/ingest/adapters/looker/evidence-documents.js'; async function writeJson(root: string, relPath: string, value: unknown): Promise { const target = join(root, relPath); diff --git a/packages/cli/src/context/ingest/adapters/looker/factory.test.ts b/packages/cli/test/context/ingest/adapters/looker/factory.test.ts similarity index 86% rename from packages/cli/src/context/ingest/adapters/looker/factory.test.ts rename to packages/cli/test/context/ingest/adapters/looker/factory.test.ts index d68be942..ed5c5633 100644 --- a/packages/cli/src/context/ingest/adapters/looker/factory.test.ts +++ b/packages/cli/test/context/ingest/adapters/looker/factory.test.ts @@ -1,13 +1,13 @@ import { describe, expect, it, vi } from 'vitest'; -import type { FetchContext } from '../../types.js'; -import type { LookerSdkPort } from './client.js'; +import type { FetchContext } from '../../../../../src/context/ingest/types.js'; +import type { LookerSdkPort } from '../../../../../src/context/ingest/adapters/looker/client.js'; import { DefaultLookerClientFactory, DefaultLookerConnectionClientFactory, type LookerCredentialResolver, -} from './factory.js'; -import type { LookerRuntimeClient } from './fetch.js'; -import type { LookerPullConfig } from './types.js'; +} from '../../../../../src/context/ingest/adapters/looker/factory.js'; +import type { LookerRuntimeClient } from '../../../../../src/context/ingest/adapters/looker/fetch.js'; +import type { LookerPullConfig } from '../../../../../src/context/ingest/adapters/looker/types.js'; function sdk(): LookerSdkPort { return { diff --git a/packages/cli/src/context/ingest/adapters/looker/fetch-report.test.ts b/packages/cli/test/context/ingest/adapters/looker/fetch-report.test.ts similarity index 97% rename from packages/cli/src/context/ingest/adapters/looker/fetch-report.test.ts rename to packages/cli/test/context/ingest/adapters/looker/fetch-report.test.ts index 157a6770..f9f5bcd3 100644 --- a/packages/cli/src/context/ingest/adapters/looker/fetch-report.test.ts +++ b/packages/cli/test/context/ingest/adapters/looker/fetch-report.test.ts @@ -2,7 +2,7 @@ import { mkdtemp, rm } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; -import { readLookerFetchReport, writeLookerFetchReport } from './fetch-report.js'; +import { readLookerFetchReport, writeLookerFetchReport } from '../../../../../src/context/ingest/adapters/looker/fetch-report.js'; describe('Looker staged fetch report', () => { let stagedDir: string; diff --git a/packages/cli/src/context/ingest/adapters/looker/fetch.test.ts b/packages/cli/test/context/ingest/adapters/looker/fetch.test.ts similarity index 99% rename from packages/cli/src/context/ingest/adapters/looker/fetch.test.ts rename to packages/cli/test/context/ingest/adapters/looker/fetch.test.ts index 2b18a3dd..95edb382 100644 --- a/packages/cli/src/context/ingest/adapters/looker/fetch.test.ts +++ b/packages/cli/test/context/ingest/adapters/looker/fetch.test.ts @@ -2,8 +2,8 @@ import { mkdtemp, readdir, readFile, rm } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { chunkLookerStagedDir } from './chunk.js'; -import { fetchLookerRuntimeBundle, type LookerRuntimeClient } from './fetch.js'; +import { chunkLookerStagedDir } from '../../../../../src/context/ingest/adapters/looker/chunk.js'; +import { fetchLookerRuntimeBundle, type LookerRuntimeClient } from '../../../../../src/context/ingest/adapters/looker/fetch.js'; const connectionId = '11111111-1111-4111-8111-111111111111'; diff --git a/packages/cli/src/context/ingest/adapters/looker/local-runtime-store.test.ts b/packages/cli/test/context/ingest/adapters/looker/local-runtime-store.test.ts similarity index 96% rename from packages/cli/src/context/ingest/adapters/looker/local-runtime-store.test.ts rename to packages/cli/test/context/ingest/adapters/looker/local-runtime-store.test.ts index 3f9bbdc5..ff7b1f5c 100644 --- a/packages/cli/src/context/ingest/adapters/looker/local-runtime-store.test.ts +++ b/packages/cli/test/context/ingest/adapters/looker/local-runtime-store.test.ts @@ -2,7 +2,7 @@ import { mkdtemp } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { describe, expect, it } from 'vitest'; -import { LocalLookerRuntimeStore } from './local-runtime-store.js'; +import { LocalLookerRuntimeStore } from '../../../../../src/context/ingest/adapters/looker/local-runtime-store.js'; describe('LocalLookerRuntimeStore', () => { async function store() { diff --git a/packages/cli/src/context/ingest/adapters/looker/looker.adapter.test.ts b/packages/cli/test/context/ingest/adapters/looker/looker.adapter.test.ts similarity index 95% rename from packages/cli/src/context/ingest/adapters/looker/looker.adapter.test.ts rename to packages/cli/test/context/ingest/adapters/looker/looker.adapter.test.ts index 64a35622..90623a37 100644 --- a/packages/cli/src/context/ingest/adapters/looker/looker.adapter.test.ts +++ b/packages/cli/test/context/ingest/adapters/looker/looker.adapter.test.ts @@ -2,8 +2,8 @@ import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import type { LookerRuntimeClient } from './fetch.js'; -import { LookerSourceAdapter } from './looker.adapter.js'; +import type { LookerRuntimeClient } from '../../../../../src/context/ingest/adapters/looker/fetch.js'; +import { LookerSourceAdapter } from '../../../../../src/context/ingest/adapters/looker/looker.adapter.js'; const connectionId = '11111111-1111-4111-8111-111111111111'; diff --git a/packages/cli/src/context/ingest/adapters/looker/mapping.test.ts b/packages/cli/test/context/ingest/adapters/looker/mapping.test.ts similarity index 98% rename from packages/cli/src/context/ingest/adapters/looker/mapping.test.ts rename to packages/cli/test/context/ingest/adapters/looker/mapping.test.ts index c7b29b8a..0ac9c067 100644 --- a/packages/cli/src/context/ingest/adapters/looker/mapping.test.ts +++ b/packages/cli/test/context/ingest/adapters/looker/mapping.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it, vi } from 'vitest'; -import type { StagedExploreFile, StagedLookmlModelsFile } from './types.js'; +import type { StagedExploreFile, StagedLookmlModelsFile } from '../../../../../src/context/ingest/adapters/looker/types.js'; import { buildLookerPullConfigFromInputs, collectExploreParseItems, @@ -12,7 +12,7 @@ import { suggestKtxConnectionForLookerConnection, validateLookerMappings, validateLookerWarehouseTarget, -} from './mapping.js'; +} from '../../../../../src/context/ingest/adapters/looker/mapping.js'; const liveConnections = [ { diff --git a/packages/cli/src/context/ingest/adapters/looker/reconcile.test.ts b/packages/cli/test/context/ingest/adapters/looker/reconcile.test.ts similarity index 84% rename from packages/cli/src/context/ingest/adapters/looker/reconcile.test.ts rename to packages/cli/test/context/ingest/adapters/looker/reconcile.test.ts index 09e8685f..2ffcdaeb 100644 --- a/packages/cli/src/context/ingest/adapters/looker/reconcile.test.ts +++ b/packages/cli/test/context/ingest/adapters/looker/reconcile.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { buildLookerReconcileNotes } from './reconcile.js'; +import { buildLookerReconcileNotes } from '../../../../../src/context/ingest/adapters/looker/reconcile.js'; describe('buildLookerReconcileNotes', () => { it('instructs reconciliation to record subsumed provenance', () => { diff --git a/packages/cli/src/context/ingest/adapters/looker/scope.test.ts b/packages/cli/test/context/ingest/adapters/looker/scope.test.ts similarity index 98% rename from packages/cli/src/context/ingest/adapters/looker/scope.test.ts rename to packages/cli/test/context/ingest/adapters/looker/scope.test.ts index d7c2c56e..55592761 100644 --- a/packages/cli/src/context/ingest/adapters/looker/scope.test.ts +++ b/packages/cli/test/context/ingest/adapters/looker/scope.test.ts @@ -2,7 +2,7 @@ import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; -import { describeLookerScope, hashLookerScope, isPathInLookerScope } from './scope.js'; +import { describeLookerScope, hashLookerScope, isPathInLookerScope } from '../../../../../src/context/ingest/adapters/looker/scope.js'; async function writeJson(stagedDir: string, relPath: string, value: unknown): Promise { const abs = join(stagedDir, relPath); diff --git a/packages/cli/src/context/ingest/adapters/looker/target-connections.test.ts b/packages/cli/test/context/ingest/adapters/looker/target-connections.test.ts similarity index 95% rename from packages/cli/src/context/ingest/adapters/looker/target-connections.test.ts rename to packages/cli/test/context/ingest/adapters/looker/target-connections.test.ts index 10b2d892..5497914d 100644 --- a/packages/cli/src/context/ingest/adapters/looker/target-connections.test.ts +++ b/packages/cli/test/context/ingest/adapters/looker/target-connections.test.ts @@ -2,7 +2,7 @@ import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; -import { listLookerTargetConnectionIds } from './target-connections.js'; +import { listLookerTargetConnectionIds } from '../../../../../src/context/ingest/adapters/looker/target-connections.js'; describe('listLookerTargetConnectionIds', () => { let stagedDir: string; diff --git a/packages/cli/src/context/ingest/adapters/looker/tools/looker-query-to-sl.tool.test.ts b/packages/cli/test/context/ingest/adapters/looker/tools/looker-query-to-sl.tool.test.ts similarity index 97% rename from packages/cli/src/context/ingest/adapters/looker/tools/looker-query-to-sl.tool.test.ts rename to packages/cli/test/context/ingest/adapters/looker/tools/looker-query-to-sl.tool.test.ts index d4cd857b..2f8dc4a4 100644 --- a/packages/cli/src/context/ingest/adapters/looker/tools/looker-query-to-sl.tool.test.ts +++ b/packages/cli/test/context/ingest/adapters/looker/tools/looker-query-to-sl.tool.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; -import type { ToolOutput } from '../../../../../context/tools/base-tool.js'; -import { buildLookerSlProposal, createLookerQueryToSlTool, type LookerSlProposal } from './looker-query-to-sl.tool.js'; +import type { ToolOutput } from '../../../../../../src/context/tools/base-tool.js'; +import { buildLookerSlProposal, createLookerQueryToSlTool, type LookerSlProposal } from '../../../../../../src/context/ingest/adapters/looker/tools/looker-query-to-sl.tool.js'; describe('buildLookerSlProposal', () => { it('suggests a measure and segment for an aggregated filtered Looker query', () => { diff --git a/packages/cli/src/context/ingest/adapters/looker/types.test.ts b/packages/cli/test/context/ingest/adapters/looker/types.test.ts similarity index 98% rename from packages/cli/src/context/ingest/adapters/looker/types.test.ts rename to packages/cli/test/context/ingest/adapters/looker/types.test.ts index 2a4c2b8c..113f9fe3 100644 --- a/packages/cli/src/context/ingest/adapters/looker/types.test.ts +++ b/packages/cli/test/context/ingest/adapters/looker/types.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { parsedTargetTableSchema } from '../../parsed-target-table.js'; +import { parsedTargetTableSchema } from '../../../../../src/context/ingest/parsed-target-table.js'; import { lookerPullConfigSchema, parseLookerPullConfig, @@ -11,7 +11,7 @@ import { stagedLookerSignalsFileSchema, stagedLookFileSchema, stagedSyncConfigSchema, -} from './types.js'; +} from '../../../../../src/context/ingest/adapters/looker/types.js'; describe('Looker staged runtime schemas', () => { it('parses pull config and staged sync config', () => { diff --git a/packages/cli/src/context/ingest/adapters/lookml/chunk.test.ts b/packages/cli/test/context/ingest/adapters/lookml/chunk.test.ts similarity index 97% rename from packages/cli/src/context/ingest/adapters/lookml/chunk.test.ts rename to packages/cli/test/context/ingest/adapters/lookml/chunk.test.ts index e9a8b5f3..5898a2a9 100644 --- a/packages/cli/src/context/ingest/adapters/lookml/chunk.test.ts +++ b/packages/cli/test/context/ingest/adapters/lookml/chunk.test.ts @@ -1,9 +1,9 @@ import { join } from 'node:path'; import { describe, expect, it } from 'vitest'; -import { chunkLookmlProject } from './chunk.js'; -import { type ParsedLookmlProject, parseLookmlStagedDir } from './parse.js'; +import { chunkLookmlProject } from '../../../../../src/context/ingest/adapters/lookml/chunk.js'; +import { type ParsedLookmlProject, parseLookmlStagedDir } from '../../../../../src/context/ingest/adapters/lookml/parse.js'; -const FIXTURE_ROOT = join(__dirname, '../../../../test/fixtures/lookml'); +const FIXTURE_ROOT = join(__dirname, '../../../../fixtures/lookml'); describe('chunkLookmlProject — first run', () => { it('single-model bundle → 1 WU with model + all views in rawFiles', async () => { diff --git a/packages/cli/src/context/ingest/adapters/lookml/detect.test.ts b/packages/cli/test/context/ingest/adapters/lookml/detect.test.ts similarity index 94% rename from packages/cli/src/context/ingest/adapters/lookml/detect.test.ts rename to packages/cli/test/context/ingest/adapters/lookml/detect.test.ts index 040c1788..12640d07 100644 --- a/packages/cli/src/context/ingest/adapters/lookml/detect.test.ts +++ b/packages/cli/test/context/ingest/adapters/lookml/detect.test.ts @@ -2,7 +2,7 @@ import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; -import { detectLookmlStagedDir } from './detect.js'; +import { detectLookmlStagedDir } from '../../../../../src/context/ingest/adapters/lookml/detect.js'; describe('detectLookmlStagedDir', () => { let stagedDir: string; diff --git a/packages/cli/src/context/ingest/adapters/lookml/fetch-report.test.ts b/packages/cli/test/context/ingest/adapters/lookml/fetch-report.test.ts similarity index 95% rename from packages/cli/src/context/ingest/adapters/lookml/fetch-report.test.ts rename to packages/cli/test/context/ingest/adapters/lookml/fetch-report.test.ts index ffeb52fb..4aa91e82 100644 --- a/packages/cli/src/context/ingest/adapters/lookml/fetch-report.test.ts +++ b/packages/cli/test/context/ingest/adapters/lookml/fetch-report.test.ts @@ -2,7 +2,7 @@ import { mkdtemp, readFile, rm } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; -import type { ParsedLookmlProject } from './parse.js'; +import type { ParsedLookmlProject } from '../../../../../src/context/ingest/adapters/lookml/parse.js'; import { LOOKML_FETCH_REPORT_FILE, LOOKML_MISMATCHED_MODELS_FILE, @@ -10,7 +10,7 @@ import { readLookmlFetchReport, readLookmlMismatchedModelNames, writeLookmlValidationArtifacts, -} from './fetch-report.js'; +} from '../../../../../src/context/ingest/adapters/lookml/fetch-report.js'; function project(models: ParsedLookmlProject['models']): ParsedLookmlProject { return { models, views: [], dashboards: [], allPaths: models.map((m) => m.path) }; diff --git a/packages/cli/src/context/ingest/adapters/lookml/fetch.test.ts b/packages/cli/test/context/ingest/adapters/lookml/fetch.test.ts similarity index 95% rename from packages/cli/src/context/ingest/adapters/lookml/fetch.test.ts rename to packages/cli/test/context/ingest/adapters/lookml/fetch.test.ts index a0c293e7..05ee9bfc 100644 --- a/packages/cli/src/context/ingest/adapters/lookml/fetch.test.ts +++ b/packages/cli/test/context/ingest/adapters/lookml/fetch.test.ts @@ -3,10 +3,10 @@ import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { makeLocalGitRepo } from '../../../test/make-local-git-repo.js'; -import { fetchLookmlRepo } from './fetch.js'; -import type { LookmlPullConfig } from './pull-config.js'; +import { fetchLookmlRepo } from '../../../../../src/context/ingest/adapters/lookml/fetch.js'; +import type { LookmlPullConfig } from '../../../../../src/context/ingest/adapters/lookml/pull-config.js'; -const FIXTURE_ROOT = join(__dirname, '../../../../test/fixtures/lookml'); +const FIXTURE_ROOT = join(__dirname, '../../../../fixtures/lookml'); function pullConfig(overrides: Partial & Pick): LookmlPullConfig { return { diff --git a/packages/cli/src/context/ingest/adapters/lookml/graph.test.ts b/packages/cli/test/context/ingest/adapters/lookml/graph.test.ts similarity index 96% rename from packages/cli/src/context/ingest/adapters/lookml/graph.test.ts rename to packages/cli/test/context/ingest/adapters/lookml/graph.test.ts index c1efd701..d0df93e3 100644 --- a/packages/cli/src/context/ingest/adapters/lookml/graph.test.ts +++ b/packages/cli/test/context/ingest/adapters/lookml/graph.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; -import { buildLookmlGraph } from './graph.js'; -import type { ParsedLookmlProject } from './parse.js'; +import { buildLookmlGraph } from '../../../../../src/context/ingest/adapters/lookml/graph.js'; +import type { ParsedLookmlProject } from '../../../../../src/context/ingest/adapters/lookml/parse.js'; type LooseParsedLookmlProject = Omit, 'models' | 'views'> & { models?: Array & { connectionName?: string | null }>; diff --git a/packages/cli/src/context/ingest/adapters/lookml/lookml.adapter.test.ts b/packages/cli/test/context/ingest/adapters/lookml/lookml.adapter.test.ts similarity index 92% rename from packages/cli/src/context/ingest/adapters/lookml/lookml.adapter.test.ts rename to packages/cli/test/context/ingest/adapters/lookml/lookml.adapter.test.ts index d22597b9..0d23cc95 100644 --- a/packages/cli/src/context/ingest/adapters/lookml/lookml.adapter.test.ts +++ b/packages/cli/test/context/ingest/adapters/lookml/lookml.adapter.test.ts @@ -3,8 +3,8 @@ import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { makeLocalGitRepo } from '../../../test/make-local-git-repo.js'; -import { LOOKML_FETCH_REPORT_FILE } from './fetch-report.js'; -import { LookmlSourceAdapter } from './lookml.adapter.js'; +import { LOOKML_FETCH_REPORT_FILE } from '../../../../../src/context/ingest/adapters/lookml/fetch-report.js'; +import { LookmlSourceAdapter } from '../../../../../src/context/ingest/adapters/lookml/lookml.adapter.js'; describe('LookmlSourceAdapter validation sidecars', () => { let tmpRoot: string; diff --git a/packages/cli/src/context/ingest/adapters/lookml/parse.test.ts b/packages/cli/test/context/ingest/adapters/lookml/parse.test.ts similarity index 97% rename from packages/cli/src/context/ingest/adapters/lookml/parse.test.ts rename to packages/cli/test/context/ingest/adapters/lookml/parse.test.ts index 84ce5b5a..5372dc63 100644 --- a/packages/cli/src/context/ingest/adapters/lookml/parse.test.ts +++ b/packages/cli/test/context/ingest/adapters/lookml/parse.test.ts @@ -2,7 +2,7 @@ import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; -import { parseLookmlStagedDir } from './parse.js'; +import { parseLookmlStagedDir } from '../../../../../src/context/ingest/adapters/lookml/parse.js'; describe('parseLookmlStagedDir', () => { let stagedDir: string; diff --git a/packages/cli/src/context/ingest/adapters/lookml/pull-config.test.ts b/packages/cli/test/context/ingest/adapters/lookml/pull-config.test.ts similarity index 98% rename from packages/cli/src/context/ingest/adapters/lookml/pull-config.test.ts rename to packages/cli/test/context/ingest/adapters/lookml/pull-config.test.ts index 2edec99f..34cd9d8e 100644 --- a/packages/cli/src/context/ingest/adapters/lookml/pull-config.test.ts +++ b/packages/cli/test/context/ingest/adapters/lookml/pull-config.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { parseLookmlPullConfig, pullConfigFromIntegrationConfig } from './pull-config.js'; +import { parseLookmlPullConfig, pullConfigFromIntegrationConfig } from '../../../../../src/context/ingest/adapters/lookml/pull-config.js'; describe('lookml pull config', () => { it('parses a minimal valid config with defaulted branch', () => { diff --git a/packages/cli/src/context/ingest/adapters/metabase/card-references.test.ts b/packages/cli/test/context/ingest/adapters/metabase/card-references.test.ts similarity index 96% rename from packages/cli/src/context/ingest/adapters/metabase/card-references.test.ts rename to packages/cli/test/context/ingest/adapters/metabase/card-references.test.ts index 8c179710..f619490f 100644 --- a/packages/cli/src/context/ingest/adapters/metabase/card-references.test.ts +++ b/packages/cli/test/context/ingest/adapters/metabase/card-references.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { CardReferenceCycleError, expandCardReferences } from './card-references.js'; +import { CardReferenceCycleError, expandCardReferences } from '../../../../../src/context/ingest/adapters/metabase/card-references.js'; describe('expandCardReferences', () => { const fetchCard = (id: number): Promise<{ native_query: string }> => { diff --git a/packages/cli/src/context/ingest/adapters/metabase/chunk.test.ts b/packages/cli/test/context/ingest/adapters/metabase/chunk.test.ts similarity index 97% rename from packages/cli/src/context/ingest/adapters/metabase/chunk.test.ts rename to packages/cli/test/context/ingest/adapters/metabase/chunk.test.ts index 1991e147..333faf26 100644 --- a/packages/cli/src/context/ingest/adapters/metabase/chunk.test.ts +++ b/packages/cli/test/context/ingest/adapters/metabase/chunk.test.ts @@ -2,10 +2,10 @@ import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join, resolve } from 'node:path'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; -import { chunkMetabaseStagedDir } from './chunk.js'; -import { stagedSyncConfigSchema } from './types.js'; +import { chunkMetabaseStagedDir } from '../../../../../src/context/ingest/adapters/metabase/chunk.js'; +import { stagedSyncConfigSchema } from '../../../../../src/context/ingest/adapters/metabase/types.js'; -const FIXTURES = resolve(__dirname, '../../../../test/fixtures/metabase'); +const FIXTURES = resolve(__dirname, '../../../../fixtures/metabase'); const SIMPLE = join(FIXTURES, 'simple'); const MULTI = join(FIXTURES, 'multi-collection'); const CARD_REF = join(FIXTURES, 'card-ref'); diff --git a/packages/cli/src/context/ingest/adapters/metabase/client-boundary.test.ts b/packages/cli/test/context/ingest/adapters/metabase/client-boundary.test.ts similarity index 92% rename from packages/cli/src/context/ingest/adapters/metabase/client-boundary.test.ts rename to packages/cli/test/context/ingest/adapters/metabase/client-boundary.test.ts index 7df6691e..7e8a0e2e 100644 --- a/packages/cli/src/context/ingest/adapters/metabase/client-boundary.test.ts +++ b/packages/cli/test/context/ingest/adapters/metabase/client-boundary.test.ts @@ -1,9 +1,9 @@ import { readFile } from 'node:fs/promises'; -import { dirname, join } from 'node:path'; +import { join } from 'node:path'; import { fileURLToPath } from 'node:url'; import { describe, expect, it } from 'vitest'; -const metabaseDir = dirname(fileURLToPath(import.meta.url)); +const metabaseDir = fileURLToPath(new URL('../../../../../src/context/ingest/adapters/metabase/', import.meta.url)); async function readMetabaseFile(name: string): Promise { return readFile(join(metabaseDir, name), 'utf-8'); diff --git a/packages/cli/src/context/ingest/adapters/metabase/client-port.test.ts b/packages/cli/test/context/ingest/adapters/metabase/client-port.test.ts similarity index 92% rename from packages/cli/src/context/ingest/adapters/metabase/client-port.test.ts rename to packages/cli/test/context/ingest/adapters/metabase/client-port.test.ts index 8f775b56..3a9e2732 100644 --- a/packages/cli/src/context/ingest/adapters/metabase/client-port.test.ts +++ b/packages/cli/test/context/ingest/adapters/metabase/client-port.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it, vi } from 'vitest'; -import type { FetchContext } from '../../types.js'; +import type { FetchContext } from '../../../../../src/context/ingest/types.js'; import { IngestMetabaseClientFactory, type MetabaseCard, @@ -8,8 +8,8 @@ import { type MetabaseRuntimeClient, type MetabaseTemplateTag, type TestConnectionResult, -} from './client-port.js'; -import type { MetabasePullConfig } from './types.js'; +} from '../../../../../src/context/ingest/adapters/metabase/client-port.js'; +import type { MetabasePullConfig } from '../../../../../src/context/ingest/adapters/metabase/types.js'; function makeRuntimeClient(): MetabaseRuntimeClient { return { diff --git a/packages/cli/src/context/ingest/adapters/metabase/client.test.ts b/packages/cli/test/context/ingest/adapters/metabase/client.test.ts similarity index 98% rename from packages/cli/src/context/ingest/adapters/metabase/client.test.ts rename to packages/cli/test/context/ingest/adapters/metabase/client.test.ts index 3d45a276..5ab2fd09 100644 --- a/packages/cli/src/context/ingest/adapters/metabase/client.test.ts +++ b/packages/cli/test/context/ingest/adapters/metabase/client.test.ts @@ -5,8 +5,8 @@ import { getDummyValueForWidgetType, MetabaseClient, stripOptionalClauses, -} from './client.js'; -import type { MetabaseCard, MetabaseTemplateTag } from './client-port.js'; +} from '../../../../../src/context/ingest/adapters/metabase/client.js'; +import type { MetabaseCard, MetabaseTemplateTag } from '../../../../../src/context/ingest/adapters/metabase/client-port.js'; const runtime = { apiUrl: 'https://metabase.example.test/api', diff --git a/packages/cli/src/context/ingest/adapters/metabase/detect.test.ts b/packages/cli/test/context/ingest/adapters/metabase/detect.test.ts similarity index 94% rename from packages/cli/src/context/ingest/adapters/metabase/detect.test.ts rename to packages/cli/test/context/ingest/adapters/metabase/detect.test.ts index 816bbef6..44abe951 100644 --- a/packages/cli/src/context/ingest/adapters/metabase/detect.test.ts +++ b/packages/cli/test/context/ingest/adapters/metabase/detect.test.ts @@ -2,7 +2,7 @@ import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; -import { detectMetabaseStagedDir } from './detect.js'; +import { detectMetabaseStagedDir } from '../../../../../src/context/ingest/adapters/metabase/detect.js'; async function touch(stagedDir: string, relPath: string, body: string): Promise { const abs = join(stagedDir, relPath); diff --git a/packages/cli/src/context/ingest/adapters/metabase/fanout-planner.test.ts b/packages/cli/test/context/ingest/adapters/metabase/fanout-planner.test.ts similarity index 94% rename from packages/cli/src/context/ingest/adapters/metabase/fanout-planner.test.ts rename to packages/cli/test/context/ingest/adapters/metabase/fanout-planner.test.ts index cb275472..d1e9e7e5 100644 --- a/packages/cli/src/context/ingest/adapters/metabase/fanout-planner.test.ts +++ b/packages/cli/test/context/ingest/adapters/metabase/fanout-planner.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { planMetabaseFanoutChildren } from './fanout-planner.js'; +import { planMetabaseFanoutChildren } from '../../../../../src/context/ingest/adapters/metabase/fanout-planner.js'; describe('planMetabaseFanoutChildren', () => { it('builds ordered child plans for sync-enabled mapped Metabase databases', () => { diff --git a/packages/cli/src/context/ingest/adapters/metabase/fetch-scope.test.ts b/packages/cli/test/context/ingest/adapters/metabase/fetch-scope.test.ts similarity index 96% rename from packages/cli/src/context/ingest/adapters/metabase/fetch-scope.test.ts rename to packages/cli/test/context/ingest/adapters/metabase/fetch-scope.test.ts index 9768c0c9..e2b1c6e7 100644 --- a/packages/cli/src/context/ingest/adapters/metabase/fetch-scope.test.ts +++ b/packages/cli/test/context/ingest/adapters/metabase/fetch-scope.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; -import { computeFetchScope, type FetchScope, hashScope, isPathInMetabaseScope } from './fetch-scope.js'; -import type { StagedSyncConfig } from './types.js'; +import { computeFetchScope, type FetchScope, hashScope, isPathInMetabaseScope } from '../../../../../src/context/ingest/adapters/metabase/fetch-scope.js'; +import type { StagedSyncConfig } from '../../../../../src/context/ingest/adapters/metabase/types.js'; const BASE_CONFIG = { metabaseConnectionId: 'a1b2c3d4-e5f6-4789-9abc-def012345678', diff --git a/packages/cli/src/context/ingest/adapters/metabase/fetch.test.ts b/packages/cli/test/context/ingest/adapters/metabase/fetch.test.ts similarity index 99% rename from packages/cli/src/context/ingest/adapters/metabase/fetch.test.ts rename to packages/cli/test/context/ingest/adapters/metabase/fetch.test.ts index 1f93765e..4e067663 100644 --- a/packages/cli/src/context/ingest/adapters/metabase/fetch.test.ts +++ b/packages/cli/test/context/ingest/adapters/metabase/fetch.test.ts @@ -2,8 +2,8 @@ import { mkdtemp, readdir, readFile, rm } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import type { FetchContext } from '../../types.js'; -import { fetchMetabaseBundle } from './fetch.js'; +import type { FetchContext } from '../../../../../src/context/ingest/types.js'; +import { fetchMetabaseBundle } from '../../../../../src/context/ingest/adapters/metabase/fetch.js'; const metabaseConnectionId = 'a1b2c3d4-e5f6-4789-9abc-def012345678'; const targetConnectionId = 'b2c3d4e5-f6a7-4890-abcd-ef0123456789'; diff --git a/packages/cli/src/context/ingest/adapters/metabase/local-metabase.adapter.test.ts b/packages/cli/test/context/ingest/adapters/metabase/local-metabase.adapter.test.ts similarity index 91% rename from packages/cli/src/context/ingest/adapters/metabase/local-metabase.adapter.test.ts rename to packages/cli/test/context/ingest/adapters/metabase/local-metabase.adapter.test.ts index c20a65ac..1f860557 100644 --- a/packages/cli/src/context/ingest/adapters/metabase/local-metabase.adapter.test.ts +++ b/packages/cli/test/context/ingest/adapters/metabase/local-metabase.adapter.test.ts @@ -2,8 +2,8 @@ import { mkdtemp, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; -import type { KtxProjectConnectionConfig } from '../../../../context/project/config.js'; -import { metabaseRuntimeConfigFromLocalConnection } from './local-metabase.adapter.js'; +import type { KtxProjectConnectionConfig } from '../../../../../src/context/project/config.js'; +import { metabaseRuntimeConfigFromLocalConnection } from '../../../../../src/context/ingest/adapters/metabase/local-metabase.adapter.js'; describe('metabaseRuntimeConfigFromLocalConnection', () => { let tempDir: string; diff --git a/packages/cli/src/context/ingest/adapters/metabase/local-source-state-store.test.ts b/packages/cli/test/context/ingest/adapters/metabase/local-source-state-store.test.ts similarity index 93% rename from packages/cli/src/context/ingest/adapters/metabase/local-source-state-store.test.ts rename to packages/cli/test/context/ingest/adapters/metabase/local-source-state-store.test.ts index 0225f398..3f80f7af 100644 --- a/packages/cli/src/context/ingest/adapters/metabase/local-source-state-store.test.ts +++ b/packages/cli/test/context/ingest/adapters/metabase/local-source-state-store.test.ts @@ -2,9 +2,9 @@ import { mkdtemp, rm } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; -import { buildDefaultKtxProjectConfig } from '../../../../context/project/config.js'; -import { connectionConfigSchema } from '../../../project/driver-schemas.js'; -import { KtxYamlMetabaseSourceStateReader, LocalMetabaseDiscoveryCache } from './local-source-state-store.js'; +import { buildDefaultKtxProjectConfig } from '../../../../../src/context/project/config.js'; +import { connectionConfigSchema } from '../../../../../src/context/project/driver-schemas.js'; +import { KtxYamlMetabaseSourceStateReader, LocalMetabaseDiscoveryCache } from '../../../../../src/context/ingest/adapters/metabase/local-source-state-store.js'; describe('Metabase YAML source state and discovery cache', () => { let tempDir: string; diff --git a/packages/cli/src/context/ingest/adapters/metabase/mapping.test.ts b/packages/cli/test/context/ingest/adapters/metabase/mapping.test.ts similarity index 98% rename from packages/cli/src/context/ingest/adapters/metabase/mapping.test.ts rename to packages/cli/test/context/ingest/adapters/metabase/mapping.test.ts index e347390c..b6f1f354 100644 --- a/packages/cli/src/context/ingest/adapters/metabase/mapping.test.ts +++ b/packages/cli/test/context/ingest/adapters/metabase/mapping.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it, vi } from 'vitest'; -import type { MetabaseRuntimeClient } from './client-port.js'; +import type { MetabaseRuntimeClient } from '../../../../../src/context/ingest/adapters/metabase/client-port.js'; import { METABASE_ENGINE_TO_CONNECTION_TYPE, computeMetabaseMappingDrift, @@ -9,7 +9,7 @@ import { refreshMetabaseMapping, validateMappingPhysicalMatch, validateMetabaseMappings, -} from './mapping.js'; +} from '../../../../../src/context/ingest/adapters/metabase/mapping.js'; describe('discoverMetabaseDatabases', () => { it('filters sample databases and extracts host plus database names from Metabase details', async () => { diff --git a/packages/cli/src/context/ingest/adapters/metabase/metabase.adapter.test.ts b/packages/cli/test/context/ingest/adapters/metabase/metabase.adapter.test.ts similarity index 97% rename from packages/cli/src/context/ingest/adapters/metabase/metabase.adapter.test.ts rename to packages/cli/test/context/ingest/adapters/metabase/metabase.adapter.test.ts index a22c1f3b..a22e8b28 100644 --- a/packages/cli/src/context/ingest/adapters/metabase/metabase.adapter.test.ts +++ b/packages/cli/test/context/ingest/adapters/metabase/metabase.adapter.test.ts @@ -2,7 +2,7 @@ import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { MetabaseSourceAdapter } from './metabase.adapter.js'; +import { MetabaseSourceAdapter } from '../../../../../src/context/ingest/adapters/metabase/metabase.adapter.js'; describe('MetabaseSourceAdapter', () => { let stagedDir: string; diff --git a/packages/cli/src/context/ingest/adapters/metabase/serialize-card.test.ts b/packages/cli/test/context/ingest/adapters/metabase/serialize-card.test.ts similarity index 98% rename from packages/cli/src/context/ingest/adapters/metabase/serialize-card.test.ts rename to packages/cli/test/context/ingest/adapters/metabase/serialize-card.test.ts index ff10ce59..c743b74d 100644 --- a/packages/cli/src/context/ingest/adapters/metabase/serialize-card.test.ts +++ b/packages/cli/test/context/ingest/adapters/metabase/serialize-card.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { extractReferencedCardIds, serializeCard } from './serialize-card.js'; +import { extractReferencedCardIds, serializeCard } from '../../../../../src/context/ingest/adapters/metabase/serialize-card.js'; describe('extractReferencedCardIds', () => { it('pulls ids out of template tags with type=card', () => { diff --git a/packages/cli/src/context/ingest/adapters/metabase/types.test.ts b/packages/cli/test/context/ingest/adapters/metabase/types.test.ts similarity index 97% rename from packages/cli/src/context/ingest/adapters/metabase/types.test.ts rename to packages/cli/test/context/ingest/adapters/metabase/types.test.ts index 4a445d89..fd92090b 100644 --- a/packages/cli/src/context/ingest/adapters/metabase/types.test.ts +++ b/packages/cli/test/context/ingest/adapters/metabase/types.test.ts @@ -4,7 +4,7 @@ import { parseMetabasePullConfig, stagedCardFileSchema, stagedSyncConfigSchema, -} from './types.js'; +} from '../../../../../src/context/ingest/adapters/metabase/types.js'; describe('metabase adapter types', () => { it('parses a valid MetabasePullConfig', () => { diff --git a/packages/cli/src/context/ingest/adapters/metricflow/chunk.test.ts b/packages/cli/test/context/ingest/adapters/metricflow/chunk.test.ts similarity index 94% rename from packages/cli/src/context/ingest/adapters/metricflow/chunk.test.ts rename to packages/cli/test/context/ingest/adapters/metricflow/chunk.test.ts index 88062fb3..b783087b 100644 --- a/packages/cli/src/context/ingest/adapters/metricflow/chunk.test.ts +++ b/packages/cli/test/context/ingest/adapters/metricflow/chunk.test.ts @@ -1,9 +1,9 @@ import { join, resolve } from 'node:path'; import { describe, expect, it } from 'vitest'; -import { chunkMetricFlowProject } from './chunk.js'; -import { parseMetricFlowStagedDir } from './parse.js'; +import { chunkMetricFlowProject } from '../../../../../src/context/ingest/adapters/metricflow/chunk.js'; +import { parseMetricFlowStagedDir } from '../../../../../src/context/ingest/adapters/metricflow/parse.js'; -const FIXTURES = resolve(__dirname, '../../../../test/fixtures/metricflow'); +const FIXTURES = resolve(__dirname, '../../../../fixtures/metricflow'); const SINGLE = join(FIXTURES, 'single-model'); const EXTENDS_CHAIN = join(FIXTURES, 'extends-chain'); const MULTI = join(FIXTURES, 'multi-component'); diff --git a/packages/cli/src/context/ingest/adapters/metricflow/deep-parse.test.ts b/packages/cli/test/context/ingest/adapters/metricflow/deep-parse.test.ts similarity index 99% rename from packages/cli/src/context/ingest/adapters/metricflow/deep-parse.test.ts rename to packages/cli/test/context/ingest/adapters/metricflow/deep-parse.test.ts index 8896db68..e6747db1 100644 --- a/packages/cli/src/context/ingest/adapters/metricflow/deep-parse.test.ts +++ b/packages/cli/test/context/ingest/adapters/metricflow/deep-parse.test.ts @@ -1,5 +1,5 @@ import { beforeEach, describe, expect, it } from 'vitest'; -import { parseMetricflowFiles, translateMetricflowJinjaFilter } from './deep-parse.js'; +import { parseMetricflowFiles, translateMetricflowJinjaFilter } from '../../../../../src/context/ingest/adapters/metricflow/deep-parse.js'; function yaml(strings: TemplateStringsArray, ...values: unknown[]): string { return String.raw(strings, ...values); diff --git a/packages/cli/src/context/ingest/adapters/metricflow/detect.test.ts b/packages/cli/test/context/ingest/adapters/metricflow/detect.test.ts similarity index 94% rename from packages/cli/src/context/ingest/adapters/metricflow/detect.test.ts rename to packages/cli/test/context/ingest/adapters/metricflow/detect.test.ts index a8df434f..77923eb7 100644 --- a/packages/cli/src/context/ingest/adapters/metricflow/detect.test.ts +++ b/packages/cli/test/context/ingest/adapters/metricflow/detect.test.ts @@ -2,7 +2,7 @@ import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; -import { detectMetricFlowStagedDir } from './detect.js'; +import { detectMetricFlowStagedDir } from '../../../../../src/context/ingest/adapters/metricflow/detect.js'; async function touch(stagedDir: string, relPath: string, body = ''): Promise { const abs = join(stagedDir, relPath); diff --git a/packages/cli/src/context/ingest/adapters/metricflow/fetch.test.ts b/packages/cli/test/context/ingest/adapters/metricflow/fetch.test.ts similarity index 97% rename from packages/cli/src/context/ingest/adapters/metricflow/fetch.test.ts rename to packages/cli/test/context/ingest/adapters/metricflow/fetch.test.ts index 70568be2..0fb67072 100644 --- a/packages/cli/src/context/ingest/adapters/metricflow/fetch.test.ts +++ b/packages/cli/test/context/ingest/adapters/metricflow/fetch.test.ts @@ -3,7 +3,7 @@ import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { makeLocalGitRepo } from '../../../test/make-local-git-repo.js'; -import { fetchMetricflowRepo } from './fetch.js'; +import { fetchMetricflowRepo } from '../../../../../src/context/ingest/adapters/metricflow/fetch.js'; async function exists(path: string): Promise { try { diff --git a/packages/cli/src/context/ingest/adapters/metricflow/graph.test.ts b/packages/cli/test/context/ingest/adapters/metricflow/graph.test.ts similarity index 97% rename from packages/cli/src/context/ingest/adapters/metricflow/graph.test.ts rename to packages/cli/test/context/ingest/adapters/metricflow/graph.test.ts index 93a3a6c6..1b252bc3 100644 --- a/packages/cli/src/context/ingest/adapters/metricflow/graph.test.ts +++ b/packages/cli/test/context/ingest/adapters/metricflow/graph.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; -import { buildMetricFlowGraph } from './graph.js'; -import type { ParsedMetricFlowProject } from './parse.js'; +import { buildMetricFlowGraph } from '../../../../../src/context/ingest/adapters/metricflow/graph.js'; +import type { ParsedMetricFlowProject } from '../../../../../src/context/ingest/adapters/metricflow/parse.js'; function project(parts: Partial): ParsedMetricFlowProject { return { diff --git a/packages/cli/src/context/ingest/adapters/metricflow/import-semantic-models.test.ts b/packages/cli/test/context/ingest/adapters/metricflow/import-semantic-models.test.ts similarity index 98% rename from packages/cli/src/context/ingest/adapters/metricflow/import-semantic-models.test.ts rename to packages/cli/test/context/ingest/adapters/metricflow/import-semantic-models.test.ts index d5a7e3c5..a247af55 100644 --- a/packages/cli/src/context/ingest/adapters/metricflow/import-semantic-models.test.ts +++ b/packages/cli/test/context/ingest/adapters/metricflow/import-semantic-models.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it, vi } from 'vitest'; -import type { MetricFlowParseResult } from './deep-parse.js'; -import { importMetricflowSemanticModels } from './import-semantic-models.js'; +import type { MetricFlowParseResult } from '../../../../../src/context/ingest/adapters/metricflow/deep-parse.js'; +import { importMetricflowSemanticModels } from '../../../../../src/context/ingest/adapters/metricflow/import-semantic-models.js'; const DBT_SYSTEM_EMAIL = ['system@kae', 'lio.dev'].join(''); diff --git a/packages/cli/src/context/ingest/adapters/metricflow/metricflow.adapter.test.ts b/packages/cli/test/context/ingest/adapters/metricflow/metricflow.adapter.test.ts similarity index 95% rename from packages/cli/src/context/ingest/adapters/metricflow/metricflow.adapter.test.ts rename to packages/cli/test/context/ingest/adapters/metricflow/metricflow.adapter.test.ts index 232624a5..099a666f 100644 --- a/packages/cli/src/context/ingest/adapters/metricflow/metricflow.adapter.test.ts +++ b/packages/cli/test/context/ingest/adapters/metricflow/metricflow.adapter.test.ts @@ -3,10 +3,10 @@ import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { makeLocalGitRepo } from '../../../test/make-local-git-repo.js'; -import type { SourceAdapter } from '../../types.js'; -import type { MetricFlowParseResult } from './deep-parse.js'; -import { MetricflowSourceAdapter } from './metricflow.adapter.js'; -import { readMetricflowProjectionConfig, writeMetricflowProjectionConfig } from './projection-config.js'; +import type { SourceAdapter } from '../../../../../src/context/ingest/types.js'; +import type { MetricFlowParseResult } from '../../../../../src/context/ingest/adapters/metricflow/deep-parse.js'; +import { MetricflowSourceAdapter } from '../../../../../src/context/ingest/adapters/metricflow/metricflow.adapter.js'; +import { readMetricflowProjectionConfig, writeMetricflowProjectionConfig } from '../../../../../src/context/ingest/adapters/metricflow/projection-config.js'; function compileOnlyRequiredDepsCheck(): void { // @ts-expect-error MetricflowSourceAdapter requires an explicit cache home. diff --git a/packages/cli/src/context/ingest/adapters/metricflow/parse.test.ts b/packages/cli/test/context/ingest/adapters/metricflow/parse.test.ts similarity index 98% rename from packages/cli/src/context/ingest/adapters/metricflow/parse.test.ts rename to packages/cli/test/context/ingest/adapters/metricflow/parse.test.ts index 72a94472..9c2e071f 100644 --- a/packages/cli/src/context/ingest/adapters/metricflow/parse.test.ts +++ b/packages/cli/test/context/ingest/adapters/metricflow/parse.test.ts @@ -2,7 +2,7 @@ import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; -import { parseMetricFlowStagedDir } from './parse.js'; +import { parseMetricFlowStagedDir } from '../../../../../src/context/ingest/adapters/metricflow/parse.js'; async function writeFixture(stagedDir: string, relPath: string, body: string): Promise { const abs = join(stagedDir, relPath); diff --git a/packages/cli/src/context/ingest/adapters/metricflow/pull-config.test.ts b/packages/cli/test/context/ingest/adapters/metricflow/pull-config.test.ts similarity index 95% rename from packages/cli/src/context/ingest/adapters/metricflow/pull-config.test.ts rename to packages/cli/test/context/ingest/adapters/metricflow/pull-config.test.ts index 5137a4e6..a12cdccd 100644 --- a/packages/cli/src/context/ingest/adapters/metricflow/pull-config.test.ts +++ b/packages/cli/test/context/ingest/adapters/metricflow/pull-config.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { parseMetricflowPullConfig, pullConfigFromMetricflowIntegration } from './pull-config.js'; +import { parseMetricflowPullConfig, pullConfigFromMetricflowIntegration } from '../../../../../src/context/ingest/adapters/metricflow/pull-config.js'; describe('metricflow pull config', () => { it('applies defaults for optional git fields', () => { diff --git a/packages/cli/src/context/ingest/adapters/metricflow/semantic-models.test.ts b/packages/cli/test/context/ingest/adapters/metricflow/semantic-models.test.ts similarity index 96% rename from packages/cli/src/context/ingest/adapters/metricflow/semantic-models.test.ts rename to packages/cli/test/context/ingest/adapters/metricflow/semantic-models.test.ts index c22ac97d..0796ff0e 100644 --- a/packages/cli/src/context/ingest/adapters/metricflow/semantic-models.test.ts +++ b/packages/cli/test/context/ingest/adapters/metricflow/semantic-models.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from 'vitest'; -import { composeOverlay } from '../../../../context/sl/semantic-layer.service.js'; -import type { SemanticLayerSource } from '../../../../context/sl/types.js'; -import type { ParsedCrossModelMetric, ParsedMetricflowRelationship, ParsedSemanticModel } from './deep-parse.js'; +import { composeOverlay } from '../../../../../src/context/sl/semantic-layer.service.js'; +import type { SemanticLayerSource } from '../../../../../src/context/sl/types.js'; +import type { ParsedCrossModelMetric, ParsedMetricflowRelationship, ParsedSemanticModel } from '../../../../../src/context/ingest/adapters/metricflow/deep-parse.js'; import { buildMetricflowColumns, buildMetricflowJoinsForModel, @@ -13,7 +13,7 @@ import { resolveMetricflowSemanticModelSourceName, rewriteMetricflowManifestJoins, toKebabCaseMetricflowName, -} from './semantic-models.js'; +} from '../../../../../src/context/ingest/adapters/metricflow/semantic-models.js'; const ordersModel: ParsedSemanticModel = { name: 'orders', diff --git a/packages/cli/src/context/ingest/adapters/notion/cluster.test.ts b/packages/cli/test/context/ingest/adapters/notion/cluster.test.ts similarity index 94% rename from packages/cli/src/context/ingest/adapters/notion/cluster.test.ts rename to packages/cli/test/context/ingest/adapters/notion/cluster.test.ts index ad41b571..ca2dfc0e 100644 --- a/packages/cli/src/context/ingest/adapters/notion/cluster.test.ts +++ b/packages/cli/test/context/ingest/adapters/notion/cluster.test.ts @@ -2,9 +2,9 @@ import { mkdir, mkdtemp, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { describe, expect, test } from 'vitest'; -import type { KtxEmbeddingPort } from '../../../core/embedding.js'; -import type { WorkUnit } from '../../types.js'; -import { clusterNotionWorkUnits, MIN_PAGES_TO_CLUSTER } from './cluster.js'; +import type { KtxEmbeddingPort } from '../../../../../src/context/core/embedding.js'; +import type { WorkUnit } from '../../../../../src/context/ingest/types.js'; +import { clusterNotionWorkUnits, MIN_PAGES_TO_CLUSTER } from '../../../../../src/context/ingest/adapters/notion/cluster.js'; function fakeEmbedding(text: string): number[] { const v = [0, 0, 0, 0]; diff --git a/packages/cli/src/context/ingest/adapters/notion/fetch.test.ts b/packages/cli/test/context/ingest/adapters/notion/fetch.test.ts similarity index 98% rename from packages/cli/src/context/ingest/adapters/notion/fetch.test.ts rename to packages/cli/test/context/ingest/adapters/notion/fetch.test.ts index b60170f7..10140074 100644 --- a/packages/cli/src/context/ingest/adapters/notion/fetch.test.ts +++ b/packages/cli/test/context/ingest/adapters/notion/fetch.test.ts @@ -2,8 +2,8 @@ import { mkdtemp, readFile, rm } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { fetchNotionSnapshot } from './fetch.js'; -import type { NotionApi } from './notion-client.js'; +import { fetchNotionSnapshot } from '../../../../../src/context/ingest/adapters/notion/fetch.js'; +import type { NotionApi } from '../../../../../src/context/ingest/adapters/notion/notion-client.js'; describe('fetchNotionSnapshot', () => { let stagedDir: string; diff --git a/packages/cli/src/context/ingest/adapters/notion/local-state-store.test.ts b/packages/cli/test/context/ingest/adapters/notion/local-state-store.test.ts similarity index 91% rename from packages/cli/src/context/ingest/adapters/notion/local-state-store.test.ts rename to packages/cli/test/context/ingest/adapters/notion/local-state-store.test.ts index 892ea6c1..da5d51c2 100644 --- a/packages/cli/src/context/ingest/adapters/notion/local-state-store.test.ts +++ b/packages/cli/test/context/ingest/adapters/notion/local-state-store.test.ts @@ -2,7 +2,7 @@ import { mkdtemp, rm } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; -import { LocalNotionRuntimeStore } from './local-state-store.js'; +import { LocalNotionRuntimeStore } from '../../../../../src/context/ingest/adapters/notion/local-state-store.js'; describe('LocalNotionRuntimeStore', () => { let tempDir: string; diff --git a/packages/cli/src/context/ingest/adapters/notion/normalize.test.ts b/packages/cli/test/context/ingest/adapters/notion/normalize.test.ts similarity index 96% rename from packages/cli/src/context/ingest/adapters/notion/normalize.test.ts rename to packages/cli/test/context/ingest/adapters/notion/normalize.test.ts index 3b90c4de..dcc4621e 100644 --- a/packages/cli/src/context/ingest/adapters/notion/normalize.test.ts +++ b/packages/cli/test/context/ingest/adapters/notion/normalize.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { normalizeNotionBlocksToMarkdown, normalizeNotionPageMetadata, propertyValueToText } from './normalize.js'; +import { normalizeNotionBlocksToMarkdown, normalizeNotionPageMetadata, propertyValueToText } from '../../../../../src/context/ingest/adapters/notion/normalize.js'; describe('Notion normalization', () => { it('converts common blocks into stable markdown', () => { diff --git a/packages/cli/src/context/ingest/adapters/notion/notion-client.test.ts b/packages/cli/test/context/ingest/adapters/notion/notion-client.test.ts similarity index 95% rename from packages/cli/src/context/ingest/adapters/notion/notion-client.test.ts rename to packages/cli/test/context/ingest/adapters/notion/notion-client.test.ts index fd3d54eb..bb3bdb0b 100644 --- a/packages/cli/src/context/ingest/adapters/notion/notion-client.test.ts +++ b/packages/cli/test/context/ingest/adapters/notion/notion-client.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it, vi } from 'vitest'; -import { retryNotionRequest } from './notion-client.js'; +import { retryNotionRequest } from '../../../../../src/context/ingest/adapters/notion/notion-client.js'; describe('Notion client retry helper', () => { it('retries rate-limited requests and then returns the response', async () => { diff --git a/packages/cli/src/context/ingest/adapters/notion/notion.adapter.test.ts b/packages/cli/test/context/ingest/adapters/notion/notion.adapter.test.ts similarity index 97% rename from packages/cli/src/context/ingest/adapters/notion/notion.adapter.test.ts rename to packages/cli/test/context/ingest/adapters/notion/notion.adapter.test.ts index 0f500d5e..e02cf10b 100644 --- a/packages/cli/src/context/ingest/adapters/notion/notion.adapter.test.ts +++ b/packages/cli/test/context/ingest/adapters/notion/notion.adapter.test.ts @@ -2,9 +2,9 @@ import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { DiffSetService } from '../../diff-set.service.js'; -import { NOTION_ORG_KNOWLEDGE_WARNING } from './chunk.js'; -import { NotionSourceAdapter } from './notion.adapter.js'; +import { DiffSetService } from '../../../../../src/context/ingest/diff-set.service.js'; +import { NOTION_ORG_KNOWLEDGE_WARNING } from '../../../../../src/context/ingest/adapters/notion/chunk.js'; +import { NotionSourceAdapter } from '../../../../../src/context/ingest/adapters/notion/notion.adapter.js'; describe('NotionSourceAdapter', () => { let stagedDir: string; diff --git a/packages/cli/src/context/ingest/artifact-gates.test.ts b/packages/cli/test/context/ingest/artifact-gates.test.ts similarity index 99% rename from packages/cli/src/context/ingest/artifact-gates.test.ts rename to packages/cli/test/context/ingest/artifact-gates.test.ts index cc786409..c93a24e5 100644 --- a/packages/cli/src/context/ingest/artifact-gates.test.ts +++ b/packages/cli/test/context/ingest/artifact-gates.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it, vi } from 'vitest'; -import { validateFinalIngestArtifacts, validateProvenanceRawPaths } from './artifact-gates.js'; +import { validateFinalIngestArtifacts, validateProvenanceRawPaths } from '../../../src/context/ingest/artifact-gates.js'; function wikiServiceWithPages( pages: Record, diff --git a/packages/cli/src/context/ingest/canonical-pins.test.ts b/packages/cli/test/context/ingest/canonical-pins.test.ts similarity index 92% rename from packages/cli/src/context/ingest/canonical-pins.test.ts rename to packages/cli/test/context/ingest/canonical-pins.test.ts index dec62360..ae645b24 100644 --- a/packages/cli/src/context/ingest/canonical-pins.test.ts +++ b/packages/cli/test/context/ingest/canonical-pins.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; -import { buildCanonicalPinsPromptBlock, type CanonicalPin, selectRelevantCanonicalPins } from './canonical-pins.js'; -import type { StageIndex } from './stages/stage-index.types.js'; +import { buildCanonicalPinsPromptBlock, type CanonicalPin, selectRelevantCanonicalPins } from '../../../src/context/ingest/canonical-pins.js'; +import type { StageIndex } from '../../../src/context/ingest/stages/stage-index.types.js'; function makeStageIndex(): StageIndex { return { diff --git a/packages/cli/src/context/ingest/clustering/kmeans.test.ts b/packages/cli/test/context/ingest/clustering/kmeans.test.ts similarity index 95% rename from packages/cli/src/context/ingest/clustering/kmeans.test.ts rename to packages/cli/test/context/ingest/clustering/kmeans.test.ts index 3cda76d1..a0346309 100644 --- a/packages/cli/src/context/ingest/clustering/kmeans.test.ts +++ b/packages/cli/test/context/ingest/clustering/kmeans.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from 'vitest'; -import { kmeans, pickK } from './kmeans.js'; +import { kmeans, pickK } from '../../../../src/context/ingest/clustering/kmeans.js'; describe('pickK', () => { test('uses ceil(N/8) heuristic clamped to [1, 10]', () => { diff --git a/packages/cli/src/context/ingest/context-candidates/candidate-dedup.service.test.ts b/packages/cli/test/context/ingest/context-candidates/candidate-dedup.service.test.ts similarity index 96% rename from packages/cli/src/context/ingest/context-candidates/candidate-dedup.service.test.ts rename to packages/cli/test/context/ingest/context-candidates/candidate-dedup.service.test.ts index a7b5520e..f69ab4db 100644 --- a/packages/cli/src/context/ingest/context-candidates/candidate-dedup.service.test.ts +++ b/packages/cli/test/context/ingest/context-candidates/candidate-dedup.service.test.ts @@ -1,8 +1,8 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; -import type { ContextCandidateForDedup } from '../ports.js'; -import { CandidateDedupService } from './candidate-dedup.service.js'; -import type { ContextCandidateStorePort } from './store.js'; -import type { ContextCandidateEmbeddingPort } from './types.js'; +import type { ContextCandidateForDedup } from '../../../../src/context/ingest/ports.js'; +import { CandidateDedupService } from '../../../../src/context/ingest/context-candidates/candidate-dedup.service.js'; +import type { ContextCandidateStorePort } from '../../../../src/context/ingest/context-candidates/store.js'; +import type { ContextCandidateEmbeddingPort } from '../../../../src/context/ingest/context-candidates/types.js'; const vector = (...values: number[]): string => JSON.stringify(values); diff --git a/packages/cli/src/context/ingest/context-candidates/context-candidate-carryforward.service.test.ts b/packages/cli/test/context/ingest/context-candidates/context-candidate-carryforward.service.test.ts similarity index 94% rename from packages/cli/src/context/ingest/context-candidates/context-candidate-carryforward.service.test.ts rename to packages/cli/test/context/ingest/context-candidates/context-candidate-carryforward.service.test.ts index df452ca7..229b4a34 100644 --- a/packages/cli/src/context/ingest/context-candidates/context-candidate-carryforward.service.test.ts +++ b/packages/cli/test/context/ingest/context-candidates/context-candidate-carryforward.service.test.ts @@ -1,8 +1,8 @@ import { createHash } from 'node:crypto'; import { describe, expect, it, vi } from 'vitest'; -import { ContextCandidateCarryforwardService } from './context-candidate-carryforward.service.js'; -import type { ContextCandidateStorePort } from './store.js'; -import type { BudgetExhaustedCandidateForCarryForward, CurrentRunEvidenceChunkForCarryForward } from './types.js'; +import { ContextCandidateCarryforwardService } from '../../../../src/context/ingest/context-candidates/context-candidate-carryforward.service.js'; +import type { ContextCandidateStorePort } from '../../../../src/context/ingest/context-candidates/store.js'; +import type { BudgetExhaustedCandidateForCarryForward, CurrentRunEvidenceChunkForCarryForward } from '../../../../src/context/ingest/context-candidates/types.js'; function candidate( overrides: Partial = {}, diff --git a/packages/cli/src/context/ingest/context-candidates/curator-pagination.service.test.ts b/packages/cli/test/context/ingest/context-candidates/curator-pagination.service.test.ts similarity index 96% rename from packages/cli/src/context/ingest/context-candidates/curator-pagination.service.test.ts rename to packages/cli/test/context/ingest/context-candidates/curator-pagination.service.test.ts index bf1876a3..7341f2e9 100644 --- a/packages/cli/src/context/ingest/context-candidates/curator-pagination.service.test.ts +++ b/packages/cli/test/context/ingest/context-candidates/curator-pagination.service.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it, vi } from 'vitest'; -import type { ContextCandidateForDedup } from '../ports.js'; -import { type CuratorPaginationInput, CuratorPaginationService } from './curator-pagination.service.js'; -import type { ContextCandidateStorePort } from './store.js'; +import type { ContextCandidateForDedup } from '../../../../src/context/ingest/ports.js'; +import { type CuratorPaginationInput, CuratorPaginationService } from '../../../../src/context/ingest/context-candidates/curator-pagination.service.js'; +import type { ContextCandidateStorePort } from '../../../../src/context/ingest/context-candidates/store.js'; const candidate = (key: string, score: number): ContextCandidateForDedup => ({ id: `id-${key}`, diff --git a/packages/cli/src/context/ingest/context-candidates/embedding-text.test.ts b/packages/cli/test/context/ingest/context-candidates/embedding-text.test.ts similarity index 78% rename from packages/cli/src/context/ingest/context-candidates/embedding-text.test.ts rename to packages/cli/test/context/ingest/context-candidates/embedding-text.test.ts index e3e2e728..65857780 100644 --- a/packages/cli/src/context/ingest/context-candidates/embedding-text.test.ts +++ b/packages/cli/test/context/ingest/context-candidates/embedding-text.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { buildContextCandidateEmbeddingText } from './embedding-text.js'; +import { buildContextCandidateEmbeddingText } from '../../../../src/context/ingest/context-candidates/embedding-text.js'; describe('buildContextCandidateEmbeddingText', () => { it('matches the existing dedup embedding input format', () => { diff --git a/packages/cli/src/context/ingest/context-candidates/store.test.ts b/packages/cli/test/context/ingest/context-candidates/store.test.ts similarity index 89% rename from packages/cli/src/context/ingest/context-candidates/store.test.ts rename to packages/cli/test/context/ingest/context-candidates/store.test.ts index 1c2311ad..3dd3cbaf 100644 --- a/packages/cli/src/context/ingest/context-candidates/store.test.ts +++ b/packages/cli/test/context/ingest/context-candidates/store.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it, vi } from 'vitest'; -import type { ContextCandidateForDedup } from '../ports.js'; -import type { ContextCandidateStorePort } from './store.js'; -import type { InsertContextCandidateInput } from './types.js'; +import type { ContextCandidateForDedup } from '../../../../src/context/ingest/ports.js'; +import type { ContextCandidateStorePort } from '../../../../src/context/ingest/context-candidates/store.js'; +import type { InsertContextCandidateInput } from '../../../../src/context/ingest/context-candidates/types.js'; const candidate: ContextCandidateForDedup = { id: 'candidate-1', diff --git a/packages/cli/src/context/ingest/context-evidence/context-evidence-index.service.test.ts b/packages/cli/test/context/ingest/context-evidence/context-evidence-index.service.test.ts similarity index 97% rename from packages/cli/src/context/ingest/context-evidence/context-evidence-index.service.test.ts rename to packages/cli/test/context/ingest/context-evidence/context-evidence-index.service.test.ts index d62c7b53..80caeec0 100644 --- a/packages/cli/src/context/ingest/context-evidence/context-evidence-index.service.test.ts +++ b/packages/cli/test/context/ingest/context-evidence/context-evidence-index.service.test.ts @@ -2,9 +2,9 @@ import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { ContextEvidenceIndexService } from './context-evidence-index.service.js'; -import type { ContextEvidenceIndexStorePort } from './store.js'; -import type { ContextEvidenceEmbeddingPort } from './types.js'; +import { ContextEvidenceIndexService } from '../../../../src/context/ingest/context-evidence/context-evidence-index.service.js'; +import type { ContextEvidenceIndexStorePort } from '../../../../src/context/ingest/context-evidence/store.js'; +import type { ContextEvidenceEmbeddingPort } from '../../../../src/context/ingest/context-evidence/types.js'; const vector384 = (first: number): number[] => [first, ...Array.from({ length: 383 }, () => 0)]; diff --git a/packages/cli/src/context/ingest/context-evidence/sqlite-context-evidence-store.test.ts b/packages/cli/test/context/ingest/context-evidence/sqlite-context-evidence-store.test.ts similarity index 98% rename from packages/cli/src/context/ingest/context-evidence/sqlite-context-evidence-store.test.ts rename to packages/cli/test/context/ingest/context-evidence/sqlite-context-evidence-store.test.ts index 6b575c00..6041c412 100644 --- a/packages/cli/src/context/ingest/context-evidence/sqlite-context-evidence-store.test.ts +++ b/packages/cli/test/context/ingest/context-evidence/sqlite-context-evidence-store.test.ts @@ -2,9 +2,9 @@ import { mkdtemp, rm } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; -import type { InsertContextCandidateInput } from '../../../context/ingest/context-candidates/types.js'; -import type { JsonValue } from '../ports.js'; -import { SqliteContextEvidenceStore } from './sqlite-context-evidence-store.js'; +import type { InsertContextCandidateInput } from '../../../../src/context/ingest/context-candidates/types.js'; +import type { JsonValue } from '../../../../src/context/ingest/ports.js'; +import { SqliteContextEvidenceStore } from '../../../../src/context/ingest/context-evidence/sqlite-context-evidence-store.js'; describe('SqliteContextEvidenceStore', () => { let tempDir: string; diff --git a/packages/cli/src/context/ingest/context-evidence/store.test.ts b/packages/cli/test/context/ingest/context-evidence/store.test.ts similarity index 92% rename from packages/cli/src/context/ingest/context-evidence/store.test.ts rename to packages/cli/test/context/ingest/context-evidence/store.test.ts index 9c2d281a..268aefec 100644 --- a/packages/cli/src/context/ingest/context-evidence/store.test.ts +++ b/packages/cli/test/context/ingest/context-evidence/store.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it, vi } from 'vitest'; -import type { ContextEvidenceIndexStorePort } from './store.js'; -import type { ReplaceContextEvidenceChunk, UpsertContextEvidenceDocument } from './types.js'; +import type { ContextEvidenceIndexStorePort } from '../../../../src/context/ingest/context-evidence/store.js'; +import type { ReplaceContextEvidenceChunk, UpsertContextEvidenceDocument } from '../../../../src/context/ingest/context-evidence/types.js'; const documentInput: UpsertContextEvidenceDocument = { runId: 'run-1', diff --git a/packages/cli/src/context/ingest/dbt-shared/project-vars.test.ts b/packages/cli/test/context/ingest/dbt-shared/project-vars.test.ts similarity index 98% rename from packages/cli/src/context/ingest/dbt-shared/project-vars.test.ts rename to packages/cli/test/context/ingest/dbt-shared/project-vars.test.ts index 3b9ed6b3..c996e7aa 100644 --- a/packages/cli/src/context/ingest/dbt-shared/project-vars.test.ts +++ b/packages/cli/test/context/ingest/dbt-shared/project-vars.test.ts @@ -7,7 +7,7 @@ import { parseProjectName, parseProjectVars, resolveJinjaVariables, -} from './project-vars.js'; +} from '../../../../src/context/ingest/dbt-shared/project-vars.js'; function entries(map: Map): Record { return Object.fromEntries([...map.entries()].sort(([a], [b]) => a.localeCompare(b))); diff --git a/packages/cli/src/context/ingest/dbt-shared/schema-files.test.ts b/packages/cli/test/context/ingest/dbt-shared/schema-files.test.ts similarity index 93% rename from packages/cli/src/context/ingest/dbt-shared/schema-files.test.ts rename to packages/cli/test/context/ingest/dbt-shared/schema-files.test.ts index f55851f6..fd39c776 100644 --- a/packages/cli/src/context/ingest/dbt-shared/schema-files.test.ts +++ b/packages/cli/test/context/ingest/dbt-shared/schema-files.test.ts @@ -2,7 +2,7 @@ import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; -import { findDbtSchemaFiles, loadDbtSchemaFiles } from './schema-files.js'; +import { findDbtSchemaFiles, loadDbtSchemaFiles } from '../../../../src/context/ingest/dbt-shared/schema-files.js'; describe('dbt shared schema files', () => { let tmpRoot: string; diff --git a/packages/cli/src/context/ingest/diff-set.service.test.ts b/packages/cli/test/context/ingest/diff-set.service.test.ts similarity index 97% rename from packages/cli/src/context/ingest/diff-set.service.test.ts rename to packages/cli/test/context/ingest/diff-set.service.test.ts index 4eb3ceaa..9f198c54 100644 --- a/packages/cli/src/context/ingest/diff-set.service.test.ts +++ b/packages/cli/test/context/ingest/diff-set.service.test.ts @@ -1,5 +1,5 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { computeDiffSetFromHashes, DiffSetService } from './diff-set.service.js'; +import { computeDiffSetFromHashes, DiffSetService } from '../../../src/context/ingest/diff-set.service.js'; function makeRepo(latest: Map) { return { diff --git a/packages/cli/src/context/ingest/final-gate-repair.test.ts b/packages/cli/test/context/ingest/final-gate-repair.test.ts similarity index 96% rename from packages/cli/src/context/ingest/final-gate-repair.test.ts rename to packages/cli/test/context/ingest/final-gate-repair.test.ts index 90ad707d..1a52442c 100644 --- a/packages/cli/src/context/ingest/final-gate-repair.test.ts +++ b/packages/cli/test/context/ingest/final-gate-repair.test.ts @@ -2,8 +2,8 @@ import { mkdir, mkdtemp, readFile, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { describe, expect, it, vi } from 'vitest'; -import { finalGateRepairPaths, repairFinalGateFailure } from './final-gate-repair.js'; -import { FileIngestTraceWriter } from './ingest-trace.js'; +import { finalGateRepairPaths, repairFinalGateFailure } from '../../../src/context/ingest/final-gate-repair.js'; +import { FileIngestTraceWriter } from '../../../src/context/ingest/ingest-trace.js'; async function makeHarness() { const root = await mkdtemp(join(tmpdir(), 'ktx-final-gate-repair-')); diff --git a/packages/cli/src/context/ingest/finalization-scope.test.ts b/packages/cli/test/context/ingest/finalization-scope.test.ts similarity index 98% rename from packages/cli/src/context/ingest/finalization-scope.test.ts rename to packages/cli/test/context/ingest/finalization-scope.test.ts index 28d0b863..02c535df 100644 --- a/packages/cli/src/context/ingest/finalization-scope.test.ts +++ b/packages/cli/test/context/ingest/finalization-scope.test.ts @@ -3,7 +3,7 @@ import { compareFinalizationDeclarations, deriveFinalizationTouchedSources, deriveFinalizationWikiPageKeys, -} from './finalization-scope.js'; +} from '../../../src/context/ingest/finalization-scope.js'; describe('deriveFinalizationWikiPageKeys', () => { it('maps changed global wiki markdown paths to page keys', () => { diff --git a/packages/cli/src/context/ingest/historic-sql-probes.test.ts b/packages/cli/test/context/ingest/historic-sql-probes.test.ts similarity index 96% rename from packages/cli/src/context/ingest/historic-sql-probes.test.ts rename to packages/cli/test/context/ingest/historic-sql-probes.test.ts index 275a84c7..542c4fde 100644 --- a/packages/cli/src/context/ingest/historic-sql-probes.test.ts +++ b/packages/cli/test/context/ingest/historic-sql-probes.test.ts @@ -1,11 +1,11 @@ import { describe, expect, it, vi } from 'vitest'; -import type { HistoricSqlDialect } from './adapters/historic-sql/types.js'; +import type { HistoricSqlDialect } from '../../../src/context/ingest/adapters/historic-sql/types.js'; import { historicSqlProbeCatalogName, runHistoricSqlReadinessProbe, type HistoricSqlProbeRunner, type HistoricSqlProbeRunnerFactoryEntry, -} from './historic-sql-probes.js'; +} from '../../../src/context/ingest/historic-sql-probes.js'; function fakeRunner( dialect: HistoricSqlDialect, diff --git a/packages/cli/src/context/ingest/historic-sql-probes/bigquery-runner.test.ts b/packages/cli/test/context/ingest/historic-sql-probes/bigquery-runner.test.ts similarity index 93% rename from packages/cli/src/context/ingest/historic-sql-probes/bigquery-runner.test.ts rename to packages/cli/test/context/ingest/historic-sql-probes/bigquery-runner.test.ts index 7a2db117..d51aaf42 100644 --- a/packages/cli/src/context/ingest/historic-sql-probes/bigquery-runner.test.ts +++ b/packages/cli/test/context/ingest/historic-sql-probes/bigquery-runner.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it, vi } from 'vitest'; -import { HistoricSqlGrantsMissingError } from '../adapters/historic-sql/errors.js'; -import { BigQueryJobsByProjectProbeRunner } from './bigquery-runner.js'; +import { HistoricSqlGrantsMissingError } from '../../../../src/context/ingest/adapters/historic-sql/errors.js'; +import { BigQueryJobsByProjectProbeRunner } from '../../../../src/context/ingest/historic-sql-probes/bigquery-runner.js'; describe('BigQueryJobsByProjectProbeRunner', () => { it('creates a region-scoped reader, runs it, and cleans up the connector', async () => { diff --git a/packages/cli/src/context/ingest/historic-sql-probes/postgres-runner.test.ts b/packages/cli/test/context/ingest/historic-sql-probes/postgres-runner.test.ts similarity index 94% rename from packages/cli/src/context/ingest/historic-sql-probes/postgres-runner.test.ts rename to packages/cli/test/context/ingest/historic-sql-probes/postgres-runner.test.ts index bcd6d187..c52443ee 100644 --- a/packages/cli/src/context/ingest/historic-sql-probes/postgres-runner.test.ts +++ b/packages/cli/test/context/ingest/historic-sql-probes/postgres-runner.test.ts @@ -3,8 +3,8 @@ import { HistoricSqlExtensionMissingError, HistoricSqlGrantsMissingError, HistoricSqlVersionUnsupportedError, -} from '../adapters/historic-sql/errors.js'; -import { PostgresPgssProbeRunner } from './postgres-runner.js'; +} from '../../../../src/context/ingest/adapters/historic-sql/errors.js'; +import { PostgresPgssProbeRunner } from '../../../../src/context/ingest/historic-sql-probes/postgres-runner.js'; describe('PostgresPgssProbeRunner', () => { it('runs the pg_stat_statements reader and cleans up the client', async () => { diff --git a/packages/cli/src/context/ingest/historic-sql-probes/snowflake-runner.test.ts b/packages/cli/test/context/ingest/historic-sql-probes/snowflake-runner.test.ts similarity index 91% rename from packages/cli/src/context/ingest/historic-sql-probes/snowflake-runner.test.ts rename to packages/cli/test/context/ingest/historic-sql-probes/snowflake-runner.test.ts index 2d6835bf..af3e73f3 100644 --- a/packages/cli/src/context/ingest/historic-sql-probes/snowflake-runner.test.ts +++ b/packages/cli/test/context/ingest/historic-sql-probes/snowflake-runner.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it, vi } from 'vitest'; -import { HistoricSqlGrantsMissingError } from '../adapters/historic-sql/errors.js'; -import { SnowflakeAccountUsageProbeRunner } from './snowflake-runner.js'; +import { HistoricSqlGrantsMissingError } from '../../../../src/context/ingest/adapters/historic-sql/errors.js'; +import { SnowflakeAccountUsageProbeRunner } from '../../../../src/context/ingest/historic-sql-probes/snowflake-runner.js'; describe('SnowflakeAccountUsageProbeRunner', () => { it('runs the account usage reader and cleans up the client', async () => { diff --git a/packages/cli/src/context/ingest/ingest-bundle.runner.isolated-diff.test.ts b/packages/cli/test/context/ingest/ingest-bundle.runner.isolated-diff.test.ts similarity index 99% rename from packages/cli/src/context/ingest/ingest-bundle.runner.isolated-diff.test.ts rename to packages/cli/test/context/ingest/ingest-bundle.runner.isolated-diff.test.ts index 0201b881..bad40098 100644 --- a/packages/cli/src/context/ingest/ingest-bundle.runner.isolated-diff.test.ts +++ b/packages/cli/test/context/ingest/ingest-bundle.runner.isolated-diff.test.ts @@ -2,12 +2,12 @@ import { mkdir, mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/promis import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { describe, expect, it, vi } from 'vitest'; -import { GitService } from '../../context/core/git.service.js'; -import { SessionWorktreeService } from '../../context/core/session-worktree.service.js'; -import { LocalGitFileStore } from '../project/local-git-file-store.js'; -import { addTouchedSlSource } from '../../context/tools/touched-sl-sources.js'; -import { IngestBundleRunner } from './ingest-bundle.runner.js'; -import type { IngestBundleRunnerDeps } from './ports.js'; +import { GitService } from '../../../src/context/core/git.service.js'; +import { SessionWorktreeService } from '../../../src/context/core/session-worktree.service.js'; +import { LocalGitFileStore } from '../../../src/context/project/local-git-file-store.js'; +import { addTouchedSlSource } from '../../../src/context/tools/touched-sl-sources.js'; +import { IngestBundleRunner } from '../../../src/context/ingest/ingest-bundle.runner.js'; +import type { IngestBundleRunnerDeps } from '../../../src/context/ingest/ports.js'; async function makeRealGitRuntime() { const homeDir = await mkdtemp(join(tmpdir(), 'ktx-isolated-runner-')); diff --git a/packages/cli/src/context/ingest/ingest-bundle.runner.test.ts b/packages/cli/test/context/ingest/ingest-bundle.runner.test.ts similarity index 99% rename from packages/cli/src/context/ingest/ingest-bundle.runner.test.ts rename to packages/cli/test/context/ingest/ingest-bundle.runner.test.ts index 85f45049..447cd01e 100644 --- a/packages/cli/src/context/ingest/ingest-bundle.runner.test.ts +++ b/packages/cli/test/context/ingest/ingest-bundle.runner.test.ts @@ -2,11 +2,11 @@ import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { addTouchedSlSource } from '../../context/tools/touched-sl-sources.js'; -import { IngestBundleRunner } from './ingest-bundle.runner.js'; -import { createMemoryFlowLiveBuffer } from './memory-flow/live-buffer.js'; -import type { MemoryFlowReplayInput } from './memory-flow/types.js'; -import type { IngestBundleRunnerDeps } from './ports.js'; +import { addTouchedSlSource } from '../../../src/context/tools/touched-sl-sources.js'; +import { IngestBundleRunner } from '../../../src/context/ingest/ingest-bundle.runner.js'; +import { createMemoryFlowLiveBuffer } from '../../../src/context/ingest/memory-flow/live-buffer.js'; +import type { MemoryFlowReplayInput } from '../../../src/context/ingest/memory-flow/types.js'; +import type { IngestBundleRunnerDeps } from '../../../src/context/ingest/ports.js'; class TestJobContext { private currentProgress = 0; diff --git a/packages/cli/src/context/ingest/ingest-prompts.test.ts b/packages/cli/test/context/ingest/ingest-prompts.test.ts similarity index 79% rename from packages/cli/src/context/ingest/ingest-prompts.test.ts rename to packages/cli/test/context/ingest/ingest-prompts.test.ts index 8adcbfef..54617eb2 100644 --- a/packages/cli/src/context/ingest/ingest-prompts.test.ts +++ b/packages/cli/test/context/ingest/ingest-prompts.test.ts @@ -8,7 +8,7 @@ function forbiddenProductPattern() { describe('ingest prompt assets', () => { it('teaches WorkUnit agents to apply canonical pins before writing contested artifacts', async () => { const prompt = await readFile( - new URL('../../prompts/memory_agent_bundle_ingest_work_unit.md', import.meta.url), + new URL('../../../src/prompts/memory_agent_bundle_ingest_work_unit.md', import.meta.url), 'utf-8', ); @@ -20,7 +20,7 @@ describe('ingest prompt assets', () => { it('uses product-neutral KTX runtime wording', async () => { const prompt = await readFile( - new URL('../../prompts/memory_agent_bundle_ingest_work_unit.md', import.meta.url), + new URL('../../../src/prompts/memory_agent_bundle_ingest_work_unit.md', import.meta.url), 'utf-8', ); @@ -31,7 +31,7 @@ describe('ingest prompt assets', () => { it('uses shipped warehouse verification tools in the WorkUnit prompt', async () => { const prompt = await readFile( - new URL('../../prompts/memory_agent_bundle_ingest_work_unit.md', import.meta.url), + new URL('../../../src/prompts/memory_agent_bundle_ingest_work_unit.md', import.meta.url), 'utf-8', ); @@ -42,7 +42,7 @@ describe('ingest prompt assets', () => { }); it('does not route historic-SQL through page-triage prompt examples', async () => { - const prompt = await readFile(new URL('../../prompts/skills/page_triage_classifier.md', import.meta.url), 'utf-8'); + const prompt = await readFile(new URL('../../../src/prompts/skills/page_triage_classifier.md', import.meta.url), 'utf-8'); expect(prompt).not.toContain(['historic_sql', 'template'].join('_')); expect(prompt).not.toContain('service_account_only=true AND below the frequency floor'); diff --git a/packages/cli/src/context/ingest/ingest-runtime-assets.test.ts b/packages/cli/test/context/ingest/ingest-runtime-assets.test.ts similarity index 92% rename from packages/cli/src/context/ingest/ingest-runtime-assets.test.ts rename to packages/cli/test/context/ingest/ingest-runtime-assets.test.ts index f6a46111..ad77e692 100644 --- a/packages/cli/src/context/ingest/ingest-runtime-assets.test.ts +++ b/packages/cli/test/context/ingest/ingest-runtime-assets.test.ts @@ -2,11 +2,11 @@ import { readFile } from 'node:fs/promises'; import { join } from 'node:path'; import { fileURLToPath } from 'node:url'; import { describe, expect, it } from 'vitest'; -import { PromptService } from '../../context/prompts/prompt.service.js'; -import { SkillsRegistryService } from '../../context/skills/skills-registry.service.js'; +import { PromptService } from '../../../src/context/prompts/prompt.service.js'; +import { SkillsRegistryService } from '../../../src/context/skills/skills-registry.service.js'; -const promptsDir = fileURLToPath(new URL('../../prompts', import.meta.url)); -const skillsDir = fileURLToPath(new URL('../../skills', import.meta.url)); +const promptsDir = fileURLToPath(new URL('../../../src/prompts', import.meta.url)); +const skillsDir = fileURLToPath(new URL('../../../src/skills', import.meta.url)); const adapterSkillNames = [ 'live_database_ingest', diff --git a/packages/cli/src/context/ingest/ingest-trace.test.ts b/packages/cli/test/context/ingest/ingest-trace.test.ts similarity index 98% rename from packages/cli/src/context/ingest/ingest-trace.test.ts rename to packages/cli/test/context/ingest/ingest-trace.test.ts index 88b56a37..b10e2118 100644 --- a/packages/cli/src/context/ingest/ingest-trace.test.ts +++ b/packages/cli/test/context/ingest/ingest-trace.test.ts @@ -2,7 +2,7 @@ import { mkdtemp, readFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { describe, expect, it, vi } from 'vitest'; -import { FileIngestTraceWriter, ingestTracePathForJob, traceTimed } from './ingest-trace.js'; +import { FileIngestTraceWriter, ingestTracePathForJob, traceTimed } from '../../../src/context/ingest/ingest-trace.js'; describe('FileIngestTraceWriter', () => { it('persists structured trace events as JSONL', async () => { diff --git a/packages/cli/src/context/ingest/isolated-diff/git-patch.test.ts b/packages/cli/test/context/ingest/isolated-diff/git-patch.test.ts similarity index 97% rename from packages/cli/src/context/ingest/isolated-diff/git-patch.test.ts rename to packages/cli/test/context/ingest/isolated-diff/git-patch.test.ts index 2a48ce9b..f9925ebb 100644 --- a/packages/cli/src/context/ingest/isolated-diff/git-patch.test.ts +++ b/packages/cli/test/context/ingest/isolated-diff/git-patch.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { assertPatchAllowedForWorkUnit, parsePatchTouchedPaths, textArtifactRoots } from './git-patch.js'; +import { assertPatchAllowedForWorkUnit, parsePatchTouchedPaths, textArtifactRoots } from '../../../../src/context/ingest/isolated-diff/git-patch.js'; describe('isolated diff patch contract', () => { it('parses touched paths from no-rename git patches', () => { diff --git a/packages/cli/src/context/ingest/isolated-diff/patch-integrator.test.ts b/packages/cli/test/context/ingest/isolated-diff/patch-integrator.test.ts similarity index 98% rename from packages/cli/src/context/ingest/isolated-diff/patch-integrator.test.ts rename to packages/cli/test/context/ingest/isolated-diff/patch-integrator.test.ts index e547e22e..1deabfe8 100644 --- a/packages/cli/src/context/ingest/isolated-diff/patch-integrator.test.ts +++ b/packages/cli/test/context/ingest/isolated-diff/patch-integrator.test.ts @@ -2,9 +2,9 @@ import { mkdir, mkdtemp, readFile, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { describe, expect, it, vi } from 'vitest'; -import { GitService } from '../../../context/core/git.service.js'; -import { FileIngestTraceWriter } from '../ingest-trace.js'; -import { integrateWorkUnitPatch } from './patch-integrator.js'; +import { GitService } from '../../../../src/context/core/git.service.js'; +import { FileIngestTraceWriter } from '../../../../src/context/ingest/ingest-trace.js'; +import { integrateWorkUnitPatch } from '../../../../src/context/ingest/isolated-diff/patch-integrator.js'; async function makeRepo() { const homeDir = await mkdtemp(join(tmpdir(), 'ktx-integrate-')); diff --git a/packages/cli/src/context/ingest/isolated-diff/textual-conflict-resolver.test.ts b/packages/cli/test/context/ingest/isolated-diff/textual-conflict-resolver.test.ts similarity index 94% rename from packages/cli/src/context/ingest/isolated-diff/textual-conflict-resolver.test.ts rename to packages/cli/test/context/ingest/isolated-diff/textual-conflict-resolver.test.ts index ae5b4e21..a03eb66d 100644 --- a/packages/cli/src/context/ingest/isolated-diff/textual-conflict-resolver.test.ts +++ b/packages/cli/test/context/ingest/isolated-diff/textual-conflict-resolver.test.ts @@ -2,8 +2,8 @@ import { mkdir, mkdtemp, readFile, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { describe, expect, it, vi } from 'vitest'; -import { FileIngestTraceWriter } from '../ingest-trace.js'; -import { resolveTextualConflict } from './textual-conflict-resolver.js'; +import { FileIngestTraceWriter } from '../../../../src/context/ingest/ingest-trace.js'; +import { resolveTextualConflict } from '../../../../src/context/ingest/isolated-diff/textual-conflict-resolver.js'; async function makeHarness() { const root = await mkdtemp(join(tmpdir(), 'ktx-textual-resolver-')); diff --git a/packages/cli/src/context/ingest/isolated-diff/work-unit-executor.test.ts b/packages/cli/test/context/ingest/isolated-diff/work-unit-executor.test.ts similarity index 95% rename from packages/cli/src/context/ingest/isolated-diff/work-unit-executor.test.ts rename to packages/cli/test/context/ingest/isolated-diff/work-unit-executor.test.ts index 5975dee8..cc06ba61 100644 --- a/packages/cli/src/context/ingest/isolated-diff/work-unit-executor.test.ts +++ b/packages/cli/test/context/ingest/isolated-diff/work-unit-executor.test.ts @@ -2,9 +2,9 @@ import { mkdir, mkdtemp, readFile, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { describe, expect, it, vi } from 'vitest'; -import { GitService } from '../../../context/core/git.service.js'; -import { FileIngestTraceWriter } from '../ingest-trace.js'; -import { runIsolatedWorkUnit } from './work-unit-executor.js'; +import { GitService } from '../../../../src/context/core/git.service.js'; +import { FileIngestTraceWriter } from '../../../../src/context/ingest/ingest-trace.js'; +import { runIsolatedWorkUnit } from '../../../../src/context/ingest/isolated-diff/work-unit-executor.js'; async function makeGit() { const homeDir = await mkdtemp(join(tmpdir(), 'ktx-isolated-wu-')); diff --git a/packages/cli/src/context/ingest/local-adapters.test.ts b/packages/cli/test/context/ingest/local-adapters.test.ts similarity index 97% rename from packages/cli/src/context/ingest/local-adapters.test.ts rename to packages/cli/test/context/ingest/local-adapters.test.ts index 373fc125..f70c4879 100644 --- a/packages/cli/src/context/ingest/local-adapters.test.ts +++ b/packages/cli/test/context/ingest/local-adapters.test.ts @@ -2,12 +2,12 @@ import { mkdtemp, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { initKtxProject, type KtxLocalProject, loadKtxProject } from '../../context/project/project.js'; -import type { SqlAnalysisPort } from '../../context/sql-analysis/ports.js'; -import type { HistoricSqlReader } from './adapters/historic-sql/types.js'; -import { LocalLookerRuntimeStore } from './adapters/looker/local-runtime-store.js'; -import { LocalNotionRuntimeStore } from './adapters/notion/local-state-store.js'; -import { createDefaultLocalIngestAdapters, localPullConfigForAdapter } from './local-adapters.js'; +import { initKtxProject, type KtxLocalProject, loadKtxProject } from '../../../src/context/project/project.js'; +import type { SqlAnalysisPort } from '../../../src/context/sql-analysis/ports.js'; +import type { HistoricSqlReader } from '../../../src/context/ingest/adapters/historic-sql/types.js'; +import { LocalLookerRuntimeStore } from '../../../src/context/ingest/adapters/looker/local-runtime-store.js'; +import { LocalNotionRuntimeStore } from '../../../src/context/ingest/adapters/notion/local-state-store.js'; +import { createDefaultLocalIngestAdapters, localPullConfigForAdapter } from '../../../src/context/ingest/local-adapters.js'; describe('local ingest adapters', () => { let tempDir: string; diff --git a/packages/cli/src/context/ingest/local-bundle-ingest.test.ts b/packages/cli/test/context/ingest/local-bundle-ingest.test.ts similarity index 97% rename from packages/cli/src/context/ingest/local-bundle-ingest.test.ts rename to packages/cli/test/context/ingest/local-bundle-ingest.test.ts index 4b4b834c..744af5be 100644 --- a/packages/cli/src/context/ingest/local-bundle-ingest.test.ts +++ b/packages/cli/test/context/ingest/local-bundle-ingest.test.ts @@ -3,22 +3,22 @@ import { tmpdir } from 'node:os'; import { join } from 'node:path'; import Database from 'better-sqlite3'; import YAML from 'yaml'; -import type { AgentRunnerPort, RunLoopParams } from '../../context/llm/runtime-port.js'; -import { initKtxProject, type KtxLocalProject, loadKtxProject } from '../../context/project/project.js'; +import type { AgentRunnerPort, RunLoopParams } from '../../../src/context/llm/runtime-port.js'; +import { initKtxProject, type KtxLocalProject, loadKtxProject } from '../../../src/context/project/project.js'; import { makeLocalGitRepo } from '../test/make-local-git-repo.js'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { FakeSourceAdapter } from './adapters/fake/fake.adapter.js'; -import { projectHistoricSqlEvidence } from './adapters/historic-sql/projection.js'; -import { LocalLookerRuntimeStore } from './adapters/looker/local-runtime-store.js'; -import { createDefaultLocalIngestAdapters, localPullConfigForAdapter } from './local-adapters.js'; -import { getLocalIngestStatus, runLocalIngest } from './local-ingest.js'; +import { FakeSourceAdapter } from '../../../src/context/ingest/adapters/fake/fake.adapter.js'; +import { projectHistoricSqlEvidence } from '../../../src/context/ingest/adapters/historic-sql/projection.js'; +import { LocalLookerRuntimeStore } from '../../../src/context/ingest/adapters/looker/local-runtime-store.js'; +import { createDefaultLocalIngestAdapters, localPullConfigForAdapter } from '../../../src/context/ingest/local-adapters.js'; +import { getLocalIngestStatus, runLocalIngest } from '../../../src/context/ingest/local-ingest.js'; import type { ChunkResult, DeterministicFinalizationContext, DiffSet, FinalizationResult, SourceAdapter, -} from './types.js'; +} from '../../../src/context/ingest/types.js'; class TestAgentRunner implements AgentRunnerPort { runLoop = vi.fn().mockResolvedValue({ stopReason: 'natural' as const }); diff --git a/packages/cli/src/context/ingest/local-bundle-runtime.test.ts b/packages/cli/test/context/ingest/local-bundle-runtime.test.ts similarity index 97% rename from packages/cli/src/context/ingest/local-bundle-runtime.test.ts rename to packages/cli/test/context/ingest/local-bundle-runtime.test.ts index 3c87c351..c17d6a6b 100644 --- a/packages/cli/src/context/ingest/local-bundle-runtime.test.ts +++ b/packages/cli/test/context/ingest/local-bundle-runtime.test.ts @@ -1,11 +1,11 @@ import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; -import type { AgentRunnerPort } from '../../context/llm/runtime-port.js'; -import { initKtxProject, type KtxLocalProject, loadKtxProject } from '../../context/project/project.js'; +import type { AgentRunnerPort } from '../../../src/context/llm/runtime-port.js'; +import { initKtxProject, type KtxLocalProject, loadKtxProject } from '../../../src/context/project/project.js'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { FakeSourceAdapter } from './adapters/fake/fake.adapter.js'; -import { createLocalBundleIngestRuntime } from './local-bundle-runtime.js'; +import { FakeSourceAdapter } from '../../../src/context/ingest/adapters/fake/fake.adapter.js'; +import { createLocalBundleIngestRuntime } from '../../../src/context/ingest/local-bundle-runtime.js'; type RuntimeWithConnectionDeps = { deps: { diff --git a/packages/cli/src/context/ingest/local-embedding-provider.integration.test.ts b/packages/cli/test/context/ingest/local-embedding-provider.integration.test.ts similarity index 90% rename from packages/cli/src/context/ingest/local-embedding-provider.integration.test.ts rename to packages/cli/test/context/ingest/local-embedding-provider.integration.test.ts index 34114e88..f0214957 100644 --- a/packages/cli/src/context/ingest/local-embedding-provider.integration.test.ts +++ b/packages/cli/test/context/ingest/local-embedding-provider.integration.test.ts @@ -2,11 +2,11 @@ import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; -import type { KtxEmbeddingPort } from '../core/embedding.js'; -import { CandidateDedupService } from './context-candidates/candidate-dedup.service.js'; -import { ContextEvidenceIndexService } from './context-evidence/context-evidence-index.service.js'; -import { SqliteContextEvidenceStore } from './context-evidence/sqlite-context-evidence-store.js'; -import type { DiffSet } from './types.js'; +import type { KtxEmbeddingPort } from '../../../src/context/core/embedding.js'; +import { CandidateDedupService } from '../../../src/context/ingest/context-candidates/candidate-dedup.service.js'; +import { ContextEvidenceIndexService } from '../../../src/context/ingest/context-evidence/context-evidence-index.service.js'; +import { SqliteContextEvidenceStore } from '../../../src/context/ingest/context-evidence/sqlite-context-evidence-store.js'; +import type { DiffSet } from '../../../src/context/ingest/types.js'; describe('local ingest embedding providers with SQLite ingest stores', () => { let tempDir: string; diff --git a/packages/cli/src/context/ingest/local-mapping-reconcile.test.ts b/packages/cli/test/context/ingest/local-mapping-reconcile.test.ts similarity index 87% rename from packages/cli/src/context/ingest/local-mapping-reconcile.test.ts rename to packages/cli/test/context/ingest/local-mapping-reconcile.test.ts index 3eed9d53..8f7080c6 100644 --- a/packages/cli/src/context/ingest/local-mapping-reconcile.test.ts +++ b/packages/cli/test/context/ingest/local-mapping-reconcile.test.ts @@ -2,10 +2,10 @@ import { mkdtemp, rm } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { afterEach, describe, expect, it } from 'vitest'; -import { ktxLocalStateDbPath } from '../../context/project/local-state-db.js'; -import type { KtxLocalProject } from '../../context/project/project.js'; -import { LocalLookerRuntimeStore } from './adapters/looker/local-runtime-store.js'; -import { seedLocalMappingStateFromKtxYaml } from './local-mapping-reconcile.js'; +import { ktxLocalStateDbPath } from '../../../src/context/project/local-state-db.js'; +import type { KtxLocalProject } from '../../../src/context/project/project.js'; +import { LocalLookerRuntimeStore } from '../../../src/context/ingest/adapters/looker/local-runtime-store.js'; +import { seedLocalMappingStateFromKtxYaml } from '../../../src/context/ingest/local-mapping-reconcile.js'; describe('local mapping yaml reconciliation bridge', () => { let tempDir: string; diff --git a/packages/cli/src/context/ingest/local-metabase-ingest.test.ts b/packages/cli/test/context/ingest/local-metabase-ingest.test.ts similarity index 95% rename from packages/cli/src/context/ingest/local-metabase-ingest.test.ts rename to packages/cli/test/context/ingest/local-metabase-ingest.test.ts index a6f5c4e0..06822aa2 100644 --- a/packages/cli/src/context/ingest/local-metabase-ingest.test.ts +++ b/packages/cli/test/context/ingest/local-metabase-ingest.test.ts @@ -1,12 +1,12 @@ import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; -import type { AgentRunnerPort, RunLoopParams } from '../../context/llm/runtime-port.js'; +import type { AgentRunnerPort, RunLoopParams } from '../../../src/context/llm/runtime-port.js'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { initKtxProject, type KtxLocalProject } from '../../context/project/project.js'; -import { LocalMetabaseDiscoveryCache } from './adapters/metabase/local-source-state-store.js'; -import { getLocalIngestStatus, runLocalMetabaseIngest } from './local-ingest.js'; -import type { ChunkResult, FetchContext, SourceAdapter } from './types.js'; +import { initKtxProject, type KtxLocalProject } from '../../../src/context/project/project.js'; +import { LocalMetabaseDiscoveryCache } from '../../../src/context/ingest/adapters/metabase/local-source-state-store.js'; +import { getLocalIngestStatus, runLocalMetabaseIngest } from '../../../src/context/ingest/local-ingest.js'; +import type { ChunkResult, FetchContext, SourceAdapter } from '../../../src/context/ingest/types.js'; class TestAgentRunner implements AgentRunnerPort { runLoop = vi.fn(async (params: RunLoopParams) => { diff --git a/packages/cli/src/context/ingest/local-stage-ingest.test.ts b/packages/cli/test/context/ingest/local-stage-ingest.test.ts similarity index 97% rename from packages/cli/src/context/ingest/local-stage-ingest.test.ts rename to packages/cli/test/context/ingest/local-stage-ingest.test.ts index 3f0e617f..c57d18b4 100644 --- a/packages/cli/src/context/ingest/local-stage-ingest.test.ts +++ b/packages/cli/test/context/ingest/local-stage-ingest.test.ts @@ -2,16 +2,16 @@ import { access, mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promise import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { initKtxProject, type KtxLocalProject, loadKtxProject } from '../../context/project/project.js'; -import { FakeSourceAdapter } from './adapters/fake/fake.adapter.js'; -import { createDefaultLocalIngestAdapters } from './local-adapters.js'; +import { initKtxProject, type KtxLocalProject, loadKtxProject } from '../../../src/context/project/project.js'; +import { FakeSourceAdapter } from '../../../src/context/ingest/adapters/fake/fake.adapter.js'; +import { createDefaultLocalIngestAdapters } from '../../../src/context/ingest/local-adapters.js'; import { getLocalStageOnlyIngestStatus, runLocalStageOnlyIngest, -} from './local-stage-ingest.js'; -import { createMemoryFlowLiveBuffer } from './memory-flow/live-buffer.js'; -import type { MemoryFlowReplayInput } from './memory-flow/types.js'; -import type { SourceAdapter } from './types.js'; +} from '../../../src/context/ingest/local-stage-ingest.js'; +import { createMemoryFlowLiveBuffer } from '../../../src/context/ingest/memory-flow/live-buffer.js'; +import type { MemoryFlowReplayInput } from '../../../src/context/ingest/memory-flow/types.js'; +import type { SourceAdapter } from '../../../src/context/ingest/types.js'; async function writeWarehouseConfig(projectDir: string): Promise { await writeFile( diff --git a/packages/cli/src/context/ingest/memory-flow/acceptance-fixtures.ts b/packages/cli/test/context/ingest/memory-flow/acceptance-fixtures.ts similarity index 98% rename from packages/cli/src/context/ingest/memory-flow/acceptance-fixtures.ts rename to packages/cli/test/context/ingest/memory-flow/acceptance-fixtures.ts index 66f1afb8..1d57aaa8 100644 --- a/packages/cli/src/context/ingest/memory-flow/acceptance-fixtures.ts +++ b/packages/cli/test/context/ingest/memory-flow/acceptance-fixtures.ts @@ -1,4 +1,4 @@ -import type { MemoryFlowReplayInput } from './types.js'; +import type { MemoryFlowReplayInput } from '../../../../src/context/ingest/memory-flow/types.js'; function baseScenario(overrides: Partial = {}): MemoryFlowReplayInput { return { diff --git a/packages/cli/src/context/ingest/memory-flow/acceptance.test.ts b/packages/cli/test/context/ingest/memory-flow/acceptance.test.ts similarity index 92% rename from packages/cli/src/context/ingest/memory-flow/acceptance.test.ts rename to packages/cli/test/context/ingest/memory-flow/acceptance.test.ts index 42bff8a0..04e7fa46 100644 --- a/packages/cli/src/context/ingest/memory-flow/acceptance.test.ts +++ b/packages/cli/test/context/ingest/memory-flow/acceptance.test.ts @@ -6,8 +6,8 @@ import { successfulReplayScenario, validationRevertScenario, } from './acceptance-fixtures.js'; -import { renderMemoryFlowReplay } from './render.js'; -import { buildMemoryFlowViewModel } from './view-model.js'; +import { renderMemoryFlowReplay } from '../../../../src/context/ingest/memory-flow/render.js'; +import { buildMemoryFlowViewModel } from '../../../../src/context/ingest/memory-flow/view-model.js'; function renderScenario(input = successfulReplayScenario(), terminalWidth = 140): string { return renderMemoryFlowReplay(buildMemoryFlowViewModel(input), { terminalWidth }); diff --git a/packages/cli/src/context/ingest/memory-flow/events.test.ts b/packages/cli/test/context/ingest/memory-flow/events.test.ts similarity index 97% rename from packages/cli/src/context/ingest/memory-flow/events.test.ts rename to packages/cli/test/context/ingest/memory-flow/events.test.ts index be97342b..e29405a4 100644 --- a/packages/cli/src/context/ingest/memory-flow/events.test.ts +++ b/packages/cli/test/context/ingest/memory-flow/events.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from 'vitest'; -import type { LocalIngestRunRecord } from '../local-stage-ingest.js'; -import type { IngestReportSnapshot } from '../reports.js'; -import { ingestReportToMemoryFlowReplay, localIngestRunToMemoryFlowReplay } from './events.js'; +import type { LocalIngestRunRecord } from '../../../../src/context/ingest/local-stage-ingest.js'; +import type { IngestReportSnapshot } from '../../../../src/context/ingest/reports.js'; +import { ingestReportToMemoryFlowReplay, localIngestRunToMemoryFlowReplay } from '../../../../src/context/ingest/memory-flow/events.js'; function localRecord(): LocalIngestRunRecord { return { diff --git a/packages/cli/src/context/ingest/memory-flow/interaction.test.ts b/packages/cli/test/context/ingest/memory-flow/interaction.test.ts similarity index 98% rename from packages/cli/src/context/ingest/memory-flow/interaction.test.ts rename to packages/cli/test/context/ingest/memory-flow/interaction.test.ts index 290180df..012373f7 100644 --- a/packages/cli/src/context/ingest/memory-flow/interaction.test.ts +++ b/packages/cli/test/context/ingest/memory-flow/interaction.test.ts @@ -8,8 +8,8 @@ import { selectedMemoryFlowColumn, selectedMemoryFlowDetails, visibleMemoryFlowChips, -} from './interaction.js'; -import type { MemoryFlowInteractionState, MemoryFlowViewModel } from './types.js'; +} from '../../../../src/context/ingest/memory-flow/interaction.js'; +import type { MemoryFlowInteractionState, MemoryFlowViewModel } from '../../../../src/context/ingest/memory-flow/types.js'; function view(): MemoryFlowViewModel { return { diff --git a/packages/cli/src/context/ingest/memory-flow/interactive-render.test.ts b/packages/cli/test/context/ingest/memory-flow/interactive-render.test.ts similarity index 95% rename from packages/cli/src/context/ingest/memory-flow/interactive-render.test.ts rename to packages/cli/test/context/ingest/memory-flow/interactive-render.test.ts index 6b703a2a..8c9a6a52 100644 --- a/packages/cli/src/context/ingest/memory-flow/interactive-render.test.ts +++ b/packages/cli/test/context/ingest/memory-flow/interactive-render.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from 'vitest'; -import { createInitialMemoryFlowInteractionState, reduceMemoryFlowInteractionState } from './interaction.js'; -import { renderMemoryFlowInteractive } from './interactive-render.js'; -import type { MemoryFlowViewModel } from './types.js'; +import { createInitialMemoryFlowInteractionState, reduceMemoryFlowInteractionState } from '../../../../src/context/ingest/memory-flow/interaction.js'; +import { renderMemoryFlowInteractive } from '../../../../src/context/ingest/memory-flow/interactive-render.js'; +import type { MemoryFlowViewModel } from '../../../../src/context/ingest/memory-flow/types.js'; function view(): MemoryFlowViewModel { return { diff --git a/packages/cli/src/context/ingest/memory-flow/live-buffer.test.ts b/packages/cli/test/context/ingest/memory-flow/live-buffer.test.ts similarity index 95% rename from packages/cli/src/context/ingest/memory-flow/live-buffer.test.ts rename to packages/cli/test/context/ingest/memory-flow/live-buffer.test.ts index fc1962a3..a9c1210b 100644 --- a/packages/cli/src/context/ingest/memory-flow/live-buffer.test.ts +++ b/packages/cli/test/context/ingest/memory-flow/live-buffer.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it, vi } from 'vitest'; -import { createMemoryFlowLiveBuffer, sanitizeMemoryFlowError } from './live-buffer.js'; -import type { MemoryFlowReplayInput } from './types.js'; +import { createMemoryFlowLiveBuffer, sanitizeMemoryFlowError } from '../../../../src/context/ingest/memory-flow/live-buffer.js'; +import type { MemoryFlowReplayInput } from '../../../../src/context/ingest/memory-flow/types.js'; function initialReplay(): MemoryFlowReplayInput { return { diff --git a/packages/cli/src/context/ingest/memory-flow/render.test.ts b/packages/cli/test/context/ingest/memory-flow/render.test.ts similarity index 95% rename from packages/cli/src/context/ingest/memory-flow/render.test.ts rename to packages/cli/test/context/ingest/memory-flow/render.test.ts index 0053eefd..adcc89f0 100644 --- a/packages/cli/src/context/ingest/memory-flow/render.test.ts +++ b/packages/cli/test/context/ingest/memory-flow/render.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; -import type { MemoryFlowViewModel } from './types.js'; -import { renderMemoryFlowReplay } from './render.js'; +import type { MemoryFlowViewModel } from '../../../../src/context/ingest/memory-flow/types.js'; +import { renderMemoryFlowReplay } from '../../../../src/context/ingest/memory-flow/render.js'; function view(): MemoryFlowViewModel { return { diff --git a/packages/cli/src/context/ingest/memory-flow/schema.test.ts b/packages/cli/test/context/ingest/memory-flow/schema.test.ts similarity index 97% rename from packages/cli/src/context/ingest/memory-flow/schema.test.ts rename to packages/cli/test/context/ingest/memory-flow/schema.test.ts index b8c70856..1aaeec4b 100644 --- a/packages/cli/src/context/ingest/memory-flow/schema.test.ts +++ b/packages/cli/test/context/ingest/memory-flow/schema.test.ts @@ -3,8 +3,8 @@ import { memoryFlowReplayInputSchema, memoryFlowStreamEventSchema, parseMemoryFlowReplayInput, -} from './schema.js'; -import type { MemoryFlowReplayInput } from './types.js'; +} from '../../../../src/context/ingest/memory-flow/schema.js'; +import type { MemoryFlowReplayInput } from '../../../../src/context/ingest/memory-flow/types.js'; function snapshot(overrides: Partial = {}): MemoryFlowReplayInput { return { diff --git a/packages/cli/src/context/ingest/memory-flow/summary.test.ts b/packages/cli/test/context/ingest/memory-flow/summary.test.ts similarity index 96% rename from packages/cli/src/context/ingest/memory-flow/summary.test.ts rename to packages/cli/test/context/ingest/memory-flow/summary.test.ts index a22ca1ff..967acf46 100644 --- a/packages/cli/src/context/ingest/memory-flow/summary.test.ts +++ b/packages/cli/test/context/ingest/memory-flow/summary.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; -import type { MemoryFlowReplayInput } from './types.js'; -import { formatMemoryFlowFinalSummary } from './summary.js'; +import type { MemoryFlowReplayInput } from '../../../../src/context/ingest/memory-flow/types.js'; +import { formatMemoryFlowFinalSummary } from '../../../../src/context/ingest/memory-flow/summary.js'; function input(overrides: Partial = {}): MemoryFlowReplayInput { return { diff --git a/packages/cli/src/context/ingest/memory-flow/view-model.test.ts b/packages/cli/test/context/ingest/memory-flow/view-model.test.ts similarity index 98% rename from packages/cli/src/context/ingest/memory-flow/view-model.test.ts rename to packages/cli/test/context/ingest/memory-flow/view-model.test.ts index 4e6edae3..6bd64943 100644 --- a/packages/cli/src/context/ingest/memory-flow/view-model.test.ts +++ b/packages/cli/test/context/ingest/memory-flow/view-model.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; -import type { MemoryFlowReplayInput } from './types.js'; -import { buildMemoryFlowViewModel } from './view-model.js'; +import type { MemoryFlowReplayInput } from '../../../../src/context/ingest/memory-flow/types.js'; +import { buildMemoryFlowViewModel } from '../../../../src/context/ingest/memory-flow/view-model.js'; function replayInput(): MemoryFlowReplayInput { return { diff --git a/packages/cli/src/context/ingest/memory-flow/visuals.test.ts b/packages/cli/test/context/ingest/memory-flow/visuals.test.ts similarity index 94% rename from packages/cli/src/context/ingest/memory-flow/visuals.test.ts rename to packages/cli/test/context/ingest/memory-flow/visuals.test.ts index 7144c897..da271248 100644 --- a/packages/cli/src/context/ingest/memory-flow/visuals.test.ts +++ b/packages/cli/test/context/ingest/memory-flow/visuals.test.ts @@ -3,8 +3,8 @@ import { buildMemoryFlowVisualModel, memoryFlowStatusBadge, renderMemoryFlowConnectorLine, -} from './visuals.js'; -import type { MemoryFlowViewModel } from './types.js'; +} from '../../../../src/context/ingest/memory-flow/visuals.js'; +import type { MemoryFlowViewModel } from '../../../../src/context/ingest/memory-flow/types.js'; function viewWithStatuses(statuses: Array<'waiting' | 'active' | 'complete' | 'warning' | 'failed'>): MemoryFlowViewModel { const titles = ['SOURCE', 'CHUNKS', 'WORKUNITS', 'ACTIONS', 'GATES', 'SAVED']; diff --git a/packages/cli/src/context/ingest/page-triage/page-triage.service.test.ts b/packages/cli/test/context/ingest/page-triage/page-triage.service.test.ts similarity index 99% rename from packages/cli/src/context/ingest/page-triage/page-triage.service.test.ts rename to packages/cli/test/context/ingest/page-triage/page-triage.service.test.ts index 6432347d..33aa2979 100644 --- a/packages/cli/src/context/ingest/page-triage/page-triage.service.test.ts +++ b/packages/cli/test/context/ingest/page-triage/page-triage.service.test.ts @@ -2,7 +2,7 @@ import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { PageTriageService } from './page-triage.service.js'; +import { PageTriageService } from '../../../../src/context/ingest/page-triage/page-triage.service.js'; describe('PageTriageService', () => { let stagedDir: string; diff --git a/packages/cli/src/context/ingest/raw-sources-paths.test.ts b/packages/cli/test/context/ingest/raw-sources-paths.test.ts similarity index 92% rename from packages/cli/src/context/ingest/raw-sources-paths.test.ts rename to packages/cli/test/context/ingest/raw-sources-paths.test.ts index dcc17ddc..045f479f 100644 --- a/packages/cli/src/context/ingest/raw-sources-paths.test.ts +++ b/packages/cli/test/context/ingest/raw-sources-paths.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { buildSyncId, provenanceMarker, rawSourcesDirForSync, rawSourcesRoot } from './raw-sources-paths.js'; +import { buildSyncId, provenanceMarker, rawSourcesDirForSync, rawSourcesRoot } from '../../../src/context/ingest/raw-sources-paths.js'; describe('raw-sources paths', () => { it('buildSyncId uses timestamp + jobId', () => { diff --git a/packages/cli/src/context/ingest/repo-fetch.test.ts b/packages/cli/test/context/ingest/repo-fetch.test.ts similarity index 95% rename from packages/cli/src/context/ingest/repo-fetch.test.ts rename to packages/cli/test/context/ingest/repo-fetch.test.ts index dcefd6ca..d9e66e33 100644 --- a/packages/cli/src/context/ingest/repo-fetch.test.ts +++ b/packages/cli/test/context/ingest/repo-fetch.test.ts @@ -4,10 +4,10 @@ import { join } from 'node:path'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { makeLocalGitRepo } from '../test/make-local-git-repo.js'; -const FIXTURE_ROOT = join(__dirname, '../../test/fixtures/lookml/single-model'); +const FIXTURE_ROOT = join(__dirname, '../../fixtures/lookml/single-model'); async function loadRepoFetch() { - return await import('./repo-fetch.js'); + return await import('../../../src/context/ingest/repo-fetch.js'); } describe('repo-fetch', () => { @@ -16,13 +16,13 @@ describe('repo-fetch', () => { beforeEach(async () => { tmpRoot = await mkdtemp(join(tmpdir(), 'repo-fetch-')); vi.resetModules(); - vi.doUnmock('./git-env.js'); + vi.doUnmock('../../../src/context/ingest/git-env.js'); }); afterEach(async () => { vi.restoreAllMocks(); vi.resetModules(); - vi.doUnmock('./git-env.js'); + vi.doUnmock('../../../src/context/ingest/git-env.js'); await rm(tmpRoot, { recursive: true, force: true }); }); @@ -98,7 +98,7 @@ describe('repo-fetch', () => { it('falls back to a fresh clone when the existing cache diverges locally', async () => { const { cloneOrPull } = await loadRepoFetch(); - const { createSimpleGit } = await import('./git-env.js'); + const { createSimpleGit } = await import('../../../src/context/ingest/git-env.js'); const repo = await makeLocalGitRepo(FIXTURE_ROOT, join(tmpRoot, 'origin')); const cacheDir = join(tmpRoot, 'cache', 'conn-diverged'); @@ -197,7 +197,7 @@ describe('repo-fetch', () => { clone: vi.fn(async () => undefined), }; - vi.doMock('./git-env.js', () => ({ + vi.doMock('../../../src/context/ingest/git-env.js', () => ({ createSimpleGit: vi.fn((baseDir?: string) => (baseDir ? worktreeGit : rootGit)), })); diff --git a/packages/cli/src/context/ingest/report-snapshot.test.ts b/packages/cli/test/context/ingest/report-snapshot.test.ts similarity index 99% rename from packages/cli/src/context/ingest/report-snapshot.test.ts rename to packages/cli/test/context/ingest/report-snapshot.test.ts index 028c222c..36f822e1 100644 --- a/packages/cli/src/context/ingest/report-snapshot.test.ts +++ b/packages/cli/test/context/ingest/report-snapshot.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { parseIngestReportSnapshot } from './report-snapshot.js'; +import { parseIngestReportSnapshot } from '../../../src/context/ingest/report-snapshot.js'; function validReportSnapshot() { return { diff --git a/packages/cli/src/context/ingest/semantic-layer-target-policy.test.ts b/packages/cli/test/context/ingest/semantic-layer-target-policy.test.ts similarity index 95% rename from packages/cli/src/context/ingest/semantic-layer-target-policy.test.ts rename to packages/cli/test/context/ingest/semantic-layer-target-policy.test.ts index 73d09dc0..5f0980f6 100644 --- a/packages/cli/src/context/ingest/semantic-layer-target-policy.test.ts +++ b/packages/cli/test/context/ingest/semantic-layer-target-policy.test.ts @@ -3,7 +3,7 @@ import { assertSemanticLayerTargetPathsAllowed, findDisallowedSemanticLayerTargetPaths, semanticLayerConnectionIdFromPath, -} from './semantic-layer-target-policy.js'; +} from '../../../src/context/ingest/semantic-layer-target-policy.js'; describe('semantic-layer target policy', () => { it('extracts connection ids from semantic-layer paths', () => { diff --git a/packages/cli/src/context/ingest/source-adapter-registry.test.ts b/packages/cli/test/context/ingest/source-adapter-registry.test.ts similarity index 87% rename from packages/cli/src/context/ingest/source-adapter-registry.test.ts rename to packages/cli/test/context/ingest/source-adapter-registry.test.ts index 9a74c597..abd34f51 100644 --- a/packages/cli/src/context/ingest/source-adapter-registry.test.ts +++ b/packages/cli/test/context/ingest/source-adapter-registry.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; -import { SourceAdapterRegistry } from './source-adapter-registry.js'; -import type { SourceAdapter } from './types.js'; +import { SourceAdapterRegistry } from '../../../src/context/ingest/source-adapter-registry.js'; +import type { SourceAdapter } from '../../../src/context/ingest/types.js'; const makeAdapter = (source: string): SourceAdapter => ({ source, diff --git a/packages/cli/src/context/ingest/sqlite-bundle-ingest-store.test.ts b/packages/cli/test/context/ingest/sqlite-bundle-ingest-store.test.ts similarity index 98% rename from packages/cli/src/context/ingest/sqlite-bundle-ingest-store.test.ts rename to packages/cli/test/context/ingest/sqlite-bundle-ingest-store.test.ts index 0cee47d0..9f6e9f9f 100644 --- a/packages/cli/src/context/ingest/sqlite-bundle-ingest-store.test.ts +++ b/packages/cli/test/context/ingest/sqlite-bundle-ingest-store.test.ts @@ -2,10 +2,10 @@ import { mkdtemp, rm } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; -import { DiffSetService } from './diff-set.service.js'; -import type { IngestDiffSummary, IngestTrigger } from '../../context/ingest/types.js'; -import type { IngestReportBody } from '../../context/ingest/reports.js'; -import { SqliteBundleIngestStore } from './sqlite-bundle-ingest-store.js'; +import { DiffSetService } from '../../../src/context/ingest/diff-set.service.js'; +import type { IngestDiffSummary, IngestTrigger } from '../../../src/context/ingest/types.js'; +import type { IngestReportBody } from '../../../src/context/ingest/reports.js'; +import { SqliteBundleIngestStore } from '../../../src/context/ingest/sqlite-bundle-ingest-store.js'; function idFactory(ids: string[]): () => string { let index = 0; diff --git a/packages/cli/src/context/ingest/sqlite-local-ingest-store.test.ts b/packages/cli/test/context/ingest/sqlite-local-ingest-store.test.ts similarity index 95% rename from packages/cli/src/context/ingest/sqlite-local-ingest-store.test.ts rename to packages/cli/test/context/ingest/sqlite-local-ingest-store.test.ts index 67fad006..3d38fe58 100644 --- a/packages/cli/src/context/ingest/sqlite-local-ingest-store.test.ts +++ b/packages/cli/test/context/ingest/sqlite-local-ingest-store.test.ts @@ -2,8 +2,8 @@ import { mkdtemp, rm } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; -import { SqliteLocalIngestStore } from './sqlite-local-ingest-store.js'; -import type { LocalIngestRunRecord } from './local-stage-ingest.js'; +import { SqliteLocalIngestStore } from '../../../src/context/ingest/sqlite-local-ingest-store.js'; +import type { LocalIngestRunRecord } from '../../../src/context/ingest/local-stage-ingest.js'; function runRecord(overrides: Partial = {}): LocalIngestRunRecord { return { diff --git a/packages/cli/src/context/ingest/stages/build-reconcile-context.context-candidates.test.ts b/packages/cli/test/context/ingest/stages/build-reconcile-context.context-candidates.test.ts similarity index 97% rename from packages/cli/src/context/ingest/stages/build-reconcile-context.context-candidates.test.ts rename to packages/cli/test/context/ingest/stages/build-reconcile-context.context-candidates.test.ts index 22427ddd..09a1c610 100644 --- a/packages/cli/src/context/ingest/stages/build-reconcile-context.context-candidates.test.ts +++ b/packages/cli/test/context/ingest/stages/build-reconcile-context.context-candidates.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { buildReconcileUserPrompt } from './build-reconcile-context.js'; +import { buildReconcileUserPrompt } from '../../../../src/context/ingest/stages/build-reconcile-context.js'; const emptyStageIndex = { jobId: 'job-1', diff --git a/packages/cli/src/context/ingest/stages/build-reconcile-context.test.ts b/packages/cli/test/context/ingest/stages/build-reconcile-context.test.ts similarity index 98% rename from packages/cli/src/context/ingest/stages/build-reconcile-context.test.ts rename to packages/cli/test/context/ingest/stages/build-reconcile-context.test.ts index 8de7611a..2aaeb061 100644 --- a/packages/cli/src/context/ingest/stages/build-reconcile-context.test.ts +++ b/packages/cli/test/context/ingest/stages/build-reconcile-context.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it, vi } from 'vitest'; -import { buildReconcileSystemPrompt, buildReconcileToolSet, buildReconcileUserPrompt } from './build-reconcile-context.js'; +import { buildReconcileSystemPrompt, buildReconcileToolSet, buildReconcileUserPrompt } from '../../../../src/context/ingest/stages/build-reconcile-context.js'; describe('buildReconcileSystemPrompt', () => { it('appends canonical pins when relevant pins are supplied', () => { diff --git a/packages/cli/src/context/ingest/stages/build-wu-context.test.ts b/packages/cli/test/context/ingest/stages/build-wu-context.test.ts similarity index 99% rename from packages/cli/src/context/ingest/stages/build-wu-context.test.ts rename to packages/cli/test/context/ingest/stages/build-wu-context.test.ts index 81c0c923..cdbb22ba 100644 --- a/packages/cli/src/context/ingest/stages/build-wu-context.test.ts +++ b/packages/cli/test/context/ingest/stages/build-wu-context.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it, vi } from 'vitest'; -import { buildWuSystemPrompt, buildWuToolSet, buildWuUserPrompt } from './build-wu-context.js'; +import { buildWuSystemPrompt, buildWuToolSet, buildWuUserPrompt } from '../../../../src/context/ingest/stages/build-wu-context.js'; describe('buildWuUserPrompt', () => { it('includes rawFiles, dependencyPaths, peerFileIndex, and priorProvenance when present', () => { diff --git a/packages/cli/src/context/ingest/stages/stage-1-stage-raw-files.test.ts b/packages/cli/test/context/ingest/stages/stage-1-stage-raw-files.test.ts similarity index 95% rename from packages/cli/src/context/ingest/stages/stage-1-stage-raw-files.test.ts rename to packages/cli/test/context/ingest/stages/stage-1-stage-raw-files.test.ts index 3cd5cde5..d943d2d0 100644 --- a/packages/cli/src/context/ingest/stages/stage-1-stage-raw-files.test.ts +++ b/packages/cli/test/context/ingest/stages/stage-1-stage-raw-files.test.ts @@ -2,7 +2,7 @@ import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; -import { stageRawFilesStage1 } from './stage-1-stage-raw-files.js'; +import { stageRawFilesStage1 } from '../../../../src/context/ingest/stages/stage-1-stage-raw-files.js'; describe('Stage 1 — stageRawFiles', () => { let stagedDir: string; diff --git a/packages/cli/src/context/ingest/stages/stage-3-work-units.test.ts b/packages/cli/test/context/ingest/stages/stage-3-work-units.test.ts similarity index 96% rename from packages/cli/src/context/ingest/stages/stage-3-work-units.test.ts rename to packages/cli/test/context/ingest/stages/stage-3-work-units.test.ts index fc39fd9b..6d6deccd 100644 --- a/packages/cli/src/context/ingest/stages/stage-3-work-units.test.ts +++ b/packages/cli/test/context/ingest/stages/stage-3-work-units.test.ts @@ -1,8 +1,8 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; -import type { CaptureSession, MemoryAction } from '../../../context/memory/types.js'; -import { addTouchedSlSource, createTouchedSlSources } from '../../../context/tools/touched-sl-sources.js'; -import type { WorkUnit } from '../types.js'; -import { executeWorkUnit, type WorkUnitExecutionDeps } from './stage-3-work-units.js'; +import type { CaptureSession, MemoryAction } from '../../../../src/context/memory/types.js'; +import { addTouchedSlSource, createTouchedSlSources } from '../../../../src/context/tools/touched-sl-sources.js'; +import type { WorkUnit } from '../../../../src/context/ingest/types.js'; +import { executeWorkUnit, type WorkUnitExecutionDeps } from '../../../../src/context/ingest/stages/stage-3-work-units.js'; const makeWu = (overrides: Partial = {}): WorkUnit => ({ unitKey: 'u1', diff --git a/packages/cli/src/context/ingest/stages/stage-4-reconciliation.test.ts b/packages/cli/test/context/ingest/stages/stage-4-reconciliation.test.ts similarity index 97% rename from packages/cli/src/context/ingest/stages/stage-4-reconciliation.test.ts rename to packages/cli/test/context/ingest/stages/stage-4-reconciliation.test.ts index 4244ab12..d66533a9 100644 --- a/packages/cli/src/context/ingest/stages/stage-4-reconciliation.test.ts +++ b/packages/cli/test/context/ingest/stages/stage-4-reconciliation.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it, vi } from 'vitest'; -import { runReconciliationStage4 } from './stage-4-reconciliation.js'; +import { runReconciliationStage4 } from '../../../../src/context/ingest/stages/stage-4-reconciliation.js'; describe('Stage 4 — runReconciliationStage4', () => { it('short-circuits when stage index is empty and eviction is empty', async () => { diff --git a/packages/cli/src/context/ingest/stages/validate-wu-sources.test.ts b/packages/cli/test/context/ingest/stages/validate-wu-sources.test.ts similarity index 93% rename from packages/cli/src/context/ingest/stages/validate-wu-sources.test.ts rename to packages/cli/test/context/ingest/stages/validate-wu-sources.test.ts index 668062e9..807a8b10 100644 --- a/packages/cli/src/context/ingest/stages/validate-wu-sources.test.ts +++ b/packages/cli/test/context/ingest/stages/validate-wu-sources.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it, vi } from 'vitest'; -import { validateWuTouchedSources } from './validate-wu-sources.js'; +import { validateWuTouchedSources } from '../../../../src/context/ingest/stages/validate-wu-sources.js'; describe('validateWuTouchedSources', () => { it('validates each touched source against its own connection', async () => { diff --git a/packages/cli/src/context/ingest/tools/emit-reconciliation-records.tool.test.ts b/packages/cli/test/context/ingest/tools/emit-reconciliation-records.tool.test.ts similarity index 93% rename from packages/cli/src/context/ingest/tools/emit-reconciliation-records.tool.test.ts rename to packages/cli/test/context/ingest/tools/emit-reconciliation-records.tool.test.ts index 1cd77514..7801038b 100644 --- a/packages/cli/src/context/ingest/tools/emit-reconciliation-records.tool.test.ts +++ b/packages/cli/test/context/ingest/tools/emit-reconciliation-records.tool.test.ts @@ -1,10 +1,10 @@ import type { Tool } from 'ai'; import { describe, expect, it } from 'vitest'; -import type { StageIndex } from '../stages/stage-index.types.js'; -import { createEmitArtifactResolutionTool } from './emit-artifact-resolution.tool.js'; -import { createEmitConflictResolutionTool } from './emit-conflict-resolution.tool.js'; -import { createEmitEvictionDecisionTool } from './emit-eviction-decision.tool.js'; -import { createEmitUnmappedFallbackTool } from './emit-unmapped-fallback.tool.js'; +import type { StageIndex } from '../../../../src/context/ingest/stages/stage-index.types.js'; +import { createEmitArtifactResolutionTool } from '../../../../src/context/ingest/tools/emit-artifact-resolution.tool.js'; +import { createEmitConflictResolutionTool } from '../../../../src/context/ingest/tools/emit-conflict-resolution.tool.js'; +import { createEmitEvictionDecisionTool } from '../../../../src/context/ingest/tools/emit-eviction-decision.tool.js'; +import { createEmitUnmappedFallbackTool } from '../../../../src/context/ingest/tools/emit-unmapped-fallback.tool.js'; function makeStageIndex(): StageIndex { return { diff --git a/packages/cli/src/context/ingest/tools/eviction-list.tool.test.ts b/packages/cli/test/context/ingest/tools/eviction-list.tool.test.ts similarity index 94% rename from packages/cli/src/context/ingest/tools/eviction-list.tool.test.ts rename to packages/cli/test/context/ingest/tools/eviction-list.tool.test.ts index 96fb7f65..3dcfd005 100644 --- a/packages/cli/src/context/ingest/tools/eviction-list.tool.test.ts +++ b/packages/cli/test/context/ingest/tools/eviction-list.tool.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it, vi } from 'vitest'; -import { createEvictionListTool } from './eviction-list.tool.js'; +import { createEvictionListTool } from '../../../../src/context/ingest/tools/eviction-list.tool.js'; describe('eviction_list tool', () => { it('returns artifacts produced for each deleted raw path', async () => { diff --git a/packages/cli/src/context/ingest/tools/read-raw-file.tool.test.ts b/packages/cli/test/context/ingest/tools/read-raw-file.tool.test.ts similarity index 96% rename from packages/cli/src/context/ingest/tools/read-raw-file.tool.test.ts rename to packages/cli/test/context/ingest/tools/read-raw-file.tool.test.ts index db4aef42..041ea188 100644 --- a/packages/cli/src/context/ingest/tools/read-raw-file.tool.test.ts +++ b/packages/cli/test/context/ingest/tools/read-raw-file.tool.test.ts @@ -2,7 +2,7 @@ import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; -import { createReadRawFileTool } from './read-raw-file.tool.js'; +import { createReadRawFileTool } from '../../../../src/context/ingest/tools/read-raw-file.tool.js'; describe('read_raw_file tool', () => { let stagedDir: string; diff --git a/packages/cli/src/context/ingest/tools/read-raw-span.tool.test.ts b/packages/cli/test/context/ingest/tools/read-raw-span.tool.test.ts similarity index 95% rename from packages/cli/src/context/ingest/tools/read-raw-span.tool.test.ts rename to packages/cli/test/context/ingest/tools/read-raw-span.tool.test.ts index 30696046..cd4e8f2a 100644 --- a/packages/cli/src/context/ingest/tools/read-raw-span.tool.test.ts +++ b/packages/cli/test/context/ingest/tools/read-raw-span.tool.test.ts @@ -2,7 +2,7 @@ import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; -import { createReadRawSpanTool } from './read-raw-span.tool.js'; +import { createReadRawSpanTool } from '../../../../src/context/ingest/tools/read-raw-span.tool.js'; describe('read_raw_span tool', () => { let stagedDir: string; diff --git a/packages/cli/src/context/ingest/tools/stage-diff.tool.test.ts b/packages/cli/test/context/ingest/tools/stage-diff.tool.test.ts similarity index 97% rename from packages/cli/src/context/ingest/tools/stage-diff.tool.test.ts rename to packages/cli/test/context/ingest/tools/stage-diff.tool.test.ts index 0dae87ab..fd756099 100644 --- a/packages/cli/src/context/ingest/tools/stage-diff.tool.test.ts +++ b/packages/cli/test/context/ingest/tools/stage-diff.tool.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { createStageDiffTool } from './stage-diff.tool.js'; +import { createStageDiffTool } from '../../../../src/context/ingest/tools/stage-diff.tool.js'; describe('stage_diff tool', () => { const stageIndex = { diff --git a/packages/cli/src/context/ingest/tools/stage-list.tool.test.ts b/packages/cli/test/context/ingest/tools/stage-list.tool.test.ts similarity index 95% rename from packages/cli/src/context/ingest/tools/stage-list.tool.test.ts rename to packages/cli/test/context/ingest/tools/stage-list.tool.test.ts index d14acb2a..3ed975bc 100644 --- a/packages/cli/src/context/ingest/tools/stage-list.tool.test.ts +++ b/packages/cli/test/context/ingest/tools/stage-list.tool.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { createStageListTool } from './stage-list.tool.js'; +import { createStageListTool } from '../../../../src/context/ingest/tools/stage-list.tool.js'; describe('stage_list tool', () => { it('returns a compact summary of the stage index', async () => { diff --git a/packages/cli/src/context/ingest/tools/tool-transcript-summary.test.ts b/packages/cli/test/context/ingest/tools/tool-transcript-summary.test.ts similarity index 97% rename from packages/cli/src/context/ingest/tools/tool-transcript-summary.test.ts rename to packages/cli/test/context/ingest/tools/tool-transcript-summary.test.ts index 9e110789..ddc8d256 100644 --- a/packages/cli/src/context/ingest/tools/tool-transcript-summary.test.ts +++ b/packages/cli/test/context/ingest/tools/tool-transcript-summary.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; -import type { ToolCallLogEntry } from './tool-call-logger.js'; -import { createMutableToolTranscriptSummary, recordToolTranscriptEntry } from './tool-transcript-summary.js'; +import type { ToolCallLogEntry } from '../../../../src/context/ingest/tools/tool-call-logger.js'; +import { createMutableToolTranscriptSummary, recordToolTranscriptEntry } from '../../../../src/context/ingest/tools/tool-transcript-summary.js'; function entry(overrides: Partial): ToolCallLogEntry { return { diff --git a/packages/cli/src/context/ingest/tools/warehouse-verification/discover-data.tool.test.ts b/packages/cli/test/context/ingest/tools/warehouse-verification/discover-data.tool.test.ts similarity index 94% rename from packages/cli/src/context/ingest/tools/warehouse-verification/discover-data.tool.test.ts rename to packages/cli/test/context/ingest/tools/warehouse-verification/discover-data.tool.test.ts index 7aebc101..09abbc6a 100644 --- a/packages/cli/src/context/ingest/tools/warehouse-verification/discover-data.tool.test.ts +++ b/packages/cli/test/context/ingest/tools/warehouse-verification/discover-data.tool.test.ts @@ -1,7 +1,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; -import type { WarehouseCatalogService } from '../../../scan/warehouse-catalog.js'; -import type { BaseTool, ToolContext } from '../../../../context/tools/base-tool.js'; -import { DiscoverDataTool } from './discover-data.tool.js'; +import type { WarehouseCatalogService } from '../../../../../src/context/scan/warehouse-catalog.js'; +import type { BaseTool, ToolContext } from '../../../../../src/context/tools/base-tool.js'; +import { DiscoverDataTool } from '../../../../../src/context/ingest/tools/warehouse-verification/discover-data.tool.js'; describe('DiscoverDataTool', () => { const wikiSearchTool = { call: vi.fn() } as unknown as BaseTool & { call: ReturnType }; diff --git a/packages/cli/src/context/ingest/tools/warehouse-verification/entity-details.tool.test.ts b/packages/cli/test/context/ingest/tools/warehouse-verification/entity-details.tool.test.ts similarity index 95% rename from packages/cli/src/context/ingest/tools/warehouse-verification/entity-details.tool.test.ts rename to packages/cli/test/context/ingest/tools/warehouse-verification/entity-details.tool.test.ts index fcef38df..0107951d 100644 --- a/packages/cli/src/context/ingest/tools/warehouse-verification/entity-details.tool.test.ts +++ b/packages/cli/test/context/ingest/tools/warehouse-verification/entity-details.tool.test.ts @@ -2,10 +2,10 @@ import { mkdtemp, rm } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; -import { initKtxProject, type KtxLocalProject } from '../../../../context/project/project.js'; -import { WarehouseCatalogService } from '../../../scan/warehouse-catalog.js'; -import type { ToolContext } from '../../../../context/tools/base-tool.js'; -import { EntityDetailsTool } from './entity-details.tool.js'; +import { initKtxProject, type KtxLocalProject } from '../../../../../src/context/project/project.js'; +import { WarehouseCatalogService } from '../../../../../src/context/scan/warehouse-catalog.js'; +import type { ToolContext } from '../../../../../src/context/tools/base-tool.js'; +import { EntityDetailsTool } from '../../../../../src/context/ingest/tools/warehouse-verification/entity-details.tool.js'; describe('EntityDetailsTool', () => { let tempDir: string; diff --git a/packages/cli/src/context/ingest/tools/warehouse-verification/sql-execution.tool.test.ts b/packages/cli/test/context/ingest/tools/warehouse-verification/sql-execution.tool.test.ts similarity index 89% rename from packages/cli/src/context/ingest/tools/warehouse-verification/sql-execution.tool.test.ts rename to packages/cli/test/context/ingest/tools/warehouse-verification/sql-execution.tool.test.ts index 4458471a..0fb87655 100644 --- a/packages/cli/src/context/ingest/tools/warehouse-verification/sql-execution.tool.test.ts +++ b/packages/cli/test/context/ingest/tools/warehouse-verification/sql-execution.tool.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it, vi } from 'vitest'; -import type { SlConnectionCatalogPort } from '../../../../context/sl/ports.js'; -import type { ToolContext } from '../../../../context/tools/base-tool.js'; -import { SqlExecutionTool } from './sql-execution.tool.js'; +import type { SlConnectionCatalogPort } from '../../../../../src/context/sl/ports.js'; +import type { ToolContext } from '../../../../../src/context/tools/base-tool.js'; +import { SqlExecutionTool } from '../../../../../src/context/ingest/tools/warehouse-verification/sql-execution.tool.js'; describe('SqlExecutionTool', () => { const connections = { diff --git a/packages/cli/src/context/ingest/wiki-body-refs.test.ts b/packages/cli/test/context/ingest/wiki-body-refs.test.ts similarity index 98% rename from packages/cli/src/context/ingest/wiki-body-refs.test.ts rename to packages/cli/test/context/ingest/wiki-body-refs.test.ts index 2af8935f..578dc600 100644 --- a/packages/cli/src/context/ingest/wiki-body-refs.test.ts +++ b/packages/cli/test/context/ingest/wiki-body-refs.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { findInvalidWikiBodyRefs, parseWikiBodyRefs } from './wiki-body-refs.js'; +import { findInvalidWikiBodyRefs, parseWikiBodyRefs } from '../../../src/context/ingest/wiki-body-refs.js'; const sources = [ { diff --git a/packages/cli/src/context/ingest/wiki-sl-ref-repair.test.ts b/packages/cli/test/context/ingest/wiki-sl-ref-repair.test.ts similarity index 97% rename from packages/cli/src/context/ingest/wiki-sl-ref-repair.test.ts rename to packages/cli/test/context/ingest/wiki-sl-ref-repair.test.ts index bcf4a993..16f0acb5 100644 --- a/packages/cli/src/context/ingest/wiki-sl-ref-repair.test.ts +++ b/packages/cli/test/context/ingest/wiki-sl-ref-repair.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it, vi } from 'vitest'; -import { repairWikiSlRefs } from './wiki-sl-ref-repair.js'; +import { repairWikiSlRefs } from '../../../src/context/ingest/wiki-sl-ref-repair.js'; describe('repairWikiSlRefs', () => { it('removes missing measure refs while keeping source, measure, segment, and manifest-backed refs', async () => { diff --git a/packages/cli/src/context/llm/ai-sdk-runtime.test.ts b/packages/cli/test/context/llm/ai-sdk-runtime.test.ts similarity index 98% rename from packages/cli/src/context/llm/ai-sdk-runtime.test.ts rename to packages/cli/test/context/llm/ai-sdk-runtime.test.ts index ba5e286d..5e5085ff 100644 --- a/packages/cli/src/context/llm/ai-sdk-runtime.test.ts +++ b/packages/cli/test/context/llm/ai-sdk-runtime.test.ts @@ -7,8 +7,8 @@ vi.mock('ai', () => ({ })); import { generateText } from 'ai'; -import { AiSdkKtxLlmRuntime } from './ai-sdk-runtime.js'; -import type { RunLoopStepInfo } from './runtime-port.js'; +import { AiSdkKtxLlmRuntime } from '../../../src/context/llm/ai-sdk-runtime.js'; +import type { RunLoopStepInfo } from '../../../src/context/llm/runtime-port.js'; describe('AiSdkKtxLlmRuntime.runAgentLoop', () => { let runtime: AiSdkKtxLlmRuntime; diff --git a/packages/cli/src/context/llm/claude-code-env.test.ts b/packages/cli/test/context/llm/claude-code-env.test.ts similarity index 92% rename from packages/cli/src/context/llm/claude-code-env.test.ts rename to packages/cli/test/context/llm/claude-code-env.test.ts index 19cbd1ff..9f015563 100644 --- a/packages/cli/src/context/llm/claude-code-env.test.ts +++ b/packages/cli/test/context/llm/claude-code-env.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { CLAUDE_CODE_PROVIDER_ENV_DENYLIST, createKtxClaudeCodeEnv } from './claude-code-env.js'; +import { CLAUDE_CODE_PROVIDER_ENV_DENYLIST, createKtxClaudeCodeEnv } from '../../../src/context/llm/claude-code-env.js'; describe('createKtxClaudeCodeEnv', () => { it('strips provider-routing credentials from the Claude Code child environment', () => { diff --git a/packages/cli/src/context/llm/claude-code-models.test.ts b/packages/cli/test/context/llm/claude-code-models.test.ts similarity index 85% rename from packages/cli/src/context/llm/claude-code-models.test.ts rename to packages/cli/test/context/llm/claude-code-models.test.ts index 482e6af8..34de9233 100644 --- a/packages/cli/src/context/llm/claude-code-models.test.ts +++ b/packages/cli/test/context/llm/claude-code-models.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { resolveClaudeCodeModel } from './claude-code-models.js'; +import { resolveClaudeCodeModel } from '../../../src/context/llm/claude-code-models.js'; describe('resolveClaudeCodeModel', () => { it.each([ diff --git a/packages/cli/src/context/llm/claude-code-runtime.test.ts b/packages/cli/test/context/llm/claude-code-runtime.test.ts similarity index 99% rename from packages/cli/src/context/llm/claude-code-runtime.test.ts rename to packages/cli/test/context/llm/claude-code-runtime.test.ts index b1003b78..205c74e0 100644 --- a/packages/cli/src/context/llm/claude-code-runtime.test.ts +++ b/packages/cli/test/context/llm/claude-code-runtime.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it, vi } from 'vitest'; import { z } from 'zod'; import type { SDKMessage } from '@anthropic-ai/claude-agent-sdk'; -import { ClaudeCodeKtxLlmRuntime, mapClaudeCodeStopReason, runClaudeCodeAuthProbe } from './claude-code-runtime.js'; +import { ClaudeCodeKtxLlmRuntime, mapClaudeCodeStopReason, runClaudeCodeAuthProbe } from '../../../src/context/llm/claude-code-runtime.js'; async function* stream(messages: SDKMessage[]): AsyncGenerator { for (const message of messages) { diff --git a/packages/cli/src/context/llm/debug-request-recorder.test.ts b/packages/cli/test/context/llm/debug-request-recorder.test.ts similarity index 98% rename from packages/cli/src/context/llm/debug-request-recorder.test.ts rename to packages/cli/test/context/llm/debug-request-recorder.test.ts index 4a00400f..e7a15b88 100644 --- a/packages/cli/src/context/llm/debug-request-recorder.test.ts +++ b/packages/cli/test/context/llm/debug-request-recorder.test.ts @@ -5,7 +5,7 @@ import { afterEach, describe, expect, it } from 'vitest'; import { createJsonlKtxLlmDebugRequestRecorder, summarizeKtxLlmDebugRequest, -} from './debug-request-recorder.js'; +} from '../../../src/context/llm/debug-request-recorder.js'; describe('summarizeKtxLlmDebugRequest', () => { it('records providerOptions positions without message text or tool schemas', () => { diff --git a/packages/cli/src/context/llm/embedding-port.test.ts b/packages/cli/test/context/llm/embedding-port.test.ts similarity index 95% rename from packages/cli/src/context/llm/embedding-port.test.ts rename to packages/cli/test/context/llm/embedding-port.test.ts index 323e7c5b..6bde1f2a 100644 --- a/packages/cli/src/context/llm/embedding-port.test.ts +++ b/packages/cli/test/context/llm/embedding-port.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it, vi } from 'vitest'; -import { KtxIngestEmbeddingPortAdapter, KtxScanEmbeddingPortAdapter } from './embedding-port.js'; +import { KtxIngestEmbeddingPortAdapter, KtxScanEmbeddingPortAdapter } from '../../../src/context/llm/embedding-port.js'; describe('KTX embedding port adapters', () => { it('adapts LLM modules embeddings to ingest embedding port shape', async () => { diff --git a/packages/cli/src/context/llm/local-config.test.ts b/packages/cli/test/context/llm/local-config.test.ts similarity index 98% rename from packages/cli/src/context/llm/local-config.test.ts rename to packages/cli/test/context/llm/local-config.test.ts index 930ee8a5..e153baaf 100644 --- a/packages/cli/src/context/llm/local-config.test.ts +++ b/packages/cli/test/context/llm/local-config.test.ts @@ -3,13 +3,13 @@ import { buildDefaultKtxProjectConfig, type KtxProjectEmbeddingConfig, type KtxProjectLlmConfig, -} from '../project/config.js'; +} from '../../../src/context/project/config.js'; import { createLocalKtxEmbeddingProviderFromConfig, createLocalKtxLlmProviderFromConfig, resolveLocalKtxEmbeddingConfig, resolveLocalKtxLlmConfig, -} from './local-config.js'; +} from '../../../src/context/llm/local-config.js'; describe('local KTX LLM config', () => { it('resolves env and file references into a KtxLlmConfig', () => { diff --git a/packages/cli/src/context/llm/runtime-local-config.test.ts b/packages/cli/test/context/llm/runtime-local-config.test.ts similarity index 93% rename from packages/cli/src/context/llm/runtime-local-config.test.ts rename to packages/cli/test/context/llm/runtime-local-config.test.ts index e5516ffa..9e432cec 100644 --- a/packages/cli/src/context/llm/runtime-local-config.test.ts +++ b/packages/cli/test/context/llm/runtime-local-config.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it, vi } from 'vitest'; -import { createLocalKtxLlmProviderFromConfig, createLocalKtxLlmRuntimeFromConfig } from './local-config.js'; +import { createLocalKtxLlmProviderFromConfig, createLocalKtxLlmRuntimeFromConfig } from '../../../src/context/llm/local-config.js'; describe('local KTX LLM runtime config', () => { it('creates a Claude Code runtime for claude-code backend without creating an AI SDK provider', () => { diff --git a/packages/cli/src/context/llm/runtime-tools.test.ts b/packages/cli/test/context/llm/runtime-tools.test.ts similarity index 91% rename from packages/cli/src/context/llm/runtime-tools.test.ts rename to packages/cli/test/context/llm/runtime-tools.test.ts index c1276d7d..f3c1b7f8 100644 --- a/packages/cli/src/context/llm/runtime-tools.test.ts +++ b/packages/cli/test/context/llm/runtime-tools.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it, vi } from 'vitest'; import { z } from 'zod'; -import { createAiSdkToolSet, createClaudeSdkTools, normalizeKtxRuntimeToolOutput } from './runtime-tools.js'; -import type { KtxRuntimeToolDescriptor } from './runtime-port.js'; +import { createAiSdkToolSet, createClaudeSdkTools, normalizeKtxRuntimeToolOutput } from '../../../src/context/llm/runtime-tools.js'; +import type { KtxRuntimeToolDescriptor } from '../../../src/context/llm/runtime-port.js'; describe('runtime tool descriptors', () => { const descriptor: KtxRuntimeToolDescriptor<{ id: string }, { ok: boolean }> = { diff --git a/packages/cli/test/context/mcp/__snapshots__/mcp-tools-list.json b/packages/cli/test/context/mcp/__snapshots__/mcp-tools-list.json new file mode 100644 index 00000000..10cb0b77 --- /dev/null +++ b/packages/cli/test/context/mcp/__snapshots__/mcp-tools-list.json @@ -0,0 +1,1620 @@ +[ + { + "name": "connection_list", + "title": "Connection List", + "description": "List configured read-only data connections available to this KTX project. Use this before connection-scoped tools when the project may have multiple warehouses.", + "inputSchema": { + "type": "object", + "properties": {}, + "$schema": "http://json-schema.org/draft-07/schema#" + }, + "outputSchema": { + "type": "object", + "properties": { + "connections": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "connectionType": { + "type": "string" + } + }, + "required": [ + "id", + "name", + "connectionType" + ], + "additionalProperties": false + } + } + }, + "required": [ + "connections" + ], + "$schema": "http://json-schema.org/draft-07/schema#", + "additionalProperties": false + }, + "annotations": { + "title": "Connection List", + "readOnlyHint": true, + "idempotentHint": true, + "openWorldHint": false + }, + "execution": { + "taskSupport": "forbidden" + } + }, + { + "name": "wiki_search", + "title": "Wiki Search", + "description": "Search KTX wiki pages for reusable business context. Example: wiki_search({ query: \"revenue recognition\", limit: 5 }).", + "inputSchema": { + "type": "object", + "properties": { + "query": { + "type": "string", + "minLength": 1, + "description": "Natural-language wiki search query, e.g. \"revenue recognition policy\"." + }, + "limit": { + "default": 10, + "description": "Maximum wiki pages to return. Defaults to 10.", + "type": "integer", + "minimum": 1, + "maximum": 50 + } + }, + "required": [ + "query" + ], + "$schema": "http://json-schema.org/draft-07/schema#" + }, + "outputSchema": { + "type": "object", + "properties": { + "results": { + "type": "array", + "items": { + "type": "object", + "properties": { + "key": { + "type": "string" + }, + "path": { + "type": "string" + }, + "scope": { + "type": "string", + "enum": [ + "GLOBAL", + "USER" + ] + }, + "summary": { + "type": "string" + }, + "score": { + "type": "number" + }, + "matchReasons": { + "type": "array", + "items": { + "type": "string" + } + }, + "lanes": { + "type": "array", + "items": { + "type": "object", + "properties": { + "lane": { + "type": "string" + }, + "status": { + "type": "string" + }, + "requestedCandidatePoolLimit": { + "type": "number" + }, + "effectiveCandidatePoolLimit": { + "type": "number" + }, + "returnedCandidateCount": { + "type": "number" + }, + "weight": { + "type": "number" + }, + "reason": { + "type": "string" + } + }, + "required": [ + "lane", + "status", + "requestedCandidatePoolLimit", + "effectiveCandidatePoolLimit", + "returnedCandidateCount", + "weight" + ], + "additionalProperties": false + } + } + }, + "required": [ + "key", + "path", + "scope", + "summary", + "score" + ], + "additionalProperties": false + } + }, + "totalFound": { + "type": "number" + } + }, + "required": [ + "results", + "totalFound" + ], + "$schema": "http://json-schema.org/draft-07/schema#", + "additionalProperties": false + }, + "annotations": { + "title": "Wiki Search", + "readOnlyHint": true, + "openWorldHint": false + }, + "execution": { + "taskSupport": "forbidden" + } + }, + { + "name": "wiki_read", + "title": "Wiki Read", + "description": "Read a KTX wiki page by key returned from wiki_search. Example: wiki_read({ key: \"global/revenue\" }).", + "inputSchema": { + "type": "object", + "properties": { + "key": { + "type": "string", + "minLength": 1, + "description": "Wiki page key returned by wiki_search, e.g. \"global/revenue\"." + } + }, + "required": [ + "key" + ], + "$schema": "http://json-schema.org/draft-07/schema#" + }, + "outputSchema": { + "type": "object", + "properties": { + "key": { + "type": "string" + }, + "summary": { + "type": "string" + }, + "content": { + "type": "string" + }, + "scope": { + "type": "string", + "enum": [ + "GLOBAL", + "USER" + ] + }, + "tags": { + "type": "array", + "items": { + "type": "string" + } + }, + "refs": { + "type": "array", + "items": { + "type": "string" + } + }, + "slRefs": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "key", + "summary", + "content", + "scope" + ], + "$schema": "http://json-schema.org/draft-07/schema#", + "additionalProperties": false + }, + "annotations": { + "title": "Wiki Read", + "readOnlyHint": true, + "idempotentHint": true, + "openWorldHint": false + }, + "execution": { + "taskSupport": "forbidden" + } + }, + { + "name": "sl_read_source", + "title": "Semantic Layer Read Source", + "description": "Read a semantic-layer YAML source by connection id and source name. Example: sl_read_source({ connectionId: \"warehouse\", sourceName: \"orders\" }).", + "inputSchema": { + "type": "object", + "properties": { + "connectionId": { + "type": "string", + "minLength": 1, + "description": "Connection id that owns the semantic-layer source." + }, + "sourceName": { + "type": "string", + "minLength": 1, + "description": "Semantic-layer source name without \".yaml\", e.g. \"orders\"." + } + }, + "required": [ + "connectionId", + "sourceName" + ], + "$schema": "http://json-schema.org/draft-07/schema#" + }, + "outputSchema": { + "type": "object", + "properties": { + "sourceName": { + "type": "string" + }, + "yaml": { + "type": "string" + } + }, + "required": [ + "sourceName", + "yaml" + ], + "$schema": "http://json-schema.org/draft-07/schema#", + "additionalProperties": false + }, + "annotations": { + "title": "Semantic Layer Read Source", + "readOnlyHint": true, + "idempotentHint": true, + "openWorldHint": false + }, + "execution": { + "taskSupport": "forbidden" + } + }, + { + "name": "sl_query", + "title": "Semantic Layer Query", + "description": "Execute a semantic-layer query and return rows, headers, generated SQL, and plan details. Example: sl_query({ connectionId: \"warehouse\", measures: [\"orders.order_count\"], dimensions: [{ field: \"orders.created_at\", granularity: \"month\" }] }).", + "inputSchema": { + "type": "object", + "properties": { + "connectionId": { + "description": "Connection id to query. Omit only when the project has exactly one configured connection.", + "type": "string", + "minLength": 1 + }, + "measures": { + "minItems": 1, + "type": "array", + "items": { + "anyOf": [ + { + "type": "string", + "description": "Semantic-layer measure key, e.g. \"orders.order_count\"." + }, + { + "type": "object", + "properties": { + "expr": { + "type": "string", + "minLength": 1, + "description": "Ad hoc aggregate expression, e.g. \"sum(orders.amount)\"." + }, + "name": { + "type": "string", + "minLength": 1, + "description": "Alias for the ad hoc measure, e.g. \"gross_revenue\"." + } + }, + "required": [ + "expr", + "name" + ] + } + ] + }, + "description": "Measures to select. Use semantic-layer keys when available." + }, + "dimensions": { + "default": [], + "description": "Dimensions to group by. Use {field, granularity?} entries.", + "type": "array", + "items": { + "type": "object", + "properties": { + "field": { + "type": "string", + "minLength": 1, + "description": "Dimension to group by, e.g. \"orders.created_at\" or \"orders.status\"." + }, + "granularity": { + "description": "Time grain for time dimensions: day, week, month, quarter, or year.", + "type": "string", + "minLength": 1 + } + }, + "required": [ + "field" + ] + } + }, + "filters": { + "default": [], + "description": "Semantic-layer filter expressions to apply.", + "type": "array", + "items": { + "type": "string", + "description": "Semantic-layer filter expression, e.g. \"orders.status = paid\"." + } + }, + "segments": { + "default": [], + "description": "Semantic-layer segment keys to apply.", + "type": "array", + "items": { + "type": "string", + "description": "Semantic-layer segment key to apply." + } + }, + "order_by": { + "default": [], + "description": "Sort clauses. Use {field, direction?} entries.", + "type": "array", + "items": { + "type": "object", + "properties": { + "field": { + "type": "string", + "minLength": 1, + "description": "Field/measure/dimension id to order by, e.g. \"orders.created_at\", a dimension key like \"mart_nrr_quarterly.quarter_label\", or a measure alias." + }, + "direction": { + "default": "asc", + "description": "Sort direction: \"asc\" or \"desc\". Defaults to \"asc\".", + "type": "string", + "enum": [ + "asc", + "desc" + ] + } + }, + "required": [ + "field" + ] + } + }, + "limit": { + "default": 1000, + "description": "Maximum rows to return. Defaults to 1000.", + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "include_empty": { + "default": true, + "description": "Whether to include empty dimension groups. Defaults to true.", + "type": "boolean" + } + }, + "required": [ + "measures" + ], + "$schema": "http://json-schema.org/draft-07/schema#" + }, + "outputSchema": { + "type": "object", + "properties": { + "connectionId": { + "type": "string" + }, + "dialect": { + "type": "string" + }, + "sql": { + "type": "string" + }, + "headers": { + "type": "array", + "items": { + "type": "string" + } + }, + "rows": { + "type": "array", + "items": { + "type": "array", + "items": {} + } + }, + "totalRows": { + "type": "number" + }, + "plan": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + }, + "required": [ + "sql", + "headers", + "rows", + "totalRows" + ], + "$schema": "http://json-schema.org/draft-07/schema#", + "additionalProperties": false + }, + "annotations": { + "title": "Semantic Layer Query", + "readOnlyHint": true, + "openWorldHint": false + }, + "execution": { + "taskSupport": "forbidden" + } + }, + { + "name": "entity_details", + "title": "Entity Details", + "description": "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\"] }] }).", + "inputSchema": { + "type": "object", + "properties": { + "connectionId": { + "type": "string", + "minLength": 1, + "description": "Connection id whose latest scan snapshot should be read." + }, + "entities": { + "minItems": 1, + "maxItems": 20, + "type": "array", + "items": { + "type": "object", + "properties": { + "table": { + "anyOf": [ + { + "type": "string", + "minLength": 1 + }, + { + "type": "object", + "properties": { + "catalog": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Catalog/project/database. Use null when not applicable." + }, + "db": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Schema/database/dataset. Use null when not applicable." + }, + "name": { + "type": "string", + "minLength": 1, + "description": "Table name." + } + }, + "required": [ + "catalog", + "db", + "name" + ] + } + ], + "description": "Table display string or canonical object ref." + }, + "columns": { + "description": "Optional column filter.", + "type": "array", + "items": { + "type": "string", + "minLength": 1, + "description": "Column name to inspect." + } + } + }, + "required": [ + "table" + ] + }, + "description": "Tables or columns to inspect. Maximum 20 entities." + } + }, + "required": [ + "connectionId", + "entities" + ], + "$schema": "http://json-schema.org/draft-07/schema#" + }, + "outputSchema": { + "type": "object", + "properties": { + "results": { + "type": "array", + "items": { + "anyOf": [ + { + "type": "object", + "properties": { + "ok": { + "type": "boolean", + "const": true + }, + "connectionId": { + "type": "string" + }, + "tableRef": { + "type": "object", + "properties": { + "catalog": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "db": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "name": { + "type": "string" + } + }, + "required": [ + "catalog", + "db", + "name" + ], + "additionalProperties": false + }, + "display": { + "type": "string" + }, + "kind": { + "type": "string", + "enum": [ + "table", + "view", + "external", + "event_stream" + ] + }, + "comment": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "estimatedRows": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ] + }, + "columns": { + "type": "array", + "items": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "nativeType": { + "type": "string" + }, + "normalizedType": { + "type": "string" + }, + "dimensionType": { + "type": "string", + "enum": [ + "time", + "string", + "number", + "boolean" + ] + }, + "nullable": { + "type": "boolean" + }, + "primaryKey": { + "type": "boolean" + }, + "comment": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "name", + "nativeType", + "normalizedType", + "dimensionType", + "nullable", + "primaryKey", + "comment" + ], + "additionalProperties": false + } + }, + "foreignKeys": { + "type": "array", + "items": { + "type": "object", + "properties": { + "fromColumn": { + "type": "string" + }, + "toCatalog": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "toDb": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "toTable": { + "type": "string" + }, + "toColumn": { + "type": "string" + }, + "constraintName": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "fromColumn", + "toCatalog", + "toDb", + "toTable", + "toColumn", + "constraintName" + ], + "additionalProperties": false + } + }, + "snapshot": { + "type": "object", + "properties": { + "syncId": { + "type": "string" + }, + "extractedAt": { + "type": "string" + }, + "scanRunId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "syncId", + "extractedAt", + "scanRunId" + ], + "additionalProperties": false + } + }, + "required": [ + "ok", + "connectionId", + "tableRef", + "display", + "kind", + "comment", + "estimatedRows", + "columns", + "foreignKeys", + "snapshot" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "ok": { + "type": "boolean", + "const": false + }, + "connectionId": { + "type": "string" + }, + "table": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "object", + "properties": { + "catalog": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "db": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "name": { + "type": "string" + } + }, + "required": [ + "catalog", + "db", + "name" + ], + "additionalProperties": false + } + ] + }, + "snapshot": { + "type": "object", + "properties": { + "syncId": { + "type": "string" + }, + "extractedAt": { + "type": "string" + }, + "scanRunId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "syncId", + "extractedAt", + "scanRunId" + ], + "additionalProperties": false + }, + "error": { + "type": "object", + "properties": { + "code": { + "type": "string", + "enum": [ + "scan_missing", + "table_not_found", + "ambiguous_table", + "column_not_found" + ] + }, + "message": { + "type": "string" + }, + "candidates": { + "anyOf": [ + { + "type": "array", + "items": { + "type": "object", + "properties": { + "tableRef": { + "type": "object", + "properties": { + "catalog": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "db": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "name": { + "type": "string" + } + }, + "required": [ + "catalog", + "db", + "name" + ], + "additionalProperties": false + }, + "display": { + "type": "string" + } + }, + "required": [ + "tableRef", + "display" + ], + "additionalProperties": false + } + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + } + }, + "required": [ + "code", + "message" + ], + "additionalProperties": false + } + }, + "required": [ + "ok", + "connectionId", + "table", + "error" + ], + "additionalProperties": false + } + ] + } + } + }, + "required": [ + "results" + ], + "$schema": "http://json-schema.org/draft-07/schema#", + "additionalProperties": false + }, + "annotations": { + "title": "Entity Details", + "readOnlyHint": true, + "idempotentHint": true, + "openWorldHint": false + }, + "execution": { + "taskSupport": "forbidden" + } + }, + { + "name": "dictionary_search", + "title": "Dictionary Search", + "description": "Search profile-sampled warehouse values to locate likely source columns for business values. Example: dictionary_search({ values: [\"Acme Corp\"], connectionId: \"warehouse\" }).", + "inputSchema": { + "type": "object", + "properties": { + "values": { + "minItems": 1, + "maxItems": 20, + "type": "array", + "items": { + "type": "string", + "minLength": 1, + "description": "Business value to locate, e.g. \"Acme Corp\" or \"enterprise\"." + }, + "description": "Values to search for in sampled warehouse dictionaries." + }, + "connectionId": { + "description": "Optional connection id. Pass it when user intent pins a specific warehouse.", + "type": "string", + "minLength": 1 + } + }, + "required": [ + "values" + ], + "$schema": "http://json-schema.org/draft-07/schema#" + }, + "outputSchema": { + "type": "object", + "properties": { + "searched": { + "type": "array", + "items": { + "type": "object", + "properties": { + "connectionId": { + "type": "string" + }, + "coverage": { + "type": "object", + "properties": { + "sampledRows": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ] + }, + "valuesPerColumn": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ] + }, + "profiledColumns": { + "type": "number" + }, + "syncId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "profiledAt": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "sampledRows", + "valuesPerColumn", + "profiledColumns", + "syncId", + "profiledAt" + ], + "additionalProperties": false + }, + "status": { + "type": "string", + "enum": [ + "ready", + "no_profile_artifact", + "no_candidate_columns" + ] + } + }, + "required": [ + "connectionId", + "coverage", + "status" + ], + "additionalProperties": false + } + }, + "results": { + "type": "array", + "items": { + "type": "object", + "properties": { + "value": { + "type": "string" + }, + "matches": { + "type": "array", + "items": { + "type": "object", + "properties": { + "connectionId": { + "type": "string" + }, + "sourceName": { + "type": "string" + }, + "columnName": { + "type": "string" + }, + "matchedValue": { + "type": "string" + }, + "cardinality": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "connectionId", + "sourceName", + "columnName", + "matchedValue", + "cardinality" + ], + "additionalProperties": false + } + }, + "misses": { + "type": "array", + "items": { + "type": "object", + "properties": { + "connectionId": { + "type": "string" + }, + "reason": { + "type": "string", + "enum": [ + "no_profile_artifact", + "no_candidate_columns", + "value_not_in_sample" + ] + } + }, + "required": [ + "connectionId", + "reason" + ], + "additionalProperties": false + } + } + }, + "required": [ + "value", + "matches", + "misses" + ], + "additionalProperties": false + } + } + }, + "required": [ + "searched", + "results" + ], + "$schema": "http://json-schema.org/draft-07/schema#", + "additionalProperties": false + }, + "annotations": { + "title": "Dictionary Search", + "readOnlyHint": true, + "openWorldHint": false + }, + "execution": { + "taskSupport": "forbidden" + } + }, + { + "name": "discover_data", + "title": "Discover Data", + "description": "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\"] }).", + "inputSchema": { + "type": "object", + "properties": { + "query": { + "type": "string", + "minLength": 1, + "description": "Natural-language discovery query, e.g. \"monthly orders by customer\"." + }, + "connectionId": { + "description": "Optional connection id. Pass it when user intent pins a specific warehouse.", + "type": "string", + "minLength": 1 + }, + "kinds": { + "description": "Optional kind filter.", + "type": "array", + "items": { + "type": "string", + "enum": [ + "wiki", + "sl_source", + "sl_measure", + "sl_dimension", + "table", + "column" + ], + "description": "Reference kind to include." + } + }, + "limit": { + "description": "Maximum refs to return. Defaults to 15.", + "default": 15, + "type": "integer", + "minimum": 1, + "maximum": 50 + } + }, + "required": [ + "query" + ], + "$schema": "http://json-schema.org/draft-07/schema#" + }, + "outputSchema": { + "type": "object", + "properties": { + "refs": { + "type": "array", + "items": { + "type": "object", + "properties": { + "kind": { + "type": "string", + "enum": [ + "wiki", + "sl_source", + "sl_measure", + "sl_dimension", + "table", + "column" + ] + }, + "id": { + "type": "string" + }, + "score": { + "type": "number" + }, + "summary": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "snippet": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "matchedOn": { + "type": "string", + "enum": [ + "name", + "display", + "description", + "comment", + "expr", + "sample_value", + "body" + ] + }, + "connectionId": { + "type": "string" + }, + "tableRef": { + "type": "object", + "properties": { + "catalog": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "db": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "name": { + "type": "string" + } + }, + "required": [ + "catalog", + "db", + "name" + ], + "additionalProperties": false + }, + "columnName": { + "type": "string" + } + }, + "required": [ + "kind", + "id", + "score", + "summary", + "snippet", + "matchedOn" + ], + "additionalProperties": false + } + } + }, + "required": [ + "refs" + ], + "$schema": "http://json-schema.org/draft-07/schema#", + "additionalProperties": false + }, + "annotations": { + "title": "Discover Data", + "readOnlyHint": true, + "openWorldHint": false + }, + "execution": { + "taskSupport": "forbidden" + } + }, + { + "name": "sql_execution", + "title": "SQL Execution", + "description": "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 }).", + "inputSchema": { + "type": "object", + "properties": { + "connectionId": { + "type": "string", + "minLength": 1, + "description": "Connection id to execute against. Required for raw SQL." + }, + "sql": { + "type": "string", + "minLength": 1, + "description": "Parser-validated read-only SQL, e.g. \"select count(*) from public.orders\"." + }, + "maxRows": { + "description": "Maximum rows to return. Defaults to 1000.", + "default": 1000, + "type": "integer", + "minimum": 1, + "maximum": 10000 + } + }, + "required": [ + "connectionId", + "sql" + ], + "$schema": "http://json-schema.org/draft-07/schema#" + }, + "outputSchema": { + "type": "object", + "properties": { + "headers": { + "type": "array", + "items": { + "type": "string" + } + }, + "headerTypes": { + "type": "array", + "items": { + "type": "string" + } + }, + "rows": { + "type": "array", + "items": { + "type": "array", + "items": {} + } + }, + "rowCount": { + "type": "number" + } + }, + "required": [ + "headers", + "rows", + "rowCount" + ], + "$schema": "http://json-schema.org/draft-07/schema#", + "additionalProperties": false + }, + "annotations": { + "title": "SQL Execution", + "readOnlyHint": true, + "openWorldHint": false + }, + "execution": { + "taskSupport": "forbidden" + } + }, + { + "name": "memory_ingest", + "title": "Memory Ingest", + "description": "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.\" }).", + "inputSchema": { + "type": "object", + "properties": { + "content": { + "type": "string", + "minLength": 1, + "description": "Free-form markdown to ingest. Include the knowledge itself plus any context (source, the user question, why this came up) that the memory agent should consider when triaging into wiki/SL." + }, + "connectionId": { + "description": "Scope this memory to a specific connection. Required when the knowledge is warehouse-specific, including measure definitions, schema gotchas, or anything tied to a particular warehouse. Omit only for global wiki knowledge.", + "type": "string", + "minLength": 1 + } + }, + "required": [ + "content" + ], + "$schema": "http://json-schema.org/draft-07/schema#" + }, + "outputSchema": { + "type": "object", + "properties": { + "runId": { + "type": "string" + } + }, + "required": [ + "runId" + ], + "$schema": "http://json-schema.org/draft-07/schema#", + "additionalProperties": false + }, + "annotations": { + "title": "Memory Ingest", + "destructiveHint": true, + "openWorldHint": false + }, + "execution": { + "taskSupport": "forbidden" + } + }, + { + "name": "memory_ingest_status", + "title": "Memory Ingest Status", + "description": "Read the current or final status for a memory ingest run. Example: memory_ingest_status({ runId: \"memory-run-1\" }).", + "inputSchema": { + "type": "object", + "properties": { + "runId": { + "type": "string", + "minLength": 1, + "description": "The memory ingest run id returned by memory_ingest." + } + }, + "required": [ + "runId" + ], + "$schema": "http://json-schema.org/draft-07/schema#" + }, + "outputSchema": { + "type": "object", + "properties": { + "runId": { + "type": "string" + }, + "status": { + "type": "string", + "enum": [ + "running", + "done", + "error" + ] + }, + "stage": { + "type": "string" + }, + "done": { + "type": "boolean" + }, + "captured": { + "type": "object", + "properties": { + "wiki": { + "type": "array", + "items": { + "type": "string" + } + }, + "sl": { + "type": "array", + "items": { + "type": "string" + } + }, + "xrefs": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "wiki", + "sl", + "xrefs" + ], + "additionalProperties": false + }, + "error": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "commitHash": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "skillsLoaded": { + "type": "array", + "items": { + "type": "string" + } + }, + "signalDetected": { + "type": "boolean" + } + }, + "required": [ + "runId", + "status", + "stage", + "done", + "captured", + "error", + "commitHash", + "skillsLoaded", + "signalDetected" + ], + "$schema": "http://json-schema.org/draft-07/schema#", + "additionalProperties": false + }, + "annotations": { + "title": "Memory Ingest Status", + "readOnlyHint": true, + "openWorldHint": false + }, + "execution": { + "taskSupport": "forbidden" + } + } +] diff --git a/packages/cli/src/context/mcp/local-project-ports.test.ts b/packages/cli/test/context/mcp/local-project-ports.test.ts similarity index 98% rename from packages/cli/src/context/mcp/local-project-ports.test.ts rename to packages/cli/test/context/mcp/local-project-ports.test.ts index aa06b47e..afe85b4c 100644 --- a/packages/cli/src/context/mcp/local-project-ports.test.ts +++ b/packages/cli/test/context/mcp/local-project-ports.test.ts @@ -2,10 +2,10 @@ import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { initKtxProject } from '../../context/project/project.js'; -import { createKtxConnectorCapabilities, type KtxQueryResult, type KtxScanConnector, type KtxSchemaSnapshot } from '../../context/scan/types.js'; -import { writeLocalSlSource } from '../../context/sl/local-sl.js'; -import { createLocalProjectMcpContextPorts } from './local-project-ports.js'; +import { initKtxProject } from '../../../src/context/project/project.js'; +import { createKtxConnectorCapabilities, type KtxQueryResult, type KtxScanConnector, type KtxSchemaSnapshot } from '../../../src/context/scan/types.js'; +import { writeLocalSlSource } from '../../../src/context/sl/local-sl.js'; +import { createLocalProjectMcpContextPorts } from '../../../src/context/mcp/local-project-ports.js'; describe('createLocalProjectMcpContextPorts', () => { let tempDir: string; @@ -56,6 +56,8 @@ describe('createLocalProjectMcpContextPorts', () => { driver: snapshot.driver, capabilities: createKtxConnectorCapabilities({ readOnlySql: queryResult !== undefined }), introspect: vi.fn(async () => snapshot), + listSchemas: vi.fn(async () => []), + listTables: vi.fn(async () => []), executeReadOnly: queryResult === undefined ? undefined : vi.fn(async () => queryResult), cleanup: vi.fn(async () => {}), }; diff --git a/packages/cli/src/context/mcp/server.test.ts b/packages/cli/test/context/mcp/server.test.ts similarity index 98% rename from packages/cli/src/context/mcp/server.test.ts rename to packages/cli/test/context/mcp/server.test.ts index e6666364..f6d3e37e 100644 --- a/packages/cli/src/context/mcp/server.test.ts +++ b/packages/cli/test/context/mcp/server.test.ts @@ -4,12 +4,12 @@ import { join } from 'node:path'; import { Client } from '@modelcontextprotocol/sdk/client/index.js'; import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js'; import { afterEach, describe, expect, it, vi } from 'vitest'; -import { createLocalProjectMemoryIngest } from '../../context/memory/local-memory.js'; -import { detectCaptureSignals } from '../../context/memory/capture-signals.js'; -import type { MemoryAgentInput } from '../../context/memory/types.js'; -import { initKtxProject } from '../../context/project/project.js'; -import { jsonToolResult } from './context-tools.js'; -import { createDefaultKtxMcpServer, createKtxMcpServer } from './server.js'; +import { createLocalProjectMemoryIngest } from '../../../src/context/memory/local-memory.js'; +import { detectCaptureSignals } from '../../../src/context/memory/capture-signals.js'; +import type { MemoryAgentInput } from '../../../src/context/memory/types.js'; +import { initKtxProject } from '../../../src/context/project/project.js'; +import { jsonToolResult } from '../../../src/context/mcp/context-tools.js'; +import { createDefaultKtxMcpServer, createKtxMcpServer } from '../../../src/context/mcp/server.js'; import type { KtxDiscoverDataMcpPort, KtxDictionarySearchMcpPort, @@ -21,7 +21,7 @@ import type { KtxSqlExecutionMcpPort, KtxSqlExecutionResponse, MemoryIngestPort, -} from './types.js'; +} from '../../../src/context/mcp/types.js'; type RegisteredTool = { name: string; diff --git a/packages/cli/src/context/memory/local-memory.test.ts b/packages/cli/test/context/memory/local-memory.test.ts similarity index 96% rename from packages/cli/src/context/memory/local-memory.test.ts rename to packages/cli/test/context/memory/local-memory.test.ts index 1a7240c9..23a93cc4 100644 --- a/packages/cli/src/context/memory/local-memory.test.ts +++ b/packages/cli/test/context/memory/local-memory.test.ts @@ -2,9 +2,9 @@ import { access, mkdtemp, readFile, rm } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { initKtxProject } from '../../context/project/project.js'; -import { createLocalProjectMemoryIngest } from './local-memory.js'; -import { LocalMemoryRunStore } from './local-memory-runs.js'; +import { initKtxProject } from '../../../src/context/project/project.js'; +import { createLocalProjectMemoryIngest } from '../../../src/context/memory/local-memory.js'; +import { LocalMemoryRunStore } from '../../../src/context/memory/local-memory-runs.js'; vi.mock('ai', () => ({ generateText: vi.fn().mockResolvedValue({ text: '', toolCalls: [] }), diff --git a/packages/cli/src/context/memory/memory-agent.service.ingest.test.ts b/packages/cli/test/context/memory/memory-agent.service.ingest.test.ts similarity index 98% rename from packages/cli/src/context/memory/memory-agent.service.ingest.test.ts rename to packages/cli/test/context/memory/memory-agent.service.ingest.test.ts index 1c13bdd2..acb1c2f8 100644 --- a/packages/cli/src/context/memory/memory-agent.service.ingest.test.ts +++ b/packages/cli/test/context/memory/memory-agent.service.ingest.test.ts @@ -13,8 +13,8 @@ vi.mock('ai', () => ({ // Imported AFTER vi.mock so the mocked module is used. import { generateText } from 'ai'; -import { SYSTEM_GIT_AUTHOR } from '../../context/tools/authors.js'; -import { MemoryAgentService } from './memory-agent.service.js'; +import { SYSTEM_GIT_AUTHOR } from '../../../src/context/tools/authors.js'; +import { MemoryAgentService } from '../../../src/context/memory/memory-agent.service.js'; interface BuiltMocks { appSettings: any; diff --git a/packages/cli/src/context/memory/memory-agent.service.test.ts b/packages/cli/test/context/memory/memory-agent.service.test.ts similarity index 98% rename from packages/cli/src/context/memory/memory-agent.service.test.ts rename to packages/cli/test/context/memory/memory-agent.service.test.ts index cea83674..ba91444e 100644 --- a/packages/cli/src/context/memory/memory-agent.service.test.ts +++ b/packages/cli/test/context/memory/memory-agent.service.test.ts @@ -1,8 +1,8 @@ import { describe, expect, it, vi } from 'vitest'; -import { validateSingleSource } from '../../context/sl/tools/sl-warehouse-validation.js'; -import { createTouchedSlSources, hasTouchedSlSource } from '../../context/tools/touched-sl-sources.js'; -import { detectCaptureSignals, isWorthAnalyzing } from './capture-signals.js'; -import { MemoryAgentService } from './memory-agent.service.js'; +import { validateSingleSource } from '../../../src/context/sl/tools/sl-warehouse-validation.js'; +import { createTouchedSlSources, hasTouchedSlSource } from '../../../src/context/tools/touched-sl-sources.js'; +import { detectCaptureSignals, isWorthAnalyzing } from '../../../src/context/memory/capture-signals.js'; +import { MemoryAgentService } from '../../../src/context/memory/memory-agent.service.js'; const passthroughValidator = { validateSingleSource: (d: unknown, c: string, n: string) => validateSingleSource(d as never, c, n), diff --git a/packages/cli/src/context/memory/memory-runs.test.ts b/packages/cli/test/context/memory/memory-runs.test.ts similarity index 95% rename from packages/cli/src/context/memory/memory-runs.test.ts rename to packages/cli/test/context/memory/memory-runs.test.ts index e4d0d4ba..b515750e 100644 --- a/packages/cli/src/context/memory/memory-runs.test.ts +++ b/packages/cli/test/context/memory/memory-runs.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it, vi } from 'vitest'; -import type { MemoryAgentInput, MemoryAgentResult } from '../../context/memory/types.js'; -import type { MemoryAgentService } from '../../context/memory/memory-agent.service.js'; -import { MemoryIngestService, type MemoryRunStorePort } from './memory-runs.js'; +import type { MemoryAgentInput, MemoryAgentResult } from '../../../src/context/memory/types.js'; +import type { MemoryAgentService } from '../../../src/context/memory/memory-agent.service.js'; +import { MemoryIngestService, type MemoryRunStorePort } from '../../../src/context/memory/memory-runs.js'; class InMemoryRunStore implements MemoryRunStorePort { readonly rows = new Map< diff --git a/packages/cli/src/context/memory/memory-runtime-assets.test.ts b/packages/cli/test/context/memory/memory-runtime-assets.test.ts similarity index 94% rename from packages/cli/src/context/memory/memory-runtime-assets.test.ts rename to packages/cli/test/context/memory/memory-runtime-assets.test.ts index 55d9047c..ab6ff324 100644 --- a/packages/cli/src/context/memory/memory-runtime-assets.test.ts +++ b/packages/cli/test/context/memory/memory-runtime-assets.test.ts @@ -2,13 +2,13 @@ import { readFile } from 'node:fs/promises'; import { join } from 'node:path'; import { fileURLToPath } from 'node:url'; import { describe, expect, it } from 'vitest'; -import { PromptService } from '../../context/prompts/prompt.service.js'; -import { SkillsRegistryService } from '../../context/skills/skills-registry.service.js'; -import { DEFAULT_SKILL_NAMES, promptNameFor } from '../../context/memory/capture-signals.js'; -import type { MemoryAgentSourceType } from '../../context/memory/types.js'; +import { PromptService } from '../../../src/context/prompts/prompt.service.js'; +import { SkillsRegistryService } from '../../../src/context/skills/skills-registry.service.js'; +import { DEFAULT_SKILL_NAMES, promptNameFor } from '../../../src/context/memory/capture-signals.js'; +import type { MemoryAgentSourceType } from '../../../src/context/memory/types.js'; -const promptsDir = fileURLToPath(new URL('../../prompts', import.meta.url)); -const skillsDir = fileURLToPath(new URL('../../skills', import.meta.url)); +const promptsDir = fileURLToPath(new URL('../../../src/prompts', import.meta.url)); +const skillsDir = fileURLToPath(new URL('../../../src/skills', import.meta.url)); const memorySourceTypes: MemoryAgentSourceType[] = ['research', 'external_ingest', 'backfill']; const expectedSkillHeadings: Record = { wiki_capture: '# Wiki Capture', diff --git a/packages/cli/src/context/project/config.test.ts b/packages/cli/test/context/project/config.test.ts similarity index 99% rename from packages/cli/src/context/project/config.test.ts rename to packages/cli/test/context/project/config.test.ts index 28e00f74..b2ea498c 100644 --- a/packages/cli/src/context/project/config.test.ts +++ b/packages/cli/test/context/project/config.test.ts @@ -5,7 +5,7 @@ import { parseKtxProjectConfig, serializeKtxProjectConfig, validateKtxProjectConfig, -} from './config.js'; +} from '../../../src/context/project/config.js'; describe('KTX project config', () => { it.each(['status', 'replay', 'run', 'watch'])('accepts former ingest subcommand name "%s" as a connection id', (connectionId) => { diff --git a/packages/cli/src/context/project/driver-schemas.test.ts b/packages/cli/test/context/project/driver-schemas.test.ts similarity index 98% rename from packages/cli/src/context/project/driver-schemas.test.ts rename to packages/cli/test/context/project/driver-schemas.test.ts index 1c5b1276..c83a27a1 100644 --- a/packages/cli/src/context/project/driver-schemas.test.ts +++ b/packages/cli/test/context/project/driver-schemas.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { connectionConfigSchema } from './driver-schemas.js'; +import { connectionConfigSchema } from '../../../src/context/project/driver-schemas.js'; describe('connectionConfigSchema (driver discriminated union)', () => { it.each([ diff --git a/packages/cli/src/context/project/local-git-file-store.test.ts b/packages/cli/test/context/project/local-git-file-store.test.ts similarity index 94% rename from packages/cli/src/context/project/local-git-file-store.test.ts rename to packages/cli/test/context/project/local-git-file-store.test.ts index 1bee3c1e..9a7a6948 100644 --- a/packages/cli/src/context/project/local-git-file-store.test.ts +++ b/packages/cli/test/context/project/local-git-file-store.test.ts @@ -2,9 +2,9 @@ import { mkdtemp, readFile, rm, stat } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; -import { GitService } from '../../context/core/git.service.js'; -import type { KtxCoreConfig } from '../../context/core/config.js'; -import { LocalGitFileStore } from './local-git-file-store.js'; +import { GitService } from '../../../src/context/core/git.service.js'; +import type { KtxCoreConfig } from '../../../src/context/core/config.js'; +import { LocalGitFileStore } from '../../../src/context/project/local-git-file-store.js'; describe('LocalGitFileStore', () => { let tempDir: string; diff --git a/packages/cli/src/context/project/mappings-yaml-schema.test.ts b/packages/cli/test/context/project/mappings-yaml-schema.test.ts similarity index 98% rename from packages/cli/src/context/project/mappings-yaml-schema.test.ts rename to packages/cli/test/context/project/mappings-yaml-schema.test.ts index f7001a70..917cd808 100644 --- a/packages/cli/src/context/project/mappings-yaml-schema.test.ts +++ b/packages/cli/test/context/project/mappings-yaml-schema.test.ts @@ -7,7 +7,7 @@ import { parseLookmlMappingBootstrap, parseLookerMappingBootstrap, parseMetabaseMappingBootstrap, -} from './mappings-yaml-schema.js'; +} from '../../../src/context/project/mappings-yaml-schema.js'; describe('ktx.yaml mapping bootstrap schema', () => { it('parses Metabase mapping intent with CLI syncMode default ALL', () => { diff --git a/packages/cli/src/context/project/project.test.ts b/packages/cli/test/context/project/project.test.ts similarity index 96% rename from packages/cli/src/context/project/project.test.ts rename to packages/cli/test/context/project/project.test.ts index 21e27d6a..668fa264 100644 --- a/packages/cli/src/context/project/project.test.ts +++ b/packages/cli/test/context/project/project.test.ts @@ -2,7 +2,7 @@ import { mkdtemp, readFile, rm, stat } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; -import { initKtxProject, loadKtxProject } from './project.js'; +import { initKtxProject, loadKtxProject } from '../../../src/context/project/project.js'; describe('KTX local project runtime', () => { let tempDir: string; diff --git a/packages/cli/src/context/project/setup-config.test.ts b/packages/cli/test/context/project/setup-config.test.ts similarity index 93% rename from packages/cli/src/context/project/setup-config.test.ts rename to packages/cli/test/context/project/setup-config.test.ts index 88c5376e..948d9d54 100644 --- a/packages/cli/src/context/project/setup-config.test.ts +++ b/packages/cli/test/context/project/setup-config.test.ts @@ -2,13 +2,13 @@ import { mkdtemp, readFile, rm } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; -import { buildDefaultKtxProjectConfig } from './config.js'; +import { buildDefaultKtxProjectConfig } from '../../../src/context/project/config.js'; import { markKtxSetupStateStepComplete, mergeKtxSetupGitignoreEntries, readKtxSetupState, setKtxSetupDatabaseConnectionIds, -} from './setup-config.js'; +} from '../../../src/context/project/setup-config.js'; describe('KTX setup config helpers', () => { let tempDir: string; diff --git a/packages/cli/src/context/prompts/prompt.service.test.ts b/packages/cli/test/context/prompts/prompt.service.test.ts similarity index 95% rename from packages/cli/src/context/prompts/prompt.service.test.ts rename to packages/cli/test/context/prompts/prompt.service.test.ts index 046b777b..df2e407c 100644 --- a/packages/cli/src/context/prompts/prompt.service.test.ts +++ b/packages/cli/test/context/prompts/prompt.service.test.ts @@ -2,7 +2,7 @@ import { mkdtemp, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; -import { PromptService } from './prompt.service.js'; +import { PromptService } from '../../../src/context/prompts/prompt.service.js'; describe('PromptService', () => { let dir: string; diff --git a/packages/cli/src/context/scan/constraint-discovery.test.ts b/packages/cli/test/context/scan/constraint-discovery.test.ts similarity index 97% rename from packages/cli/src/context/scan/constraint-discovery.test.ts rename to packages/cli/test/context/scan/constraint-discovery.test.ts index 78620204..0a06f1f1 100644 --- a/packages/cli/src/context/scan/constraint-discovery.test.ts +++ b/packages/cli/test/context/scan/constraint-discovery.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { constraintDiscoveryWarning, tryConstraintQuery } from './constraint-discovery.js'; +import { constraintDiscoveryWarning, tryConstraintQuery } from '../../../src/context/scan/constraint-discovery.js'; describe('tryConstraintQuery', () => { it('returns the query value when the query succeeds', async () => { diff --git a/packages/cli/src/context/scan/credentials.test.ts b/packages/cli/test/context/scan/credentials.test.ts similarity index 96% rename from packages/cli/src/context/scan/credentials.test.ts rename to packages/cli/test/context/scan/credentials.test.ts index 891c58a9..62ee2952 100644 --- a/packages/cli/src/context/scan/credentials.test.ts +++ b/packages/cli/test/context/scan/credentials.test.ts @@ -1,13 +1,13 @@ import { describe, expect, it } from 'vitest'; -import { REDACTED_KTX_CREDENTIAL_VALUE } from '../core/redaction.js'; +import { REDACTED_KTX_CREDENTIAL_VALUE } from '../../../src/context/core/redaction.js'; import { redactKtxCredentialEnvelope, redactKtxCredentialValue, redactKtxScanMetadata, redactKtxScanReport, redactKtxScanWarning, -} from './credentials.js'; -import type { KtxCredentialEnvelope, KtxScanReport, KtxScanWarning } from './types.js'; +} from '../../../src/context/scan/credentials.js'; +import type { KtxCredentialEnvelope, KtxScanReport, KtxScanWarning } from '../../../src/context/scan/types.js'; describe('KTX scan credential redaction', () => { it('keeps credential references inspectable', () => { diff --git a/packages/cli/src/context/scan/data-dictionary.test.ts b/packages/cli/test/context/scan/data-dictionary.test.ts similarity index 99% rename from packages/cli/src/context/scan/data-dictionary.test.ts rename to packages/cli/test/context/scan/data-dictionary.test.ts index b8b39376..daf20559 100644 --- a/packages/cli/src/context/scan/data-dictionary.test.ts +++ b/packages/cli/test/context/scan/data-dictionary.test.ts @@ -3,7 +3,7 @@ import { defaultKtxDataDictionarySettings, isKtxDataDictionaryCandidate, shouldKtxSampleColumnForDictionary, -} from './data-dictionary.js'; +} from '../../../src/context/scan/data-dictionary.js'; const defaultPatterns = defaultKtxDataDictionarySettings.excludePatterns; diff --git a/packages/cli/src/context/scan/description-generation.test.ts b/packages/cli/test/context/scan/description-generation.test.ts similarity index 99% rename from packages/cli/src/context/scan/description-generation.test.ts rename to packages/cli/test/context/scan/description-generation.test.ts index bc7b1e25..811752e5 100644 --- a/packages/cli/src/context/scan/description-generation.test.ts +++ b/packages/cli/test/context/scan/description-generation.test.ts @@ -12,8 +12,8 @@ import { buildKtxTableDescriptionPrompt, type KtxDescriptionCachePort, KtxDescriptionGenerator, -} from './description-generation.js'; -import { createKtxConnectorCapabilities, type KtxScanConnector } from './types.js'; +} from '../../../src/context/scan/description-generation.js'; +import { createKtxConnectorCapabilities, type KtxScanConnector } from '../../../src/context/scan/types.js'; function createCache(initial: Record = {}): KtxDescriptionCachePort { const data = new Map(Object.entries(initial)); @@ -72,6 +72,8 @@ function createConnector(): KtxScanConnector { introspect: vi.fn(async () => { throw new Error('introspection is not used by description generation'); }), + listSchemas: vi.fn(async () => []), + listTables: vi.fn(async () => []), sampleColumn: vi.fn(async () => ({ values: ['paid', 'refunded', null], nullCount: 1, diff --git a/packages/cli/src/context/scan/embedding-text.test.ts b/packages/cli/test/context/scan/embedding-text.test.ts similarity index 94% rename from packages/cli/src/context/scan/embedding-text.test.ts rename to packages/cli/test/context/scan/embedding-text.test.ts index ee019bce..523d1d5c 100644 --- a/packages/cli/src/context/scan/embedding-text.test.ts +++ b/packages/cli/test/context/scan/embedding-text.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { buildKtxColumnEmbeddingText } from './embedding-text.js'; +import { buildKtxColumnEmbeddingText } from '../../../src/context/scan/embedding-text.js'; describe('KTX scan embedding text', () => { it('builds column embedding text with table, description, FK, and sample-value context', () => { diff --git a/packages/cli/src/context/scan/enrichment-state.test.ts b/packages/cli/test/context/scan/enrichment-state.test.ts similarity index 95% rename from packages/cli/src/context/scan/enrichment-state.test.ts rename to packages/cli/test/context/scan/enrichment-state.test.ts index 4ae597c6..24b4bae3 100644 --- a/packages/cli/src/context/scan/enrichment-state.test.ts +++ b/packages/cli/test/context/scan/enrichment-state.test.ts @@ -6,9 +6,9 @@ import { completedKtxScanEnrichmentStateSummary, computeKtxScanEnrichmentInputHash, summarizeKtxScanEnrichmentState, -} from './enrichment-state.js'; -import { SqliteLocalScanEnrichmentStateStore } from './sqlite-local-enrichment-state-store.js'; -import type { KtxSchemaSnapshot } from './types.js'; +} from '../../../src/context/scan/enrichment-state.js'; +import { SqliteLocalScanEnrichmentStateStore } from '../../../src/context/scan/sqlite-local-enrichment-state-store.js'; +import type { KtxSchemaSnapshot } from '../../../src/context/scan/types.js'; const snapshot: KtxSchemaSnapshot = { connectionId: 'warehouse', diff --git a/packages/cli/src/context/scan/enrichment-summary.test.ts b/packages/cli/test/context/scan/enrichment-summary.test.ts similarity index 96% rename from packages/cli/src/context/scan/enrichment-summary.test.ts rename to packages/cli/test/context/scan/enrichment-summary.test.ts index f320046b..783e18cd 100644 --- a/packages/cli/src/context/scan/enrichment-summary.test.ts +++ b/packages/cli/test/context/scan/enrichment-summary.test.ts @@ -3,7 +3,7 @@ import { failedKtxScanEnrichmentSummary, ktxScanErrorMessage, skippedKtxScanEnrichmentSummary, -} from './enrichment-summary.js'; +} from '../../../src/context/scan/enrichment-summary.js'; describe('KTX scan enrichment summaries', () => { it('keeps structural scans skipped when no enrichment was requested', () => { diff --git a/packages/cli/src/context/scan/enrichment-types.test.ts b/packages/cli/test/context/scan/enrichment-types.test.ts similarity index 99% rename from packages/cli/src/context/scan/enrichment-types.test.ts rename to packages/cli/test/context/scan/enrichment-types.test.ts index 3f7828dc..72e7b247 100644 --- a/packages/cli/src/context/scan/enrichment-types.test.ts +++ b/packages/cli/test/context/scan/enrichment-types.test.ts @@ -9,7 +9,7 @@ import type { KtxRelationshipUpdate, KtxScanMetadataStore, KtxStructuralSyncPlan, -} from './enrichment-types.js'; +} from '../../../src/context/scan/enrichment-types.js'; describe('KTX scan enrichment contracts', () => { it('models an enriched schema with reusable table, column, and relationship metadata', () => { diff --git a/packages/cli/src/context/scan/entity-details.test.ts b/packages/cli/test/context/scan/entity-details.test.ts similarity index 91% rename from packages/cli/src/context/scan/entity-details.test.ts rename to packages/cli/test/context/scan/entity-details.test.ts index ddccef87..ea8d01b7 100644 --- a/packages/cli/src/context/scan/entity-details.test.ts +++ b/packages/cli/test/context/scan/entity-details.test.ts @@ -2,9 +2,9 @@ import { mkdtemp, rm } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; -import { initKtxProject, type KtxLocalProject } from '../../context/project/project.js'; -import { createKtxEntityDetailsService } from './entity-details.js'; -import type { KtxConnectionDriver, KtxScanReport, KtxSchemaTable } from './types.js'; +import { initKtxProject, type KtxLocalProject } from '../../../src/context/project/project.js'; +import { createKtxEntityDetailsService } from '../../../src/context/scan/entity-details.js'; +import type { KtxConnectionDriver, KtxScanReport, KtxSchemaTable } from '../../../src/context/scan/types.js'; describe('createKtxEntityDetailsService', () => { let tempDir: string; @@ -201,6 +201,22 @@ describe('createKtxEntityDetailsService', () => { }); }); + it('resolves quoted qualified display strings through the dialect parser', async () => { + await seedScan({ syncId: 'sync-1', runId: 'scan-1' }); + const service = createKtxEntityDetailsService(project); + + const result = await service.read({ + connectionId: 'warehouse', + entities: [{ table: '"public"."orders"' }], + }); + + expect(result.results[0]).toMatchObject({ + ok: true, + display: 'public.orders', + tableRef: { catalog: null, db: 'public', name: 'orders' }, + }); + }); + it('filters requested columns while keeping full-table foreign keys', async () => { await seedScan({ syncId: 'sync-1', runId: 'scan-1' }); const service = createKtxEntityDetailsService(project); diff --git a/packages/cli/src/context/scan/local-enrichment-artifacts.test.ts b/packages/cli/test/context/scan/local-enrichment-artifacts.test.ts similarity index 98% rename from packages/cli/src/context/scan/local-enrichment-artifacts.test.ts rename to packages/cli/test/context/scan/local-enrichment-artifacts.test.ts index 8a49fc78..638bafb2 100644 --- a/packages/cli/src/context/scan/local-enrichment-artifacts.test.ts +++ b/packages/cli/test/context/scan/local-enrichment-artifacts.test.ts @@ -3,10 +3,10 @@ import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import YAML from 'yaml'; -import { initKtxProject, type KtxLocalProject } from '../../context/project/project.js'; -import type { KtxLocalScanEnrichmentResult } from './local-enrichment.js'; -import { writeLocalScanEnrichmentArtifacts, writeLocalScanManifestShards } from './local-enrichment-artifacts.js'; -import type { KtxSchemaSnapshot } from './types.js'; +import { initKtxProject, type KtxLocalProject } from '../../../src/context/project/project.js'; +import type { KtxLocalScanEnrichmentResult } from '../../../src/context/scan/local-enrichment.js'; +import { writeLocalScanEnrichmentArtifacts, writeLocalScanManifestShards } from '../../../src/context/scan/local-enrichment-artifacts.js'; +import type { KtxSchemaSnapshot } from '../../../src/context/scan/types.js'; const snapshot: KtxSchemaSnapshot = { connectionId: 'warehouse', diff --git a/packages/cli/src/context/scan/local-enrichment.test.ts b/packages/cli/test/context/scan/local-enrichment.test.ts similarity index 96% rename from packages/cli/src/context/scan/local-enrichment.test.ts rename to packages/cli/test/context/scan/local-enrichment.test.ts index 9647c8b9..9704d071 100644 --- a/packages/cli/src/context/scan/local-enrichment.test.ts +++ b/packages/cli/test/context/scan/local-enrichment.test.ts @@ -1,17 +1,17 @@ import Database from 'better-sqlite3'; import { describe, expect, it, vi } from 'vitest'; -import { buildDefaultKtxProjectConfig } from '../project/config.js'; +import { buildDefaultKtxProjectConfig } from '../../../src/context/project/config.js'; import type { KtxScanEnrichmentCompletedStage, KtxScanEnrichmentFailedStage, KtxScanEnrichmentStageLookup, KtxScanEnrichmentStateStore, -} from './enrichment-state.js'; +} from '../../../src/context/scan/enrichment-state.js'; import { createDeterministicLocalScanEnrichmentProviders, runLocalScanEnrichment, snapshotToKtxEnrichedSchema, -} from './local-enrichment.js'; +} from '../../../src/context/scan/local-enrichment.js'; import { createKtxConnectorCapabilities, type KtxQueryResult, @@ -20,7 +20,7 @@ import { type KtxScanConnector, type KtxScanContext, type KtxSchemaSnapshot, -} from './types.js'; +} from '../../../src/context/scan/types.js'; function fakeScanEmbedding(options: { dimensions: number; maxBatchSize?: number }): KtxEmbeddingPort { return { @@ -104,6 +104,8 @@ function connector(): KtxScanConnector { columnStats: true, }), introspect: vi.fn(async () => snapshot), + listSchemas: vi.fn(async () => []), + listTables: vi.fn(async () => []), sampleTable: vi.fn(async () => ({ headers: ['id', 'customer_id'], rows: [[1, 10]], @@ -331,6 +333,27 @@ describe('local scan enrichment', () => { expect(scanConnector.introspect).toHaveBeenCalledTimes(1); }); + it('fails when connector driver and snapshot driver differ', async () => { + const mismatchedConnector: KtxScanConnector = { + ...connector(), + driver: 'mysql', + }; + + await expect( + runLocalScanEnrichment({ + connectionId: 'warehouse', + mode: 'relationships', + detectRelationships: true, + connector: mismatchedConnector, + snapshot, + context: { runId: 'scan-run-driver-mismatch' }, + providers: null, + }), + ).rejects.toThrow( + 'ktx scan connector driver "mysql" does not match snapshot driver "postgres" for connection "warehouse"', + ); + }); + it('runs deterministic relationship detection for relationship scans', async () => { const result = await runLocalScanEnrichment({ connectionId: 'warehouse', diff --git a/packages/cli/src/context/scan/local-scan.test.ts b/packages/cli/test/context/scan/local-scan.test.ts similarity index 98% rename from packages/cli/src/context/scan/local-scan.test.ts rename to packages/cli/test/context/scan/local-scan.test.ts index f3c1353d..931fd6b0 100644 --- a/packages/cli/src/context/scan/local-scan.test.ts +++ b/packages/cli/test/context/scan/local-scan.test.ts @@ -3,18 +3,23 @@ import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import YAML from 'yaml'; -import type { SourceAdapter } from '../../context/ingest/types.js'; -import type { KtxLlmRuntimePort } from '../../context/llm/runtime-port.js'; -import { initKtxProject, type KtxLocalProject, loadKtxProject } from '../../context/project/project.js'; -import { resolveEnabledTables } from './enabled-tables.js'; -import { getLocalScanReport, getLocalScanStatus, runLocalScan } from './local-scan.js'; -import { tableRefKey, tableRefSet, type KtxTableRefKey } from './table-ref.js'; +import type { SourceAdapter } from '../../../src/context/ingest/types.js'; +import type { KtxLlmRuntimePort } from '../../../src/context/llm/runtime-port.js'; +import { initKtxProject, type KtxLocalProject, loadKtxProject } from '../../../src/context/project/project.js'; +import { resolveEnabledTables } from '../../../src/context/scan/enabled-tables.js'; +import { getLocalScanReport, getLocalScanStatus, runLocalScan } from '../../../src/context/scan/local-scan.js'; +import { tableRefKey, tableRefSet, type KtxTableRefKey } from '../../../src/context/scan/table-ref.js'; import type { KtxQueryResult, KtxReadOnlyQueryInput, KtxScanConnector, KtxSchemaSnapshot, -} from './types.js'; +} from '../../../src/context/scan/types.js'; + +const connectorScopeListing = { + listSchemas: vi.fn(async () => []), + listTables: vi.fn(async () => []), +}; function relationshipSqlResult( input: KtxReadOnlyQueryInput, @@ -254,6 +259,7 @@ function nativeScanConnector(options: { cleanup?: () => Promise } = {}): K formalForeignKeys: false, estimatedRowCounts: false, }, + ...connectorScopeListing, introspect: vi.fn(async () => nativeScanSnapshot()), sampleTable: vi.fn(async () => ({ headers: ['id'], rows: [[1]], totalRows: 1 })), sampleColumn: vi.fn(async () => ({ values: ['1'], nullCount: 0, distinctCount: 1 })), @@ -656,6 +662,7 @@ describe('local scan', () => { formalForeignKeys: false, estimatedRowCounts: false, }, + ...connectorScopeListing, async introspect() { return { connectionId: 'warehouse', @@ -741,6 +748,7 @@ describe('local scan', () => { formalForeignKeys: false, estimatedRowCounts: true, }, + ...connectorScopeListing, async introspect() { return { connectionId: 'warehouse', @@ -930,6 +938,14 @@ describe('local scan', () => { }; } + async listSchemas(): Promise { + return []; + } + + async listTables() { + return []; + } + async executeReadOnly(input: KtxReadOnlyQueryInput): Promise { return relationshipSqlResult(input); } @@ -972,6 +988,7 @@ describe('local scan', () => { formalForeignKeys: false, estimatedRowCounts: true, }, + ...connectorScopeListing, async introspect() { return { connectionId: 'warehouse', @@ -1073,6 +1090,7 @@ describe('local scan', () => { formalForeignKeys: false, estimatedRowCounts: true, }, + ...connectorScopeListing, async introspect() { return { connectionId: 'warehouse', @@ -1200,6 +1218,7 @@ describe('local scan', () => { formalForeignKeys: false, estimatedRowCounts: true, }, + ...connectorScopeListing, async introspect() { return { connectionId: 'warehouse', @@ -1340,6 +1359,7 @@ describe('local scan', () => { formalForeignKeys: false, estimatedRowCounts: true, }, + ...connectorScopeListing, async introspect() { return { connectionId: 'warehouse', @@ -1455,6 +1475,7 @@ describe('local scan', () => { formalForeignKeys: false, estimatedRowCounts: false, }, + ...connectorScopeListing, async introspect() { return { connectionId: 'warehouse', @@ -1550,6 +1571,7 @@ describe('local scan', () => { formalForeignKeys: false, estimatedRowCounts: false, }, + ...connectorScopeListing, async introspect() { return { connectionId: 'warehouse', @@ -1666,6 +1688,7 @@ describe('local scan', () => { formalForeignKeys: false, estimatedRowCounts: false, }, + ...connectorScopeListing, async introspect() { return { connectionId: 'warehouse', diff --git a/packages/cli/src/context/scan/local-structural-artifacts.test.ts b/packages/cli/test/context/scan/local-structural-artifacts.test.ts similarity index 97% rename from packages/cli/src/context/scan/local-structural-artifacts.test.ts rename to packages/cli/test/context/scan/local-structural-artifacts.test.ts index f5c5869c..519bb26d 100644 --- a/packages/cli/src/context/scan/local-structural-artifacts.test.ts +++ b/packages/cli/test/context/scan/local-structural-artifacts.test.ts @@ -2,8 +2,8 @@ import { mkdtemp, rm } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; -import { initKtxProject, type KtxLocalProject } from '../../context/project/project.js'; -import { readLocalScanStructuralSnapshot } from './local-structural-artifacts.js'; +import { initKtxProject, type KtxLocalProject } from '../../../src/context/project/project.js'; +import { readLocalScanStructuralSnapshot } from '../../../src/context/scan/local-structural-artifacts.js'; describe('readLocalScanStructuralSnapshot', () => { let tempDir: string; diff --git a/packages/cli/src/context/scan/relationship-benchmark-report.test.ts b/packages/cli/test/context/scan/relationship-benchmark-report.test.ts similarity index 99% rename from packages/cli/src/context/scan/relationship-benchmark-report.test.ts rename to packages/cli/test/context/scan/relationship-benchmark-report.test.ts index 8941eec1..0837ed3c 100644 --- a/packages/cli/src/context/scan/relationship-benchmark-report.test.ts +++ b/packages/cli/test/context/scan/relationship-benchmark-report.test.ts @@ -2,12 +2,12 @@ import { describe, expect, it } from 'vitest'; import { buildKtxRelationshipBenchmarkReport, formatKtxRelationshipBenchmarkReportMarkdown, -} from './relationship-benchmark-report.js'; +} from '../../../src/context/scan/relationship-benchmark-report.js'; import type { KtxRelationshipBenchmarkCaseResult, KtxRelationshipBenchmarkFixture, KtxRelationshipBenchmarkSuiteResult, -} from './relationship-benchmarks.js'; +} from '../../../src/context/scan/relationship-benchmarks.js'; type CaseResultOverrides = Omit, 'metrics'> & { metrics?: Partial; diff --git a/packages/cli/src/context/scan/relationship-benchmarks.test.ts b/packages/cli/test/context/scan/relationship-benchmarks.test.ts similarity index 96% rename from packages/cli/src/context/scan/relationship-benchmarks.test.ts rename to packages/cli/test/context/scan/relationship-benchmarks.test.ts index aff025aa..daaa2142 100644 --- a/packages/cli/src/context/scan/relationship-benchmarks.test.ts +++ b/packages/cli/test/context/scan/relationship-benchmarks.test.ts @@ -5,7 +5,7 @@ import { describe, expect, it } from 'vitest'; import type { KtxRelationshipBenchmarkExpectedLinks, KtxRelationshipBenchmarkFixture, -} from './relationship-benchmarks.js'; +} from '../../../src/context/scan/relationship-benchmarks.js'; import { currentKtxRelationshipBenchmarkDetector, loadKtxRelationshipBenchmarkFixture, @@ -13,8 +13,8 @@ import { maskKtxRelationshipBenchmarkSnapshot, runKtxRelationshipBenchmarkCase, runKtxRelationshipBenchmarkSuite, -} from './relationship-benchmarks.js'; -import type { KtxSchemaSnapshot } from './types.js'; +} from '../../../src/context/scan/relationship-benchmarks.js'; +import type { KtxSchemaSnapshot } from '../../../src/context/scan/types.js'; const EXPECTED_LINKS: KtxRelationshipBenchmarkExpectedLinks = { expectedPks: [ @@ -140,7 +140,7 @@ function snapshot(): KtxSchemaSnapshot { describe('relationship benchmarks', () => { it('keeps the current benchmark detector on the relationship-discovery path only', async () => { - const source = await readFile(new URL('relationship-benchmarks.ts', import.meta.url), 'utf-8'); + const source = await readFile(new URL('../../../src/context/scan/relationship-benchmarks.ts', import.meta.url), 'utf-8'); expect(source).not.toMatch(/KtxRelationshipDetector/); expect(source).not.toMatch(/relationship-detection\.js/); @@ -261,7 +261,7 @@ describe('relationship benchmarks', () => { }); it('loads the composite-key fixture and accepts composite ground truth as headline evidence', async () => { - const fixtureRoot = new URL('../../test/fixtures/relationship-benchmarks/', import.meta.url); + const fixtureRoot = new URL('../../fixtures/relationship-benchmarks', import.meta.url); const fixture = await loadKtxRelationshipBenchmarkFixture( join(fixtureRoot.pathname, 'composite_keys_no_declared_constraints'), ); @@ -586,7 +586,7 @@ describe('relationship benchmarks', () => { }); it('loads every checked-in relationship benchmark fixture with explicit provenance', async () => { - const fixtureRoot = new URL('../../test/fixtures/relationship-benchmarks/', import.meta.url); + const fixtureRoot = new URL('../../fixtures/relationship-benchmarks', import.meta.url); const fixtureDirs = (await readdir(fixtureRoot, { withFileTypes: true })) .filter((entry) => entry.isDirectory()) .map((entry) => entry.name) @@ -601,7 +601,7 @@ describe('relationship benchmarks', () => { }); it('loads May 8 evidence-fusion adversarial fixtures as reported synthetic evidence', async () => { - const fixtureRoot = new URL('../../test/fixtures/relationship-benchmarks/', import.meta.url); + const fixtureRoot = new URL('../../fixtures/relationship-benchmarks', import.meta.url); const fixtures = await loadKtxRelationshipBenchmarkFixtures(fixtureRoot.pathname); const byId = new Map(fixtures.map((fixture) => [fixture.id, fixture])); const adversarialIds = [ @@ -634,7 +634,7 @@ describe('relationship benchmarks', () => { }); it('loads the May 8 scale stress fixture with bounded benchmark validation', async () => { - const fixtureRoot = new URL('../../test/fixtures/relationship-benchmarks/', import.meta.url); + const fixtureRoot = new URL('../../fixtures/relationship-benchmarks', import.meta.url); const fixture = await loadKtxRelationshipBenchmarkFixture( join(fixtureRoot.pathname, 'scale_stress_no_declared_constraints'), ); @@ -651,7 +651,7 @@ describe('relationship benchmarks', () => { }); adHocRelationshipBenchmarkIt('runs the scale stress fixture inside the benchmark validation budget', async () => { - const fixtureRoot = new URL('../../test/fixtures/relationship-benchmarks/', import.meta.url); + const fixtureRoot = new URL('../../fixtures/relationship-benchmarks', import.meta.url); const fixture = await loadKtxRelationshipBenchmarkFixture( join(fixtureRoot.pathname, 'scale_stress_no_declared_constraints'), ); @@ -846,7 +846,7 @@ describe('relationship benchmarks', () => { }); it('loads the packaged B2B demo fixtures and records the current relationship-discovery baseline', async () => { - const fixtureRoot = new URL('../../test/fixtures/relationship-benchmarks/', import.meta.url); + const fixtureRoot = new URL('../../fixtures/relationship-benchmarks', import.meta.url); const declared = await loadKtxRelationshipBenchmarkFixture( join(fixtureRoot.pathname, 'demo_b2b_declared_metadata'), ); @@ -927,7 +927,7 @@ describe('relationship benchmarks', () => { }); it('loads the public Chinook benchmark fixture with declared metadata', async () => { - const fixtureRoot = new URL('../../test/fixtures/relationship-benchmarks/', import.meta.url); + const fixtureRoot = new URL('../../fixtures/relationship-benchmarks', import.meta.url); const fixture = await loadKtxRelationshipBenchmarkFixture( join(fixtureRoot.pathname, 'chinook_with_declared_metadata'), ); @@ -945,7 +945,7 @@ describe('relationship benchmarks', () => { }); it('loads the public Northwind benchmark fixture with declared metadata', async () => { - const fixtureRoot = new URL('../../test/fixtures/relationship-benchmarks/', import.meta.url); + const fixtureRoot = new URL('../../fixtures/relationship-benchmarks', import.meta.url); const fixture = await loadKtxRelationshipBenchmarkFixture( join(fixtureRoot.pathname, 'northwind_with_declared_metadata'), ); @@ -961,7 +961,7 @@ describe('relationship benchmarks', () => { }); it('loads the public Sakila benchmark fixture with declared metadata', async () => { - const fixtureRoot = new URL('../../test/fixtures/relationship-benchmarks/', import.meta.url); + const fixtureRoot = new URL('../../fixtures/relationship-benchmarks', import.meta.url); const fixture = await loadKtxRelationshipBenchmarkFixture( join(fixtureRoot.pathname, 'sakila_with_declared_metadata'), ); @@ -977,7 +977,7 @@ describe('relationship benchmarks', () => { }); it('loads the public AdventureWorksLT benchmark fixture with declared metadata', async () => { - const fixtureRoot = new URL('../../test/fixtures/relationship-benchmarks/', import.meta.url); + const fixtureRoot = new URL('../../fixtures/relationship-benchmarks', import.meta.url); const fixture = await loadKtxRelationshipBenchmarkFixture( join(fixtureRoot.pathname, 'adventureworkslt_with_declared_metadata'), ); @@ -1037,7 +1037,7 @@ describe('relationship benchmarks', () => { }); it('loads the full AdventureWorks OLTP benchmark fixture with declared metadata', async () => { - const fixtureRoot = new URL('../../test/fixtures/relationship-benchmarks/', import.meta.url); + const fixtureRoot = new URL('../../fixtures/relationship-benchmarks', import.meta.url); const fixture = await loadKtxRelationshipBenchmarkFixture( join(fixtureRoot.pathname, 'adventureworks_oltp_with_declared_metadata'), ); @@ -1097,7 +1097,7 @@ describe('relationship benchmarks', () => { }); it('loads the row-bearing natural-key fixture and counts it as headline evidence', async () => { - const fixtureRoot = new URL('../../test/fixtures/relationship-benchmarks/', import.meta.url); + const fixtureRoot = new URL('../../fixtures/relationship-benchmarks', import.meta.url); const naturalKeys = await loadKtxRelationshipBenchmarkFixture( join(fixtureRoot.pathname, 'natural_keys_no_declared_constraints'), ); @@ -1131,7 +1131,7 @@ describe('relationship benchmarks', () => { }); it('accepts plan-code suffix relationships only when validation is available', async () => { - const fixtureRoot = new URL('../../test/fixtures/relationship-benchmarks/', import.meta.url); + const fixtureRoot = new URL('../../fixtures/relationship-benchmarks', import.meta.url); const fixture = await loadKtxRelationshipBenchmarkFixture( join(fixtureRoot.pathname, 'plan_code_no_declared_constraints'), ); @@ -1192,7 +1192,7 @@ describe('relationship benchmarks', () => { }); it('uses embedding fixtures for semantic alias relationship benchmark cases', async () => { - const fixtureRoot = new URL('../../test/fixtures/relationship-benchmarks/', import.meta.url); + const fixtureRoot = new URL('../../fixtures/relationship-benchmarks', import.meta.url); const fixture = await loadKtxRelationshipBenchmarkFixture( join(fixtureRoot.pathname, 'semantic_embedding_aliases_no_declared_constraints'), ); @@ -1223,7 +1223,7 @@ describe('relationship benchmarks', () => { }); it('loads the Orbit-style product fixture as curated relationship-discovery benchmark evidence', async () => { - const fixtureRoot = new URL('../../test/fixtures/relationship-benchmarks/', import.meta.url); + const fixtureRoot = new URL('../../fixtures/relationship-benchmarks', import.meta.url); const fixture = await loadKtxRelationshipBenchmarkFixture( join(fixtureRoot.pathname, 'orbit_style_product_no_declared_constraints'), ); diff --git a/packages/cli/src/context/scan/relationship-budget.test.ts b/packages/cli/test/context/scan/relationship-budget.test.ts similarity index 97% rename from packages/cli/src/context/scan/relationship-budget.test.ts rename to packages/cli/test/context/scan/relationship-budget.test.ts index 479e5b23..b3c6edcf 100644 --- a/packages/cli/src/context/scan/relationship-budget.test.ts +++ b/packages/cli/test/context/scan/relationship-budget.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { applyKtxRelationshipValidationBudget, defaultKtxRelationshipValidationBudget } from './relationship-budget.js'; +import { applyKtxRelationshipValidationBudget, defaultKtxRelationshipValidationBudget } from '../../../src/context/scan/relationship-budget.js'; interface Candidate { id: string; diff --git a/packages/cli/src/context/scan/relationship-candidates.test.ts b/packages/cli/test/context/scan/relationship-candidates.test.ts similarity index 98% rename from packages/cli/src/context/scan/relationship-candidates.test.ts rename to packages/cli/test/context/scan/relationship-candidates.test.ts index 795d7791..cfe5ce2c 100644 --- a/packages/cli/src/context/scan/relationship-candidates.test.ts +++ b/packages/cli/test/context/scan/relationship-candidates.test.ts @@ -1,12 +1,12 @@ import { describe, expect, it } from 'vitest'; -import type { KtxEnrichedColumn, KtxEnrichedSchema, KtxEnrichedTable } from './enrichment-types.js'; -import { normalizeKtxRelationshipName } from './relationship-name-similarity.js'; +import type { KtxEnrichedColumn, KtxEnrichedSchema, KtxEnrichedTable } from '../../../src/context/scan/enrichment-types.js'; +import { normalizeKtxRelationshipName } from '../../../src/context/scan/relationship-name-similarity.js'; import { generateKtxRelationshipDiscoveryCandidates, inferKtxRelationshipTargetPks, mergeKtxRelationshipDiscoveryCandidates, -} from './relationship-candidates.js'; -import type { KtxRelationshipProfileArtifact } from './relationship-profiling.js'; +} from '../../../src/context/scan/relationship-candidates.js'; +import type { KtxRelationshipProfileArtifact } from '../../../src/context/scan/relationship-profiling.js'; function column( tableId: string, diff --git a/packages/cli/src/context/scan/relationship-composite-candidates.test.ts b/packages/cli/test/context/scan/relationship-composite-candidates.test.ts similarity index 80% rename from packages/cli/src/context/scan/relationship-composite-candidates.test.ts rename to packages/cli/test/context/scan/relationship-composite-candidates.test.ts index abf495e1..e0a9ca6c 100644 --- a/packages/cli/src/context/scan/relationship-composite-candidates.test.ts +++ b/packages/cli/test/context/scan/relationship-composite-candidates.test.ts @@ -1,11 +1,12 @@ import Database from 'better-sqlite3'; import { join } from 'node:path'; import { describe, expect, it } from 'vitest'; -import { snapshotToKtxEnrichedSchema } from './local-enrichment.js'; -import { loadKtxRelationshipBenchmarkFixture, maskKtxRelationshipBenchmarkSnapshot } from './relationship-benchmarks.js'; -import { discoverKtxCompositeRelationships } from './relationship-composite-candidates.js'; -import { profileKtxRelationshipSchema, type KtxRelationshipReadOnlyExecutor } from './relationship-profiling.js'; -import type { KtxQueryResult, KtxReadOnlyQueryInput, KtxScanContext } from './types.js'; +import { getDialectForDriver } from '../../../src/context/connections/dialects.js'; +import { snapshotToKtxEnrichedSchema } from '../../../src/context/scan/local-enrichment.js'; +import { loadKtxRelationshipBenchmarkFixture, maskKtxRelationshipBenchmarkSnapshot } from '../../../src/context/scan/relationship-benchmarks.js'; +import { discoverKtxCompositeRelationships } from '../../../src/context/scan/relationship-composite-candidates.js'; +import { profileKtxRelationshipSchema, type KtxRelationshipReadOnlyExecutor } from '../../../src/context/scan/relationship-profiling.js'; +import type { KtxQueryResult, KtxReadOnlyQueryInput, KtxScanContext } from '../../../src/context/scan/types.js'; class TestSqliteExecutor implements KtxRelationshipReadOnlyExecutor { private readonly db: Database.Database; @@ -32,7 +33,7 @@ class TestSqliteExecutor implements KtxRelationshipReadOnlyExecutor { describe('composite relationship discovery detector', () => { it('infers composite primary keys and validates composite foreign keys from row evidence', async () => { - const fixtureRoot = new URL('../../test/fixtures/relationship-benchmarks/', import.meta.url); + const fixtureRoot = new URL('../../fixtures/relationship-benchmarks', import.meta.url); const fixture = await loadKtxRelationshipBenchmarkFixture( join(fixtureRoot.pathname, 'composite_keys_no_declared_constraints'), ); @@ -41,7 +42,7 @@ describe('composite relationship discovery detector', () => { const executor = new TestSqliteExecutor(fixture.dataPath ?? ''); const profiles = await profileKtxRelationshipSchema({ connectionId: snapshot.connectionId, - driver: snapshot.driver, + dialect: getDialectForDriver(snapshot.driver), schema, executor, ctx: { runId: 'test:composite-profile' }, @@ -49,7 +50,7 @@ describe('composite relationship discovery detector', () => { const result = await discoverKtxCompositeRelationships({ connectionId: snapshot.connectionId, - driver: snapshot.driver, + dialect: getDialectForDriver(snapshot.driver), schema, profiles, executor, diff --git a/packages/cli/src/context/scan/relationship-diagnostics.test.ts b/packages/cli/test/context/scan/relationship-diagnostics.test.ts similarity index 98% rename from packages/cli/src/context/scan/relationship-diagnostics.test.ts rename to packages/cli/test/context/scan/relationship-diagnostics.test.ts index 7c1dbb76..647ac931 100644 --- a/packages/cli/src/context/scan/relationship-diagnostics.test.ts +++ b/packages/cli/test/context/scan/relationship-diagnostics.test.ts @@ -1,11 +1,11 @@ import { describe, expect, it } from 'vitest'; -import type { KtxEnrichedRelationship, KtxRelationshipEndpoint } from './enrichment-types.js'; -import type { KtxResolvedRelationshipDiscoveryCandidate } from './relationship-graph-resolver.js'; +import type { KtxEnrichedRelationship, KtxRelationshipEndpoint } from '../../../src/context/scan/enrichment-types.js'; +import type { KtxResolvedRelationshipDiscoveryCandidate } from '../../../src/context/scan/relationship-graph-resolver.js'; import { buildKtxRelationshipArtifacts, buildKtxRelationshipDiagnostics, emptyKtxRelationshipProfileArtifact, -} from './relationship-diagnostics.js'; +} from '../../../src/context/scan/relationship-diagnostics.js'; function endpoint(table: string, column: string): KtxRelationshipEndpoint { return { diff --git a/packages/cli/src/context/scan/relationship-discovery.test.ts b/packages/cli/test/context/scan/relationship-discovery.test.ts similarity index 94% rename from packages/cli/src/context/scan/relationship-discovery.test.ts rename to packages/cli/test/context/scan/relationship-discovery.test.ts index 400fae62..55341645 100644 --- a/packages/cli/src/context/scan/relationship-discovery.test.ts +++ b/packages/cli/test/context/scan/relationship-discovery.test.ts @@ -1,15 +1,16 @@ import Database from 'better-sqlite3'; import { afterEach, describe, expect, it, vi } from 'vitest'; -import type { KtxLlmRuntimePort } from '../../context/llm/runtime-port.js'; -import { buildDefaultKtxProjectConfig } from '../project/config.js'; -import { snapshotToKtxEnrichedSchema } from './local-enrichment.js'; +import type { KtxLlmRuntimePort } from '../../../src/context/llm/runtime-port.js'; +import { getDialectForDriver } from '../../../src/context/connections/dialects.js'; +import { buildDefaultKtxProjectConfig } from '../../../src/context/project/config.js'; +import { snapshotToKtxEnrichedSchema } from '../../../src/context/scan/local-enrichment.js'; import { loadKtxRelationshipBenchmarkFixture, maskKtxRelationshipBenchmarkSnapshot, -} from './relationship-benchmarks.js'; -import { discoverKtxRelationships } from './relationship-discovery.js'; -import { createKtxConnectorCapabilities } from './types.js'; -import type { KtxQueryResult, KtxReadOnlyQueryInput, KtxScanConnector, KtxScanContext, KtxSchemaSnapshot } from './types.js'; +} from '../../../src/context/scan/relationship-benchmarks.js'; +import { discoverKtxRelationships } from '../../../src/context/scan/relationship-discovery.js'; +import { createKtxConnectorCapabilities } from '../../../src/context/scan/types.js'; +import type { KtxQueryResult, KtxReadOnlyQueryInput, KtxScanConnector, KtxScanContext, KtxSchemaSnapshot } from '../../../src/context/scan/types.js'; class InMemorySqliteExecutor { readonly db = new Database(':memory:'); @@ -212,6 +213,8 @@ function connector(executor: InMemorySqliteExecutor | null): KtxScanConnector { columnSampling: false, }), introspect: async () => snapshot(), + listSchemas: async () => [], + listTables: async () => [], executeReadOnly: executor ? executor.executeReadOnly.bind(executor) : undefined, }; } @@ -308,7 +311,7 @@ describe('production relationship discovery', () => { const result = await discoverKtxRelationships({ connectionId: 'warehouse', - driver: 'sqlite', + dialect: getDialectForDriver('sqlite'), connector: connector(executor), schema: snapshotToKtxEnrichedSchema(snapshot()), context: { runId: 'relationship-run-1' }, @@ -347,7 +350,7 @@ describe('production relationship discovery', () => { const schema = naturalKeySnapshot(); const result = await discoverKtxRelationships({ connectionId: 'warehouse', - driver: 'sqlite', + dialect: getDialectForDriver('sqlite'), connector: { ...connector(executor), introspect: async () => schema, @@ -397,7 +400,7 @@ describe('production relationship discovery', () => { const result = await discoverKtxRelationships({ connectionId: 'warehouse', - driver: 'sqlite', + dialect: getDialectForDriver('sqlite'), connector: { ...connector(executor), introspect: async () => sourceSnapshot, @@ -430,7 +433,7 @@ describe('production relationship discovery', () => { it('keeps candidates review-only when read-only SQL is unavailable', async () => { const result = await discoverKtxRelationships({ connectionId: 'warehouse', - driver: 'sqlite', + dialect: getDialectForDriver('sqlite'), connector: connector(null), schema: snapshotToKtxEnrichedSchema(snapshot()), context: { runId: 'relationship-run-no-sql' }, @@ -456,7 +459,7 @@ describe('production relationship discovery', () => { const sourceSnapshot = declaredForeignKeySnapshot(); const result = await discoverKtxRelationships({ connectionId: 'warehouse', - driver: 'sqlite', + dialect: getDialectForDriver('sqlite'), connector: connector(null), schema: snapshotToKtxEnrichedSchema(sourceSnapshot), context: { runId: 'formal-metadata-no-sql' }, @@ -503,7 +506,7 @@ describe('production relationship discovery', () => { const result = await discoverKtxRelationships({ connectionId: 'warehouse', - driver: 'sqlite', + dialect: getDialectForDriver('sqlite'), connector: connector(executor), schema: snapshotToKtxEnrichedSchema(llmOnlyRelationshipSnapshot()), context: { runId: 'llm-relationship-orchestrator' }, @@ -543,7 +546,7 @@ describe('production relationship discovery', () => { const result = await discoverKtxRelationships({ connectionId: 'warehouse', - driver: 'sqlite', + dialect: getDialectForDriver('sqlite'), connector: connector(executor), schema: snapshotToKtxEnrichedSchema(snapshot()), context: { runId: 'configured-thresholds' }, @@ -604,7 +607,7 @@ describe('production relationship discovery', () => { const result = await discoverKtxRelationships({ connectionId: 'warehouse', - driver: 'sqlite', + dialect: getDialectForDriver('sqlite'), connector: { ...connector(executor), introspect: async () => richSnapshot, @@ -628,7 +631,7 @@ describe('production relationship discovery', () => { it('accepts SQL-validated composite relationships in production relationship-discovery detection', async () => { const fixtureRoot = new URL( - '../../test/fixtures/relationship-benchmarks/composite_keys_no_declared_constraints', + '../../fixtures/relationship-benchmarks/composite_keys_no_declared_constraints', import.meta.url, ); const fixture = await loadKtxRelationshipBenchmarkFixture(fixtureRoot.pathname); @@ -644,6 +647,8 @@ describe('production relationship discovery', () => { columnSampling: false, }), introspect: async () => maskedSnapshot, + listSchemas: async () => [], + listTables: async () => [], executeReadOnly: async (input) => { const rows = database.prepare(input.sql).all() as Record[]; const headers = Object.keys(rows[0] ?? {}); @@ -658,7 +663,7 @@ describe('production relationship discovery', () => { const result = await discoverKtxRelationships({ connectionId: maskedSnapshot.connectionId, - driver: maskedSnapshot.driver, + dialect: getDialectForDriver(maskedSnapshot.driver), connector: testConnector, schema: snapshotToKtxEnrichedSchema(maskedSnapshot, new Map()), context: { runId: 'test:production-composite' }, diff --git a/packages/cli/src/context/scan/relationship-formal-metadata.test.ts b/packages/cli/test/context/scan/relationship-formal-metadata.test.ts similarity index 96% rename from packages/cli/src/context/scan/relationship-formal-metadata.test.ts rename to packages/cli/test/context/scan/relationship-formal-metadata.test.ts index 8e4a57ff..3fd94a42 100644 --- a/packages/cli/src/context/scan/relationship-formal-metadata.test.ts +++ b/packages/cli/test/context/scan/relationship-formal-metadata.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; -import type { KtxEnrichedRelationship, KtxEnrichedSchema } from './enrichment-types.js'; -import { collectKtxFormalMetadataRelationships } from './relationship-formal-metadata.js'; +import type { KtxEnrichedRelationship, KtxEnrichedSchema } from '../../../src/context/scan/enrichment-types.js'; +import { collectKtxFormalMetadataRelationships } from '../../../src/context/scan/relationship-formal-metadata.js'; function schema(relationships: KtxEnrichedRelationship[]): KtxEnrichedSchema { return { diff --git a/packages/cli/src/context/scan/relationship-graph-resolver.test.ts b/packages/cli/test/context/scan/relationship-graph-resolver.test.ts similarity index 98% rename from packages/cli/src/context/scan/relationship-graph-resolver.test.ts rename to packages/cli/test/context/scan/relationship-graph-resolver.test.ts index 945e8257..643d7956 100644 --- a/packages/cli/src/context/scan/relationship-graph-resolver.test.ts +++ b/packages/cli/test/context/scan/relationship-graph-resolver.test.ts @@ -4,10 +4,10 @@ import type { KtxEnrichedSchema, KtxEnrichedTable, KtxRelationshipEndpoint, -} from './enrichment-types.js'; -import type { KtxRelationshipProfileArtifact } from './relationship-profiling.js'; -import type { KtxValidatedRelationshipDiscoveryCandidate } from './relationship-validation.js'; -import { resolveKtxRelationshipGraph } from './relationship-graph-resolver.js'; +} from '../../../src/context/scan/enrichment-types.js'; +import type { KtxRelationshipProfileArtifact } from '../../../src/context/scan/relationship-profiling.js'; +import type { KtxValidatedRelationshipDiscoveryCandidate } from '../../../src/context/scan/relationship-validation.js'; +import { resolveKtxRelationshipGraph } from '../../../src/context/scan/relationship-graph-resolver.js'; function column(tableId: string, name: string, overrides: Partial = {}): KtxEnrichedColumn { const tableRef = overrides.tableRef ?? { catalog: null, db: null, name: tableId }; diff --git a/packages/cli/src/context/scan/relationship-llm-proposal.test.ts b/packages/cli/test/context/scan/relationship-llm-proposal.test.ts similarity index 94% rename from packages/cli/src/context/scan/relationship-llm-proposal.test.ts rename to packages/cli/test/context/scan/relationship-llm-proposal.test.ts index 46e22dcb..0713a1c6 100644 --- a/packages/cli/src/context/scan/relationship-llm-proposal.test.ts +++ b/packages/cli/test/context/scan/relationship-llm-proposal.test.ts @@ -1,8 +1,8 @@ import { describe, expect, it, vi } from 'vitest'; -import type { KtxLlmRuntimePort } from '../../context/llm/runtime-port.js'; -import type { KtxEnrichedColumn, KtxEnrichedSchema, KtxEnrichedTable } from './enrichment-types.js'; -import type { KtxRelationshipProfileArtifact } from './relationship-profiling.js'; -import { proposeKtxRelationshipCandidatesWithLlm } from './relationship-llm-proposal.js'; +import type { KtxLlmRuntimePort } from '../../../src/context/llm/runtime-port.js'; +import type { KtxEnrichedColumn, KtxEnrichedSchema, KtxEnrichedTable } from '../../../src/context/scan/enrichment-types.js'; +import type { KtxRelationshipProfileArtifact } from '../../../src/context/scan/relationship-profiling.js'; +import { proposeKtxRelationshipCandidatesWithLlm } from '../../../src/context/scan/relationship-llm-proposal.js'; function llmRuntime(output?: unknown): KtxLlmRuntimePort { return { diff --git a/packages/cli/src/context/scan/relationship-locality.test.ts b/packages/cli/test/context/scan/relationship-locality.test.ts similarity index 96% rename from packages/cli/src/context/scan/relationship-locality.test.ts rename to packages/cli/test/context/scan/relationship-locality.test.ts index 85dd4350..1a7f09fd 100644 --- a/packages/cli/src/context/scan/relationship-locality.test.ts +++ b/packages/cli/test/context/scan/relationship-locality.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; -import type { KtxEnrichedColumn, KtxEnrichedTable } from './enrichment-types.js'; -import { localCandidateTables } from './relationship-locality.js'; +import type { KtxEnrichedColumn, KtxEnrichedTable } from '../../../src/context/scan/enrichment-types.js'; +import { localCandidateTables } from '../../../src/context/scan/relationship-locality.js'; function column( tableId: string, diff --git a/packages/cli/src/context/scan/relationship-name-similarity.test.ts b/packages/cli/test/context/scan/relationship-name-similarity.test.ts similarity index 97% rename from packages/cli/src/context/scan/relationship-name-similarity.test.ts rename to packages/cli/test/context/scan/relationship-name-similarity.test.ts index 34730c81..0f8f437c 100644 --- a/packages/cli/src/context/scan/relationship-name-similarity.test.ts +++ b/packages/cli/test/context/scan/relationship-name-similarity.test.ts @@ -5,7 +5,7 @@ import { singularizeKtxRelationshipToken, tokenSimilarity, tokenizeKtxRelationshipName, -} from './relationship-name-similarity.js'; +} from '../../../src/context/scan/relationship-name-similarity.js'; describe('relationship name similarity', () => { it('tokenizes common warehouse naming styles', () => { diff --git a/packages/cli/src/context/scan/relationship-profiling.test.ts b/packages/cli/test/context/scan/relationship-profiling.test.ts similarity index 90% rename from packages/cli/src/context/scan/relationship-profiling.test.ts rename to packages/cli/test/context/scan/relationship-profiling.test.ts index 76151d23..7983d958 100644 --- a/packages/cli/src/context/scan/relationship-profiling.test.ts +++ b/packages/cli/test/context/scan/relationship-profiling.test.ts @@ -2,16 +2,12 @@ import { readFile } from 'node:fs/promises'; import { join } from 'node:path'; import Database from 'better-sqlite3'; import { afterEach, describe, expect, it, vi } from 'vitest'; -import type { KtxEnrichedColumn, KtxEnrichedSchema, KtxEnrichedTable } from './enrichment-types.js'; -import { snapshotToKtxEnrichedSchema } from './local-enrichment.js'; -import { loadKtxRelationshipBenchmarkFixture, maskKtxRelationshipBenchmarkSnapshot } from './relationship-benchmarks.js'; -import { - createKtxRelationshipProfileCache, - formatKtxRelationshipTableRef, - profileKtxRelationshipSchema, - quoteKtxRelationshipIdentifier, -} from './relationship-profiling.js'; -import type { KtxQueryResult, KtxReadOnlyQueryInput, KtxScanContext } from './types.js'; +import { getDialectForDriver } from '../../../src/context/connections/dialects.js'; +import type { KtxEnrichedColumn, KtxEnrichedSchema, KtxEnrichedTable } from '../../../src/context/scan/enrichment-types.js'; +import { snapshotToKtxEnrichedSchema } from '../../../src/context/scan/local-enrichment.js'; +import { loadKtxRelationshipBenchmarkFixture, maskKtxRelationshipBenchmarkSnapshot } from '../../../src/context/scan/relationship-benchmarks.js'; +import { createKtxRelationshipProfileCache, profileKtxRelationshipSchema } from '../../../src/context/scan/relationship-profiling.js'; +import type { KtxQueryResult, KtxReadOnlyQueryInput, KtxScanContext } from '../../../src/context/scan/types.js'; class InMemorySqliteExecutor { readonly db = new Database(':memory:'); @@ -104,7 +100,7 @@ describe('relationship profiling', () => { }); it('keeps profiling on the batched table path', async () => { - const source = await readFile(new URL('relationship-profiling.ts', import.meta.url), 'utf-8'); + const source = await readFile(new URL('../../../src/context/scan/relationship-profiling.ts', import.meta.url), 'utf-8'); expect(source).not.toMatch(new RegExp('queryColumn' + 'Profile')); expect(source).not.toMatch(/for \(const column of table\.columns\)[\s\S]*executeReadOnly/); @@ -112,16 +108,6 @@ describe('relationship profiling', () => { expect(source).toMatch(/UNION ALL/); }); - it('quotes identifiers and formats table refs for supported local SQL drivers', () => { - expect(quoteKtxRelationshipIdentifier('sqlite', 'odd"name')).toBe('"odd""name"'); - expect(quoteKtxRelationshipIdentifier('mysql', 'odd`name')).toBe('`odd``name`'); - expect(quoteKtxRelationshipIdentifier('sqlserver', 'odd]name')).toBe('[odd]]name]'); - expect(formatKtxRelationshipTableRef('sqlite', { catalog: null, db: null, name: 'accounts' })).toBe('"accounts"'); - expect(formatKtxRelationshipTableRef('postgres', { catalog: null, db: 'analytics', name: 'accounts' })).toBe( - '"analytics"."accounts"', - ); - }); - it('profiles row count, null rate, uniqueness, sample values, and text lengths', async () => { executor = new InMemorySqliteExecutor(); executor.db.exec(` @@ -135,7 +121,7 @@ describe('relationship profiling', () => { const result = await profileKtxRelationshipSchema({ connectionId: 'warehouse', - driver: 'sqlite', + dialect: getDialectForDriver('sqlite'), schema: schema([ table('accounts', [ column('accounts', 'id', { primaryKey: false, nullable: false }), @@ -197,7 +183,7 @@ describe('relationship profiling', () => { const result = await profileKtxRelationshipSchema({ connectionId: 'warehouse', - driver: 'sqlite', + dialect: getDialectForDriver('sqlite'), schema: schema([ table('accounts', [ column('accounts', 'id', { nullable: false }), @@ -240,7 +226,7 @@ describe('relationship profiling', () => { const profiles = await profileKtxRelationshipSchema({ connectionId: 'warehouse', - driver: 'sqlite', + dialect: getDialectForDriver('sqlite'), schema: schema([ table('accounts', [ column('accounts', 'id', { nullable: false }), @@ -291,7 +277,7 @@ describe('relationship profiling', () => { const first = await profileKtxRelationshipSchema({ connectionId: 'warehouse', - driver: 'sqlite', + dialect: getDialectForDriver('sqlite'), schema: relationshipSchema, executor, ctx: { runId: 'profile-cache-run' }, @@ -299,7 +285,7 @@ describe('relationship profiling', () => { }); const second = await profileKtxRelationshipSchema({ connectionId: 'warehouse', - driver: 'sqlite', + dialect: getDialectForDriver('sqlite'), schema: relationshipSchema, executor, ctx: { runId: 'profile-cache-run' }, @@ -307,7 +293,7 @@ describe('relationship profiling', () => { }); const third = await profileKtxRelationshipSchema({ connectionId: 'warehouse', - driver: 'sqlite', + dialect: getDialectForDriver('sqlite'), schema: relationshipSchema, executor, ctx: { runId: 'profile-cache-fresh-run' }, @@ -323,7 +309,7 @@ describe('relationship profiling', () => { }); it('profiles the checked-in scale stress fixture with one query per table', async () => { - const fixtureRoot = new URL('../../test/fixtures/relationship-benchmarks/', import.meta.url); + const fixtureRoot = new URL('../../fixtures/relationship-benchmarks', import.meta.url); const fixture = await loadKtxRelationshipBenchmarkFixture(join(fixtureRoot.pathname, 'scale_stress_no_declared_constraints')); if (!fixture.dataPath) { throw new Error('scale_stress_no_declared_constraints is missing data.sqlite'); @@ -336,7 +322,7 @@ describe('relationship profiling', () => { try { const result = await profileKtxRelationshipSchema({ connectionId: fixture.snapshot.connectionId, - driver: fixture.snapshot.driver, + dialect: getDialectForDriver(fixture.snapshot.driver), schema: snapshotToKtxEnrichedSchema(maskedSnapshot, new Map()), executor: scaleExecutor, ctx: { runId: 'scale-stress-profile-query-count' }, @@ -381,7 +367,7 @@ describe('relationship profiling', () => { await profileKtxRelationshipSchema({ connectionId: 'warehouse', - driver: 'sqlite', + dialect: getDialectForDriver('sqlite'), schema: schemaWithTables(['accounts', 'orders', 'payments', 'refunds']), executor, ctx: { runId: 'profile-concurrency' }, @@ -417,7 +403,7 @@ describe('relationship profiling', () => { const result = await profileKtxRelationshipSchema({ connectionId: 'warehouse', - driver: 'sqlite', + dialect: getDialectForDriver('sqlite'), schema: schemaWithTables(['accounts', 'orders']), executor, ctx: { runId: 'profile-error-isolated' }, diff --git a/packages/cli/src/context/scan/relationship-scoring.test.ts b/packages/cli/test/context/scan/relationship-scoring.test.ts similarity index 98% rename from packages/cli/src/context/scan/relationship-scoring.test.ts rename to packages/cli/test/context/scan/relationship-scoring.test.ts index 30127913..bd683f55 100644 --- a/packages/cli/src/context/scan/relationship-scoring.test.ts +++ b/packages/cli/test/context/scan/relationship-scoring.test.ts @@ -5,7 +5,7 @@ import { normalizeKtxRelationshipScoreWeights, scoreKtxRelationshipCandidate, type KtxRelationshipSignalVector, -} from './relationship-scoring.js'; +} from '../../../src/context/scan/relationship-scoring.js'; function signals(overrides: Partial = {}): KtxRelationshipSignalVector { return { diff --git a/packages/cli/src/context/scan/relationship-validation.test.ts b/packages/cli/test/context/scan/relationship-validation.test.ts similarity index 93% rename from packages/cli/src/context/scan/relationship-validation.test.ts rename to packages/cli/test/context/scan/relationship-validation.test.ts index 856cf60a..cd7771d9 100644 --- a/packages/cli/src/context/scan/relationship-validation.test.ts +++ b/packages/cli/test/context/scan/relationship-validation.test.ts @@ -1,11 +1,12 @@ import Database from 'better-sqlite3'; import { afterEach, describe, expect, it } from 'vitest'; -import type { KtxEnrichedColumn, KtxEnrichedSchema, KtxEnrichedTable } from './enrichment-types.js'; -import { generateKtxRelationshipDiscoveryCandidates } from './relationship-candidates.js'; -import type { KtxRelationshipProfileArtifact } from './relationship-profiling.js'; -import { profileKtxRelationshipSchema } from './relationship-profiling.js'; -import { validateKtxRelationshipDiscoveryCandidates } from './relationship-validation.js'; -import type { KtxQueryResult, KtxReadOnlyQueryInput, KtxScanContext } from './types.js'; +import { getDialectForDriver } from '../../../src/context/connections/dialects.js'; +import type { KtxEnrichedColumn, KtxEnrichedSchema, KtxEnrichedTable } from '../../../src/context/scan/enrichment-types.js'; +import { generateKtxRelationshipDiscoveryCandidates } from '../../../src/context/scan/relationship-candidates.js'; +import type { KtxRelationshipProfileArtifact } from '../../../src/context/scan/relationship-profiling.js'; +import { profileKtxRelationshipSchema } from '../../../src/context/scan/relationship-profiling.js'; +import { validateKtxRelationshipDiscoveryCandidates } from '../../../src/context/scan/relationship-validation.js'; +import type { KtxQueryResult, KtxReadOnlyQueryInput, KtxScanContext } from '../../../src/context/scan/types.js'; class InMemorySqliteExecutor { readonly db = new Database(':memory:'); @@ -99,7 +100,7 @@ describe('relationship validation', () => { const testSchema = schema(); const profiles = await profileKtxRelationshipSchema({ connectionId: 'warehouse', - driver: 'sqlite', + dialect: getDialectForDriver('sqlite'), schema: testSchema, executor, ctx: { runId: 'validate-test' }, @@ -110,7 +111,7 @@ describe('relationship validation', () => { const validated = await validateKtxRelationshipDiscoveryCandidates({ connectionId: 'warehouse', - driver: 'sqlite', + dialect: getDialectForDriver('sqlite'), candidates, profiles, executor, @@ -148,7 +149,7 @@ describe('relationship validation', () => { const testSchema = schema(); const profiles = await profileKtxRelationshipSchema({ connectionId: 'warehouse', - driver: 'sqlite', + dialect: getDialectForDriver('sqlite'), schema: testSchema, executor, ctx: { runId: 'validate-test' }, @@ -159,7 +160,7 @@ describe('relationship validation', () => { const validated = await validateKtxRelationshipDiscoveryCandidates({ connectionId: 'warehouse', - driver: 'sqlite', + dialect: getDialectForDriver('sqlite'), candidates, profiles, executor, @@ -198,7 +199,7 @@ describe('relationship validation', () => { const testSchema = schema(); const profiles = await profileKtxRelationshipSchema({ connectionId: 'warehouse', - driver: 'sqlite', + dialect: getDialectForDriver('sqlite'), schema: testSchema, executor, ctx: { runId: 'validate-budget-profile' }, @@ -211,7 +212,7 @@ describe('relationship validation', () => { const validated = await validateKtxRelationshipDiscoveryCandidates({ connectionId: 'warehouse', - driver: 'sqlite', + dialect: getDialectForDriver('sqlite'), candidates, profiles, executor, @@ -253,7 +254,7 @@ describe('relationship validation', () => { ]); const profiles = await profileKtxRelationshipSchema({ connectionId: 'warehouse', - driver: 'sqlite', + dialect: getDialectForDriver('sqlite'), schema: testSchema, executor, ctx: { runId: 'validate-zero-budget-profile' }, @@ -263,7 +264,7 @@ describe('relationship validation', () => { const validated = await validateKtxRelationshipDiscoveryCandidates({ connectionId: 'warehouse', - driver: 'sqlite', + dialect: getDialectForDriver('sqlite'), candidates, profiles, executor, @@ -300,7 +301,7 @@ describe('relationship validation', () => { ]); const profiles = await profileKtxRelationshipSchema({ connectionId: 'warehouse', - driver: 'sqlite', + dialect: getDialectForDriver('sqlite'), schema: testSchema, executor, ctx: { runId: 'llm-rejected-validation' }, @@ -329,7 +330,7 @@ describe('relationship validation', () => { const [validated] = await validateKtxRelationshipDiscoveryCandidates({ connectionId: 'warehouse', - driver: 'sqlite', + dialect: getDialectForDriver('sqlite'), candidates: [llmCandidate], profiles, executor, @@ -374,7 +375,7 @@ describe('relationship validation', () => { ]); const profiles = await profileKtxRelationshipSchema({ connectionId: 'warehouse', - driver: 'sqlite', + dialect: getDialectForDriver('sqlite'), schema: testSchema, executor, ctx: { runId: 'validation-concurrency-profile' }, @@ -383,7 +384,7 @@ describe('relationship validation', () => { await validateKtxRelationshipDiscoveryCandidates({ connectionId: 'warehouse', - driver: 'sqlite', + dialect: getDialectForDriver('sqlite'), candidates, profiles, executor: throttled, @@ -475,7 +476,7 @@ describe('relationship validation', () => { const [validated] = await validateKtxRelationshipDiscoveryCandidates({ connectionId: 'warehouse', - driver: 'sqlite', + dialect: getDialectForDriver('sqlite'), candidates: [candidate], profiles, executor, diff --git a/packages/cli/src/context/scan/table-ref.test.ts b/packages/cli/test/context/scan/table-ref.test.ts similarity index 98% rename from packages/cli/src/context/scan/table-ref.test.ts rename to packages/cli/test/context/scan/table-ref.test.ts index 510b6c82..233af18c 100644 --- a/packages/cli/src/context/scan/table-ref.test.ts +++ b/packages/cli/test/context/scan/table-ref.test.ts @@ -5,7 +5,7 @@ import { tableRefKey, tableRefSet, type KtxTableRefKey, -} from './table-ref.js'; +} from '../../../src/context/scan/table-ref.js'; describe('tableRefKey roundtrip', () => { it('encodes and decodes a three-part ref', () => { diff --git a/packages/cli/src/context/scan/type-normalization.test.ts b/packages/cli/test/context/scan/type-normalization.test.ts similarity index 92% rename from packages/cli/src/context/scan/type-normalization.test.ts rename to packages/cli/test/context/scan/type-normalization.test.ts index 5dc0adf2..fa19df32 100644 --- a/packages/cli/src/context/scan/type-normalization.test.ts +++ b/packages/cli/test/context/scan/type-normalization.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { inferKtxDimensionType, ktxColumnTypeMappingFromNative, normalizeKtxNativeType } from './type-normalization.js'; +import { inferKtxDimensionType, ktxColumnTypeMappingFromNative, normalizeKtxNativeType } from '../../../src/context/scan/type-normalization.js'; describe('KTX scan type normalization', () => { it('normalizes native database type strings', () => { diff --git a/packages/cli/src/context/scan/types.test.ts b/packages/cli/test/context/scan/types.test.ts similarity index 97% rename from packages/cli/src/context/scan/types.test.ts rename to packages/cli/test/context/scan/types.test.ts index 309db88e..8aa55dba 100644 --- a/packages/cli/src/context/scan/types.test.ts +++ b/packages/cli/test/context/scan/types.test.ts @@ -15,7 +15,7 @@ import { type KtxScanContext, type KtxScanInput, type KtxSchemaSnapshot, -} from './types.js'; +} from '../../../src/context/scan/types.js'; describe('KTX scan contract types', () => { it('defaults to structural-only connector capabilities', () => { @@ -93,6 +93,8 @@ describe('KTX scan contract types', () => { expect(ctx.runId).toBe('scan-run-1'); return snapshot; }, + listSchemas: async () => [], + listTables: async () => [], }; await expect( @@ -164,6 +166,8 @@ describe('KTX scan contract types', () => { tables: [], }; }, + listSchemas: async () => [], + listTables: async () => [], }; await expect( diff --git a/packages/cli/src/context/scan/warehouse-catalog.test.ts b/packages/cli/test/context/scan/warehouse-catalog.test.ts similarity index 92% rename from packages/cli/src/context/scan/warehouse-catalog.test.ts rename to packages/cli/test/context/scan/warehouse-catalog.test.ts index 6ef1f03a..300eba91 100644 --- a/packages/cli/src/context/scan/warehouse-catalog.test.ts +++ b/packages/cli/test/context/scan/warehouse-catalog.test.ts @@ -2,8 +2,8 @@ import { mkdtemp, rm } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; -import { initKtxProject, type KtxLocalProject } from '../../context/project/project.js'; -import { WarehouseCatalogService } from './warehouse-catalog.js'; +import { initKtxProject, type KtxLocalProject } from '../../../src/context/project/project.js'; +import { WarehouseCatalogService } from '../../../src/context/scan/warehouse-catalog.js'; describe('WarehouseCatalogService', () => { let tempDir: string; @@ -156,6 +156,17 @@ describe('WarehouseCatalogService', () => { }); }); + it('keeps one-part table display fallback for loose catalog resolution', async () => { + await seedLiveDatabaseScan(); + const catalog = new WarehouseCatalogService({ fileStore: project.fileStore }); + + await expect(catalog.resolveDisplay('warehouse', 'orders')).resolves.toMatchObject({ + resolved: { catalog: null, db: 'public', name: 'orders' }, + candidates: [], + dialect: 'postgres', + }); + }); + it('treats two-part BigQuery identifiers as ambiguous instead of guessing', async () => { await seedLiveDatabaseScan('warehouse', 'sync-bigquery', 'bigquery'); const catalog = new WarehouseCatalogService({ fileStore: project.fileStore }); diff --git a/packages/cli/src/context/search/backend-conformance.test-utils.test.ts b/packages/cli/test/context/search/backend-conformance.test-utils.test.ts similarity index 95% rename from packages/cli/src/context/search/backend-conformance.test-utils.test.ts rename to packages/cli/test/context/search/backend-conformance.test-utils.test.ts index c9ecebb7..b49f866a 100644 --- a/packages/cli/src/context/search/backend-conformance.test-utils.test.ts +++ b/packages/cli/test/context/search/backend-conformance.test-utils.test.ts @@ -2,22 +2,22 @@ import { mkdtemp, rm } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { afterEach, beforeEach, describe, it } from 'vitest'; -import { SqliteContextEvidenceStore } from '../ingest/context-evidence/sqlite-context-evidence-store.js'; -import type { JsonValue } from '../ingest/ports.js'; -import { initKtxProject, type KtxLocalProject } from '../project/project.js'; -import { type LocalSlSourceSearchResult, searchLocalSlSources, writeLocalSlSource } from '../sl/local-sl.js'; -import type { ContextEvidenceSearchResult } from '../tools/context-evidence-tool-store.js'; +import { SqliteContextEvidenceStore } from '../../../src/context/ingest/context-evidence/sqlite-context-evidence-store.js'; +import type { JsonValue } from '../../../src/context/ingest/ports.js'; +import { initKtxProject, type KtxLocalProject } from '../../../src/context/project/project.js'; +import { type LocalSlSourceSearchResult, searchLocalSlSources, writeLocalSlSource } from '../../../src/context/sl/local-sl.js'; +import type { ContextEvidenceSearchResult } from '../../../src/context/tools/context-evidence-tool-store.js'; import { type LocalKnowledgeSearchResult, searchLocalKnowledgePages, writeLocalKnowledgePage, -} from '../wiki/local-knowledge.js'; +} from '../../../src/context/wiki/local-knowledge.js'; import { assertSearchBackendCapabilities, assertSearchBackendConformanceCase, type SearchBackendConformanceResult, } from './backend-conformance.test-utils.js'; -import type { SearchBackendCapabilities } from './types.js'; +import type { SearchBackendCapabilities } from '../../../src/context/search/types.js'; const SQLITE_SEARCH_CAPABILITIES = { fts: true, diff --git a/packages/cli/src/context/search/backend-conformance.test-utils.ts b/packages/cli/test/context/search/backend-conformance.test-utils.ts similarity index 99% rename from packages/cli/src/context/search/backend-conformance.test-utils.ts rename to packages/cli/test/context/search/backend-conformance.test-utils.ts index fa6070b2..506cc22c 100644 --- a/packages/cli/src/context/search/backend-conformance.test-utils.ts +++ b/packages/cli/test/context/search/backend-conformance.test-utils.ts @@ -1,4 +1,4 @@ -import type { SearchBackendCapabilities, SearchLaneStatus } from './types.js'; +import type { SearchBackendCapabilities, SearchLaneStatus } from '../../../src/context/search/types.js'; export interface SearchBackendConformanceLane { lane: string; diff --git a/packages/cli/src/context/search/discover.test.ts b/packages/cli/test/context/search/discover.test.ts similarity index 97% rename from packages/cli/src/context/search/discover.test.ts rename to packages/cli/test/context/search/discover.test.ts index 931de2be..77e35e18 100644 --- a/packages/cli/src/context/search/discover.test.ts +++ b/packages/cli/test/context/search/discover.test.ts @@ -2,9 +2,9 @@ import { mkdtemp, rm } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; -import { initKtxProject, type KtxLocalProject } from '../../context/project/project.js'; -import { writeLocalKnowledgePage } from '../wiki/local-knowledge.js'; -import { createKtxDiscoverDataService } from './discover.js'; +import { initKtxProject, type KtxLocalProject } from '../../../src/context/project/project.js'; +import { writeLocalKnowledgePage } from '../../../src/context/wiki/local-knowledge.js'; +import { createKtxDiscoverDataService } from '../../../src/context/search/discover.js'; describe('createKtxDiscoverDataService', () => { let tempDir: string; diff --git a/packages/cli/src/context/search/hybrid-search-core.test.ts b/packages/cli/test/context/search/hybrid-search-core.test.ts similarity index 96% rename from packages/cli/src/context/search/hybrid-search-core.test.ts rename to packages/cli/test/context/search/hybrid-search-core.test.ts index 2350e2ed..5952e3ee 100644 --- a/packages/cli/src/context/search/hybrid-search-core.test.ts +++ b/packages/cli/test/context/search/hybrid-search-core.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; -import { HybridSearchCore } from './hybrid-search-core.js'; -import type { SearchCandidateGenerator } from './types.js'; +import { HybridSearchCore } from '../../../src/context/search/hybrid-search-core.js'; +import type { SearchCandidateGenerator } from '../../../src/context/search/types.js'; function generator( lane: string, diff --git a/packages/cli/src/context/search/pglite-owner-process.test.ts b/packages/cli/test/context/search/pglite-owner-process.test.ts similarity index 98% rename from packages/cli/src/context/search/pglite-owner-process.test.ts rename to packages/cli/test/context/search/pglite-owner-process.test.ts index 3a15eea9..df3d096b 100644 --- a/packages/cli/src/context/search/pglite-owner-process.test.ts +++ b/packages/cli/test/context/search/pglite-owner-process.test.ts @@ -4,8 +4,8 @@ import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { Client } from 'pg'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; -import { assertSearchBackendConformanceCase } from '../../context/search/backend-conformance.test-utils.js'; -import { KtxPGliteOwnerProcess } from './pglite-owner-process.js'; +import { assertSearchBackendConformanceCase } from './backend-conformance.test-utils.js'; +import { KtxPGliteOwnerProcess } from '../../../src/context/search/pglite-owner-process.js'; async function allocatePort(): Promise { const server = createServer(); diff --git a/packages/cli/src/context/search/pglite-runtime-boundary.test.ts b/packages/cli/test/context/search/pglite-runtime-boundary.test.ts similarity index 96% rename from packages/cli/src/context/search/pglite-runtime-boundary.test.ts rename to packages/cli/test/context/search/pglite-runtime-boundary.test.ts index feb7443d..c9fe80d2 100644 --- a/packages/cli/src/context/search/pglite-runtime-boundary.test.ts +++ b/packages/cli/test/context/search/pglite-runtime-boundary.test.ts @@ -3,7 +3,7 @@ import { join } from 'node:path'; import { fileURLToPath } from 'node:url'; import { describe, expect, it } from 'vitest'; -const ktxRoot = fileURLToPath(new URL('../../../../../', import.meta.url)); +const ktxRoot = fileURLToPath(new URL('../../../../..', import.meta.url)); function readKtxFile(relativePath: string): string { return readFileSync(join(ktxRoot, relativePath), 'utf8'); diff --git a/packages/cli/src/context/search/pglite-spike.test.ts b/packages/cli/test/context/search/pglite-spike.test.ts similarity index 98% rename from packages/cli/src/context/search/pglite-spike.test.ts rename to packages/cli/test/context/search/pglite-spike.test.ts index 470000da..2183630b 100644 --- a/packages/cli/src/context/search/pglite-spike.test.ts +++ b/packages/cli/test/context/search/pglite-spike.test.ts @@ -5,8 +5,8 @@ import { PGlite, type PGliteInterface } from '@electric-sql/pglite'; import { pg_trgm } from '@electric-sql/pglite/contrib/pg_trgm'; import { vector } from '@electric-sql/pglite/vector'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; -import { assertSearchBackendCapabilities, assertSearchBackendConformanceCase } from '../../context/search/backend-conformance.test-utils.js'; -import type { SearchBackendCapabilities } from '../../context/search/types.js'; +import { assertSearchBackendCapabilities, assertSearchBackendConformanceCase } from './backend-conformance.test-utils.js'; +import type { SearchBackendCapabilities } from '../../../src/context/search/types.js'; type PGliteDb = PGliteInterface; diff --git a/packages/cli/src/context/search/query.test.ts b/packages/cli/test/context/search/query.test.ts similarity index 95% rename from packages/cli/src/context/search/query.test.ts rename to packages/cli/test/context/search/query.test.ts index 64f1fd0b..b8e7660f 100644 --- a/packages/cli/src/context/search/query.test.ts +++ b/packages/cli/test/context/search/query.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { defaultLaneCandidatePoolLimit, normalizeSearchQuery } from './query.js'; +import { defaultLaneCandidatePoolLimit, normalizeSearchQuery } from '../../../src/context/search/query.js'; describe('search query helpers', () => { it('normalizes punctuation and duplicate terms into stable lowercase tokens', () => { diff --git a/packages/cli/src/context/search/rrf.test.ts b/packages/cli/test/context/search/rrf.test.ts similarity index 90% rename from packages/cli/src/context/search/rrf.test.ts rename to packages/cli/test/context/search/rrf.test.ts index cbb4065b..42890989 100644 --- a/packages/cli/src/context/search/rrf.test.ts +++ b/packages/cli/test/context/search/rrf.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; -import { compareFusedSearchCandidates, DEFAULT_SEARCH_LANE_WEIGHTS, rrfContribution } from './rrf.js'; -import type { FusedSearchCandidate } from './types.js'; +import { compareFusedSearchCandidates, DEFAULT_SEARCH_LANE_WEIGHTS, rrfContribution } from '../../../src/context/search/rrf.js'; +import type { FusedSearchCandidate } from '../../../src/context/search/types.js'; describe('RRF scoring', () => { it('uses the shared lane weights from the hybrid search spec', () => { diff --git a/packages/cli/src/context/skills/skills-registry.service.test.ts b/packages/cli/test/context/skills/skills-registry.service.test.ts similarity index 98% rename from packages/cli/src/context/skills/skills-registry.service.test.ts rename to packages/cli/test/context/skills/skills-registry.service.test.ts index 9bb716dd..2f6f1aaf 100644 --- a/packages/cli/src/context/skills/skills-registry.service.test.ts +++ b/packages/cli/test/context/skills/skills-registry.service.test.ts @@ -2,7 +2,7 @@ import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; -import { SkillsRegistryService } from './skills-registry.service.js'; +import { SkillsRegistryService } from '../../../src/context/skills/skills-registry.service.js'; describe('SkillsRegistryService', () => { let service: SkillsRegistryService; diff --git a/packages/cli/src/context/sl/dictionary-search.test.ts b/packages/cli/test/context/sl/dictionary-search.test.ts similarity index 97% rename from packages/cli/src/context/sl/dictionary-search.test.ts rename to packages/cli/test/context/sl/dictionary-search.test.ts index 1838f0d9..b7a6beeb 100644 --- a/packages/cli/src/context/sl/dictionary-search.test.ts +++ b/packages/cli/test/context/sl/dictionary-search.test.ts @@ -2,8 +2,8 @@ import { mkdtemp, rm } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; -import { initKtxProject, type KtxLocalProject } from '../../context/project/project.js'; -import { createKtxDictionarySearchService } from './dictionary-search.js'; +import { initKtxProject, type KtxLocalProject } from '../../../src/context/project/project.js'; +import { createKtxDictionarySearchService } from '../../../src/context/sl/dictionary-search.js'; describe('createKtxDictionarySearchService', () => { let tempDir: string; diff --git a/packages/cli/src/context/sl/local-query.test.ts b/packages/cli/test/context/sl/local-query.test.ts similarity index 97% rename from packages/cli/src/context/sl/local-query.test.ts rename to packages/cli/test/context/sl/local-query.test.ts index 800bdb95..4137f596 100644 --- a/packages/cli/src/context/sl/local-query.test.ts +++ b/packages/cli/test/context/sl/local-query.test.ts @@ -2,9 +2,9 @@ import { mkdtemp, rm } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import type { KtxSemanticLayerComputePort } from '../../context/daemon/semantic-layer-compute.js'; -import { initKtxProject, type KtxLocalProject } from '../../context/project/project.js'; -import { compileLocalSlQuery } from './local-query.js'; +import type { KtxSemanticLayerComputePort } from '../../../src/context/daemon/semantic-layer-compute.js'; +import { initKtxProject, type KtxLocalProject } from '../../../src/context/project/project.js'; +import { compileLocalSlQuery } from '../../../src/context/sl/local-query.js'; describe('compileLocalSlQuery', () => { let tempDir: string; diff --git a/packages/cli/src/context/sl/local-sl.test.ts b/packages/cli/test/context/sl/local-sl.test.ts similarity index 98% rename from packages/cli/src/context/sl/local-sl.test.ts rename to packages/cli/test/context/sl/local-sl.test.ts index 3ba00a92..1115f387 100644 --- a/packages/cli/src/context/sl/local-sl.test.ts +++ b/packages/cli/test/context/sl/local-sl.test.ts @@ -2,14 +2,14 @@ import { access, mkdtemp, rm } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; -import { initKtxProject, type KtxLocalProject } from '../../context/project/project.js'; +import { initKtxProject, type KtxLocalProject } from '../../../src/context/project/project.js'; import { listLocalSlSources, readLocalSlSource, searchLocalSlSources, validateLocalSlSource, writeLocalSlSource, -} from './local-sl.js'; +} from '../../../src/context/sl/local-sl.js'; const ORDERS_YAML = [ 'name: orders', diff --git a/packages/cli/src/context/sl/pglite-sl-search-prototype.test.ts b/packages/cli/test/context/sl/pglite-sl-search-prototype.test.ts similarity index 95% rename from packages/cli/src/context/sl/pglite-sl-search-prototype.test.ts rename to packages/cli/test/context/sl/pglite-sl-search-prototype.test.ts index 372f8668..8ebb8646 100644 --- a/packages/cli/src/context/sl/pglite-sl-search-prototype.test.ts +++ b/packages/cli/test/context/sl/pglite-sl-search-prototype.test.ts @@ -3,10 +3,10 @@ import { createServer } from 'node:net'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; -import { initKtxProject, type KtxLocalProject } from '../../context/project/project.js'; -import { assertSearchBackendConformanceCase } from '../../context/search/backend-conformance.test-utils.js'; -import { searchLocalSlSources, writeLocalSlSource, type LocalSlSourceSearchResult } from './local-sl.js'; -import { searchLocalSlSourcesWithPglitePrototype } from './pglite-sl-search-prototype.js'; +import { initKtxProject, type KtxLocalProject } from '../../../src/context/project/project.js'; +import { assertSearchBackendConformanceCase } from '../search/backend-conformance.test-utils.js'; +import { searchLocalSlSources, writeLocalSlSource, type LocalSlSourceSearchResult } from '../../../src/context/sl/local-sl.js'; +import { searchLocalSlSourcesWithPglitePrototype } from '../../../src/context/sl/pglite-sl-search-prototype.js'; const ORDERS_YAML = [ 'name: orders', diff --git a/packages/cli/src/context/sl/schemas.contract.test.ts b/packages/cli/test/context/sl/schemas.contract.test.ts similarity index 88% rename from packages/cli/src/context/sl/schemas.contract.test.ts rename to packages/cli/test/context/sl/schemas.contract.test.ts index 1b0dac20..9b1293f1 100644 --- a/packages/cli/src/context/sl/schemas.contract.test.ts +++ b/packages/cli/test/context/sl/schemas.contract.test.ts @@ -2,14 +2,14 @@ import { execFileSync } from 'node:child_process'; import { Ajv2020 } from 'ajv/dist/2020.js'; import { describe, expect, it } from 'vitest'; -import { resolvedSourceSchema } from './schemas.js'; -import { toResolvedWire } from './semantic-layer.service.js'; -import type { SemanticLayerSource } from './types.js'; +import { resolvedSourceSchema } from '../../../src/context/sl/schemas.js'; +import { toResolvedWire } from '../../../src/context/sl/semantic-layer.service.js'; +import type { SemanticLayerSource } from '../../../src/context/sl/types.js'; function loadPythonSourceDefinitionSchema(): Record | null { try { const stdout = execFileSync('uv', ['run', 'python', '-m', 'semantic_layer', 'dump-schema'], { - cwd: new URL('../../../../', import.meta.url), + cwd: new URL('../../../..', import.meta.url), encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], }); diff --git a/packages/cli/src/context/sl/semantic-layer.service.test.ts b/packages/cli/test/context/sl/semantic-layer.service.test.ts similarity index 99% rename from packages/cli/src/context/sl/semantic-layer.service.test.ts rename to packages/cli/test/context/sl/semantic-layer.service.test.ts index cd14d66a..f8b919bb 100644 --- a/packages/cli/src/context/sl/semantic-layer.service.test.ts +++ b/packages/cli/test/context/sl/semantic-layer.service.test.ts @@ -11,9 +11,9 @@ import { SemanticLayerService, toResolvedWire, UnknownColumnOverrideError, -} from './semantic-layer.service.js'; -import { resolvedSourceSchema, sourceDefinitionSchema, sourceOverlaySchema } from './schemas.js'; -import type { SemanticLayerSource } from './types.js'; +} from '../../../src/context/sl/semantic-layer.service.js'; +import { resolvedSourceSchema, sourceDefinitionSchema, sourceOverlaySchema } from '../../../src/context/sl/schemas.js'; +import type { SemanticLayerSource } from '../../../src/context/sl/types.js'; const pythonPort = { validateSources: vi.fn(), diff --git a/packages/cli/src/context/sl/sl-dictionary-profile.test.ts b/packages/cli/test/context/sl/sl-dictionary-profile.test.ts similarity index 94% rename from packages/cli/src/context/sl/sl-dictionary-profile.test.ts rename to packages/cli/test/context/sl/sl-dictionary-profile.test.ts index f7aa3854..7a36924f 100644 --- a/packages/cli/src/context/sl/sl-dictionary-profile.test.ts +++ b/packages/cli/test/context/sl/sl-dictionary-profile.test.ts @@ -2,8 +2,8 @@ import { mkdtemp, rm } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; -import { initKtxProject, type KtxLocalProject } from '../../context/project/project.js'; -import { loadLatestSlDictionaryEntries } from './sl-dictionary-profile.js'; +import { initKtxProject, type KtxLocalProject } from '../../../src/context/project/project.js'; +import { loadLatestSlDictionaryEntries } from '../../../src/context/sl/sl-dictionary-profile.js'; describe('loadLatestSlDictionaryEntries', () => { let tempDir: string; diff --git a/packages/cli/src/context/sl/sl-search.service.test.ts b/packages/cli/test/context/sl/sl-search.service.test.ts similarity index 98% rename from packages/cli/src/context/sl/sl-search.service.test.ts rename to packages/cli/test/context/sl/sl-search.service.test.ts index 164c3954..052cdee9 100644 --- a/packages/cli/src/context/sl/sl-search.service.test.ts +++ b/packages/cli/test/context/sl/sl-search.service.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it, vi } from 'vitest'; -import { buildSemanticLayerSourceSearchText, SlSearchService } from './sl-search.service.js'; -import type { SemanticLayerSource } from './types.js'; +import { buildSemanticLayerSourceSearchText, SlSearchService } from '../../../src/context/sl/sl-search.service.js'; +import type { SemanticLayerSource } from '../../../src/context/sl/types.js'; describe('SlSearchService', () => { it('builds search text from source, columns, measures, and joins', () => { diff --git a/packages/cli/src/context/sl/sqlite-sl-sources-index.test.ts b/packages/cli/test/context/sl/sqlite-sl-sources-index.test.ts similarity index 98% rename from packages/cli/src/context/sl/sqlite-sl-sources-index.test.ts rename to packages/cli/test/context/sl/sqlite-sl-sources-index.test.ts index 91a7727e..d0002dae 100644 --- a/packages/cli/src/context/sl/sqlite-sl-sources-index.test.ts +++ b/packages/cli/test/context/sl/sqlite-sl-sources-index.test.ts @@ -2,7 +2,7 @@ import { access, mkdtemp, rm } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; -import { SqliteSlSourcesIndex } from './sqlite-sl-sources-index.js'; +import { SqliteSlSourcesIndex } from '../../../src/context/sl/sqlite-sl-sources-index.js'; describe('SqliteSlSourcesIndex', () => { let tempDir: string; diff --git a/packages/cli/src/context/sl/tools/connection-id-schema.test.ts b/packages/cli/test/context/sl/tools/connection-id-schema.test.ts similarity index 87% rename from packages/cli/src/context/sl/tools/connection-id-schema.test.ts rename to packages/cli/test/context/sl/tools/connection-id-schema.test.ts index 48e023e5..1108ce96 100644 --- a/packages/cli/src/context/sl/tools/connection-id-schema.test.ts +++ b/packages/cli/test/context/sl/tools/connection-id-schema.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { slToolConnectionIdSchema } from './connection-id-schema.js'; +import { slToolConnectionIdSchema } from '../../../../src/context/sl/tools/connection-id-schema.js'; describe('slToolConnectionIdSchema', () => { it('accepts app UUIDs and local project connection ids', () => { diff --git a/packages/cli/src/context/sl/tools/sl-discover.tool.test.ts b/packages/cli/test/context/sl/tools/sl-discover.tool.test.ts similarity index 86% rename from packages/cli/src/context/sl/tools/sl-discover.tool.test.ts rename to packages/cli/test/context/sl/tools/sl-discover.tool.test.ts index 6dc30478..6a673d2c 100644 --- a/packages/cli/src/context/sl/tools/sl-discover.tool.test.ts +++ b/packages/cli/test/context/sl/tools/sl-discover.tool.test.ts @@ -1,9 +1,9 @@ import { describe, expect, it, vi } from 'vitest'; -import type { ToolContext } from '../../../context/tools/base-tool.js'; -import type { ToolSession } from '../../../context/tools/tool-session.js'; -import { createTouchedSlSources } from '../../../context/tools/touched-sl-sources.js'; -import type { SemanticLayerSource } from '../types.js'; -import { SlDiscoverTool } from './sl-discover.tool.js'; +import type { ToolContext } from '../../../../src/context/tools/base-tool.js'; +import type { ToolSession } from '../../../../src/context/tools/tool-session.js'; +import { createTouchedSlSources } from '../../../../src/context/tools/touched-sl-sources.js'; +import type { SemanticLayerSource } from '../../../../src/context/sl/types.js'; +import { SlDiscoverTool } from '../../../../src/context/sl/tools/sl-discover.tool.js'; function makeTool() { const semanticLayerService = { diff --git a/packages/cli/src/context/sl/tools/sl-edit-source.tool.test.ts b/packages/cli/test/context/sl/tools/sl-edit-source.tool.test.ts similarity index 96% rename from packages/cli/src/context/sl/tools/sl-edit-source.tool.test.ts rename to packages/cli/test/context/sl/tools/sl-edit-source.tool.test.ts index cf66baf8..fee83ea6 100644 --- a/packages/cli/src/context/sl/tools/sl-edit-source.tool.test.ts +++ b/packages/cli/test/context/sl/tools/sl-edit-source.tool.test.ts @@ -1,8 +1,8 @@ import { describe, expect, it, vi } from 'vitest'; -import type { ToolSession } from '../../../context/tools/tool-session.js'; -import { createTouchedSlSources, hasTouchedSlSource } from '../../../context/tools/touched-sl-sources.js'; -import type { ToolContext } from '../../../context/tools/base-tool.js'; -import { SlEditSourceTool } from './sl-edit-source.tool.js'; +import type { ToolSession } from '../../../../src/context/tools/tool-session.js'; +import { createTouchedSlSources, hasTouchedSlSource } from '../../../../src/context/tools/touched-sl-sources.js'; +import type { ToolContext } from '../../../../src/context/tools/base-tool.js'; +import { SlEditSourceTool } from '../../../../src/context/sl/tools/sl-edit-source.tool.js'; function makeTool(overrides: any = {}) { const semanticLayerService = { diff --git a/packages/cli/src/context/sl/tools/sl-read-source.tool.session.test.ts b/packages/cli/test/context/sl/tools/sl-read-source.tool.session.test.ts similarity index 87% rename from packages/cli/src/context/sl/tools/sl-read-source.tool.session.test.ts rename to packages/cli/test/context/sl/tools/sl-read-source.tool.session.test.ts index 481c4cfe..121a012b 100644 --- a/packages/cli/src/context/sl/tools/sl-read-source.tool.session.test.ts +++ b/packages/cli/test/context/sl/tools/sl-read-source.tool.session.test.ts @@ -1,8 +1,8 @@ import { describe, expect, it, vi } from 'vitest'; -import type { ToolSession } from '../../../context/tools/tool-session.js'; -import { createTouchedSlSources } from '../../../context/tools/touched-sl-sources.js'; -import type { ToolContext } from '../../../context/tools/base-tool.js'; -import { SlReadSourceTool } from './sl-read-source.tool.js'; +import type { ToolSession } from '../../../../src/context/tools/tool-session.js'; +import { createTouchedSlSources } from '../../../../src/context/tools/touched-sl-sources.js'; +import type { ToolContext } from '../../../../src/context/tools/base-tool.js'; +import { SlReadSourceTool } from '../../../../src/context/sl/tools/sl-read-source.tool.js'; function makeTool(overrides: Partial> = {}) { const semanticLayerService = { diff --git a/packages/cli/src/context/sl/tools/sl-rollback.tool.test.ts b/packages/cli/test/context/sl/tools/sl-rollback.tool.test.ts similarity index 90% rename from packages/cli/src/context/sl/tools/sl-rollback.tool.test.ts rename to packages/cli/test/context/sl/tools/sl-rollback.tool.test.ts index 5a1927a4..6d2d787a 100644 --- a/packages/cli/src/context/sl/tools/sl-rollback.tool.test.ts +++ b/packages/cli/test/context/sl/tools/sl-rollback.tool.test.ts @@ -1,8 +1,8 @@ import { describe, expect, it, vi } from 'vitest'; -import type { ToolSession } from '../../../context/tools/tool-session.js'; -import { createTouchedSlSources, hasTouchedSlSource } from '../../../context/tools/touched-sl-sources.js'; -import type { ToolContext } from '../../../context/tools/base-tool.js'; -import { SlRollbackTool } from './sl-rollback.tool.js'; +import type { ToolSession } from '../../../../src/context/tools/tool-session.js'; +import { createTouchedSlSources, hasTouchedSlSource } from '../../../../src/context/tools/touched-sl-sources.js'; +import type { ToolContext } from '../../../../src/context/tools/base-tool.js'; +import { SlRollbackTool } from '../../../../src/context/sl/tools/sl-rollback.tool.js'; function makeSession(overrides: Partial = {}): ToolSession { return { diff --git a/packages/cli/src/context/sl/tools/sl-validate.tool.test.ts b/packages/cli/test/context/sl/tools/sl-validate.tool.test.ts similarity index 84% rename from packages/cli/src/context/sl/tools/sl-validate.tool.test.ts rename to packages/cli/test/context/sl/tools/sl-validate.tool.test.ts index f0c18eac..ff7cee5f 100644 --- a/packages/cli/src/context/sl/tools/sl-validate.tool.test.ts +++ b/packages/cli/test/context/sl/tools/sl-validate.tool.test.ts @@ -1,10 +1,10 @@ import { describe, expect, it, vi } from 'vitest'; -import type { ToolSession } from '../../../context/tools/tool-session.js'; -import { createTouchedSlSources } from '../../../context/tools/touched-sl-sources.js'; -import type { ToolContext } from '../../../context/tools/base-tool.js'; -import type { SemanticLayerService } from '../semantic-layer.service.js'; -import type { SemanticLayerSource } from '../types.js'; -import { SlValidateTool, validateSemanticLayerEndpoint } from './sl-validate.tool.js'; +import type { ToolSession } from '../../../../src/context/tools/tool-session.js'; +import { createTouchedSlSources } from '../../../../src/context/tools/touched-sl-sources.js'; +import type { ToolContext } from '../../../../src/context/tools/base-tool.js'; +import type { SemanticLayerService } from '../../../../src/context/sl/semantic-layer.service.js'; +import type { SemanticLayerSource } from '../../../../src/context/sl/types.js'; +import { SlValidateTool, validateSemanticLayerEndpoint } from '../../../../src/context/sl/tools/sl-validate.tool.js'; describe('validateSemanticLayerEndpoint', () => { it('uses the connection warehouse dialect, not hardcoded postgres', async () => { diff --git a/packages/cli/src/context/sl/tools/sl-warehouse-validation.test.ts b/packages/cli/test/context/sl/tools/sl-warehouse-validation.test.ts similarity index 98% rename from packages/cli/src/context/sl/tools/sl-warehouse-validation.test.ts rename to packages/cli/test/context/sl/tools/sl-warehouse-validation.test.ts index 5796cdb7..d8d45a81 100644 --- a/packages/cli/src/context/sl/tools/sl-warehouse-validation.test.ts +++ b/packages/cli/test/context/sl/tools/sl-warehouse-validation.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it, vi } from 'vitest'; -import { validateSingleSource } from './sl-warehouse-validation.js'; +import { validateSingleSource } from '../../../../src/context/sl/tools/sl-warehouse-validation.js'; function makeDeps(opts: { sourceYaml: string; executeQuery: ReturnType }) { return { diff --git a/packages/cli/src/context/sl/tools/sl-write-source.tool.test.ts b/packages/cli/test/context/sl/tools/sl-write-source.tool.test.ts similarity index 97% rename from packages/cli/src/context/sl/tools/sl-write-source.tool.test.ts rename to packages/cli/test/context/sl/tools/sl-write-source.tool.test.ts index f168095c..ab3ee308 100644 --- a/packages/cli/src/context/sl/tools/sl-write-source.tool.test.ts +++ b/packages/cli/test/context/sl/tools/sl-write-source.tool.test.ts @@ -1,8 +1,8 @@ import { describe, expect, it, vi } from 'vitest'; -import type { ToolSession } from '../../../context/tools/tool-session.js'; -import { createTouchedSlSources, hasTouchedSlSource } from '../../../context/tools/touched-sl-sources.js'; -import type { ToolContext } from '../../../context/tools/base-tool.js'; -import { SlWriteSourceTool } from './sl-write-source.tool.js'; +import type { ToolSession } from '../../../../src/context/tools/tool-session.js'; +import { createTouchedSlSources, hasTouchedSlSource } from '../../../../src/context/tools/touched-sl-sources.js'; +import type { ToolContext } from '../../../../src/context/tools/base-tool.js'; +import { SlWriteSourceTool } from '../../../../src/context/sl/tools/sl-write-source.tool.js'; function makeTool(overrides: Partial> = {}) { const semanticLayerService = { diff --git a/packages/cli/src/context/sql-analysis/http-sql-analysis-port.test.ts b/packages/cli/test/context/sql-analysis/http-sql-analysis-port.test.ts similarity index 98% rename from packages/cli/src/context/sql-analysis/http-sql-analysis-port.test.ts rename to packages/cli/test/context/sql-analysis/http-sql-analysis-port.test.ts index 2d759369..02b275a6 100644 --- a/packages/cli/src/context/sql-analysis/http-sql-analysis-port.test.ts +++ b/packages/cli/test/context/sql-analysis/http-sql-analysis-port.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it, vi } from 'vitest'; -import { createHttpSqlAnalysisPort } from './http-sql-analysis-port.js'; +import { createHttpSqlAnalysisPort } from '../../../src/context/sql-analysis/http-sql-analysis-port.js'; describe('createHttpSqlAnalysisPort', () => { it('calls the SQL-analysis fingerprint endpoint and maps snake_case response fields', async () => { diff --git a/packages/cli/src/context/test/make-local-git-repo.ts b/packages/cli/test/context/test/make-local-git-repo.ts similarity index 95% rename from packages/cli/src/context/test/make-local-git-repo.ts rename to packages/cli/test/context/test/make-local-git-repo.ts index a7b4c662..c60187ac 100644 --- a/packages/cli/src/context/test/make-local-git-repo.ts +++ b/packages/cli/test/context/test/make-local-git-repo.ts @@ -1,7 +1,7 @@ import { cp, mkdir, rm, writeFile } from 'node:fs/promises'; import { join } from 'node:path'; import type { SimpleGit } from 'simple-git'; -import { createSimpleGit } from '../ingest/git-env.js'; +import { createSimpleGit } from '../../../src/context/ingest/git-env.js'; export interface LocalGitRepo { repoDir: string; diff --git a/packages/cli/src/context/tools/context-evidence-tools.test.ts b/packages/cli/test/context/tools/context-evidence-tools.test.ts similarity index 95% rename from packages/cli/src/context/tools/context-evidence-tools.test.ts rename to packages/cli/test/context/tools/context-evidence-tools.test.ts index 08a8654f..f8adb618 100644 --- a/packages/cli/src/context/tools/context-evidence-tools.test.ts +++ b/packages/cli/test/context/tools/context-evidence-tools.test.ts @@ -3,17 +3,17 @@ import { mkdtemp, rm } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import type { KtxEmbeddingPort } from '../../context/core/embedding.js'; -import { SqliteContextEvidenceStore } from '../ingest/context-evidence/sqlite-context-evidence-store.js'; -import { ContextCandidateMarkTool } from './context-candidate-mark.tool.js'; -import { ContextCandidateWriteTool } from './context-candidate-write.tool.js'; -import { ContextEvidenceNeighborsTool } from './context-evidence-neighbors.tool.js'; -import { ContextEvidenceReadTool } from './context-evidence-read.tool.js'; -import { ContextEvidenceSearchTool } from './context-evidence-search.tool.js'; -import type { ContextEvidenceToolStorePort } from './context-evidence-tool-store.js'; -import { createTouchedSlSources } from '../../context/tools/touched-sl-sources.js'; -import type { ToolContext } from '../../context/tools/base-tool.js'; -import type { ToolSession } from '../../context/tools/tool-session.js'; +import type { KtxEmbeddingPort } from '../../../src/context/core/embedding.js'; +import { SqliteContextEvidenceStore } from '../../../src/context/ingest/context-evidence/sqlite-context-evidence-store.js'; +import { ContextCandidateMarkTool } from '../../../src/context/tools/context-candidate-mark.tool.js'; +import { ContextCandidateWriteTool } from '../../../src/context/tools/context-candidate-write.tool.js'; +import { ContextEvidenceNeighborsTool } from '../../../src/context/tools/context-evidence-neighbors.tool.js'; +import { ContextEvidenceReadTool } from '../../../src/context/tools/context-evidence-read.tool.js'; +import { ContextEvidenceSearchTool } from '../../../src/context/tools/context-evidence-search.tool.js'; +import type { ContextEvidenceToolStorePort } from '../../../src/context/tools/context-evidence-tool-store.js'; +import { createTouchedSlSources } from '../../../src/context/tools/touched-sl-sources.js'; +import type { ToolContext } from '../../../src/context/tools/base-tool.js'; +import type { ToolSession } from '../../../src/context/tools/tool-session.js'; const ingestContext = (): ToolContext => ({ sourceId: 'ingest', diff --git a/packages/cli/src/context/tools/touched-sl-sources.test.ts b/packages/cli/test/context/tools/touched-sl-sources.test.ts similarity index 96% rename from packages/cli/src/context/tools/touched-sl-sources.test.ts rename to packages/cli/test/context/tools/touched-sl-sources.test.ts index 818676d2..f84f5d6b 100644 --- a/packages/cli/src/context/tools/touched-sl-sources.test.ts +++ b/packages/cli/test/context/tools/touched-sl-sources.test.ts @@ -7,7 +7,7 @@ import { listTouchedSlSources, touchedSlSourceCount, touchedSlSourceNamesForConnection, -} from './touched-sl-sources.js'; +} from '../../../src/context/tools/touched-sl-sources.js'; describe('target-aware touched SL source helpers', () => { it('deduplicates by connectionId and sourceName while preserving target identity', () => { diff --git a/packages/cli/src/context/wiki/knowledge-wiki.service.test.ts b/packages/cli/test/context/wiki/knowledge-wiki.service.test.ts similarity index 98% rename from packages/cli/src/context/wiki/knowledge-wiki.service.test.ts rename to packages/cli/test/context/wiki/knowledge-wiki.service.test.ts index 88bd92ab..efc9c69c 100644 --- a/packages/cli/src/context/wiki/knowledge-wiki.service.test.ts +++ b/packages/cli/test/context/wiki/knowledge-wiki.service.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it, vi } from 'vitest'; -import { KnowledgeWikiService, type WikiFrontmatter } from './knowledge-wiki.service.js'; +import { KnowledgeWikiService, type WikiFrontmatter } from '../../../src/context/wiki/knowledge-wiki.service.js'; function makeService() { const pagesRepository: Record> = { diff --git a/packages/cli/src/context/wiki/local-knowledge.test.ts b/packages/cli/test/context/wiki/local-knowledge.test.ts similarity index 98% rename from packages/cli/src/context/wiki/local-knowledge.test.ts rename to packages/cli/test/context/wiki/local-knowledge.test.ts index 8229d5e7..fa70bcc5 100644 --- a/packages/cli/src/context/wiki/local-knowledge.test.ts +++ b/packages/cli/test/context/wiki/local-knowledge.test.ts @@ -2,13 +2,13 @@ import { access, mkdtemp, rm } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; -import { initKtxProject, type KtxLocalProject } from '../../context/project/project.js'; +import { initKtxProject, type KtxLocalProject } from '../../../src/context/project/project.js'; import { listLocalKnowledgePages, readLocalKnowledgePage, searchLocalKnowledgePages, writeLocalKnowledgePage, -} from './local-knowledge.js'; +} from '../../../src/context/wiki/local-knowledge.js'; class FakeEmbeddingPort { readonly maxBatchSize = 16; diff --git a/packages/cli/src/context/wiki/sqlite-knowledge-index.test.ts b/packages/cli/test/context/wiki/sqlite-knowledge-index.test.ts similarity index 98% rename from packages/cli/src/context/wiki/sqlite-knowledge-index.test.ts rename to packages/cli/test/context/wiki/sqlite-knowledge-index.test.ts index 940e9954..5a3b0dc1 100644 --- a/packages/cli/src/context/wiki/sqlite-knowledge-index.test.ts +++ b/packages/cli/test/context/wiki/sqlite-knowledge-index.test.ts @@ -2,7 +2,7 @@ import { access, mkdtemp, rm } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; -import { SqliteKnowledgeIndex, type SqliteKnowledgeIndexPage } from './sqlite-knowledge-index.js'; +import { SqliteKnowledgeIndex, type SqliteKnowledgeIndexPage } from '../../../src/context/wiki/sqlite-knowledge-index.js'; describe('SqliteKnowledgeIndex', () => { let tempDir: string; diff --git a/packages/cli/src/context/wiki/tools/wiki-list-tags.tool.test.ts b/packages/cli/test/context/wiki/tools/wiki-list-tags.tool.test.ts similarity index 91% rename from packages/cli/src/context/wiki/tools/wiki-list-tags.tool.test.ts rename to packages/cli/test/context/wiki/tools/wiki-list-tags.tool.test.ts index 49605c4f..6c6ec209 100644 --- a/packages/cli/src/context/wiki/tools/wiki-list-tags.tool.test.ts +++ b/packages/cli/test/context/wiki/tools/wiki-list-tags.tool.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it, vi } from 'vitest'; -import type { ToolContext } from '../../../context/tools/base-tool.js'; -import { WikiListTagsTool } from './wiki-list-tags.tool.js'; +import type { ToolContext } from '../../../../src/context/tools/base-tool.js'; +import { WikiListTagsTool } from '../../../../src/context/wiki/tools/wiki-list-tags.tool.js'; describe('WikiListTagsTool', () => { const baseContext: ToolContext = { sourceId: 's', messageId: 'm', userId: 'u' }; diff --git a/packages/cli/src/context/wiki/tools/wiki-read.tool.test.ts b/packages/cli/test/context/wiki/tools/wiki-read.tool.test.ts similarity index 91% rename from packages/cli/src/context/wiki/tools/wiki-read.tool.test.ts rename to packages/cli/test/context/wiki/tools/wiki-read.tool.test.ts index ac75b174..f70b34ec 100644 --- a/packages/cli/src/context/wiki/tools/wiki-read.tool.test.ts +++ b/packages/cli/test/context/wiki/tools/wiki-read.tool.test.ts @@ -1,8 +1,8 @@ import { describe, expect, it, vi } from 'vitest'; -import type { ToolSession } from '../../../context/tools/tool-session.js'; -import { createTouchedSlSources } from '../../../context/tools/touched-sl-sources.js'; -import type { ToolContext } from '../../../context/tools/base-tool.js'; -import { WikiReadTool } from './wiki-read.tool.js'; +import type { ToolSession } from '../../../../src/context/tools/tool-session.js'; +import { createTouchedSlSources } from '../../../../src/context/tools/touched-sl-sources.js'; +import type { ToolContext } from '../../../../src/context/tools/base-tool.js'; +import { WikiReadTool } from '../../../../src/context/wiki/tools/wiki-read.tool.js'; describe('WikiReadTool', () => { const baseContext: ToolContext = { sourceId: 's', messageId: 'm', userId: 'u' }; diff --git a/packages/cli/src/context/wiki/tools/wiki-remove.tool.test.ts b/packages/cli/test/context/wiki/tools/wiki-remove.tool.test.ts similarity index 93% rename from packages/cli/src/context/wiki/tools/wiki-remove.tool.test.ts rename to packages/cli/test/context/wiki/tools/wiki-remove.tool.test.ts index 8130613c..afabc8c0 100644 --- a/packages/cli/src/context/wiki/tools/wiki-remove.tool.test.ts +++ b/packages/cli/test/context/wiki/tools/wiki-remove.tool.test.ts @@ -1,8 +1,8 @@ import { describe, expect, it, vi } from 'vitest'; -import type { ToolSession } from '../../../context/tools/tool-session.js'; -import { createTouchedSlSources } from '../../../context/tools/touched-sl-sources.js'; -import type { ToolContext } from '../../../context/tools/base-tool.js'; -import { WikiRemoveTool } from './wiki-remove.tool.js'; +import type { ToolSession } from '../../../../src/context/tools/tool-session.js'; +import { createTouchedSlSources } from '../../../../src/context/tools/touched-sl-sources.js'; +import type { ToolContext } from '../../../../src/context/tools/base-tool.js'; +import { WikiRemoveTool } from '../../../../src/context/wiki/tools/wiki-remove.tool.js'; describe('WikiRemoveTool', () => { const baseContext: ToolContext = { sourceId: 's', messageId: 'm', userId: 'u' }; diff --git a/packages/cli/src/context/wiki/tools/wiki-search.tool.test.ts b/packages/cli/test/context/wiki/tools/wiki-search.tool.test.ts similarity index 93% rename from packages/cli/src/context/wiki/tools/wiki-search.tool.test.ts rename to packages/cli/test/context/wiki/tools/wiki-search.tool.test.ts index 24840a4f..d21f9825 100644 --- a/packages/cli/src/context/wiki/tools/wiki-search.tool.test.ts +++ b/packages/cli/test/context/wiki/tools/wiki-search.tool.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it, vi } from 'vitest'; -import { WikiSearchTool } from './wiki-search.tool.js'; +import { WikiSearchTool } from '../../../../src/context/wiki/tools/wiki-search.tool.js'; describe('WikiSearchTool', () => { it('searches through the injected wiki adapter port', async () => { diff --git a/packages/cli/src/context/wiki/tools/wiki-write.tool.test.ts b/packages/cli/test/context/wiki/tools/wiki-write.tool.test.ts similarity index 97% rename from packages/cli/src/context/wiki/tools/wiki-write.tool.test.ts rename to packages/cli/test/context/wiki/tools/wiki-write.tool.test.ts index ad2bc54b..deadd716 100644 --- a/packages/cli/src/context/wiki/tools/wiki-write.tool.test.ts +++ b/packages/cli/test/context/wiki/tools/wiki-write.tool.test.ts @@ -1,8 +1,8 @@ import { describe, expect, it, vi } from 'vitest'; -import type { ToolSession } from '../../../context/tools/tool-session.js'; -import { createTouchedSlSources } from '../../../context/tools/touched-sl-sources.js'; -import type { ToolContext } from '../../../context/tools/base-tool.js'; -import { WikiWriteTool } from './wiki-write.tool.js'; +import type { ToolSession } from '../../../../src/context/tools/tool-session.js'; +import { createTouchedSlSources } from '../../../../src/context/tools/touched-sl-sources.js'; +import type { ToolContext } from '../../../../src/context/tools/base-tool.js'; +import { WikiWriteTool } from '../../../../src/context/wiki/tools/wiki-write.tool.js'; function makeTool(overrides: any = {}) { const wikiService = { diff --git a/packages/cli/src/context/wiki/wiki-ref-validation.test.ts b/packages/cli/test/context/wiki/wiki-ref-validation.test.ts similarity index 96% rename from packages/cli/src/context/wiki/wiki-ref-validation.test.ts rename to packages/cli/test/context/wiki/wiki-ref-validation.test.ts index 6e0e8563..b6fd0012 100644 --- a/packages/cli/src/context/wiki/wiki-ref-validation.test.ts +++ b/packages/cli/test/context/wiki/wiki-ref-validation.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it, vi } from 'vitest'; -import { findDanglingWikiRefsForActions } from './wiki-ref-validation.js'; +import { findDanglingWikiRefsForActions } from '../../../src/context/wiki/wiki-ref-validation.js'; function makeWikiService(pages: Record) { return { diff --git a/packages/cli/src/database-tree-picker.test.ts b/packages/cli/test/database-tree-picker.test.ts similarity index 73% rename from packages/cli/src/database-tree-picker.test.ts rename to packages/cli/test/database-tree-picker.test.ts index 4dd1dca3..182f0235 100644 --- a/packages/cli/src/database-tree-picker.test.ts +++ b/packages/cli/test/database-tree-picker.test.ts @@ -4,9 +4,9 @@ import { type DatabaseScopePromptAdapter, type DatabaseTreePickerRenderer, type PickDatabaseScopeArgs, -} from './database-tree-picker.js'; -import type { TreePickerChrome, TreePickerResult } from './tree-picker-tui.js'; -import type { PickerState } from './tree-picker-state.js'; +} from '../src/database-tree-picker.js'; +import type { TreePickerChrome, TreePickerResult } from '../src/tree-picker-tui.js'; +import type { PickerState } from '../src/tree-picker-state.js'; function makeIo() { let stdout = ''; @@ -52,10 +52,10 @@ function captureRenderer(): { } const discovered = [ - { schema: 'analytics', name: 'customers', kind: 'table' as const }, - { schema: 'analytics', name: 'orders', kind: 'table' as const }, - { schema: 'public', name: 'events', kind: 'view' as const }, - { schema: 'public', name: 'sessions', kind: 'table' as const }, + { catalog: null, schema: 'analytics', name: 'customers', kind: 'table' as const }, + { catalog: null, schema: 'analytics', name: 'orders', kind: 'table' as const }, + { catalog: null, schema: 'public', name: 'events', kind: 'view' as const }, + { catalog: null, schema: 'public', name: 'sessions', kind: 'table' as const }, ]; function promptAdapter(overrides: Partial = {}): DatabaseScopePromptAdapter { @@ -88,7 +88,7 @@ describe('pickDatabaseScope', () => { select: vi.fn(async () => 'save'), }); const listTablesForSchemas = vi.fn(async () => [ - { schema: 'analytics', name: 'orders', kind: 'table' as const }, + { catalog: null, schema: 'analytics', name: 'orders', kind: 'table' as const }, ]); const result = await pickDatabaseScope( @@ -114,6 +114,58 @@ describe('pickDatabaseScope', () => { }); }); + it('emits fully-qualified catalog.schema.name ids for catalog-bearing drivers and round-trips existing selection', async () => { + const promptsSave = promptAdapter({ + autocompleteMultiselect: vi.fn(async () => ['analytics']), + select: vi.fn(async () => 'save'), + }); + const listTablesForSchemas = vi.fn(async () => [ + { catalog: 'project-1', schema: 'analytics', name: 'orders', kind: 'table' as const }, + { catalog: 'project-1', schema: 'analytics', name: 'customers', kind: 'table' as const }, + ]); + const saveResult = await pickDatabaseScope( + baseArgs({ + schemas: ['analytics'], + schemaSuggestion: { excluded: new Set(), suggested: new Set(['analytics']) }, + listTablesForSchemas, + prompts: promptsSave, + }), + makeIo().io, + captureRenderer().renderer, + ); + expect(saveResult).toEqual({ + kind: 'selected', + activeSchemas: ['analytics'], + enabledTables: ['project-1.analytics.orders', 'project-1.analytics.customers'], + }); + + const { renderer, capture, setResult } = captureRenderer(); + setResult({ + kind: 'save', + selectedIds: ['project-1.analytics.orders'], + }); + const refineResult = await pickDatabaseScope( + baseArgs({ + schemas: ['analytics'], + schemaSuggestion: { excluded: new Set(), suggested: new Set(['analytics']) }, + existing: { enabledTables: ['project-1.analytics.orders'] }, + listTablesForSchemas, + prompts: promptAdapter({ + autocompleteMultiselect: vi.fn(async () => ['analytics']), + select: vi.fn(async () => 'refine'), + }), + }), + makeIo().io, + renderer, + ); + expect(refineResult).toEqual({ + kind: 'selected', + activeSchemas: ['analytics'], + enabledTables: ['project-1.analytics.orders'], + }); + expect([...(capture.state?.checked ?? [])]).toContain('project-1.analytics.orders'); + }); + it('routes partial existing allowlists through Stage 2 so save preserves table selections', async () => { const { renderer, setResult } = captureRenderer(); setResult({ kind: 'save', selectedIds: ['analytics.customers'] }); @@ -122,8 +174,8 @@ describe('pickDatabaseScope', () => { select: vi.fn(async () => 'save'), }); const listTablesForSchemas = vi.fn(async () => [ - { schema: 'analytics', name: 'customers', kind: 'table' as const }, - { schema: 'analytics', name: 'orders', kind: 'table' as const }, + { catalog: null, schema: 'analytics', name: 'customers', kind: 'table' as const }, + { catalog: null, schema: 'analytics', name: 'orders', kind: 'table' as const }, ]); const result = await pickDatabaseScope( @@ -161,6 +213,7 @@ describe('pickDatabaseScope', () => { 'public.events', 'public.sessions', ]); + expect([...(capture.state?.expanded ?? [])].sort()).toEqual(['analytics', 'public']); expect(capture.state?.byId.get('public.events')?.title).toBe('events (view)'); }); diff --git a/packages/cli/src/demo-assets.test.ts b/packages/cli/test/demo-assets.test.ts similarity index 99% rename from packages/cli/src/demo-assets.test.ts rename to packages/cli/test/demo-assets.test.ts index 052eda83..80573d3d 100644 --- a/packages/cli/src/demo-assets.test.ts +++ b/packages/cli/test/demo-assets.test.ts @@ -10,7 +10,7 @@ import { defaultDemoProjectDir, ensureDemoProject, ensureSeededDemoProject, -} from './demo-assets.js'; +} from '../src/demo-assets.js'; const packagedDemoSource = 'packaged-orbit-demo'; diff --git a/packages/cli/src/demo-metrics.test.ts b/packages/cli/test/demo-metrics.test.ts similarity index 97% rename from packages/cli/src/demo-metrics.test.ts rename to packages/cli/test/demo-metrics.test.ts index 9e40be36..fcfe90c8 100644 --- a/packages/cli/src/demo-metrics.test.ts +++ b/packages/cli/test/demo-metrics.test.ts @@ -1,4 +1,4 @@ -import type { MemoryFlowEvent, MemoryFlowReplayInput } from './context/ingest/memory-flow/types.js'; +import type { MemoryFlowEvent, MemoryFlowReplayInput } from '../src/context/ingest/memory-flow/types.js'; import { describe, expect, it } from 'vitest'; import { buildDemoMetrics, @@ -8,7 +8,7 @@ import { formatTokens, formatTokensPerSec, progressBar, -} from './demo-metrics.js'; +} from '../src/demo-metrics.js'; function snapshot(events: MemoryFlowEvent[], overrides: Partial = {}): MemoryFlowReplayInput { return { diff --git a/packages/cli/src/doctor.test.ts b/packages/cli/test/doctor.test.ts similarity index 99% rename from packages/cli/src/doctor.test.ts rename to packages/cli/test/doctor.test.ts index 64050623..e3871f28 100644 --- a/packages/cli/src/doctor.test.ts +++ b/packages/cli/test/doctor.test.ts @@ -7,7 +7,7 @@ import { runKtxDoctor, runSetupDoctorChecks, type DoctorCheck, -} from './doctor.js'; +} from '../src/doctor.js'; function makeIo() { let stdout = ''; diff --git a/packages/cli/src/embedding-resolution.test.ts b/packages/cli/test/embedding-resolution.test.ts similarity index 94% rename from packages/cli/src/embedding-resolution.test.ts rename to packages/cli/test/embedding-resolution.test.ts index 40c71538..d9546c36 100644 --- a/packages/cli/src/embedding-resolution.test.ts +++ b/packages/cli/test/embedding-resolution.test.ts @@ -1,8 +1,8 @@ import { describe, expect, it, vi } from 'vitest'; -import { buildDefaultKtxProjectConfig, type KtxProjectConfig } from './context/project/config.js'; -import type { KtxLocalProject } from './context/project/project.js'; -import { resolveProjectEmbeddingProvider } from './embedding-resolution.js'; -import type { ManagedLocalEmbeddingsDaemon } from './managed-local-embeddings.js'; +import { buildDefaultKtxProjectConfig, type KtxProjectConfig } from '../src/context/project/config.js'; +import type { KtxLocalProject } from '../src/context/project/project.js'; +import { resolveProjectEmbeddingProvider } from '../src/embedding-resolution.js'; +import type { ManagedLocalEmbeddingsDaemon } from '../src/managed-local-embeddings.js'; function projectWithConfig(config: KtxProjectConfig): KtxLocalProject { return { diff --git a/packages/cli/src/example-smoke.test.ts b/packages/cli/test/example-smoke.test.ts similarity index 100% rename from packages/cli/src/example-smoke.test.ts rename to packages/cli/test/example-smoke.test.ts diff --git a/packages/cli/src/test/fixtures/lookml/extends-chain/orders.model.lkml b/packages/cli/test/fixtures/lookml/extends-chain/orders.model.lkml similarity index 100% rename from packages/cli/src/test/fixtures/lookml/extends-chain/orders.model.lkml rename to packages/cli/test/fixtures/lookml/extends-chain/orders.model.lkml diff --git a/packages/cli/src/test/fixtures/lookml/extends-chain/views/base.view.lkml b/packages/cli/test/fixtures/lookml/extends-chain/views/base.view.lkml similarity index 100% rename from packages/cli/src/test/fixtures/lookml/extends-chain/views/base.view.lkml rename to packages/cli/test/fixtures/lookml/extends-chain/views/base.view.lkml diff --git a/packages/cli/src/test/fixtures/lookml/extends-chain/views/orders.view.lkml b/packages/cli/test/fixtures/lookml/extends-chain/views/orders.view.lkml similarity index 100% rename from packages/cli/src/test/fixtures/lookml/extends-chain/views/orders.view.lkml rename to packages/cli/test/fixtures/lookml/extends-chain/views/orders.view.lkml diff --git a/packages/cli/src/test/fixtures/lookml/extends-chain/views/orders_ext.view.lkml b/packages/cli/test/fixtures/lookml/extends-chain/views/orders_ext.view.lkml similarity index 100% rename from packages/cli/src/test/fixtures/lookml/extends-chain/views/orders_ext.view.lkml rename to packages/cli/test/fixtures/lookml/extends-chain/views/orders_ext.view.lkml diff --git a/packages/cli/src/test/fixtures/lookml/multi-model/marketing.model.lkml b/packages/cli/test/fixtures/lookml/multi-model/marketing.model.lkml similarity index 100% rename from packages/cli/src/test/fixtures/lookml/multi-model/marketing.model.lkml rename to packages/cli/test/fixtures/lookml/multi-model/marketing.model.lkml diff --git a/packages/cli/src/test/fixtures/lookml/multi-model/orders.model.lkml b/packages/cli/test/fixtures/lookml/multi-model/orders.model.lkml similarity index 100% rename from packages/cli/src/test/fixtures/lookml/multi-model/orders.model.lkml rename to packages/cli/test/fixtures/lookml/multi-model/orders.model.lkml diff --git a/packages/cli/src/test/fixtures/lookml/multi-model/views/campaigns.view.lkml b/packages/cli/test/fixtures/lookml/multi-model/views/campaigns.view.lkml similarity index 100% rename from packages/cli/src/test/fixtures/lookml/multi-model/views/campaigns.view.lkml rename to packages/cli/test/fixtures/lookml/multi-model/views/campaigns.view.lkml diff --git a/packages/cli/src/test/fixtures/lookml/multi-model/views/orders.view.lkml b/packages/cli/test/fixtures/lookml/multi-model/views/orders.view.lkml similarity index 100% rename from packages/cli/src/test/fixtures/lookml/multi-model/views/orders.view.lkml rename to packages/cli/test/fixtures/lookml/multi-model/views/orders.view.lkml diff --git a/packages/cli/src/test/fixtures/lookml/multi-model/views/shared_dims.view.lkml b/packages/cli/test/fixtures/lookml/multi-model/views/shared_dims.view.lkml similarity index 100% rename from packages/cli/src/test/fixtures/lookml/multi-model/views/shared_dims.view.lkml rename to packages/cli/test/fixtures/lookml/multi-model/views/shared_dims.view.lkml diff --git a/packages/cli/src/test/fixtures/lookml/single-model/orders.model.lkml b/packages/cli/test/fixtures/lookml/single-model/orders.model.lkml similarity index 100% rename from packages/cli/src/test/fixtures/lookml/single-model/orders.model.lkml rename to packages/cli/test/fixtures/lookml/single-model/orders.model.lkml diff --git a/packages/cli/src/test/fixtures/lookml/single-model/views/customers.view.lkml b/packages/cli/test/fixtures/lookml/single-model/views/customers.view.lkml similarity index 100% rename from packages/cli/src/test/fixtures/lookml/single-model/views/customers.view.lkml rename to packages/cli/test/fixtures/lookml/single-model/views/customers.view.lkml diff --git a/packages/cli/src/test/fixtures/lookml/single-model/views/orders.view.lkml b/packages/cli/test/fixtures/lookml/single-model/views/orders.view.lkml similarity index 100% rename from packages/cli/src/test/fixtures/lookml/single-model/views/orders.view.lkml rename to packages/cli/test/fixtures/lookml/single-model/views/orders.view.lkml diff --git a/packages/cli/src/test/fixtures/lookml/three-churn/billing.model.lkml b/packages/cli/test/fixtures/lookml/three-churn/billing.model.lkml similarity index 100% rename from packages/cli/src/test/fixtures/lookml/three-churn/billing.model.lkml rename to packages/cli/test/fixtures/lookml/three-churn/billing.model.lkml diff --git a/packages/cli/src/test/fixtures/lookml/three-churn/customers.model.lkml b/packages/cli/test/fixtures/lookml/three-churn/customers.model.lkml similarity index 100% rename from packages/cli/src/test/fixtures/lookml/three-churn/customers.model.lkml rename to packages/cli/test/fixtures/lookml/three-churn/customers.model.lkml diff --git a/packages/cli/src/test/fixtures/lookml/three-churn/support.model.lkml b/packages/cli/test/fixtures/lookml/three-churn/support.model.lkml similarity index 100% rename from packages/cli/src/test/fixtures/lookml/three-churn/support.model.lkml rename to packages/cli/test/fixtures/lookml/three-churn/support.model.lkml diff --git a/packages/cli/src/test/fixtures/lookml/three-churn/views/billing/billing_churn_risk.view.lkml b/packages/cli/test/fixtures/lookml/three-churn/views/billing/billing_churn_risk.view.lkml similarity index 100% rename from packages/cli/src/test/fixtures/lookml/three-churn/views/billing/billing_churn_risk.view.lkml rename to packages/cli/test/fixtures/lookml/three-churn/views/billing/billing_churn_risk.view.lkml diff --git a/packages/cli/src/test/fixtures/lookml/three-churn/views/customers/customer_churn_risk.view.lkml b/packages/cli/test/fixtures/lookml/three-churn/views/customers/customer_churn_risk.view.lkml similarity index 100% rename from packages/cli/src/test/fixtures/lookml/three-churn/views/customers/customer_churn_risk.view.lkml rename to packages/cli/test/fixtures/lookml/three-churn/views/customers/customer_churn_risk.view.lkml diff --git a/packages/cli/src/test/fixtures/lookml/three-churn/views/support/support_churn_risk.view.lkml b/packages/cli/test/fixtures/lookml/three-churn/views/support/support_churn_risk.view.lkml similarity index 100% rename from packages/cli/src/test/fixtures/lookml/three-churn/views/support/support_churn_risk.view.lkml rename to packages/cli/test/fixtures/lookml/three-churn/views/support/support_churn_risk.view.lkml diff --git a/packages/cli/src/test/fixtures/metabase/card-ref/cards/10.json b/packages/cli/test/fixtures/metabase/card-ref/cards/10.json similarity index 100% rename from packages/cli/src/test/fixtures/metabase/card-ref/cards/10.json rename to packages/cli/test/fixtures/metabase/card-ref/cards/10.json diff --git a/packages/cli/src/test/fixtures/metabase/card-ref/cards/11.json b/packages/cli/test/fixtures/metabase/card-ref/cards/11.json similarity index 100% rename from packages/cli/src/test/fixtures/metabase/card-ref/cards/11.json rename to packages/cli/test/fixtures/metabase/card-ref/cards/11.json diff --git a/packages/cli/src/test/fixtures/metabase/card-ref/collections/5.json b/packages/cli/test/fixtures/metabase/card-ref/collections/5.json similarity index 100% rename from packages/cli/src/test/fixtures/metabase/card-ref/collections/5.json rename to packages/cli/test/fixtures/metabase/card-ref/collections/5.json diff --git a/packages/cli/src/test/fixtures/metabase/card-ref/databases/42.json b/packages/cli/test/fixtures/metabase/card-ref/databases/42.json similarity index 100% rename from packages/cli/src/test/fixtures/metabase/card-ref/databases/42.json rename to packages/cli/test/fixtures/metabase/card-ref/databases/42.json diff --git a/packages/cli/src/test/fixtures/metabase/card-ref/sync-config.json b/packages/cli/test/fixtures/metabase/card-ref/sync-config.json similarity index 100% rename from packages/cli/src/test/fixtures/metabase/card-ref/sync-config.json rename to packages/cli/test/fixtures/metabase/card-ref/sync-config.json diff --git a/packages/cli/src/test/fixtures/metabase/multi-collection/cards/1.json b/packages/cli/test/fixtures/metabase/multi-collection/cards/1.json similarity index 100% rename from packages/cli/src/test/fixtures/metabase/multi-collection/cards/1.json rename to packages/cli/test/fixtures/metabase/multi-collection/cards/1.json diff --git a/packages/cli/src/test/fixtures/metabase/multi-collection/cards/2.json b/packages/cli/test/fixtures/metabase/multi-collection/cards/2.json similarity index 100% rename from packages/cli/src/test/fixtures/metabase/multi-collection/cards/2.json rename to packages/cli/test/fixtures/metabase/multi-collection/cards/2.json diff --git a/packages/cli/src/test/fixtures/metabase/multi-collection/cards/3.json b/packages/cli/test/fixtures/metabase/multi-collection/cards/3.json similarity index 100% rename from packages/cli/src/test/fixtures/metabase/multi-collection/cards/3.json rename to packages/cli/test/fixtures/metabase/multi-collection/cards/3.json diff --git a/packages/cli/src/test/fixtures/metabase/multi-collection/collections/5.json b/packages/cli/test/fixtures/metabase/multi-collection/collections/5.json similarity index 100% rename from packages/cli/src/test/fixtures/metabase/multi-collection/collections/5.json rename to packages/cli/test/fixtures/metabase/multi-collection/collections/5.json diff --git a/packages/cli/src/test/fixtures/metabase/multi-collection/collections/6.json b/packages/cli/test/fixtures/metabase/multi-collection/collections/6.json similarity index 100% rename from packages/cli/src/test/fixtures/metabase/multi-collection/collections/6.json rename to packages/cli/test/fixtures/metabase/multi-collection/collections/6.json diff --git a/packages/cli/src/test/fixtures/metabase/multi-collection/databases/42.json b/packages/cli/test/fixtures/metabase/multi-collection/databases/42.json similarity index 100% rename from packages/cli/src/test/fixtures/metabase/multi-collection/databases/42.json rename to packages/cli/test/fixtures/metabase/multi-collection/databases/42.json diff --git a/packages/cli/src/test/fixtures/metabase/multi-collection/sync-config.json b/packages/cli/test/fixtures/metabase/multi-collection/sync-config.json similarity index 100% rename from packages/cli/src/test/fixtures/metabase/multi-collection/sync-config.json rename to packages/cli/test/fixtures/metabase/multi-collection/sync-config.json diff --git a/packages/cli/src/test/fixtures/metabase/simple/cards/1.json b/packages/cli/test/fixtures/metabase/simple/cards/1.json similarity index 100% rename from packages/cli/src/test/fixtures/metabase/simple/cards/1.json rename to packages/cli/test/fixtures/metabase/simple/cards/1.json diff --git a/packages/cli/src/test/fixtures/metabase/simple/cards/2.json b/packages/cli/test/fixtures/metabase/simple/cards/2.json similarity index 100% rename from packages/cli/src/test/fixtures/metabase/simple/cards/2.json rename to packages/cli/test/fixtures/metabase/simple/cards/2.json diff --git a/packages/cli/src/test/fixtures/metabase/simple/collections/5.json b/packages/cli/test/fixtures/metabase/simple/collections/5.json similarity index 100% rename from packages/cli/src/test/fixtures/metabase/simple/collections/5.json rename to packages/cli/test/fixtures/metabase/simple/collections/5.json diff --git a/packages/cli/src/test/fixtures/metabase/simple/databases/42.json b/packages/cli/test/fixtures/metabase/simple/databases/42.json similarity index 100% rename from packages/cli/src/test/fixtures/metabase/simple/databases/42.json rename to packages/cli/test/fixtures/metabase/simple/databases/42.json diff --git a/packages/cli/src/test/fixtures/metabase/simple/sync-config.json b/packages/cli/test/fixtures/metabase/simple/sync-config.json similarity index 100% rename from packages/cli/src/test/fixtures/metabase/simple/sync-config.json rename to packages/cli/test/fixtures/metabase/simple/sync-config.json diff --git a/packages/cli/src/test/fixtures/metricflow/dbt-mixed/dbt_project.yml b/packages/cli/test/fixtures/metricflow/dbt-mixed/dbt_project.yml similarity index 100% rename from packages/cli/src/test/fixtures/metricflow/dbt-mixed/dbt_project.yml rename to packages/cli/test/fixtures/metricflow/dbt-mixed/dbt_project.yml diff --git a/packages/cli/src/test/fixtures/metricflow/dbt-mixed/models/orders.yml b/packages/cli/test/fixtures/metricflow/dbt-mixed/models/orders.yml similarity index 100% rename from packages/cli/src/test/fixtures/metricflow/dbt-mixed/models/orders.yml rename to packages/cli/test/fixtures/metricflow/dbt-mixed/models/orders.yml diff --git a/packages/cli/src/test/fixtures/metricflow/extends-chain/metrics/orders_final.yml b/packages/cli/test/fixtures/metricflow/extends-chain/metrics/orders_final.yml similarity index 100% rename from packages/cli/src/test/fixtures/metricflow/extends-chain/metrics/orders_final.yml rename to packages/cli/test/fixtures/metricflow/extends-chain/metrics/orders_final.yml diff --git a/packages/cli/src/test/fixtures/metricflow/extends-chain/models/orders.yml b/packages/cli/test/fixtures/metricflow/extends-chain/models/orders.yml similarity index 100% rename from packages/cli/src/test/fixtures/metricflow/extends-chain/models/orders.yml rename to packages/cli/test/fixtures/metricflow/extends-chain/models/orders.yml diff --git a/packages/cli/src/test/fixtures/metricflow/extends-chain/models/orders_ext.yml b/packages/cli/test/fixtures/metricflow/extends-chain/models/orders_ext.yml similarity index 100% rename from packages/cli/src/test/fixtures/metricflow/extends-chain/models/orders_ext.yml rename to packages/cli/test/fixtures/metricflow/extends-chain/models/orders_ext.yml diff --git a/packages/cli/src/test/fixtures/metricflow/multi-component/models/marketing/campaigns.yml b/packages/cli/test/fixtures/metricflow/multi-component/models/marketing/campaigns.yml similarity index 100% rename from packages/cli/src/test/fixtures/metricflow/multi-component/models/marketing/campaigns.yml rename to packages/cli/test/fixtures/metricflow/multi-component/models/marketing/campaigns.yml diff --git a/packages/cli/src/test/fixtures/metricflow/multi-component/models/sales/orders.yml b/packages/cli/test/fixtures/metricflow/multi-component/models/sales/orders.yml similarity index 100% rename from packages/cli/src/test/fixtures/metricflow/multi-component/models/sales/orders.yml rename to packages/cli/test/fixtures/metricflow/multi-component/models/sales/orders.yml diff --git a/packages/cli/src/test/fixtures/metricflow/single-model/models/orders.yml b/packages/cli/test/fixtures/metricflow/single-model/models/orders.yml similarity index 100% rename from packages/cli/src/test/fixtures/metricflow/single-model/models/orders.yml rename to packages/cli/test/fixtures/metricflow/single-model/models/orders.yml diff --git a/packages/cli/src/test/fixtures/relationship-benchmarks/abbreviated_old_no_declared_constraints/data.sqlite b/packages/cli/test/fixtures/relationship-benchmarks/abbreviated_old_no_declared_constraints/data.sqlite similarity index 100% rename from packages/cli/src/test/fixtures/relationship-benchmarks/abbreviated_old_no_declared_constraints/data.sqlite rename to packages/cli/test/fixtures/relationship-benchmarks/abbreviated_old_no_declared_constraints/data.sqlite diff --git a/packages/cli/src/test/fixtures/relationship-benchmarks/abbreviated_old_no_declared_constraints/expected-links.yaml b/packages/cli/test/fixtures/relationship-benchmarks/abbreviated_old_no_declared_constraints/expected-links.yaml similarity index 100% rename from packages/cli/src/test/fixtures/relationship-benchmarks/abbreviated_old_no_declared_constraints/expected-links.yaml rename to packages/cli/test/fixtures/relationship-benchmarks/abbreviated_old_no_declared_constraints/expected-links.yaml diff --git a/packages/cli/src/test/fixtures/relationship-benchmarks/abbreviated_old_no_declared_constraints/fixture.yaml b/packages/cli/test/fixtures/relationship-benchmarks/abbreviated_old_no_declared_constraints/fixture.yaml similarity index 100% rename from packages/cli/src/test/fixtures/relationship-benchmarks/abbreviated_old_no_declared_constraints/fixture.yaml rename to packages/cli/test/fixtures/relationship-benchmarks/abbreviated_old_no_declared_constraints/fixture.yaml diff --git a/packages/cli/src/test/fixtures/relationship-benchmarks/abbreviated_old_no_declared_constraints/snapshot.json b/packages/cli/test/fixtures/relationship-benchmarks/abbreviated_old_no_declared_constraints/snapshot.json similarity index 100% rename from packages/cli/src/test/fixtures/relationship-benchmarks/abbreviated_old_no_declared_constraints/snapshot.json rename to packages/cli/test/fixtures/relationship-benchmarks/abbreviated_old_no_declared_constraints/snapshot.json diff --git a/packages/cli/src/test/fixtures/relationship-benchmarks/adventureworks_oltp_with_declared_metadata/expected-links.yaml b/packages/cli/test/fixtures/relationship-benchmarks/adventureworks_oltp_with_declared_metadata/expected-links.yaml similarity index 100% rename from packages/cli/src/test/fixtures/relationship-benchmarks/adventureworks_oltp_with_declared_metadata/expected-links.yaml rename to packages/cli/test/fixtures/relationship-benchmarks/adventureworks_oltp_with_declared_metadata/expected-links.yaml diff --git a/packages/cli/src/test/fixtures/relationship-benchmarks/adventureworks_oltp_with_declared_metadata/fixture.yaml b/packages/cli/test/fixtures/relationship-benchmarks/adventureworks_oltp_with_declared_metadata/fixture.yaml similarity index 100% rename from packages/cli/src/test/fixtures/relationship-benchmarks/adventureworks_oltp_with_declared_metadata/fixture.yaml rename to packages/cli/test/fixtures/relationship-benchmarks/adventureworks_oltp_with_declared_metadata/fixture.yaml diff --git a/packages/cli/src/test/fixtures/relationship-benchmarks/adventureworks_oltp_with_declared_metadata/snapshot.json b/packages/cli/test/fixtures/relationship-benchmarks/adventureworks_oltp_with_declared_metadata/snapshot.json similarity index 100% rename from packages/cli/src/test/fixtures/relationship-benchmarks/adventureworks_oltp_with_declared_metadata/snapshot.json rename to packages/cli/test/fixtures/relationship-benchmarks/adventureworks_oltp_with_declared_metadata/snapshot.json diff --git a/packages/cli/src/test/fixtures/relationship-benchmarks/adventureworkslt_with_declared_metadata/expected-links.yaml b/packages/cli/test/fixtures/relationship-benchmarks/adventureworkslt_with_declared_metadata/expected-links.yaml similarity index 100% rename from packages/cli/src/test/fixtures/relationship-benchmarks/adventureworkslt_with_declared_metadata/expected-links.yaml rename to packages/cli/test/fixtures/relationship-benchmarks/adventureworkslt_with_declared_metadata/expected-links.yaml diff --git a/packages/cli/src/test/fixtures/relationship-benchmarks/adventureworkslt_with_declared_metadata/fixture.yaml b/packages/cli/test/fixtures/relationship-benchmarks/adventureworkslt_with_declared_metadata/fixture.yaml similarity index 100% rename from packages/cli/src/test/fixtures/relationship-benchmarks/adventureworkslt_with_declared_metadata/fixture.yaml rename to packages/cli/test/fixtures/relationship-benchmarks/adventureworkslt_with_declared_metadata/fixture.yaml diff --git a/packages/cli/src/test/fixtures/relationship-benchmarks/adventureworkslt_with_declared_metadata/snapshot.json b/packages/cli/test/fixtures/relationship-benchmarks/adventureworkslt_with_declared_metadata/snapshot.json similarity index 100% rename from packages/cli/src/test/fixtures/relationship-benchmarks/adventureworkslt_with_declared_metadata/snapshot.json rename to packages/cli/test/fixtures/relationship-benchmarks/adventureworkslt_with_declared_metadata/snapshot.json diff --git a/packages/cli/src/test/fixtures/relationship-benchmarks/analytical_warehouse_no_naming_convention/data.sqlite b/packages/cli/test/fixtures/relationship-benchmarks/analytical_warehouse_no_naming_convention/data.sqlite similarity index 100% rename from packages/cli/src/test/fixtures/relationship-benchmarks/analytical_warehouse_no_naming_convention/data.sqlite rename to packages/cli/test/fixtures/relationship-benchmarks/analytical_warehouse_no_naming_convention/data.sqlite diff --git a/packages/cli/src/test/fixtures/relationship-benchmarks/analytical_warehouse_no_naming_convention/expected-links.yaml b/packages/cli/test/fixtures/relationship-benchmarks/analytical_warehouse_no_naming_convention/expected-links.yaml similarity index 100% rename from packages/cli/src/test/fixtures/relationship-benchmarks/analytical_warehouse_no_naming_convention/expected-links.yaml rename to packages/cli/test/fixtures/relationship-benchmarks/analytical_warehouse_no_naming_convention/expected-links.yaml diff --git a/packages/cli/src/test/fixtures/relationship-benchmarks/analytical_warehouse_no_naming_convention/fixture.yaml b/packages/cli/test/fixtures/relationship-benchmarks/analytical_warehouse_no_naming_convention/fixture.yaml similarity index 100% rename from packages/cli/src/test/fixtures/relationship-benchmarks/analytical_warehouse_no_naming_convention/fixture.yaml rename to packages/cli/test/fixtures/relationship-benchmarks/analytical_warehouse_no_naming_convention/fixture.yaml diff --git a/packages/cli/src/test/fixtures/relationship-benchmarks/analytical_warehouse_no_naming_convention/snapshot.json b/packages/cli/test/fixtures/relationship-benchmarks/analytical_warehouse_no_naming_convention/snapshot.json similarity index 100% rename from packages/cli/src/test/fixtures/relationship-benchmarks/analytical_warehouse_no_naming_convention/snapshot.json rename to packages/cli/test/fixtures/relationship-benchmarks/analytical_warehouse_no_naming_convention/snapshot.json diff --git a/packages/cli/src/test/fixtures/relationship-benchmarks/chinook_with_declared_metadata/expected-links.yaml b/packages/cli/test/fixtures/relationship-benchmarks/chinook_with_declared_metadata/expected-links.yaml similarity index 100% rename from packages/cli/src/test/fixtures/relationship-benchmarks/chinook_with_declared_metadata/expected-links.yaml rename to packages/cli/test/fixtures/relationship-benchmarks/chinook_with_declared_metadata/expected-links.yaml diff --git a/packages/cli/src/test/fixtures/relationship-benchmarks/chinook_with_declared_metadata/fixture.yaml b/packages/cli/test/fixtures/relationship-benchmarks/chinook_with_declared_metadata/fixture.yaml similarity index 100% rename from packages/cli/src/test/fixtures/relationship-benchmarks/chinook_with_declared_metadata/fixture.yaml rename to packages/cli/test/fixtures/relationship-benchmarks/chinook_with_declared_metadata/fixture.yaml diff --git a/packages/cli/src/test/fixtures/relationship-benchmarks/chinook_with_declared_metadata/snapshot.json b/packages/cli/test/fixtures/relationship-benchmarks/chinook_with_declared_metadata/snapshot.json similarity index 100% rename from packages/cli/src/test/fixtures/relationship-benchmarks/chinook_with_declared_metadata/snapshot.json rename to packages/cli/test/fixtures/relationship-benchmarks/chinook_with_declared_metadata/snapshot.json diff --git a/packages/cli/src/test/fixtures/relationship-benchmarks/composite_keys_no_declared_constraints/data.sqlite b/packages/cli/test/fixtures/relationship-benchmarks/composite_keys_no_declared_constraints/data.sqlite similarity index 100% rename from packages/cli/src/test/fixtures/relationship-benchmarks/composite_keys_no_declared_constraints/data.sqlite rename to packages/cli/test/fixtures/relationship-benchmarks/composite_keys_no_declared_constraints/data.sqlite diff --git a/packages/cli/src/test/fixtures/relationship-benchmarks/composite_keys_no_declared_constraints/expected-links.yaml b/packages/cli/test/fixtures/relationship-benchmarks/composite_keys_no_declared_constraints/expected-links.yaml similarity index 100% rename from packages/cli/src/test/fixtures/relationship-benchmarks/composite_keys_no_declared_constraints/expected-links.yaml rename to packages/cli/test/fixtures/relationship-benchmarks/composite_keys_no_declared_constraints/expected-links.yaml diff --git a/packages/cli/src/test/fixtures/relationship-benchmarks/composite_keys_no_declared_constraints/fixture.yaml b/packages/cli/test/fixtures/relationship-benchmarks/composite_keys_no_declared_constraints/fixture.yaml similarity index 100% rename from packages/cli/src/test/fixtures/relationship-benchmarks/composite_keys_no_declared_constraints/fixture.yaml rename to packages/cli/test/fixtures/relationship-benchmarks/composite_keys_no_declared_constraints/fixture.yaml diff --git a/packages/cli/src/test/fixtures/relationship-benchmarks/composite_keys_no_declared_constraints/snapshot.json b/packages/cli/test/fixtures/relationship-benchmarks/composite_keys_no_declared_constraints/snapshot.json similarity index 100% rename from packages/cli/src/test/fixtures/relationship-benchmarks/composite_keys_no_declared_constraints/snapshot.json rename to packages/cli/test/fixtures/relationship-benchmarks/composite_keys_no_declared_constraints/snapshot.json diff --git a/packages/cli/src/test/fixtures/relationship-benchmarks/demo_b2b_declared_metadata/data.sqlite b/packages/cli/test/fixtures/relationship-benchmarks/demo_b2b_declared_metadata/data.sqlite similarity index 100% rename from packages/cli/src/test/fixtures/relationship-benchmarks/demo_b2b_declared_metadata/data.sqlite rename to packages/cli/test/fixtures/relationship-benchmarks/demo_b2b_declared_metadata/data.sqlite diff --git a/packages/cli/src/test/fixtures/relationship-benchmarks/demo_b2b_declared_metadata/expected-links.yaml b/packages/cli/test/fixtures/relationship-benchmarks/demo_b2b_declared_metadata/expected-links.yaml similarity index 100% rename from packages/cli/src/test/fixtures/relationship-benchmarks/demo_b2b_declared_metadata/expected-links.yaml rename to packages/cli/test/fixtures/relationship-benchmarks/demo_b2b_declared_metadata/expected-links.yaml diff --git a/packages/cli/src/test/fixtures/relationship-benchmarks/demo_b2b_declared_metadata/fixture.yaml b/packages/cli/test/fixtures/relationship-benchmarks/demo_b2b_declared_metadata/fixture.yaml similarity index 100% rename from packages/cli/src/test/fixtures/relationship-benchmarks/demo_b2b_declared_metadata/fixture.yaml rename to packages/cli/test/fixtures/relationship-benchmarks/demo_b2b_declared_metadata/fixture.yaml diff --git a/packages/cli/src/test/fixtures/relationship-benchmarks/demo_b2b_declared_metadata/snapshot.json b/packages/cli/test/fixtures/relationship-benchmarks/demo_b2b_declared_metadata/snapshot.json similarity index 100% rename from packages/cli/src/test/fixtures/relationship-benchmarks/demo_b2b_declared_metadata/snapshot.json rename to packages/cli/test/fixtures/relationship-benchmarks/demo_b2b_declared_metadata/snapshot.json diff --git a/packages/cli/src/test/fixtures/relationship-benchmarks/demo_b2b_no_declared_constraints/data.sqlite b/packages/cli/test/fixtures/relationship-benchmarks/demo_b2b_no_declared_constraints/data.sqlite similarity index 100% rename from packages/cli/src/test/fixtures/relationship-benchmarks/demo_b2b_no_declared_constraints/data.sqlite rename to packages/cli/test/fixtures/relationship-benchmarks/demo_b2b_no_declared_constraints/data.sqlite diff --git a/packages/cli/src/test/fixtures/relationship-benchmarks/demo_b2b_no_declared_constraints/expected-links.yaml b/packages/cli/test/fixtures/relationship-benchmarks/demo_b2b_no_declared_constraints/expected-links.yaml similarity index 100% rename from packages/cli/src/test/fixtures/relationship-benchmarks/demo_b2b_no_declared_constraints/expected-links.yaml rename to packages/cli/test/fixtures/relationship-benchmarks/demo_b2b_no_declared_constraints/expected-links.yaml diff --git a/packages/cli/src/test/fixtures/relationship-benchmarks/demo_b2b_no_declared_constraints/fixture.yaml b/packages/cli/test/fixtures/relationship-benchmarks/demo_b2b_no_declared_constraints/fixture.yaml similarity index 100% rename from packages/cli/src/test/fixtures/relationship-benchmarks/demo_b2b_no_declared_constraints/fixture.yaml rename to packages/cli/test/fixtures/relationship-benchmarks/demo_b2b_no_declared_constraints/fixture.yaml diff --git a/packages/cli/src/test/fixtures/relationship-benchmarks/demo_b2b_no_declared_constraints/snapshot.json b/packages/cli/test/fixtures/relationship-benchmarks/demo_b2b_no_declared_constraints/snapshot.json similarity index 100% rename from packages/cli/src/test/fixtures/relationship-benchmarks/demo_b2b_no_declared_constraints/snapshot.json rename to packages/cli/test/fixtures/relationship-benchmarks/demo_b2b_no_declared_constraints/snapshot.json diff --git a/packages/cli/src/test/fixtures/relationship-benchmarks/mixed_case_within_schema_no_declared_constraints/data.sqlite b/packages/cli/test/fixtures/relationship-benchmarks/mixed_case_within_schema_no_declared_constraints/data.sqlite similarity index 100% rename from packages/cli/src/test/fixtures/relationship-benchmarks/mixed_case_within_schema_no_declared_constraints/data.sqlite rename to packages/cli/test/fixtures/relationship-benchmarks/mixed_case_within_schema_no_declared_constraints/data.sqlite diff --git a/packages/cli/src/test/fixtures/relationship-benchmarks/mixed_case_within_schema_no_declared_constraints/expected-links.yaml b/packages/cli/test/fixtures/relationship-benchmarks/mixed_case_within_schema_no_declared_constraints/expected-links.yaml similarity index 100% rename from packages/cli/src/test/fixtures/relationship-benchmarks/mixed_case_within_schema_no_declared_constraints/expected-links.yaml rename to packages/cli/test/fixtures/relationship-benchmarks/mixed_case_within_schema_no_declared_constraints/expected-links.yaml diff --git a/packages/cli/src/test/fixtures/relationship-benchmarks/mixed_case_within_schema_no_declared_constraints/fixture.yaml b/packages/cli/test/fixtures/relationship-benchmarks/mixed_case_within_schema_no_declared_constraints/fixture.yaml similarity index 100% rename from packages/cli/src/test/fixtures/relationship-benchmarks/mixed_case_within_schema_no_declared_constraints/fixture.yaml rename to packages/cli/test/fixtures/relationship-benchmarks/mixed_case_within_schema_no_declared_constraints/fixture.yaml diff --git a/packages/cli/src/test/fixtures/relationship-benchmarks/mixed_case_within_schema_no_declared_constraints/snapshot.json b/packages/cli/test/fixtures/relationship-benchmarks/mixed_case_within_schema_no_declared_constraints/snapshot.json similarity index 100% rename from packages/cli/src/test/fixtures/relationship-benchmarks/mixed_case_within_schema_no_declared_constraints/snapshot.json rename to packages/cli/test/fixtures/relationship-benchmarks/mixed_case_within_schema_no_declared_constraints/snapshot.json diff --git a/packages/cli/src/test/fixtures/relationship-benchmarks/natural_keys_no_declared_constraints/data.sqlite b/packages/cli/test/fixtures/relationship-benchmarks/natural_keys_no_declared_constraints/data.sqlite similarity index 100% rename from packages/cli/src/test/fixtures/relationship-benchmarks/natural_keys_no_declared_constraints/data.sqlite rename to packages/cli/test/fixtures/relationship-benchmarks/natural_keys_no_declared_constraints/data.sqlite diff --git a/packages/cli/src/test/fixtures/relationship-benchmarks/natural_keys_no_declared_constraints/expected-links.yaml b/packages/cli/test/fixtures/relationship-benchmarks/natural_keys_no_declared_constraints/expected-links.yaml similarity index 100% rename from packages/cli/src/test/fixtures/relationship-benchmarks/natural_keys_no_declared_constraints/expected-links.yaml rename to packages/cli/test/fixtures/relationship-benchmarks/natural_keys_no_declared_constraints/expected-links.yaml diff --git a/packages/cli/src/test/fixtures/relationship-benchmarks/natural_keys_no_declared_constraints/fixture.yaml b/packages/cli/test/fixtures/relationship-benchmarks/natural_keys_no_declared_constraints/fixture.yaml similarity index 100% rename from packages/cli/src/test/fixtures/relationship-benchmarks/natural_keys_no_declared_constraints/fixture.yaml rename to packages/cli/test/fixtures/relationship-benchmarks/natural_keys_no_declared_constraints/fixture.yaml diff --git a/packages/cli/src/test/fixtures/relationship-benchmarks/natural_keys_no_declared_constraints/snapshot.json b/packages/cli/test/fixtures/relationship-benchmarks/natural_keys_no_declared_constraints/snapshot.json similarity index 100% rename from packages/cli/src/test/fixtures/relationship-benchmarks/natural_keys_no_declared_constraints/snapshot.json rename to packages/cli/test/fixtures/relationship-benchmarks/natural_keys_no_declared_constraints/snapshot.json diff --git a/packages/cli/src/test/fixtures/relationship-benchmarks/non_english_naming_no_declared_constraints/data.sqlite b/packages/cli/test/fixtures/relationship-benchmarks/non_english_naming_no_declared_constraints/data.sqlite similarity index 100% rename from packages/cli/src/test/fixtures/relationship-benchmarks/non_english_naming_no_declared_constraints/data.sqlite rename to packages/cli/test/fixtures/relationship-benchmarks/non_english_naming_no_declared_constraints/data.sqlite diff --git a/packages/cli/src/test/fixtures/relationship-benchmarks/non_english_naming_no_declared_constraints/expected-links.yaml b/packages/cli/test/fixtures/relationship-benchmarks/non_english_naming_no_declared_constraints/expected-links.yaml similarity index 100% rename from packages/cli/src/test/fixtures/relationship-benchmarks/non_english_naming_no_declared_constraints/expected-links.yaml rename to packages/cli/test/fixtures/relationship-benchmarks/non_english_naming_no_declared_constraints/expected-links.yaml diff --git a/packages/cli/src/test/fixtures/relationship-benchmarks/non_english_naming_no_declared_constraints/fixture.yaml b/packages/cli/test/fixtures/relationship-benchmarks/non_english_naming_no_declared_constraints/fixture.yaml similarity index 100% rename from packages/cli/src/test/fixtures/relationship-benchmarks/non_english_naming_no_declared_constraints/fixture.yaml rename to packages/cli/test/fixtures/relationship-benchmarks/non_english_naming_no_declared_constraints/fixture.yaml diff --git a/packages/cli/src/test/fixtures/relationship-benchmarks/non_english_naming_no_declared_constraints/snapshot.json b/packages/cli/test/fixtures/relationship-benchmarks/non_english_naming_no_declared_constraints/snapshot.json similarity index 100% rename from packages/cli/src/test/fixtures/relationship-benchmarks/non_english_naming_no_declared_constraints/snapshot.json rename to packages/cli/test/fixtures/relationship-benchmarks/non_english_naming_no_declared_constraints/snapshot.json diff --git a/packages/cli/src/test/fixtures/relationship-benchmarks/northwind_with_declared_metadata/expected-links.yaml b/packages/cli/test/fixtures/relationship-benchmarks/northwind_with_declared_metadata/expected-links.yaml similarity index 100% rename from packages/cli/src/test/fixtures/relationship-benchmarks/northwind_with_declared_metadata/expected-links.yaml rename to packages/cli/test/fixtures/relationship-benchmarks/northwind_with_declared_metadata/expected-links.yaml diff --git a/packages/cli/src/test/fixtures/relationship-benchmarks/northwind_with_declared_metadata/fixture.yaml b/packages/cli/test/fixtures/relationship-benchmarks/northwind_with_declared_metadata/fixture.yaml similarity index 100% rename from packages/cli/src/test/fixtures/relationship-benchmarks/northwind_with_declared_metadata/fixture.yaml rename to packages/cli/test/fixtures/relationship-benchmarks/northwind_with_declared_metadata/fixture.yaml diff --git a/packages/cli/src/test/fixtures/relationship-benchmarks/northwind_with_declared_metadata/snapshot.json b/packages/cli/test/fixtures/relationship-benchmarks/northwind_with_declared_metadata/snapshot.json similarity index 100% rename from packages/cli/src/test/fixtures/relationship-benchmarks/northwind_with_declared_metadata/snapshot.json rename to packages/cli/test/fixtures/relationship-benchmarks/northwind_with_declared_metadata/snapshot.json diff --git a/packages/cli/src/test/fixtures/relationship-benchmarks/orbit_style_product_no_declared_constraints/data.sqlite b/packages/cli/test/fixtures/relationship-benchmarks/orbit_style_product_no_declared_constraints/data.sqlite similarity index 100% rename from packages/cli/src/test/fixtures/relationship-benchmarks/orbit_style_product_no_declared_constraints/data.sqlite rename to packages/cli/test/fixtures/relationship-benchmarks/orbit_style_product_no_declared_constraints/data.sqlite diff --git a/packages/cli/src/test/fixtures/relationship-benchmarks/orbit_style_product_no_declared_constraints/expected-links.yaml b/packages/cli/test/fixtures/relationship-benchmarks/orbit_style_product_no_declared_constraints/expected-links.yaml similarity index 100% rename from packages/cli/src/test/fixtures/relationship-benchmarks/orbit_style_product_no_declared_constraints/expected-links.yaml rename to packages/cli/test/fixtures/relationship-benchmarks/orbit_style_product_no_declared_constraints/expected-links.yaml diff --git a/packages/cli/src/test/fixtures/relationship-benchmarks/orbit_style_product_no_declared_constraints/fixture.yaml b/packages/cli/test/fixtures/relationship-benchmarks/orbit_style_product_no_declared_constraints/fixture.yaml similarity index 100% rename from packages/cli/src/test/fixtures/relationship-benchmarks/orbit_style_product_no_declared_constraints/fixture.yaml rename to packages/cli/test/fixtures/relationship-benchmarks/orbit_style_product_no_declared_constraints/fixture.yaml diff --git a/packages/cli/src/test/fixtures/relationship-benchmarks/orbit_style_product_no_declared_constraints/snapshot.json b/packages/cli/test/fixtures/relationship-benchmarks/orbit_style_product_no_declared_constraints/snapshot.json similarity index 100% rename from packages/cli/src/test/fixtures/relationship-benchmarks/orbit_style_product_no_declared_constraints/snapshot.json rename to packages/cli/test/fixtures/relationship-benchmarks/orbit_style_product_no_declared_constraints/snapshot.json diff --git a/packages/cli/src/test/fixtures/relationship-benchmarks/plan_code_no_declared_constraints/data.sqlite b/packages/cli/test/fixtures/relationship-benchmarks/plan_code_no_declared_constraints/data.sqlite similarity index 100% rename from packages/cli/src/test/fixtures/relationship-benchmarks/plan_code_no_declared_constraints/data.sqlite rename to packages/cli/test/fixtures/relationship-benchmarks/plan_code_no_declared_constraints/data.sqlite diff --git a/packages/cli/src/test/fixtures/relationship-benchmarks/plan_code_no_declared_constraints/expected-links.yaml b/packages/cli/test/fixtures/relationship-benchmarks/plan_code_no_declared_constraints/expected-links.yaml similarity index 100% rename from packages/cli/src/test/fixtures/relationship-benchmarks/plan_code_no_declared_constraints/expected-links.yaml rename to packages/cli/test/fixtures/relationship-benchmarks/plan_code_no_declared_constraints/expected-links.yaml diff --git a/packages/cli/src/test/fixtures/relationship-benchmarks/plan_code_no_declared_constraints/fixture.yaml b/packages/cli/test/fixtures/relationship-benchmarks/plan_code_no_declared_constraints/fixture.yaml similarity index 100% rename from packages/cli/src/test/fixtures/relationship-benchmarks/plan_code_no_declared_constraints/fixture.yaml rename to packages/cli/test/fixtures/relationship-benchmarks/plan_code_no_declared_constraints/fixture.yaml diff --git a/packages/cli/src/test/fixtures/relationship-benchmarks/plan_code_no_declared_constraints/snapshot.json b/packages/cli/test/fixtures/relationship-benchmarks/plan_code_no_declared_constraints/snapshot.json similarity index 100% rename from packages/cli/src/test/fixtures/relationship-benchmarks/plan_code_no_declared_constraints/snapshot.json rename to packages/cli/test/fixtures/relationship-benchmarks/plan_code_no_declared_constraints/snapshot.json diff --git a/packages/cli/src/test/fixtures/relationship-benchmarks/polymorphic_partial_overlap_no_declared_constraints/data.sqlite b/packages/cli/test/fixtures/relationship-benchmarks/polymorphic_partial_overlap_no_declared_constraints/data.sqlite similarity index 100% rename from packages/cli/src/test/fixtures/relationship-benchmarks/polymorphic_partial_overlap_no_declared_constraints/data.sqlite rename to packages/cli/test/fixtures/relationship-benchmarks/polymorphic_partial_overlap_no_declared_constraints/data.sqlite diff --git a/packages/cli/src/test/fixtures/relationship-benchmarks/polymorphic_partial_overlap_no_declared_constraints/expected-links.yaml b/packages/cli/test/fixtures/relationship-benchmarks/polymorphic_partial_overlap_no_declared_constraints/expected-links.yaml similarity index 100% rename from packages/cli/src/test/fixtures/relationship-benchmarks/polymorphic_partial_overlap_no_declared_constraints/expected-links.yaml rename to packages/cli/test/fixtures/relationship-benchmarks/polymorphic_partial_overlap_no_declared_constraints/expected-links.yaml diff --git a/packages/cli/src/test/fixtures/relationship-benchmarks/polymorphic_partial_overlap_no_declared_constraints/fixture.yaml b/packages/cli/test/fixtures/relationship-benchmarks/polymorphic_partial_overlap_no_declared_constraints/fixture.yaml similarity index 100% rename from packages/cli/src/test/fixtures/relationship-benchmarks/polymorphic_partial_overlap_no_declared_constraints/fixture.yaml rename to packages/cli/test/fixtures/relationship-benchmarks/polymorphic_partial_overlap_no_declared_constraints/fixture.yaml diff --git a/packages/cli/src/test/fixtures/relationship-benchmarks/polymorphic_partial_overlap_no_declared_constraints/snapshot.json b/packages/cli/test/fixtures/relationship-benchmarks/polymorphic_partial_overlap_no_declared_constraints/snapshot.json similarity index 100% rename from packages/cli/src/test/fixtures/relationship-benchmarks/polymorphic_partial_overlap_no_declared_constraints/snapshot.json rename to packages/cli/test/fixtures/relationship-benchmarks/polymorphic_partial_overlap_no_declared_constraints/snapshot.json diff --git a/packages/cli/src/test/fixtures/relationship-benchmarks/sakila_with_declared_metadata/expected-links.yaml b/packages/cli/test/fixtures/relationship-benchmarks/sakila_with_declared_metadata/expected-links.yaml similarity index 100% rename from packages/cli/src/test/fixtures/relationship-benchmarks/sakila_with_declared_metadata/expected-links.yaml rename to packages/cli/test/fixtures/relationship-benchmarks/sakila_with_declared_metadata/expected-links.yaml diff --git a/packages/cli/src/test/fixtures/relationship-benchmarks/sakila_with_declared_metadata/fixture.yaml b/packages/cli/test/fixtures/relationship-benchmarks/sakila_with_declared_metadata/fixture.yaml similarity index 100% rename from packages/cli/src/test/fixtures/relationship-benchmarks/sakila_with_declared_metadata/fixture.yaml rename to packages/cli/test/fixtures/relationship-benchmarks/sakila_with_declared_metadata/fixture.yaml diff --git a/packages/cli/src/test/fixtures/relationship-benchmarks/sakila_with_declared_metadata/snapshot.json b/packages/cli/test/fixtures/relationship-benchmarks/sakila_with_declared_metadata/snapshot.json similarity index 100% rename from packages/cli/src/test/fixtures/relationship-benchmarks/sakila_with_declared_metadata/snapshot.json rename to packages/cli/test/fixtures/relationship-benchmarks/sakila_with_declared_metadata/snapshot.json diff --git a/packages/cli/src/test/fixtures/relationship-benchmarks/scale_stress_no_declared_constraints/data.sqlite.gz b/packages/cli/test/fixtures/relationship-benchmarks/scale_stress_no_declared_constraints/data.sqlite.gz similarity index 100% rename from packages/cli/src/test/fixtures/relationship-benchmarks/scale_stress_no_declared_constraints/data.sqlite.gz rename to packages/cli/test/fixtures/relationship-benchmarks/scale_stress_no_declared_constraints/data.sqlite.gz diff --git a/packages/cli/src/test/fixtures/relationship-benchmarks/scale_stress_no_declared_constraints/expected-links.yaml b/packages/cli/test/fixtures/relationship-benchmarks/scale_stress_no_declared_constraints/expected-links.yaml similarity index 100% rename from packages/cli/src/test/fixtures/relationship-benchmarks/scale_stress_no_declared_constraints/expected-links.yaml rename to packages/cli/test/fixtures/relationship-benchmarks/scale_stress_no_declared_constraints/expected-links.yaml diff --git a/packages/cli/src/test/fixtures/relationship-benchmarks/scale_stress_no_declared_constraints/fixture.yaml b/packages/cli/test/fixtures/relationship-benchmarks/scale_stress_no_declared_constraints/fixture.yaml similarity index 100% rename from packages/cli/src/test/fixtures/relationship-benchmarks/scale_stress_no_declared_constraints/fixture.yaml rename to packages/cli/test/fixtures/relationship-benchmarks/scale_stress_no_declared_constraints/fixture.yaml diff --git a/packages/cli/src/test/fixtures/relationship-benchmarks/scale_stress_no_declared_constraints/snapshot.json.gz b/packages/cli/test/fixtures/relationship-benchmarks/scale_stress_no_declared_constraints/snapshot.json.gz similarity index 100% rename from packages/cli/src/test/fixtures/relationship-benchmarks/scale_stress_no_declared_constraints/snapshot.json.gz rename to packages/cli/test/fixtures/relationship-benchmarks/scale_stress_no_declared_constraints/snapshot.json.gz diff --git a/packages/cli/src/test/fixtures/relationship-benchmarks/semantic_embedding_aliases_no_declared_constraints/column-embeddings.json b/packages/cli/test/fixtures/relationship-benchmarks/semantic_embedding_aliases_no_declared_constraints/column-embeddings.json similarity index 100% rename from packages/cli/src/test/fixtures/relationship-benchmarks/semantic_embedding_aliases_no_declared_constraints/column-embeddings.json rename to packages/cli/test/fixtures/relationship-benchmarks/semantic_embedding_aliases_no_declared_constraints/column-embeddings.json diff --git a/packages/cli/src/test/fixtures/relationship-benchmarks/semantic_embedding_aliases_no_declared_constraints/data.sqlite b/packages/cli/test/fixtures/relationship-benchmarks/semantic_embedding_aliases_no_declared_constraints/data.sqlite similarity index 100% rename from packages/cli/src/test/fixtures/relationship-benchmarks/semantic_embedding_aliases_no_declared_constraints/data.sqlite rename to packages/cli/test/fixtures/relationship-benchmarks/semantic_embedding_aliases_no_declared_constraints/data.sqlite diff --git a/packages/cli/src/test/fixtures/relationship-benchmarks/semantic_embedding_aliases_no_declared_constraints/expected-links.yaml b/packages/cli/test/fixtures/relationship-benchmarks/semantic_embedding_aliases_no_declared_constraints/expected-links.yaml similarity index 100% rename from packages/cli/src/test/fixtures/relationship-benchmarks/semantic_embedding_aliases_no_declared_constraints/expected-links.yaml rename to packages/cli/test/fixtures/relationship-benchmarks/semantic_embedding_aliases_no_declared_constraints/expected-links.yaml diff --git a/packages/cli/src/test/fixtures/relationship-benchmarks/semantic_embedding_aliases_no_declared_constraints/fixture.yaml b/packages/cli/test/fixtures/relationship-benchmarks/semantic_embedding_aliases_no_declared_constraints/fixture.yaml similarity index 100% rename from packages/cli/src/test/fixtures/relationship-benchmarks/semantic_embedding_aliases_no_declared_constraints/fixture.yaml rename to packages/cli/test/fixtures/relationship-benchmarks/semantic_embedding_aliases_no_declared_constraints/fixture.yaml diff --git a/packages/cli/src/test/fixtures/relationship-benchmarks/semantic_embedding_aliases_no_declared_constraints/snapshot.json b/packages/cli/test/fixtures/relationship-benchmarks/semantic_embedding_aliases_no_declared_constraints/snapshot.json similarity index 100% rename from packages/cli/src/test/fixtures/relationship-benchmarks/semantic_embedding_aliases_no_declared_constraints/snapshot.json rename to packages/cli/test/fixtures/relationship-benchmarks/semantic_embedding_aliases_no_declared_constraints/snapshot.json diff --git a/packages/cli/src/index.test.ts b/packages/cli/test/index.test.ts similarity index 99% rename from packages/cli/src/index.test.ts rename to packages/cli/test/index.test.ts index c482e452..a60c48f2 100644 --- a/packages/cli/src/index.test.ts +++ b/packages/cli/test/index.test.ts @@ -2,7 +2,7 @@ import { mkdtemp, rm, writeFile } from 'node:fs/promises'; import { createRequire } from 'node:module'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; -import { initKtxProject } from './context/project/project.js'; +import { initKtxProject } from '../src/context/project/project.js'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { @@ -15,7 +15,7 @@ import { sanitizeMemoryFlowTuiError, startLiveMemoryFlowTui, warnVizFallbackOnce, -} from './index.js'; +} from '../src/index.js'; const require = createRequire(import.meta.url); diff --git a/packages/cli/src/ingest-query-executor.test.ts b/packages/cli/test/ingest-query-executor.test.ts similarity index 89% rename from packages/cli/src/ingest-query-executor.test.ts rename to packages/cli/test/ingest-query-executor.test.ts index 14b714d9..372cd362 100644 --- a/packages/cli/src/ingest-query-executor.test.ts +++ b/packages/cli/test/ingest-query-executor.test.ts @@ -1,7 +1,7 @@ -import type { KtxLocalProject } from './context/project/project.js'; -import { createKtxConnectorCapabilities, type KtxScanConnector } from './context/scan/types.js'; +import type { KtxLocalProject } from '../src/context/project/project.js'; +import { createKtxConnectorCapabilities, type KtxScanConnector } from '../src/context/scan/types.js'; import { describe, expect, it, vi } from 'vitest'; -import { createKtxCliIngestQueryExecutor } from './ingest-query-executor.js'; +import { createKtxCliIngestQueryExecutor } from '../src/ingest-query-executor.js'; function project(): KtxLocalProject { return { @@ -31,6 +31,8 @@ function connector(overrides: Partial = {}): KtxScanConnector })), cleanup: vi.fn(async () => {}), ...overrides, + listSchemas: overrides.listSchemas ?? vi.fn(async () => []), + listTables: overrides.listTables ?? vi.fn(async () => []), }; } diff --git a/packages/cli/src/ingest-report-file.test.ts b/packages/cli/test/ingest-report-file.test.ts similarity index 96% rename from packages/cli/src/ingest-report-file.test.ts rename to packages/cli/test/ingest-report-file.test.ts index 5183764b..071876cf 100644 --- a/packages/cli/src/ingest-report-file.test.ts +++ b/packages/cli/test/ingest-report-file.test.ts @@ -2,7 +2,7 @@ import { mkdtemp, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; -import { readIngestReportSnapshotFile } from './ingest-report-file.js'; +import { readIngestReportSnapshotFile } from '../src/ingest-report-file.js'; function reportSnapshot() { return { diff --git a/packages/cli/src/ingest-viz.test.ts b/packages/cli/test/ingest-viz.test.ts similarity index 99% rename from packages/cli/src/ingest-viz.test.ts rename to packages/cli/test/ingest-viz.test.ts index 17b35f75..a794043f 100644 --- a/packages/cli/src/ingest-viz.test.ts +++ b/packages/cli/test/ingest-viz.test.ts @@ -1,10 +1,10 @@ import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; -import type { LocalIngestResult, RunLocalIngestOptions } from './context/ingest/local-ingest.js'; -import type { MemoryFlowReplayInput } from './context/ingest/memory-flow/types.js'; +import type { LocalIngestResult, RunLocalIngestOptions } from '../src/context/ingest/local-ingest.js'; +import type { MemoryFlowReplayInput } from '../src/context/ingest/memory-flow/types.js'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { runKtxIngest } from './ingest.js'; +import { runKtxIngest } from '../src/ingest.js'; import { completedLocalBundleRun, emitLiveLocalMemoryFlow, @@ -14,7 +14,7 @@ import { writeBundleReportFile, writeWarehouseConfig, } from './ingest.test-utils.js'; -import { resetVizFallbackWarningsForTest } from './viz-fallback.js'; +import { resetVizFallbackWarningsForTest } from '../src/viz-fallback.js'; describe('runKtxIngest viz and replay', () => { let tempDir: string; diff --git a/packages/cli/src/ingest.test-utils.ts b/packages/cli/test/ingest.test-utils.ts similarity index 95% rename from packages/cli/src/ingest.test-utils.ts rename to packages/cli/test/ingest.test-utils.ts index 81600df7..7198ff5d 100644 --- a/packages/cli/src/ingest.test-utils.ts +++ b/packages/cli/test/ingest.test-utils.ts @@ -1,21 +1,21 @@ import { EventEmitter } from 'node:events'; import { mkdir, writeFile } from 'node:fs/promises'; import { join } from 'node:path'; -import type { AgentRunnerPort, RunLoopParams } from './context/llm/runtime-port.js'; -import { KtxYamlMetabaseSourceStateReader, LocalMetabaseDiscoveryCache } from './context/ingest/adapters/metabase/local-source-state-store.js'; -import { MetabaseSourceAdapter } from './context/ingest/adapters/metabase/metabase.adapter.js'; -import { getLocalIngestStatus, type LocalIngestResult, type RunLocalIngestOptions } from './context/ingest/local-ingest.js'; -import type { ChunkResult, FetchContext, SourceAdapter } from './context/ingest/types.js'; -import type { IngestReportSnapshot } from './context/ingest/reports.js'; -import type { LookerMappingClient, LookerTableIdentifierParser } from './context/ingest/adapters/looker/mapping.js'; -import type { LookerRuntimeClient } from './context/ingest/adapters/looker/fetch.js'; -import type { MemoryFlowEventSink } from './context/ingest/memory-flow/types.js'; -import type { MetabaseCard, MetabaseCardSummary, MetabaseClientFactory, MetabaseRuntimeClient } from './context/ingest/adapters/metabase/client-port.js'; -import type { SqliteBundleIngestStore } from './context/ingest/sqlite-bundle-ingest-store.js'; -import { ktxLocalStateDbPath } from './context/project/local-state-db.js'; -import { loadKtxProject } from './context/project/project.js'; +import type { AgentRunnerPort, RunLoopParams } from '../src/context/llm/runtime-port.js'; +import { KtxYamlMetabaseSourceStateReader, LocalMetabaseDiscoveryCache } from '../src/context/ingest/adapters/metabase/local-source-state-store.js'; +import { MetabaseSourceAdapter } from '../src/context/ingest/adapters/metabase/metabase.adapter.js'; +import { getLocalIngestStatus, type LocalIngestResult, type RunLocalIngestOptions } from '../src/context/ingest/local-ingest.js'; +import type { ChunkResult, FetchContext, SourceAdapter } from '../src/context/ingest/types.js'; +import type { IngestReportSnapshot } from '../src/context/ingest/reports.js'; +import type { LookerMappingClient, LookerTableIdentifierParser } from '../src/context/ingest/adapters/looker/mapping.js'; +import type { LookerRuntimeClient } from '../src/context/ingest/adapters/looker/fetch.js'; +import type { MemoryFlowEventSink } from '../src/context/ingest/memory-flow/types.js'; +import type { MetabaseCard, MetabaseCardSummary, MetabaseClientFactory, MetabaseRuntimeClient } from '../src/context/ingest/adapters/metabase/client-port.js'; +import type { SqliteBundleIngestStore } from '../src/context/ingest/sqlite-bundle-ingest-store.js'; +import { ktxLocalStateDbPath } from '../src/context/project/local-state-db.js'; +import { loadKtxProject } from '../src/context/project/project.js'; import { expect, vi } from 'vitest'; -import { runKtxIngest } from './ingest.js'; +import { runKtxIngest } from '../src/ingest.js'; export function makeIo( options: { @@ -675,7 +675,7 @@ export function localFakeBundleReport( } export async function localBundleStore(projectDir: string, ids: [string, string]): Promise { - const { SqliteBundleIngestStore } = await import('./context/ingest/sqlite-bundle-ingest-store.js');; + const { SqliteBundleIngestStore } = await import('../src/context/ingest/sqlite-bundle-ingest-store.js');; const project = await loadKtxProject({ projectDir }); return new SqliteBundleIngestStore({ dbPath: ktxLocalStateDbPath(project), diff --git a/packages/cli/src/ingest.test.ts b/packages/cli/test/ingest.test.ts similarity index 98% rename from packages/cli/src/ingest.test.ts rename to packages/cli/test/ingest.test.ts index 6de72879..eef751ba 100644 --- a/packages/cli/src/ingest.test.ts +++ b/packages/cli/test/ingest.test.ts @@ -1,15 +1,15 @@ import { access, mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; -import { LocalLookerRuntimeStore } from './context/ingest/adapters/looker/local-runtime-store.js'; -import { LocalMetabaseDiscoveryCache } from './context/ingest/adapters/metabase/local-source-state-store.js'; -import type { LocalIngestResult, LocalMetabaseFanoutProgress, RunLocalIngestOptions } from './context/ingest/local-ingest.js'; -import type { SourceAdapter } from './context/ingest/types.js'; -import { initKtxProject, loadKtxProject } from './context/project/project.js'; -import { ktxLocalStateDbPath } from './context/project/local-state-db.js'; +import { LocalLookerRuntimeStore } from '../src/context/ingest/adapters/looker/local-runtime-store.js'; +import { LocalMetabaseDiscoveryCache } from '../src/context/ingest/adapters/metabase/local-source-state-store.js'; +import type { LocalIngestResult, LocalMetabaseFanoutProgress, RunLocalIngestOptions } from '../src/context/ingest/local-ingest.js'; +import type { SourceAdapter } from '../src/context/ingest/types.js'; +import { initKtxProject, loadKtxProject } from '../src/context/project/project.js'; +import { ktxLocalStateDbPath } from '../src/context/project/local-state-db.js'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { type KtxIngestArgs, type KtxIngestDeps, runKtxIngest } from './ingest.js'; -import type { KtxCliLocalIngestAdaptersOptions } from './local-adapters.js'; +import { type KtxIngestArgs, type KtxIngestDeps, runKtxIngest } from '../src/ingest.js'; +import type { KtxCliLocalIngestAdaptersOptions } from '../src/local-adapters.js'; import { CliLookerSlWritingAgentRunner, CliMetabaseAgentRunner, @@ -25,8 +25,8 @@ import { writeMetabaseConfig, writeWarehouseConfig, } from './ingest.test-utils.js'; -import { resetVizFallbackWarningsForTest } from './viz-fallback.js'; -import { runKtxSetup } from './setup.js'; +import { resetVizFallbackWarningsForTest } from '../src/viz-fallback.js'; +import { runKtxSetup } from '../src/setup.js'; describe('runKtxIngest', () => { let tempDir: string; diff --git a/packages/cli/src/io/logger.test.ts b/packages/cli/test/io/logger.test.ts similarity index 97% rename from packages/cli/src/io/logger.test.ts rename to packages/cli/test/io/logger.test.ts index bf21a150..46248d02 100644 --- a/packages/cli/src/io/logger.test.ts +++ b/packages/cli/test/io/logger.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it, vi } from 'vitest'; -import { createCliOperationalLogger, createNoopOperationalLogger } from './logger.js'; +import { createCliOperationalLogger, createNoopOperationalLogger } from '../../src/io/logger.js'; function makeIo() { let stdout = ''; diff --git a/packages/cli/src/io/mode.test.ts b/packages/cli/test/io/mode.test.ts similarity index 95% rename from packages/cli/src/io/mode.test.ts rename to packages/cli/test/io/mode.test.ts index cfc9a9fc..4d36c37a 100644 --- a/packages/cli/src/io/mode.test.ts +++ b/packages/cli/test/io/mode.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; -import type { KtxCliIo } from '../cli-runtime.js'; -import { resolveOutputMode } from './mode.js'; +import type { KtxCliIo } from '../../src/cli-runtime.js'; +import { resolveOutputMode } from '../../src/io/mode.js'; function ioWith(isTTY: boolean | undefined): KtxCliIo { return { diff --git a/packages/cli/src/io/print-list.test.ts b/packages/cli/test/io/print-list.test.ts similarity index 98% rename from packages/cli/src/io/print-list.test.ts rename to packages/cli/test/io/print-list.test.ts index f084e519..f065d067 100644 --- a/packages/cli/src/io/print-list.test.ts +++ b/packages/cli/test/io/print-list.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from 'vitest'; -import type { KtxCliIo } from '../cli-runtime.js'; -import { createRankBadgeFormatter, printList, type PrintListColumn } from './print-list.js'; -import { SYMBOLS } from './symbols.js'; +import type { KtxCliIo } from '../../src/cli-runtime.js'; +import { createRankBadgeFormatter, printList, type PrintListColumn } from '../../src/io/print-list.js'; +import { SYMBOLS } from '../../src/io/symbols.js'; function recorder(): { io: KtxCliIo; out: () => string; err: () => string } { let stdout = ''; diff --git a/packages/cli/src/knowledge.test.ts b/packages/cli/test/knowledge.test.ts similarity index 96% rename from packages/cli/src/knowledge.test.ts rename to packages/cli/test/knowledge.test.ts index 69581f0f..339eb659 100644 --- a/packages/cli/src/knowledge.test.ts +++ b/packages/cli/test/knowledge.test.ts @@ -2,11 +2,11 @@ import { mkdtemp, rm } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { stripVTControlCharacters } from 'node:util'; -import { initKtxProject, loadKtxProject } from './context/project/project.js'; -import type { KtxEmbeddingPort } from './context/core/embedding.js'; -import { writeLocalKnowledgePage } from './context/wiki/local-knowledge.js'; +import { initKtxProject, loadKtxProject } from '../src/context/project/project.js'; +import type { KtxEmbeddingPort } from '../src/context/core/embedding.js'; +import { writeLocalKnowledgePage } from '../src/context/wiki/local-knowledge.js'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { runKtxKnowledge } from './knowledge.js'; +import { runKtxKnowledge } from '../src/knowledge.js'; function makeIo(options: { isTTY?: boolean } = {}) { let stdout = ''; diff --git a/packages/cli/src/llm/embedding-health.test.ts b/packages/cli/test/llm/embedding-health.test.ts similarity index 97% rename from packages/cli/src/llm/embedding-health.test.ts rename to packages/cli/test/llm/embedding-health.test.ts index 65956311..f659b2d6 100644 --- a/packages/cli/src/llm/embedding-health.test.ts +++ b/packages/cli/test/llm/embedding-health.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it, vi } from 'vitest'; -import { runKtxEmbeddingHealthCheck } from './embedding-health.js'; +import { runKtxEmbeddingHealthCheck } from '../../src/llm/embedding-health.js'; describe('KTX embedding health check', () => { it('runs a one-shot OpenAI embedding check through the configured provider', async () => { diff --git a/packages/cli/src/llm/embedding-provider.test.ts b/packages/cli/test/llm/embedding-provider.test.ts similarity index 96% rename from packages/cli/src/llm/embedding-provider.test.ts rename to packages/cli/test/llm/embedding-provider.test.ts index c649a948..071a17b0 100644 --- a/packages/cli/src/llm/embedding-provider.test.ts +++ b/packages/cli/test/llm/embedding-provider.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it, vi } from 'vitest'; -import { createKtxEmbeddingProvider } from './embedding-provider.js'; -import type { KtxEmbeddingConfig } from './types.js'; +import { createKtxEmbeddingProvider } from '../../src/llm/embedding-provider.js'; +import type { KtxEmbeddingConfig } from '../../src/llm/types.js'; describe('createKtxEmbeddingProvider', () => { it('rejects deterministic embeddings', () => { diff --git a/packages/cli/src/llm/message-builder.test.ts b/packages/cli/test/llm/message-builder.test.ts similarity index 97% rename from packages/cli/src/llm/message-builder.test.ts rename to packages/cli/test/llm/message-builder.test.ts index 60f7d948..5ebbb590 100644 --- a/packages/cli/src/llm/message-builder.test.ts +++ b/packages/cli/test/llm/message-builder.test.ts @@ -1,7 +1,7 @@ import type { ModelMessage } from 'ai'; import { describe, expect, it } from 'vitest'; -import { KtxMessageBuilder, splitKtxSystemMessages } from './message-builder.js'; -import { createKtxLlmProvider } from './model-provider.js'; +import { KtxMessageBuilder, splitKtxSystemMessages } from '../../src/llm/message-builder.js'; +import { createKtxLlmProvider } from '../../src/llm/model-provider.js'; function makeBuilder(overrides: Parameters[0]['promptCaching'] = {}) { const provider = createKtxLlmProvider({ diff --git a/packages/cli/src/llm/model-health.test.ts b/packages/cli/test/llm/model-health.test.ts similarity index 97% rename from packages/cli/src/llm/model-health.test.ts rename to packages/cli/test/llm/model-health.test.ts index 8cf7a7ee..0af6f8e8 100644 --- a/packages/cli/src/llm/model-health.test.ts +++ b/packages/cli/test/llm/model-health.test.ts @@ -1,6 +1,6 @@ import { wrapLanguageModel as defaultWrapLanguageModel } from 'ai'; import { describe, expect, it, vi } from 'vitest'; -import { runKtxLlmHealthCheck } from './model-health.js'; +import { runKtxLlmHealthCheck } from '../../src/llm/model-health.js'; const anthropicModel = { modelId: 'claude-sonnet-4-6' } as never; diff --git a/packages/cli/src/llm/model-provider.test.ts b/packages/cli/test/llm/model-provider.test.ts similarity index 99% rename from packages/cli/src/llm/model-provider.test.ts rename to packages/cli/test/llm/model-provider.test.ts index 1a61d0a1..0e3ef045 100644 --- a/packages/cli/src/llm/model-provider.test.ts +++ b/packages/cli/test/llm/model-provider.test.ts @@ -1,7 +1,7 @@ import { devToolsMiddleware as defaultDevToolsMiddleware } from '@ai-sdk/devtools'; import { wrapLanguageModel as defaultWrapLanguageModel, type LanguageModel } from 'ai'; import { describe, expect, it, vi } from 'vitest'; -import { createKtxLlmProvider, type KtxLlmProviderFactoryDeps } from './model-provider.js'; +import { createKtxLlmProvider, type KtxLlmProviderFactoryDeps } from '../../src/llm/model-provider.js'; const languageModel = (modelId: string, provider = 'test'): LanguageModel => ({ modelId, provider }) as LanguageModel; const devtoolsMiddleware = (): ReturnType => ({ specificationVersion: 'v3' }); diff --git a/packages/cli/src/llm/repair.test.ts b/packages/cli/test/llm/repair.test.ts similarity index 97% rename from packages/cli/src/llm/repair.test.ts rename to packages/cli/test/llm/repair.test.ts index bef53a46..743039a9 100644 --- a/packages/cli/src/llm/repair.test.ts +++ b/packages/cli/test/llm/repair.test.ts @@ -1,6 +1,6 @@ import { NoSuchToolError, type LanguageModel } from 'ai'; import { describe, expect, it, vi } from 'vitest'; -import { createKtxToolCallRepairHandler } from './repair.js'; +import { createKtxToolCallRepairHandler } from '../../src/llm/repair.js'; const repairModel = { modelId: 'claude-repair', provider: 'anthropic' } as LanguageModel; diff --git a/packages/cli/src/local-adapters.test.ts b/packages/cli/test/local-adapters.test.ts similarity index 97% rename from packages/cli/src/local-adapters.test.ts rename to packages/cli/test/local-adapters.test.ts index a4e856b4..c7ae58cc 100644 --- a/packages/cli/src/local-adapters.test.ts +++ b/packages/cli/test/local-adapters.test.ts @@ -1,9 +1,9 @@ import { mkdtemp, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; -import { loadKtxProject } from './context/project/project.js'; +import { loadKtxProject } from '../src/context/project/project.js'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; -import { createKtxCliLocalIngestAdapters } from './local-adapters.js'; +import { createKtxCliLocalIngestAdapters } from '../src/local-adapters.js'; function sqlAnalysisStub() { return { diff --git a/packages/cli/src/local-scan-connectors.test.ts b/packages/cli/test/local-scan-connectors.test.ts similarity index 94% rename from packages/cli/src/local-scan-connectors.test.ts rename to packages/cli/test/local-scan-connectors.test.ts index a993faa2..1dadb6c4 100644 --- a/packages/cli/src/local-scan-connectors.test.ts +++ b/packages/cli/test/local-scan-connectors.test.ts @@ -1,9 +1,9 @@ import { mkdtemp, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; -import { initKtxProject, loadKtxProject } from './context/project/project.js'; +import { initKtxProject, loadKtxProject } from '../src/context/project/project.js'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { createKtxCliScanConnector } from './local-scan-connectors.js'; +import { createKtxCliScanConnector } from '../src/local-scan-connectors.js'; const bigQueryMock = vi.hoisted(() => ({ constructorInputs: [] as Array<{ @@ -12,7 +12,7 @@ const bigQueryMock = vi.hoisted(() => ({ }>, })); -vi.mock('./connectors/bigquery/connector.js', () => ({ +vi.mock('../src/connectors/bigquery/connector.js', () => ({ isKtxBigQueryConnectionConfig: (connection: { driver?: unknown } | undefined) => String(connection?.driver ?? '').toLowerCase() === 'bigquery', KtxBigQueryScanConnector: class { diff --git a/packages/cli/src/managed-local-embeddings.test.ts b/packages/cli/test/managed-local-embeddings.test.ts similarity index 96% rename from packages/cli/src/managed-local-embeddings.test.ts rename to packages/cli/test/managed-local-embeddings.test.ts index 9ee938fb..9c78e177 100644 --- a/packages/cli/src/managed-local-embeddings.test.ts +++ b/packages/cli/test/managed-local-embeddings.test.ts @@ -3,10 +3,10 @@ import { ensureManagedLocalEmbeddingsDaemon, managedLocalEmbeddingHealthConfig, tryUseManagedLocalEmbeddingsDaemon, -} from './managed-local-embeddings.js'; -import type { ManagedPythonCommandRuntime } from './managed-python-command.js'; -import type { ManagedPythonDaemonStartResult } from './managed-python-daemon.js'; -import type { ManagedPythonDaemonLayout } from './managed-python-runtime.js'; +} from '../src/managed-local-embeddings.js'; +import type { ManagedPythonCommandRuntime } from '../src/managed-python-command.js'; +import type { ManagedPythonDaemonStartResult } from '../src/managed-python-daemon.js'; +import type { ManagedPythonDaemonLayout } from '../src/managed-python-runtime.js'; function makeIo() { let stdout = ''; diff --git a/packages/cli/src/managed-mcp-daemon.test.ts b/packages/cli/test/managed-mcp-daemon.test.ts similarity index 99% rename from packages/cli/src/managed-mcp-daemon.test.ts rename to packages/cli/test/managed-mcp-daemon.test.ts index d72bb6a4..81566d40 100644 --- a/packages/cli/src/managed-mcp-daemon.test.ts +++ b/packages/cli/test/managed-mcp-daemon.test.ts @@ -9,7 +9,7 @@ import { stopKtxMcpDaemon, type KtxMcpDaemonChild, type KtxMcpDaemonState, -} from './managed-mcp-daemon.js'; +} from '../src/managed-mcp-daemon.js'; type KtxMcpDaemonStartOptions = Parameters[0]; diff --git a/packages/cli/src/managed-python-command.test.ts b/packages/cli/test/managed-python-command.test.ts similarity index 99% rename from packages/cli/src/managed-python-command.test.ts rename to packages/cli/test/managed-python-command.test.ts index 717accf4..f08589f1 100644 --- a/packages/cli/src/managed-python-command.test.ts +++ b/packages/cli/test/managed-python-command.test.ts @@ -4,14 +4,14 @@ import { ensureManagedPythonCommandRuntime, managedRuntimeInstallCommand, runtimeInstallPolicyFromFlags, -} from './managed-python-command.js'; +} from '../src/managed-python-command.js'; import type { InstalledKtxRuntimeManifest, KtxRuntimeFeature, ManagedPythonRuntimeInstallResult, ManagedPythonRuntimeLayout, ManagedPythonRuntimeStatus, -} from './managed-python-runtime.js'; +} from '../src/managed-python-runtime.js'; function makeIo() { let stdout = ''; diff --git a/packages/cli/src/managed-python-daemon.test.ts b/packages/cli/test/managed-python-daemon.test.ts similarity index 83% rename from packages/cli/src/managed-python-daemon.test.ts rename to packages/cli/test/managed-python-daemon.test.ts index d56a27b1..4684c731 100644 --- a/packages/cli/src/managed-python-daemon.test.ts +++ b/packages/cli/test/managed-python-daemon.test.ts @@ -3,6 +3,7 @@ import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { + ManagedPythonDaemonStartError, readManagedPythonDaemonStatus, startManagedPythonDaemon, stopAllManagedPythonDaemons, @@ -12,13 +13,13 @@ import { type ManagedPythonDaemonProcessInfo, type ManagedPythonDaemonSpawn, type ManagedPythonDaemonState, -} from './managed-python-daemon.js'; +} from '../src/managed-python-daemon.js'; import type { InstalledKtxRuntimeManifest, ManagedPythonDaemonLayout, ManagedPythonRuntimeInstallResult, ManagedPythonRuntimeLayout, -} from './managed-python-runtime.js'; +} from '../src/managed-python-runtime.js'; function layout(root: string): ManagedPythonDaemonLayout { const projectDir = join(root, 'project'); @@ -244,6 +245,76 @@ describe('KTX daemon lifecycle', () => { }); }); + it('kills the spawned daemon when the startup health check times out', async () => { + const spawnDaemon = makeSpawn(7777); + const killProcess = vi.fn(); + const fetch = vi.fn().mockRejectedValue(new Error('fetch failed')); + + await expect( + startManagedPythonDaemon({ + ...daemonOptionsBase(tempDir), + features: ['core'], + installRuntime: vi.fn(async () => installResult(tempDir)), + spawnDaemon, + fetch, + processAlive: vi.fn(() => true), + killProcess, + allocatePort: vi.fn(async () => 61234), + now: () => new Date('2026-05-11T00:00:00.000Z'), + startupTimeoutMs: 5, + pollIntervalMs: 1, + }), + ).rejects.toBeInstanceOf(ManagedPythonDaemonStartError); + + expect(killProcess).toHaveBeenCalledWith(7777); + await expect(readFile(layout(tempDir).daemonStatePath, 'utf8')).rejects.toMatchObject({ code: 'ENOENT' }); + }); + + it('surfaces the underlying fetch cause in the startup failure message', async () => { + const cause = new Error('connect ECONNREFUSED 127.0.0.1:61234'); + const fetchError = new Error('fetch failed'); + (fetchError as Error & { cause?: unknown }).cause = cause; + + const error = await startManagedPythonDaemon({ + ...daemonOptionsBase(tempDir), + features: ['core'], + installRuntime: vi.fn(async () => installResult(tempDir)), + spawnDaemon: makeSpawn(7778), + fetch: vi.fn().mockRejectedValue(fetchError), + processAlive: vi.fn(() => false), + killProcess: vi.fn(), + allocatePort: vi.fn(async () => 61234), + now: () => new Date('2026-05-11T00:00:00.000Z'), + startupTimeoutMs: 5, + pollIntervalMs: 1, + }).catch((value: unknown) => value); + + expect(error).toBeInstanceOf(ManagedPythonDaemonStartError); + const startError = error as ManagedPythonDaemonStartError; + expect(startError.detail).toContain('fetch failed'); + expect(startError.detail).toContain('ECONNREFUSED'); + expect(startError.message).toContain('ECONNREFUSED'); + }); + + it('exposes the daemon stderr log path on startup failure', async () => { + const error = await startManagedPythonDaemon({ + ...daemonOptionsBase(tempDir), + features: ['core'], + installRuntime: vi.fn(async () => installResult(tempDir)), + spawnDaemon: makeSpawn(7779), + fetch: vi.fn().mockRejectedValue(new Error('fetch failed')), + processAlive: vi.fn(() => false), + killProcess: vi.fn(), + allocatePort: vi.fn(async () => 61234), + now: () => new Date('2026-05-11T00:00:00.000Z'), + startupTimeoutMs: 5, + pollIntervalMs: 1, + }).catch((value: unknown) => value); + + expect(error).toBeInstanceOf(ManagedPythonDaemonStartError); + expect((error as ManagedPythonDaemonStartError).stderrLog).toBe(layout(tempDir).daemonStderrPath); + }); + it('reuses a healthy daemon with the requested feature set', async () => { await mkdir(layout(tempDir).daemonStateDir, { recursive: true }); await writeFile(layout(tempDir).daemonStatePath, `${JSON.stringify(runningState(tempDir), null, 2)}\n`); diff --git a/packages/cli/src/managed-python-http.test.ts b/packages/cli/test/managed-python-http.test.ts similarity index 99% rename from packages/cli/src/managed-python-http.test.ts rename to packages/cli/test/managed-python-http.test.ts index 73cae844..f19b6d72 100644 --- a/packages/cli/src/managed-python-http.test.ts +++ b/packages/cli/test/managed-python-http.test.ts @@ -5,7 +5,7 @@ import { createManagedDaemonSqlAnalysisPort, createManagedPythonDaemonBaseUrlResolver, managedDaemonDatabaseIntrospectionOptions, -} from './managed-python-http.js'; +} from '../src/managed-python-http.js'; function io() { let stderr = ''; diff --git a/packages/cli/src/managed-python-runtime.test.ts b/packages/cli/test/managed-python-runtime.test.ts similarity index 99% rename from packages/cli/src/managed-python-runtime.test.ts rename to packages/cli/test/managed-python-runtime.test.ts index 92e34e35..143802ad 100644 --- a/packages/cli/src/managed-python-runtime.test.ts +++ b/packages/cli/test/managed-python-runtime.test.ts @@ -13,7 +13,7 @@ import { readManagedPythonRuntimeStatus, verifyRuntimeAsset, type ManagedPythonRuntimeExec, -} from './managed-python-runtime.js'; +} from '../src/managed-python-runtime.js'; function runtimeWheelContents(input: { label?: string; requiresPython?: string | null } = {}): Buffer { const label = input.label ?? 'runtime-wheel'; diff --git a/packages/cli/src/mcp-http-server.test.ts b/packages/cli/test/mcp-http-server.test.ts similarity index 99% rename from packages/cli/src/mcp-http-server.test.ts rename to packages/cli/test/mcp-http-server.test.ts index d34f0c0c..ddd0bf0f 100644 --- a/packages/cli/src/mcp-http-server.test.ts +++ b/packages/cli/test/mcp-http-server.test.ts @@ -7,7 +7,7 @@ import { isMcpRequestAuthorized, normalizeHostHeader, runKtxMcpHttpServer, -} from './mcp-http-server.js'; +} from '../src/mcp-http-server.js'; describe('normalizeHostHeader', () => { it('normalizes host headers before allow-list comparison', () => { diff --git a/packages/cli/src/mcp-server-factory.test.ts b/packages/cli/test/mcp-server-factory.test.ts similarity index 85% rename from packages/cli/src/mcp-server-factory.test.ts rename to packages/cli/test/mcp-server-factory.test.ts index 64c7275d..05ac5aac 100644 --- a/packages/cli/src/mcp-server-factory.test.ts +++ b/packages/cli/test/mcp-server-factory.test.ts @@ -1,10 +1,10 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { createDefaultKtxMcpServer } from './context/mcp/server.js'; -import { createLocalProjectMcpContextPorts } from './context/mcp/local-project-ports.js'; -import { createLocalProjectMemoryIngest } from './context/memory/local-memory.js'; -import { resolveProjectEmbeddingProvider } from './embedding-resolution.js'; -import { createKtxCliScanConnector } from './local-scan-connectors.js'; -import { createKtxMcpServerFactory } from './mcp-server-factory.js'; +import { createDefaultKtxMcpServer } from '../src/context/mcp/server.js'; +import { createLocalProjectMcpContextPorts } from '../src/context/mcp/local-project-ports.js'; +import { createLocalProjectMemoryIngest } from '../src/context/memory/local-memory.js'; +import { resolveProjectEmbeddingProvider } from '../src/embedding-resolution.js'; +import { createKtxCliScanConnector } from '../src/local-scan-connectors.js'; +import { createKtxMcpServerFactory } from '../src/mcp-server-factory.js'; type FakeEmbeddingProvider = { maxBatchSize: number; @@ -19,7 +19,7 @@ const mocks = vi.hoisted(() => ({ memoryIngest: { ingest: vi.fn(), status: vi.fn(), waitForRun: vi.fn() }, })); -vi.mock('./context/llm/embedding-port.js', () => ({ +vi.mock('../src/context/llm/embedding-port.js', () => ({ KtxIngestEmbeddingPortAdapter: class { readonly maxBatchSize: number; @@ -37,35 +37,35 @@ vi.mock('./context/llm/embedding-port.js', () => ({ }, })); -vi.mock('./context/mcp/server.js', () => ({ +vi.mock('../src/context/mcp/server.js', () => ({ createDefaultKtxMcpServer: vi.fn(() => ({ kind: 'mcp-server' })), })); -vi.mock('./context/mcp/local-project-ports.js', () => ({ +vi.mock('../src/context/mcp/local-project-ports.js', () => ({ createLocalProjectMcpContextPorts: vi.fn(() => ({ context_tool: { name: 'context_tool' } })), })); -vi.mock('./context/memory/local-memory.js', () => ({ +vi.mock('../src/context/memory/local-memory.js', () => ({ createLocalProjectMemoryIngest: vi.fn(() => mocks.memoryIngest), })); -vi.mock('./embedding-resolution.js', () => ({ +vi.mock('../src/embedding-resolution.js', () => ({ resolveProjectEmbeddingProvider: vi.fn(), })); -vi.mock('./ingest-query-executor.js', () => ({ +vi.mock('../src/ingest-query-executor.js', () => ({ createKtxCliIngestQueryExecutor: vi.fn(() => mocks.queryExecutor), })); -vi.mock('./local-scan-connectors.js', () => ({ +vi.mock('../src/local-scan-connectors.js', () => ({ createKtxCliScanConnector: vi.fn(() => ({ source: 'fake-scan-connector' })), })); -vi.mock('./managed-python-command.js', () => ({ +vi.mock('../src/managed-python-command.js', () => ({ createManagedPythonSemanticLayerComputePort: vi.fn(async () => mocks.semanticLayerCompute), })); -vi.mock('./managed-python-http.js', () => ({ +vi.mock('../src/managed-python-http.js', () => ({ createManagedDaemonSqlAnalysisPort: vi.fn(() => mocks.sqlAnalysis), })); diff --git a/packages/cli/src/memory-flow-interactive.test.ts b/packages/cli/test/memory-flow-interactive.test.ts similarity index 97% rename from packages/cli/src/memory-flow-interactive.test.ts rename to packages/cli/test/memory-flow-interactive.test.ts index d6976a03..befc7f01 100644 --- a/packages/cli/src/memory-flow-interactive.test.ts +++ b/packages/cli/test/memory-flow-interactive.test.ts @@ -1,7 +1,7 @@ import { EventEmitter } from 'node:events'; -import type { MemoryFlowReplayInput } from './context/ingest/memory-flow/types.js'; +import type { MemoryFlowReplayInput } from '../src/context/ingest/memory-flow/types.js'; import { describe, expect, it, vi } from 'vitest'; -import { memoryFlowCommandForKey, renderMemoryFlowInteractively } from './memory-flow-interactive.js'; +import { memoryFlowCommandForKey, renderMemoryFlowInteractively } from '../src/memory-flow-interactive.js'; class FakeStdin extends EventEmitter { isTTY = true; diff --git a/packages/cli/src/memory-flow-tui.test.tsx b/packages/cli/test/memory-flow-tui.test.tsx similarity index 99% rename from packages/cli/src/memory-flow-tui.test.tsx rename to packages/cli/test/memory-flow-tui.test.tsx index 09d50125..1bb38b72 100644 --- a/packages/cli/src/memory-flow-tui.test.tsx +++ b/packages/cli/test/memory-flow-tui.test.tsx @@ -1,5 +1,5 @@ /* @jsxImportSource react */ -import type { MemoryFlowReplayInput } from './context/ingest/memory-flow/types.js'; +import type { MemoryFlowReplayInput } from '../src/context/ingest/memory-flow/types.js'; import { render as renderInkTest } from 'ink-testing-library'; import React, { type ReactNode } from 'react'; import { describe, expect, it, vi } from 'vitest'; @@ -11,7 +11,7 @@ import { startLiveMemoryFlowTui, type KtxMemoryFlowTuiIo, type MemoryFlowInkInstance, -} from './memory-flow-tui.js'; +} from '../src/memory-flow-tui.js'; function replayInput(): MemoryFlowReplayInput { return { diff --git a/packages/cli/src/next-steps.test.ts b/packages/cli/test/next-steps.test.ts similarity index 99% rename from packages/cli/src/next-steps.test.ts rename to packages/cli/test/next-steps.test.ts index 8a3e5e2a..c700de9e 100644 --- a/packages/cli/src/next-steps.test.ts +++ b/packages/cli/test/next-steps.test.ts @@ -4,7 +4,7 @@ import { KTX_NEXT_STEP_COMMANDS, formatNextStepLines, formatSetupNextStepLines, -} from './next-steps.js'; +} from '../src/next-steps.js'; describe('KTX demo next steps', () => { it('uses supported context-build commands before agent usage', () => { diff --git a/packages/cli/src/notion-page-picker.test.ts b/packages/cli/test/notion-page-picker.test.ts similarity index 98% rename from packages/cli/src/notion-page-picker.test.ts rename to packages/cli/test/notion-page-picker.test.ts index 29f5a352..dda695cb 100644 --- a/packages/cli/src/notion-page-picker.test.ts +++ b/packages/cli/test/notion-page-picker.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it, vi } from 'vitest'; -import type { PickerState } from './tree-picker-state.js'; -import type { TreePickerChrome, TreePickerResult, TreePickerTuiIo } from './tree-picker-tui.js'; +import type { PickerState } from '../src/tree-picker-state.js'; +import type { TreePickerChrome, TreePickerResult, TreePickerTuiIo } from '../src/tree-picker-tui.js'; import { discoverNotionPickerPages, notionPickerPageFromSearchResult, @@ -8,7 +8,7 @@ import { pickNotionRootPages, resolveNotionWorkspaceLabel, type NotionPickerApi, -} from './notion-page-picker.js'; +} from '../src/notion-page-picker.js'; function makeIo() { let stdout = ''; diff --git a/packages/cli/src/print-command-tree.test.ts b/packages/cli/test/print-command-tree.test.ts similarity index 96% rename from packages/cli/src/print-command-tree.test.ts rename to packages/cli/test/print-command-tree.test.ts index edd0b69a..688818ab 100644 --- a/packages/cli/src/print-command-tree.test.ts +++ b/packages/cli/test/print-command-tree.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { renderKtxCommandTree } from './print-command-tree.js'; +import { renderKtxCommandTree } from '../src/print-command-tree.js'; describe('renderKtxCommandTree', () => { it('renders an indented tree rooted at "ktx" with known top-level commands', () => { diff --git a/packages/cli/src/project-dir.test.ts b/packages/cli/test/project-dir.test.ts similarity index 98% rename from packages/cli/src/project-dir.test.ts rename to packages/cli/test/project-dir.test.ts index 25a9b585..14c3ceb7 100644 --- a/packages/cli/src/project-dir.test.ts +++ b/packages/cli/test/project-dir.test.ts @@ -2,7 +2,7 @@ import { mkdtemp, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { runKtxCli, type KtxCliDeps } from './index.js'; +import { runKtxCli, type KtxCliDeps } from '../src/index.js'; async function makeFixtureProject(prefix: string): Promise { const dir = await mkdtemp(join(tmpdir(), prefix)); diff --git a/packages/cli/src/project-resolver.test.ts b/packages/cli/test/project-resolver.test.ts similarity index 98% rename from packages/cli/src/project-resolver.test.ts rename to packages/cli/test/project-resolver.test.ts index 39dab27b..9680fd78 100644 --- a/packages/cli/src/project-resolver.test.ts +++ b/packages/cli/test/project-resolver.test.ts @@ -3,7 +3,7 @@ import { tmpdir } from 'node:os'; import { join, resolve } from 'node:path'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; -import { findNearestKtxProjectDir, resolveKtxProjectDir } from './project-resolver.js'; +import { findNearestKtxProjectDir, resolveKtxProjectDir } from '../src/project-resolver.js'; describe('resolveKtxProjectDir', () => { let tempDir: string; diff --git a/packages/cli/src/prompt-navigation.test.ts b/packages/cli/test/prompt-navigation.test.ts similarity index 97% rename from packages/cli/src/prompt-navigation.test.ts rename to packages/cli/test/prompt-navigation.test.ts index 9338b56e..e0b4cf8b 100644 --- a/packages/cli/src/prompt-navigation.test.ts +++ b/packages/cli/test/prompt-navigation.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { withMenuOptionSpacing, withMultiselectNavigation, withTextInputNavigation } from './prompt-navigation.js'; +import { withMenuOptionSpacing, withMultiselectNavigation, withTextInputNavigation } from '../src/prompt-navigation.js'; describe('prompt navigation helpers', () => { it('leaves compact single-line menu prompts unchanged', () => { diff --git a/packages/cli/src/proxy-env.test.ts b/packages/cli/test/proxy-env.test.ts similarity index 92% rename from packages/cli/src/proxy-env.test.ts rename to packages/cli/test/proxy-env.test.ts index 1da7bc91..38279a04 100644 --- a/packages/cli/src/proxy-env.test.ts +++ b/packages/cli/test/proxy-env.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { sanitizeChildProxyEnv } from './proxy-env.js'; +import { sanitizeChildProxyEnv } from '../src/proxy-env.js'; describe('sanitizeChildProxyEnv', () => { it('drops IPv6 CIDR no-proxy entries and normalizes both env keys', () => { diff --git a/packages/cli/src/public-ingest-copy.test.ts b/packages/cli/test/public-ingest-copy.test.ts similarity index 98% rename from packages/cli/src/public-ingest-copy.test.ts rename to packages/cli/test/public-ingest-copy.test.ts index d13696df..539d76ce 100644 --- a/packages/cli/src/public-ingest-copy.test.ts +++ b/packages/cli/test/public-ingest-copy.test.ts @@ -3,7 +3,7 @@ import { publicDatabaseIngestMessage, publicIngestOutputLine, publicQueryHistoryMessage, -} from './public-ingest-copy.js'; +} from '../src/public-ingest-copy.js'; describe('public ingest copy sanitizers', () => { it('maps database scan progress into schema-context wording', () => { diff --git a/packages/cli/src/public-ingest.test.ts b/packages/cli/test/public-ingest.test.ts similarity index 99% rename from packages/cli/src/public-ingest.test.ts rename to packages/cli/test/public-ingest.test.ts index d6ced94d..b926793c 100644 --- a/packages/cli/src/public-ingest.test.ts +++ b/packages/cli/test/public-ingest.test.ts @@ -1,16 +1,16 @@ import { mkdtemp, rm } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; -import { buildDefaultKtxProjectConfig, type KtxProjectConfig } from './context/project/config.js'; -import { initKtxProject } from './context/project/project.js'; +import { buildDefaultKtxProjectConfig, type KtxProjectConfig } from '../src/context/project/config.js'; +import { initKtxProject } from '../src/context/project/project.js'; import { afterEach, describe, expect, it, vi } from 'vitest'; import { buildPublicIngestPlan, type KtxPublicIngestDeps, type KtxPublicIngestProject, runKtxPublicIngest, -} from './public-ingest.js'; -import type { ManagedPythonCommandRuntime } from './managed-python-command.js'; +} from '../src/public-ingest.js'; +import type { ManagedPythonCommandRuntime } from '../src/managed-python-command.js'; function makeIo(options: { isTTY?: boolean; interactive?: boolean } = {}) { let stdout = ''; diff --git a/packages/cli/src/runtime-requirements.test.ts b/packages/cli/test/runtime-requirements.test.ts similarity index 97% rename from packages/cli/src/runtime-requirements.test.ts rename to packages/cli/test/runtime-requirements.test.ts index 35e94eae..0a951056 100644 --- a/packages/cli/src/runtime-requirements.test.ts +++ b/packages/cli/test/runtime-requirements.test.ts @@ -1,9 +1,9 @@ -import { buildDefaultKtxProjectConfig, type KtxProjectConfig } from './context/project/config.js'; +import { buildDefaultKtxProjectConfig, type KtxProjectConfig } from '../src/context/project/config.js'; import { describe, expect, it } from 'vitest'; import { resolveProjectRuntimeRequirements, resolvePublicIngestRuntimeRequirements, -} from './runtime-requirements.js'; +} from '../src/runtime-requirements.js'; describe('runtime requirement detection', () => { it('does not require runtime for agent/MCP setup alone', () => { diff --git a/packages/cli/src/runtime.test.ts b/packages/cli/test/runtime.test.ts similarity index 99% rename from packages/cli/src/runtime.test.ts rename to packages/cli/test/runtime.test.ts index 266359e4..4525bf83 100644 --- a/packages/cli/src/runtime.test.ts +++ b/packages/cli/test/runtime.test.ts @@ -3,13 +3,13 @@ import type { ManagedPythonDaemonStopAllResult, ManagedPythonDaemonStartResult, ManagedPythonDaemonStopResult, -} from './managed-python-daemon.js'; +} from '../src/managed-python-daemon.js'; import type { ManagedPythonRuntimeDoctorCheck, ManagedPythonRuntimeInstallResult, ManagedPythonRuntimeStatus, -} from './managed-python-runtime.js'; -import { runKtxRuntime, type KtxRuntimeDeps } from './runtime.js'; +} from '../src/managed-python-runtime.js'; +import { runKtxRuntime, type KtxRuntimeDeps } from '../src/runtime.js'; function makeIo() { let stdout = ''; diff --git a/packages/cli/src/scan.test.ts b/packages/cli/test/scan.test.ts similarity index 98% rename from packages/cli/src/scan.test.ts rename to packages/cli/test/scan.test.ts index 6db8243a..837acb10 100644 --- a/packages/cli/src/scan.test.ts +++ b/packages/cli/test/scan.test.ts @@ -1,12 +1,12 @@ import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; -import type { SourceAdapter } from './context/ingest/types.js'; -import { initKtxProject } from './context/project/project.js'; -import type { KtxScanReport } from './context/scan/types.js'; -import type { LocalScanRunResult, RunLocalScanOptions } from './context/scan/local-scan.js'; +import type { SourceAdapter } from '../src/context/ingest/types.js'; +import { initKtxProject } from '../src/context/project/project.js'; +import type { KtxScanReport } from '../src/context/scan/types.js'; +import type { LocalScanRunResult, RunLocalScanOptions } from '../src/context/scan/local-scan.js'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { createCliScanProgress, runKtxScan, type KtxScanDeps } from './scan.js'; +import { createCliScanProgress, runKtxScan, type KtxScanDeps } from '../src/scan.js'; const sqlServerExtractSchema = vi.hoisted(() => vi.fn(async (connectionId: string) => ({ @@ -138,35 +138,35 @@ const KtxPostgresScanConnector = vi.hoisted( }, ); -vi.mock('./connectors/sqlserver/connector.js', () => ({ +vi.mock('../src/connectors/sqlserver/connector.js', () => ({ isKtxSqlServerConnectionConfig, KtxSqlServerScanConnector, })); -vi.mock('./connectors/sqlserver/live-database-introspection.js', () => ({ +vi.mock('../src/connectors/sqlserver/live-database-introspection.js', () => ({ createSqlServerLiveDatabaseIntrospection, })); -vi.mock('./connectors/bigquery/connector.js', () => ({ +vi.mock('../src/connectors/bigquery/connector.js', () => ({ isKtxBigQueryConnectionConfig, KtxBigQueryScanConnector, })); -vi.mock('./connectors/bigquery/live-database-introspection.js', () => ({ +vi.mock('../src/connectors/bigquery/live-database-introspection.js', () => ({ createBigQueryLiveDatabaseIntrospection, })); -vi.mock('./connectors/snowflake/connector.js', () => ({ +vi.mock('../src/connectors/snowflake/connector.js', () => ({ isKtxSnowflakeConnectionConfig, KtxSnowflakeScanConnector, })); -vi.mock('./connectors/snowflake/live-database-introspection.js', () => ({ +vi.mock('../src/connectors/snowflake/live-database-introspection.js', () => ({ createSnowflakeLiveDatabaseIntrospection, })); -vi.mock('./connectors/postgres/connector.js', () => ({ +vi.mock('../src/connectors/postgres/connector.js', () => ({ isKtxPostgresConnectionConfig, KtxPostgresScanConnector, })); -vi.mock('./connectors/postgres/live-database-introspection.js', () => ({ +vi.mock('../src/connectors/postgres/live-database-introspection.js', () => ({ createPostgresLiveDatabaseIntrospection, })); diff --git a/packages/cli/src/setup-agents.test.ts b/packages/cli/test/setup-agents.test.ts similarity index 99% rename from packages/cli/src/setup-agents.test.ts rename to packages/cli/test/setup-agents.test.ts index bd521787..c6c2d7c4 100644 --- a/packages/cli/src/setup-agents.test.ts +++ b/packages/cli/test/setup-agents.test.ts @@ -1,7 +1,7 @@ import { mkdir, mkdtemp, readFile, rm, stat, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; -import { readKtxSetupState } from './context/project/setup-config.js'; +import { readKtxSetupState } from '../src/context/project/setup-config.js'; import { strFromU8, unzipSync } from 'fflate'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { @@ -11,7 +11,7 @@ import { readKtxAgentInstallManifest, removeKtxAgentInstall, runKtxSetupAgentsStep, -} from './setup-agents.js'; +} from '../src/setup-agents.js'; function makeIo() { let stdout = ''; diff --git a/packages/cli/src/setup-context.test.ts b/packages/cli/test/setup-context.test.ts similarity index 99% rename from packages/cli/src/setup-context.test.ts rename to packages/cli/test/setup-context.test.ts index 7bcad93e..9757cc62 100644 --- a/packages/cli/src/setup-context.test.ts +++ b/packages/cli/test/setup-context.test.ts @@ -1,8 +1,8 @@ import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; -import { buildDefaultKtxProjectConfig, parseKtxProjectConfig, serializeKtxProjectConfig, type KtxProjectConfig } from './context/project/config.js'; -import { readKtxSetupState, writeKtxSetupState } from './context/project/setup-config.js'; +import { buildDefaultKtxProjectConfig, parseKtxProjectConfig, serializeKtxProjectConfig, type KtxProjectConfig } from '../src/context/project/config.js'; +import { readKtxSetupState, writeKtxSetupState } from '../src/context/project/setup-config.js'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { @@ -11,7 +11,7 @@ import { runKtxSetupContextStep, type KtxSetupContextDeps, writeKtxSetupContextState, -} from './setup-context.js'; +} from '../src/setup-context.js'; function makeIo() { let stdout = ''; diff --git a/packages/cli/src/setup-databases.test.ts b/packages/cli/test/setup-databases.test.ts similarity index 94% rename from packages/cli/src/setup-databases.test.ts rename to packages/cli/test/setup-databases.test.ts index 57f507d5..15d27e3c 100644 --- a/packages/cli/src/setup-databases.test.ts +++ b/packages/cli/test/setup-databases.test.ts @@ -1,21 +1,22 @@ import { mkdtemp, readFile, rm, stat, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join, resolve } from 'node:path'; -import { initKtxProject, loadKtxProject } from './context/project/project.js'; -import { parseKtxProjectConfig } from './context/project/config.js'; -import { readKtxSetupState, writeKtxSetupState } from './context/project/setup-config.js'; +import { initKtxProject, loadKtxProject } from '../src/context/project/project.js'; +import { parseKtxProjectConfig } from '../src/context/project/config.js'; +import { readKtxSetupState, writeKtxSetupState } from '../src/context/project/setup-config.js'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { type KtxSetupDatabaseDriver, type KtxSetupDatabasesDeps, type KtxSetupDatabasesPromptAdapter, runKtxSetupDatabasesStep, -} from './setup-databases.js'; -import type { KtxCliIo } from './cli-runtime.js'; +} from '../src/setup-databases.js'; +import type { KtxCliIo } from '../src/cli-runtime.js'; import type { DatabaseScopePickResult, PickDatabaseScopeArgs, -} from './database-tree-picker.js'; +} from '../src/database-tree-picker.js'; +import type { KtxSetupPromptOption } from '../src/setup-prompts.js'; function makeIo() { let stdout = ''; @@ -164,13 +165,13 @@ describe('setup databases step', () => { 'Which databases should KTX connect to?\n' + 'Use Up/Down to move, Space to select or unselect, Enter to confirm, Escape to go back, or Ctrl+C to exit.', options: [ - { value: 'sqlite', label: 'SQLite' }, { value: 'postgres', label: 'PostgreSQL' }, + { value: 'bigquery', label: 'BigQuery' }, + { value: 'snowflake', label: 'Snowflake' }, { value: 'mysql', label: 'MySQL' }, { value: 'clickhouse', label: 'ClickHouse' }, { value: 'sqlserver', label: 'SQL Server' }, - { value: 'bigquery', label: 'BigQuery' }, - { value: 'snowflake', label: 'Snowflake' }, + { value: 'sqlite', label: 'SQLite' }, ], required: true, }); @@ -381,12 +382,16 @@ describe('setup databases step', () => { it('emits debug telemetry when setup writes a database connection', async () => { vi.stubEnv('KTX_TELEMETRY_DEBUG', '1'); + vi.stubEnv('KTX_TELEMETRY_DISABLED', ''); + vi.stubEnv('DO_NOT_TRACK', ''); vi.stubEnv('CI', ''); const io = makeIo(); const prompts = makePromptAdapter({ selectValues: ['url'], textValues: ['', 'env:DATABASE_URL'], }); + const listSchemas = vi.fn(async () => []); + const listTables = vi.fn(async () => []); const result = await runKtxSetupDatabasesStep( { @@ -397,7 +402,13 @@ describe('setup databases step', () => { skipDatabases: false, }, io.io, - { prompts, testConnection: vi.fn(async () => 0), scanConnection: vi.fn(async () => 0) }, + { + prompts, + testConnection: vi.fn(async () => 0), + scanConnection: vi.fn(async () => 0), + listSchemas, + listTables, + }, ); expect(result.status).toBe('ready'); @@ -1029,7 +1040,7 @@ describe('setup databases step', () => { const testConnection = vi.fn(async () => 0); const scanConnection = vi.fn(async () => 0); const listSchemas = vi.fn(async () => ['analytics', 'public']); - const listTables = vi.fn(async () => [{ schema: 'analytics', name: 'customers', kind: 'table' as const }]); + const listTables = vi.fn(async () => [{ catalog: null, schema: 'analytics', name: 'customers', kind: 'table' as const }]); const pickers = makePickerStubs({ scopes: [{ schemas: ['analytics'], tables: ['analytics.customers'] }], }); @@ -1100,9 +1111,9 @@ describe('setup databases step', () => { }); const listSchemas = vi.fn(async () => ['orbit_analytics', 'orbit_raw', 'public']); const listTables = vi.fn(async () => [ - { schema: 'public', name: 'customers', kind: 'table' as const }, - { schema: 'public', name: 'orders', kind: 'table' as const }, - { schema: 'public', name: 'products', kind: 'table' as const }, + { catalog: null, schema: 'public', name: 'customers', kind: 'table' as const }, + { catalog: null, schema: 'public', name: 'orders', kind: 'table' as const }, + { catalog: null, schema: 'public', name: 'products', kind: 'table' as const }, ]); const pickers = makePickerStubs({ scopes: [{ schemas: ['public'], tables: ['public.customers', 'public.orders'] }], @@ -1174,8 +1185,8 @@ describe('setup databases step', () => { const scanConnection = vi.fn(async () => 0); const listSchemas = vi.fn(async () => ['analytics', 'public']); const listTables = vi.fn(async () => [ - { schema: 'analytics', name: 'customers', kind: 'table' as const }, - { schema: 'public', name: 'orders', kind: 'table' as const }, + { catalog: null, schema: 'analytics', name: 'customers', kind: 'table' as const }, + { catalog: null, schema: 'public', name: 'orders', kind: 'table' as const }, ]); const pickers = makePickerStubs({ scopes: ['back'] }); @@ -1240,8 +1251,8 @@ describe('setup databases step', () => { const scanConnection = vi.fn(async () => 0); const listSchemas = vi.fn(async () => ['public']); const listTables = vi.fn(async () => [ - { schema: 'public', name: 'customers', kind: 'table' as const }, - { schema: 'public', name: 'orders', kind: 'table' as const }, + { catalog: null, schema: 'public', name: 'customers', kind: 'table' as const }, + { catalog: null, schema: 'public', name: 'orders', kind: 'table' as const }, ]); const pickers = makePickerStubs({ scopes: [{ schemas: ['public'], tables: 'back' }] }); @@ -1301,8 +1312,8 @@ describe('setup databases step', () => { return 'back'; }); const listTables = vi.fn(async () => [ - { schema: 'public', name: 'customers', kind: 'table' as const }, - { schema: 'public', name: 'orders', kind: 'table' as const }, + { catalog: null, schema: 'public', name: 'customers', kind: 'table' as const }, + { catalog: null, schema: 'public', name: 'orders', kind: 'table' as const }, ]); const pickers = makePickerStubs({ scopes: ['enable-all'] }); @@ -1600,7 +1611,7 @@ describe('setup databases step', () => { }); const listSchemas = vi.fn(async () => ['analytics', 'mart']); const listTables = vi.fn(async (_projectDir: string, _connectionId: string, schemas?: string[]) => - (schemas ?? []).map((schema) => ({ schema, name: 'orders', kind: 'table' as const })), + (schemas ?? []).map((schema) => ({ catalog: null, schema, name: 'orders', kind: 'table' as const })), ); const pickDatabaseScope = vi.fn(async (args: PickDatabaseScopeArgs) => { const scopedArgs = args as PickDatabaseScopeArgs & { @@ -1657,7 +1668,7 @@ describe('setup databases step', () => { textValues: ['bigquery-warehouse', '/tmp/service-account.json', 'US'], }); const listSchemas = vi.fn(async () => ['analytics']); - const listTables = vi.fn(async () => [{ schema: 'analytics', name: 'orders', kind: 'table' as const }]); + const listTables = vi.fn(async () => [{ catalog: 'project-1', schema: 'analytics', name: 'orders', kind: 'table' as const }]); const pickDatabaseScope = vi.fn(async () => ({ kind: 'selected' as const, activeSchemas: ['analytics'], @@ -1690,9 +1701,9 @@ describe('setup databases step', () => { }); const listSchemas = vi.fn(async () => ['orbit_analytics', 'orbit_raw', 'public']); const listTables = vi.fn(async () => [ - { schema: 'orbit_analytics', name: 'events', kind: 'table' as const }, - { schema: 'orbit_raw', name: 'inputs', kind: 'table' as const }, - { schema: 'public', name: 'misc', kind: 'table' as const }, + { catalog: null, schema: 'orbit_analytics', name: 'events', kind: 'table' as const }, + { catalog: null, schema: 'orbit_raw', name: 'inputs', kind: 'table' as const }, + { catalog: null, schema: 'public', name: 'misc', kind: 'table' as const }, ]); const pickers = makePickerStubs({ scopes: [ @@ -1751,7 +1762,7 @@ describe('setup databases step', () => { throw new Error('permission denied to list schemas'); }); const listTables = vi.fn(async (_projectDir: string, _connectionId: string, schemas?: string[]) => - (schemas ?? []).map((schema) => ({ schema, name: 'events', kind: 'table' as const })), + (schemas ?? []).map((schema) => ({ catalog: null, schema, name: 'events', kind: 'table' as const })), ); const pickers = makePickerStubs({ scopes: [ @@ -1798,18 +1809,18 @@ describe('setup databases step', () => { it('passes schemas and a lazy table callback to the scope picker instead of eager table discovery', async () => { const listSchemas = vi.fn(async () => ['analytics', 'raw']); const listTables = vi.fn(async (_projectDir: string, _connectionId: string, schemas?: string[]) => - (schemas ?? []).map((schema) => ({ schema, name: 'orders', kind: 'table' as const })), + (schemas ?? []).map((schema) => ({ catalog: null, schema, name: 'orders', kind: 'table' as const })), ); const pickDatabaseScope = vi.fn(async (args: PickDatabaseScopeArgs) => { const lazyArgs = args as PickDatabaseScopeArgs & { schemas: string[]; - listTablesForSchemas: (schemas: string[]) => Promise>; + listTablesForSchemas: (schemas: string[]) => Promise>; }; expect(lazyArgs.schemas).toEqual(['analytics', 'raw']); expect(args).not.toHaveProperty('discovered'); expect(listTables).not.toHaveBeenCalled(); const tables = await lazyArgs.listTablesForSchemas(['analytics']); - expect(tables).toEqual([{ schema: 'analytics', name: 'orders', kind: 'table' }]); + expect(tables).toEqual([{ catalog: null, schema: 'analytics', name: 'orders', kind: 'table' }]); return { kind: 'selected' as const, activeSchemas: ['analytics'], enabledTables: ['analytics.orders'] }; }); @@ -2547,6 +2558,81 @@ describe('setup databases step', () => { expect(io.stdout()).toContain('Setup written; query history will be skipped until fixed.'); }); + it('lets interactive BigQuery setup disable unavailable query history and retry after scan failure', async () => { + const io = makeIo(); + const failurePromptOptions: KtxSetupPromptOption[][] = []; + let failurePromptCount = 0; + const prompts = makePromptAdapter({ + textValues: ['/tmp/service-account.json', 'US'], + }); + vi.mocked(prompts.select).mockImplementation(async ({ message, options }) => { + if (message.startsWith('Enable query-history ingest')) return 'yes'; + if (message.includes('How much database context should KTX build?')) return 'fast'; + if (message.startsWith('Database setup failed for analytics')) { + failurePromptCount += 1; + failurePromptOptions.push(options); + if (failurePromptCount === 1) return 'disable-query-history'; + throw new Error('setup did not disable query history before retrying'); + } + throw new Error(`unexpected select prompt: ${message}`); + }); + const runner = { + ...fakeHistoricSqlRunner('bigquery', 'INFORMATION_SCHEMA.JOBS_BY_PROJECT'), + fixAdvice: () => ({ + failHeadline: 'BigQuery principal cannot read INFORMATION_SCHEMA.JOBS_BY_PROJECT', + remediation: + 'Grant roles/bigquery.resourceViewer on the BigQuery project, or grant a custom role containing bigquery.jobs.listAll.', + }), + }; + const historicSqlReadinessProbe = vi.fn(async () => ({ + ok: false as const, + dialect: 'bigquery' as const, + runner, + error: new Error('access denied'), + })); + let scanAttempts = 0; + const scanConnection = vi.fn(async () => { + scanAttempts += 1; + return scanAttempts === 1 ? 1 : 0; + }); + + const result = await runKtxSetupDatabasesStep( + { + projectDir: tempDir, + inputMode: 'auto', + databaseDrivers: ['bigquery'], + databaseConnectionId: 'analytics', + databaseSchemas: [], + skipDatabases: false, + }, + io.io, + { + prompts, + testConnection: vi.fn(async () => 0), + scanConnection, + historicSqlReadinessProbe, + listSchemas: vi.fn(async () => ['analytics']), + listTables: vi.fn(async () => [{ catalog: null, schema: 'analytics', name: 'orders', kind: 'table' as const }]), + }, + ); + + expect(result.status).toBe('ready'); + expect(scanConnection).toHaveBeenCalledTimes(2); + expect(historicSqlReadinessProbe).toHaveBeenCalledTimes(1); + expect(failurePromptOptions[0]).toContainEqual({ + value: 'disable-query-history', + label: 'Disable query history and retry', + }); + const config = parseKtxProjectConfig(await readFile(join(tempDir, 'ktx.yaml'), 'utf-8')); + expect(config.connections.analytics).toMatchObject({ + context: { + queryHistory: { + enabled: false, + }, + }, + }); + }); + it('enables query history on an existing Postgres connection', async () => { await writeFile( join(tempDir, 'ktx.yaml'), diff --git a/packages/cli/src/setup-demo-tour.test.ts b/packages/cli/test/setup-demo-tour.test.ts similarity index 98% rename from packages/cli/src/setup-demo-tour.test.ts rename to packages/cli/test/setup-demo-tour.test.ts index 1d57b010..3916076c 100644 --- a/packages/cli/src/setup-demo-tour.test.ts +++ b/packages/cli/test/setup-demo-tour.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it, vi } from 'vitest'; -import type { KtxSetupAgentsResult } from './setup-agents.js'; +import type { KtxSetupAgentsResult } from '../src/setup-agents.js'; import { buildDemoReplayTimeline, DEMO_REPLAY_TARGETS, @@ -8,7 +8,7 @@ import { renderDemoCardContent, renderDemoCompletionSummary, runDemoTour, -} from './setup-demo-tour.js'; +} from '../src/setup-demo-tour.js'; /** Strip ANSI escape sequences for plain-text assertions. */ function stripAnsi(text: string): string { diff --git a/packages/cli/src/setup-embeddings.test.ts b/packages/cli/test/setup-embeddings.test.ts similarity index 92% rename from packages/cli/src/setup-embeddings.test.ts rename to packages/cli/test/setup-embeddings.test.ts index bf9e2b2d..9af9f913 100644 --- a/packages/cli/src/setup-embeddings.test.ts +++ b/packages/cli/test/setup-embeddings.test.ts @@ -1,11 +1,12 @@ import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; -import { initKtxProject } from './context/project/project.js'; -import { parseKtxProjectConfig } from './context/project/config.js'; -import { readKtxSetupState, writeKtxSetupState } from './context/project/setup-config.js'; +import { initKtxProject } from '../src/context/project/project.js'; +import { parseKtxProjectConfig } from '../src/context/project/config.js'; +import { readKtxSetupState, writeKtxSetupState } from '../src/context/project/setup-config.js'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { type KtxSetupEmbeddingsPromptAdapter, runKtxSetupEmbeddingsStep } from './setup-embeddings.js'; +import { ManagedPythonDaemonStartError } from '../src/managed-python-daemon.js'; +import { type KtxSetupEmbeddingsPromptAdapter, runKtxSetupEmbeddingsStep } from '../src/setup-embeddings.js'; const EMBEDDING_OPTION_PROMPT_MESSAGE = [ 'Which embedding option should KTX use?', @@ -366,6 +367,40 @@ describe('setup embeddings step', () => { expect(io.stderr()).not.toContain('daemon traceback line 5'); }); + it('prints the daemon stderr tail when the daemon fails to start', async () => { + const io = makeIo(); + const stderrLog = join(tempDir, '.ktx', 'runtime', 'daemon.stderr.log'); + await mkdir(join(tempDir, '.ktx', 'runtime'), { recursive: true }); + await writeFile( + stderrLog, + Array.from({ length: 45 }, (_value, index) => `daemon startup traceback ${index + 1}`).join('\n'), + ); + + const result = await runKtxSetupEmbeddingsStep( + { + projectDir: tempDir, + inputMode: 'disabled', + cliVersion: '0.2.0', + runtimeInstallPolicy: 'auto', + skipEmbeddings: false, + }, + io.io, + { + env: {}, + ensureLocalEmbeddings: vi.fn(async () => { + throw new ManagedPythonDaemonStartError('fetch failed: connect ECONNREFUSED 127.0.0.1:61234', stderrLog); + }), + }, + ); + + expect(result.status).toBe('failed'); + expect(io.stderr()).toContain('Local embedding health check failed: fetch failed: connect ECONNREFUSED'); + expect(io.stderr()).toContain('Recent KTX daemon stderr:'); + expect(io.stderr()).toContain('daemon startup traceback 6'); + expect(io.stderr()).toContain('daemon startup traceback 45'); + expect(io.stderr()).not.toContain('daemon startup traceback 5'); + }); + it('does not print daemon stderr diagnostics when the log is unavailable or empty', async () => { const io = makeIo(); diff --git a/packages/cli/src/setup-interrupt.test.ts b/packages/cli/test/setup-interrupt.test.ts similarity index 99% rename from packages/cli/src/setup-interrupt.test.ts rename to packages/cli/test/setup-interrupt.test.ts index 62917db6..a1ff6b10 100644 --- a/packages/cli/src/setup-interrupt.test.ts +++ b/packages/cli/test/setup-interrupt.test.ts @@ -4,7 +4,7 @@ import { KtxSetupExitError, withSetupInterruptConfirmation, type SetupInterruptTracker, -} from './setup-interrupt.js'; +} from '../src/setup-interrupt.js'; const CANCEL = Symbol('cancel'); diff --git a/packages/cli/src/setup-models.test.ts b/packages/cli/test/setup-models.test.ts similarity index 99% rename from packages/cli/src/setup-models.test.ts rename to packages/cli/test/setup-models.test.ts index 444c3b4d..f054beff 100644 --- a/packages/cli/src/setup-models.test.ts +++ b/packages/cli/test/setup-models.test.ts @@ -1,16 +1,16 @@ import { mkdir, mkdtemp, readFile, rm, stat, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; -import { initKtxProject } from './context/project/project.js'; -import { parseKtxProjectConfig } from './context/project/config.js'; -import { readKtxSetupState, writeKtxSetupState } from './context/project/setup-config.js'; +import { initKtxProject } from '../src/context/project/project.js'; +import { parseKtxProjectConfig } from '../src/context/project/config.js'; +import { readKtxSetupState, writeKtxSetupState } from '../src/context/project/setup-config.js'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { BUNDLED_ANTHROPIC_MODELS, fetchAnthropicModels, type KtxSetupModelPromptAdapter, runKtxSetupAnthropicModelStep, -} from './setup-models.js'; +} from '../src/setup-models.js'; function makeIo() { let stdout = ''; diff --git a/packages/cli/src/setup-project.test.ts b/packages/cli/test/setup-project.test.ts similarity index 98% rename from packages/cli/src/setup-project.test.ts rename to packages/cli/test/setup-project.test.ts index 89663bbf..c77a2080 100644 --- a/packages/cli/src/setup-project.test.ts +++ b/packages/cli/test/setup-project.test.ts @@ -1,10 +1,10 @@ import { mkdir, mkdtemp, readFile, rm, stat, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; -import { readKtxSetupState } from './context/project/setup-config.js'; +import { readKtxSetupState } from '../src/context/project/setup-config.js'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { gray } from './io/symbols.js'; -import { type KtxSetupProjectPromptAdapter, runKtxSetupProjectStep } from './setup-project.js'; +import { gray } from '../src/io/symbols.js'; +import { type KtxSetupProjectPromptAdapter, runKtxSetupProjectStep } from '../src/setup-project.js'; function makeIo(options: { stdoutIsTty?: boolean } = {}) { let stdout = ''; diff --git a/packages/cli/src/setup-prompts.test.ts b/packages/cli/test/setup-prompts.test.ts similarity index 97% rename from packages/cli/src/setup-prompts.test.ts rename to packages/cli/test/setup-prompts.test.ts index 95f4b68b..46628b1c 100644 --- a/packages/cli/src/setup-prompts.test.ts +++ b/packages/cli/test/setup-prompts.test.ts @@ -2,7 +2,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { createKtxSetupPromptAdapter, type KtxSetupPromptOption, -} from './setup-prompts.js'; +} from '../src/setup-prompts.js'; const mocks = vi.hoisted(() => { const cancelSymbol = Symbol('cancel'); @@ -39,7 +39,7 @@ vi.mock('@clack/prompts', () => ({ text: mocks.text, })); -vi.mock('./setup-interrupt.js', () => ({ +vi.mock('../src/setup-interrupt.js', () => ({ withSetupInterruptConfirmation: mocks.withSetupInterruptConfirmation, })); @@ -213,7 +213,7 @@ describe('setup prompt adapter', () => { }); it('keeps setup intro and note plain for non-stream output', async () => { - const { createKtxSetupUiAdapter } = await import('./setup-prompts.js'); + const { createKtxSetupUiAdapter } = await import('../src/setup-prompts.js'); const chunks: string[] = []; const io = { stdout: { @@ -235,7 +235,7 @@ describe('setup prompt adapter', () => { }); it('uses Clack intro and note for writable TTY output', async () => { - const { createKtxSetupUiAdapter } = await import('./setup-prompts.js'); + const { createKtxSetupUiAdapter } = await import('../src/setup-prompts.js'); const output = { columns: 80, isTTY: true, diff --git a/packages/cli/src/setup-ready-menu.test.ts b/packages/cli/test/setup-ready-menu.test.ts similarity index 96% rename from packages/cli/src/setup-ready-menu.test.ts rename to packages/cli/test/setup-ready-menu.test.ts index 028b94ee..82c92a1c 100644 --- a/packages/cli/src/setup-ready-menu.test.ts +++ b/packages/cli/test/setup-ready-menu.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it, vi } from 'vitest'; -import { isKtxPreAgentSetupReady, isKtxSetupReady, runKtxSetupReadyChangeMenu } from './setup-ready-menu.js'; -import type { KtxSetupStatus } from './setup.js'; +import { isKtxPreAgentSetupReady, isKtxSetupReady, runKtxSetupReadyChangeMenu } from '../src/setup-ready-menu.js'; +import type { KtxSetupStatus } from '../src/setup.js'; const readyStatus: KtxSetupStatus = { project: { path: '/tmp/revenue', ready: true }, diff --git a/packages/cli/src/setup-runtime.test.ts b/packages/cli/test/setup-runtime.test.ts similarity index 95% rename from packages/cli/src/setup-runtime.test.ts rename to packages/cli/test/setup-runtime.test.ts index 2fb1f1f2..ab5777e4 100644 --- a/packages/cli/src/setup-runtime.test.ts +++ b/packages/cli/test/setup-runtime.test.ts @@ -1,10 +1,10 @@ import { mkdtemp, rm } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; -import { buildDefaultKtxProjectConfig, type KtxProjectConfig } from './context/project/config.js'; -import { readKtxSetupState } from './context/project/setup-config.js'; +import { buildDefaultKtxProjectConfig, type KtxProjectConfig } from '../src/context/project/config.js'; +import { readKtxSetupState } from '../src/context/project/setup-config.js'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { runKtxSetupRuntimeStep } from './setup-runtime.js'; +import { runKtxSetupRuntimeStep } from '../src/setup-runtime.js'; function makeIo() { let stdout = ''; diff --git a/packages/cli/src/setup-secrets.test.ts b/packages/cli/test/setup-secrets.test.ts similarity index 97% rename from packages/cli/src/setup-secrets.test.ts rename to packages/cli/test/setup-secrets.test.ts index 16589db0..67a288f3 100644 --- a/packages/cli/src/setup-secrets.test.ts +++ b/packages/cli/test/setup-secrets.test.ts @@ -2,7 +2,7 @@ import { mkdtemp, readFile, rm, stat } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join, resolve } from 'node:path'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; -import { envCredentialReference, writeProjectLocalSecretReference } from './setup-secrets.js'; +import { envCredentialReference, writeProjectLocalSecretReference } from '../src/setup-secrets.js'; describe('setup secrets', () => { let tempDir: string; diff --git a/packages/cli/src/setup-sources-notion.test.ts b/packages/cli/test/setup-sources-notion.test.ts similarity index 90% rename from packages/cli/src/setup-sources-notion.test.ts rename to packages/cli/test/setup-sources-notion.test.ts index 1306b07b..ce9210c1 100644 --- a/packages/cli/src/setup-sources-notion.test.ts +++ b/packages/cli/test/setup-sources-notion.test.ts @@ -1,13 +1,13 @@ import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; -import { initKtxProject } from './context/project/project.js'; -import { type KtxProjectConnectionConfig, parseKtxProjectConfig, serializeKtxProjectConfig } from './context/project/config.js'; +import { initKtxProject } from '../src/context/project/project.js'; +import { type KtxProjectConnectionConfig, parseKtxProjectConfig, serializeKtxProjectConfig } from '../src/context/project/config.js'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { runKtxSetupSourcesStep, type KtxSetupSourcesPromptAdapter, -} from './setup-sources.js'; +} from '../src/setup-sources.js'; const notionMocks = vi.hoisted(() => ({ tokens: [] as string[], @@ -15,8 +15,8 @@ const notionMocks = vi.hoisted(() => ({ retrievePage: vi.fn(async () => ({ id: 'page-1' })), })); -vi.mock('./context/ingest/adapters/notion/notion-client.js', async (importOriginal) => { - const actual = await importOriginal(); +vi.mock('../src/context/ingest/adapters/notion/notion-client.js', async (importOriginal) => { + const actual = await importOriginal(); return { ...actual, NotionClient: vi.fn().mockImplementation(function NotionClient(token: string) { diff --git a/packages/cli/src/setup-sources.test.ts b/packages/cli/test/setup-sources.test.ts similarity index 99% rename from packages/cli/src/setup-sources.test.ts rename to packages/cli/test/setup-sources.test.ts index c0b2c781..b426ad10 100644 --- a/packages/cli/src/setup-sources.test.ts +++ b/packages/cli/test/setup-sources.test.ts @@ -1,17 +1,17 @@ import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; -import { initKtxProject } from './context/project/project.js'; -import { type KtxProjectConnectionConfig, parseKtxProjectConfig, serializeKtxProjectConfig } from './context/project/config.js'; -import { readKtxSetupState } from './context/project/setup-config.js'; +import { initKtxProject } from '../src/context/project/project.js'; +import { type KtxProjectConnectionConfig, parseKtxProjectConfig, serializeKtxProjectConfig } from '../src/context/project/config.js'; +import { readKtxSetupState } from '../src/context/project/setup-config.js'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import type { KtxCliIo } from './cli-runtime.js'; +import type { KtxCliIo } from '../src/cli-runtime.js'; import { runKtxSetupSourcesStep, type KtxSetupSourcesDeps, type KtxSetupSourcesPromptAdapter, type KtxSetupSourceType, -} from './setup-sources.js'; +} from '../src/setup-sources.js'; function makeIo() { let stdout = ''; diff --git a/packages/cli/src/setup.test.ts b/packages/cli/test/setup.test.ts similarity index 99% rename from packages/cli/src/setup.test.ts rename to packages/cli/test/setup.test.ts index 26dc0324..6c928033 100644 --- a/packages/cli/src/setup.test.ts +++ b/packages/cli/test/setup.test.ts @@ -3,15 +3,15 @@ import { mkdir, mkdtemp, readFile, readdir, rm, stat, writeFile } from 'node:fs/ import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { promisify } from 'node:util'; -import { writeKtxSetupState } from './context/project/setup-config.js'; +import { writeKtxSetupState } from '../src/context/project/setup-config.js'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { localFakeBundleReport, persistLocalBundleReport } from './ingest.test-utils.js'; -import { contextBuildCommands, writeKtxSetupContextState } from './setup-context.js'; -import { runDemoTour } from './setup-demo-tour.js'; -import { formatKtxSetupCompletionSummary, formatKtxSetupStatus, readKtxSetupStatus, runKtxSetup } from './setup.js'; +import { contextBuildCommands, writeKtxSetupContextState } from '../src/setup-context.js'; +import { runDemoTour } from '../src/setup-demo-tour.js'; +import { formatKtxSetupCompletionSummary, formatKtxSetupStatus, readKtxSetupStatus, runKtxSetup } from '../src/setup.js'; -vi.mock('./setup-demo-tour.js', () => ({ +vi.mock('../src/setup-demo-tour.js', () => ({ runDemoTour: vi.fn(async () => 0), })); diff --git a/packages/cli/src/sl.test.ts b/packages/cli/test/sl.test.ts similarity index 99% rename from packages/cli/src/sl.test.ts rename to packages/cli/test/sl.test.ts index 7fa855d0..7b4b7795 100644 --- a/packages/cli/src/sl.test.ts +++ b/packages/cli/test/sl.test.ts @@ -3,9 +3,9 @@ import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { stripVTControlCharacters } from 'node:util'; import Database from 'better-sqlite3'; -import { initKtxProject } from './context/project/project.js'; +import { initKtxProject } from '../src/context/project/project.js'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { runKtxSl } from './sl.js'; +import { runKtxSl } from '../src/sl.js'; const ORDERS_YAML = [ 'name: orders', diff --git a/packages/cli/src/source-mapping.test.ts b/packages/cli/test/source-mapping.test.ts similarity index 94% rename from packages/cli/src/source-mapping.test.ts rename to packages/cli/test/source-mapping.test.ts index 83f9496b..5099d4c0 100644 --- a/packages/cli/src/source-mapping.test.ts +++ b/packages/cli/test/source-mapping.test.ts @@ -2,8 +2,8 @@ import { mkdtemp, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; -import type { KtxCliIo } from './cli-runtime.js'; -import { runKtxSourceMapping } from './source-mapping.js'; +import type { KtxCliIo } from '../src/cli-runtime.js'; +import { runKtxSourceMapping } from '../src/source-mapping.js'; function makeIo() { let stdout = ''; diff --git a/packages/cli/src/sql.test.ts b/packages/cli/test/sql.test.ts similarity index 95% rename from packages/cli/src/sql.test.ts rename to packages/cli/test/sql.test.ts index 51cfe920..b48ebe5b 100644 --- a/packages/cli/src/sql.test.ts +++ b/packages/cli/test/sql.test.ts @@ -1,12 +1,12 @@ import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; -import { initKtxProject } from './context/project/project.js'; -import { parseKtxProjectConfig, serializeKtxProjectConfig } from './context/project/config.js'; -import type { KtxScanConnector } from './context/scan/types.js'; -import type { SqlAnalysisPort } from './context/sql-analysis/ports.js'; +import { initKtxProject } from '../src/context/project/project.js'; +import { parseKtxProjectConfig, serializeKtxProjectConfig } from '../src/context/project/config.js'; +import type { KtxScanConnector } from '../src/context/scan/types.js'; +import type { SqlAnalysisPort } from '../src/context/sql-analysis/ports.js'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { runKtxSql } from './sql.js'; +import { runKtxSql } from '../src/sql.js'; function makeIo(options: { isTTY?: boolean } = {}) { let stdout = ''; @@ -66,6 +66,8 @@ function makeConnector(overrides: Partial = {}): KtxScanConnec })), cleanup: vi.fn(async () => undefined), ...overrides, + listSchemas: overrides.listSchemas ?? vi.fn(async () => []), + listTables: overrides.listTables ?? vi.fn(async () => []), }; } diff --git a/packages/cli/src/standalone-smoke.test.ts b/packages/cli/test/standalone-smoke.test.ts similarity index 99% rename from packages/cli/src/standalone-smoke.test.ts rename to packages/cli/test/standalone-smoke.test.ts index 7e6ed56e..4007afcb 100644 --- a/packages/cli/src/standalone-smoke.test.ts +++ b/packages/cli/test/standalone-smoke.test.ts @@ -3,7 +3,7 @@ import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join, resolve } from 'node:path'; import { promisify } from 'node:util'; -import { parseKtxProjectConfig } from './context/project/config.js'; +import { parseKtxProjectConfig } from '../src/context/project/config.js'; import Database from 'better-sqlite3'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; diff --git a/packages/cli/src/status-project.test.ts b/packages/cli/test/status-project.test.ts similarity index 99% rename from packages/cli/src/status-project.test.ts rename to packages/cli/test/status-project.test.ts index 8f35cfe8..38d5aa6f 100644 --- a/packages/cli/src/status-project.test.ts +++ b/packages/cli/test/status-project.test.ts @@ -3,13 +3,13 @@ import { tmpdir } from 'node:os'; import { join } from 'node:path'; import Database from 'better-sqlite3'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; -import { buildDefaultKtxProjectConfig, type KtxProjectConfig } from './context/project/config.js'; -import type { KtxLocalProject } from './context/project/project.js'; +import { buildDefaultKtxProjectConfig, type KtxProjectConfig } from '../src/context/project/config.js'; +import type { KtxLocalProject } from '../src/context/project/project.js'; import { buildLocalStatsStatus, buildProjectStatus, renderProjectStatus, -} from './status-project.js'; +} from '../src/status-project.js'; function projectWithConfig(config: KtxProjectConfig): KtxLocalProject { return { diff --git a/packages/cli/src/telemetry/command-hook.test.ts b/packages/cli/test/telemetry/command-hook.test.ts similarity index 95% rename from packages/cli/src/telemetry/command-hook.test.ts rename to packages/cli/test/telemetry/command-hook.test.ts index ffd0485b..92105151 100644 --- a/packages/cli/src/telemetry/command-hook.test.ts +++ b/packages/cli/test/telemetry/command-hook.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; -import { beginCommandSpan, completeCommandSpan, resetCommandSpan } from './command-hook.js'; +import { beginCommandSpan, completeCommandSpan, resetCommandSpan } from '../../src/telemetry/command-hook.js'; describe('telemetry command hook', () => { it('builds a completed command event from a span', () => { diff --git a/packages/cli/src/telemetry/demo-detect.test.ts b/packages/cli/test/telemetry/demo-detect.test.ts similarity index 91% rename from packages/cli/src/telemetry/demo-detect.test.ts rename to packages/cli/test/telemetry/demo-detect.test.ts index b371694e..4640766f 100644 --- a/packages/cli/src/telemetry/demo-detect.test.ts +++ b/packages/cli/test/telemetry/demo-detect.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; -import { isDemoConnection } from './demo-detect.js'; +import { isDemoConnection } from '../../src/telemetry/demo-detect.js'; describe('isDemoConnection', () => { it('detects only the packaged Orbit SQLite demo recipe', () => { diff --git a/packages/cli/src/telemetry/emitter.test.ts b/packages/cli/test/telemetry/emitter.test.ts similarity index 96% rename from packages/cli/src/telemetry/emitter.test.ts rename to packages/cli/test/telemetry/emitter.test.ts index 9c732997..98400860 100644 --- a/packages/cli/src/telemetry/emitter.test.ts +++ b/packages/cli/test/telemetry/emitter.test.ts @@ -4,8 +4,8 @@ import { __resetTelemetryEmitterForTests, shutdownTelemetryEmitter, trackTelemetryEvent, -} from './emitter.js'; -import type { BuiltTelemetryEvent } from './events.js'; +} from '../../src/telemetry/emitter.js'; +import type { BuiltTelemetryEvent } from '../../src/telemetry/events.js'; const captures: unknown[] = []; const shutdown = vi.fn(async () => {}); diff --git a/packages/cli/src/telemetry/events.snapshot.test.ts b/packages/cli/test/telemetry/events.snapshot.test.ts similarity index 99% rename from packages/cli/src/telemetry/events.snapshot.test.ts rename to packages/cli/test/telemetry/events.snapshot.test.ts index 1df95aa0..7a1b76f7 100644 --- a/packages/cli/src/telemetry/events.snapshot.test.ts +++ b/packages/cli/test/telemetry/events.snapshot.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; -import { buildTelemetryEvent, type TelemetryCommonEnvelope } from './events.js'; +import { buildTelemetryEvent, type TelemetryCommonEnvelope } from '../../src/telemetry/events.js'; const BLACKLIST = [ '/Users/', diff --git a/packages/cli/src/telemetry/events.test.ts b/packages/cli/test/telemetry/events.test.ts similarity index 99% rename from packages/cli/src/telemetry/events.test.ts rename to packages/cli/test/telemetry/events.test.ts index 3726ddde..29108600 100644 --- a/packages/cli/src/telemetry/events.test.ts +++ b/packages/cli/test/telemetry/events.test.ts @@ -5,7 +5,7 @@ import { telemetryEventCatalog, telemetryEventSchemas, type TelemetryCommonEnvelope, -} from './events.js'; +} from '../../src/telemetry/events.js'; const envelope: TelemetryCommonEnvelope = { cliVersion: '0.4.1', diff --git a/packages/cli/src/telemetry/identity.test.ts b/packages/cli/test/telemetry/identity.test.ts similarity index 99% rename from packages/cli/src/telemetry/identity.test.ts rename to packages/cli/test/telemetry/identity.test.ts index 06d76043..31c3bfb5 100644 --- a/packages/cli/src/telemetry/identity.test.ts +++ b/packages/cli/test/telemetry/identity.test.ts @@ -9,7 +9,7 @@ import { readExistingTelemetryProjectId, TELEMETRY_NOTICE, type TelemetryIdentityEnv, -} from './identity.js'; +} from '../../src/telemetry/identity.js'; function makeIo(stdoutIsTTY = true) { let stderr = ''; diff --git a/packages/cli/test/telemetry/index.test.ts b/packages/cli/test/telemetry/index.test.ts new file mode 100644 index 00000000..8d8f932b --- /dev/null +++ b/packages/cli/test/telemetry/index.test.ts @@ -0,0 +1,63 @@ +import { mkdtemp, readFile, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import type { KtxCliIo } from '../../src/cli-runtime.js'; +import { emitTelemetryEvent } from '../../src/telemetry/index.js'; + +function makeIo(): { io: KtxCliIo; stderr: () => string } { + let stderr = ''; + return { + io: { + stdout: { + isTTY: true, + write: () => {}, + }, + stderr: { + write: (chunk) => { + stderr += chunk; + }, + }, + }, + stderr: () => stderr, + }; +} + +describe('emitTelemetryEvent', () => { + let homeDir: string; + + beforeEach(async () => { + homeDir = await mkdtemp(join(tmpdir(), 'ktx-telemetry-index-')); + vi.stubEnv('HOME', homeDir); + }); + + afterEach(async () => { + vi.unstubAllEnvs(); + await rm(homeDir, { recursive: true, force: true }); + }); + + it('prints debug telemetry when live telemetry is disabled without creating an identity file', async () => { + vi.stubEnv('KTX_TELEMETRY_DEBUG', '1'); + vi.stubEnv('KTX_TELEMETRY_DISABLED', '1'); + vi.stubEnv('DO_NOT_TRACK', '1'); + const testIo = makeIo(); + const projectDir = join(homeDir, 'private-project'); + + await emitTelemetryEvent({ + name: 'connection_added', + projectDir, + io: testIo.io, + packageInfo: { name: '@kaelio/ktx', version: '0.0.0-test' }, + fields: { + driver: 'sqlite', + isDemoConnection: false, + }, + }); + + expect(testIo.stderr()).toContain('[telemetry]'); + expect(testIo.stderr()).toContain('"event":"connection_added"'); + expect(testIo.stderr()).not.toContain(projectDir); + await expect(readFile(join(homeDir, '.ktx', 'telemetry.json'), 'utf-8')).rejects.toThrow(); + }); +}); diff --git a/packages/cli/src/telemetry/project-snapshot.test.ts b/packages/cli/test/telemetry/project-snapshot.test.ts similarity index 96% rename from packages/cli/src/telemetry/project-snapshot.test.ts rename to packages/cli/test/telemetry/project-snapshot.test.ts index a1c06472..4a868426 100644 --- a/packages/cli/src/telemetry/project-snapshot.test.ts +++ b/packages/cli/test/telemetry/project-snapshot.test.ts @@ -3,7 +3,7 @@ import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; -import { buildProjectStackSnapshotFields } from './project-snapshot.js'; +import { buildProjectStackSnapshotFields } from '../../src/telemetry/project-snapshot.js'; describe('buildProjectStackSnapshotFields', () => { let projectDir: string; diff --git a/packages/cli/src/telemetry/schema-writer.test.ts b/packages/cli/test/telemetry/schema-writer.test.ts similarity index 90% rename from packages/cli/src/telemetry/schema-writer.test.ts rename to packages/cli/test/telemetry/schema-writer.test.ts index a6539421..498869f9 100644 --- a/packages/cli/src/telemetry/schema-writer.test.ts +++ b/packages/cli/test/telemetry/schema-writer.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; -import { buildTelemetrySchemaArtifact } from './schema-writer.js'; +import { buildTelemetrySchemaArtifact } from '../../src/telemetry/schema-writer.js'; describe('telemetry schema writer', () => { it('exports a schema artifact with the full catalog and strict metadata', () => { diff --git a/packages/cli/src/telemetry/scrubber.test.ts b/packages/cli/test/telemetry/scrubber.test.ts similarity index 94% rename from packages/cli/src/telemetry/scrubber.test.ts rename to packages/cli/test/telemetry/scrubber.test.ts index 87eb74d4..a12946d4 100644 --- a/packages/cli/src/telemetry/scrubber.test.ts +++ b/packages/cli/test/telemetry/scrubber.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; -import { scrubErrorClass } from './scrubber.js'; +import { scrubErrorClass } from '../../src/telemetry/scrubber.js'; class KtxProjectMissingAbortError extends Error {} diff --git a/packages/cli/src/text-ingest.test.ts b/packages/cli/test/text-ingest.test.ts similarity index 97% rename from packages/cli/src/text-ingest.test.ts rename to packages/cli/test/text-ingest.test.ts index b7737f36..5122208e 100644 --- a/packages/cli/src/text-ingest.test.ts +++ b/packages/cli/test/text-ingest.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it, vi } from 'vitest'; -import type { MemoryIngestStatus } from './context/memory/memory-runs.js'; -import type { KtxLocalProject } from './context/project/project.js'; -import { runKtxTextIngest, type TextMemoryIngestPort } from './text-ingest.js'; +import type { MemoryIngestStatus } from '../src/context/memory/memory-runs.js'; +import type { KtxLocalProject } from '../src/context/project/project.js'; +import { runKtxTextIngest, type TextMemoryIngestPort } from '../src/text-ingest.js'; function makeIo(options: { isTTY?: boolean } = {}) { let stdout = ''; diff --git a/packages/cli/src/tree-picker-state.test.ts b/packages/cli/test/tree-picker-state.test.ts similarity index 99% rename from packages/cli/src/tree-picker-state.test.ts rename to packages/cli/test/tree-picker-state.test.ts index e8d6afd1..50d47d89 100644 --- a/packages/cli/src/tree-picker-state.test.ts +++ b/packages/cli/test/tree-picker-state.test.ts @@ -14,7 +14,7 @@ import { toggleChecked, visibleNodeIds, type TreePickerNodeInput, -} from './tree-picker-state.js'; +} from '../src/tree-picker-state.js'; const IDS = { engineering: '11111111-1111-1111-1111-111111111111', diff --git a/packages/cli/src/tree-picker-tui.test.tsx b/packages/cli/test/tree-picker-tui.test.tsx similarity index 99% rename from packages/cli/src/tree-picker-tui.test.tsx rename to packages/cli/test/tree-picker-tui.test.tsx index 18877778..f4f642c0 100644 --- a/packages/cli/src/tree-picker-tui.test.tsx +++ b/packages/cli/test/tree-picker-tui.test.tsx @@ -2,7 +2,7 @@ import { render as renderInkTest } from 'ink-testing-library'; import { type ReactNode } from 'react'; import { describe, expect, it, vi } from 'vitest'; -import { buildInitialState, buildPickerTree, type TreePickerNodeInput } from './tree-picker-state.js'; +import { buildInitialState, buildPickerTree, type TreePickerNodeInput } from '../src/tree-picker-state.js'; import { TreePickerApp, renderTreePickerTui, @@ -14,7 +14,7 @@ import { type TreePickerChrome, type TreePickerInkInstance, type TreePickerInkRenderOptions, -} from './tree-picker-tui.js'; +} from '../src/tree-picker-tui.js'; const IDS = { engineering: '11111111-1111-1111-1111-111111111111', diff --git a/packages/cli/src/viz-fallback.test.ts b/packages/cli/test/viz-fallback.test.ts similarity index 99% rename from packages/cli/src/viz-fallback.test.ts rename to packages/cli/test/viz-fallback.test.ts index f42eb440..b7b38158 100644 --- a/packages/cli/src/viz-fallback.test.ts +++ b/packages/cli/test/viz-fallback.test.ts @@ -4,7 +4,7 @@ import { resetVizFallbackWarningsForTest, resolveVizFallback, warnVizFallbackOnce, -} from './viz-fallback.js'; +} from '../src/viz-fallback.js'; function io(options: { stdoutTty?: boolean; stdinTty?: boolean; rawMode?: boolean }) { return { diff --git a/packages/cli/tsconfig.test.json b/packages/cli/tsconfig.test.json new file mode 100644 index 00000000..e4b4d755 --- /dev/null +++ b/packages/cli/tsconfig.test.json @@ -0,0 +1,10 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "noEmit": true, + "rootDir": ".", + "tsBuildInfoFile": "./dist/.tsbuildinfo.test" + }, + "include": ["src/**/*.ts", "src/**/*.tsx", "test/**/*.ts", "test/**/*.tsx"], + "exclude": ["dist", "node_modules"] +} diff --git a/packages/cli/vitest.config.ts b/packages/cli/vitest.config.ts index ca1a6b26..9260a927 100644 --- a/packages/cli/vitest.config.ts +++ b/packages/cli/vitest.config.ts @@ -9,7 +9,7 @@ export default defineConfig({ }, test: { root: '.', - include: ['src/**/*.test.ts', 'src/**/*.test.tsx'], + include: ['test/**/*.test.ts', 'test/**/*.test.tsx'], testTimeout: 30_000, }, }); diff --git a/scripts/anti-fixture-conditional.test.mjs b/scripts/anti-fixture-conditional.test.mjs index b4a3241c..44728c48 100644 --- a/scripts/anti-fixture-conditional.test.mjs +++ b/scripts/anti-fixture-conditional.test.mjs @@ -19,7 +19,7 @@ const RELATIONSHIP_RUNTIME_SOURCES = Object.freeze([ ]); async function checkedInFixtureIds() { - const fixtureRoot = new URL('packages/cli/src/test/fixtures/relationship-benchmarks/', KTX_ROOT); + const fixtureRoot = new URL('packages/cli/test/fixtures/relationship-benchmarks/', KTX_ROOT); const entries = await readdir(fixtureRoot, { withFileTypes: true }); return entries .filter((entry) => entry.isDirectory()) diff --git a/scripts/build-benchmark-snapshot.test.mjs b/scripts/build-benchmark-snapshot.test.mjs index 6e3f5189..acea6ff9 100644 --- a/scripts/build-benchmark-snapshot.test.mjs +++ b/scripts/build-benchmark-snapshot.test.mjs @@ -257,7 +257,7 @@ describe('buildBenchmarkSnapshot', () => { assert.equal( packageJson.scripts['relationships:benchmarks:test'], - 'KTX_RUN_RELATIONSHIP_BENCHMARKS=1 vitest run src/context/scan/relationship-benchmarks.test.ts', + 'KTX_RUN_RELATIONSHIP_BENCHMARKS=1 vitest run test/context/scan/relationship-benchmarks.test.ts', ); }); }); diff --git a/scripts/check-boundaries.mjs b/scripts/check-boundaries.mjs index 5766ac78..97ffef64 100644 --- a/scripts/check-boundaries.mjs +++ b/scripts/check-boundaries.mjs @@ -68,6 +68,13 @@ const contextProductionLlmBoundaryPatterns = [ }, ]; +const concreteDialectImportPatterns = [ + /from\s+['"][^'"]*connectors\/[^'"]+\/dialect\.js['"]/, + /import\s*\(\s*['"][^'"]*connectors\/[^'"]+\/dialect\.js['"]\s*\)/, + /from\s+['"]\.\/dialect\.js['"]/, + /import\s*\(\s*['"]\.\/dialect\.js['"]\s*\)/, +]; + function normalizePath(filePath) { return filePath.split(path.sep).join('/'); } @@ -99,6 +106,13 @@ function scansForContextProductionLlmBoundaries(relativePath) { return scansForLlmBoundaries(relativePath) && !isTestSource(relativePath); } +function scansForConcreteDialectImportBoundaries(relativePath) { + return ( + relativePath.startsWith('packages/cli/src/context/scan/') || + /^packages\/cli\/src\/connectors\/[^/]+\/connector\.ts$/.test(relativePath) + ); +} + function scansForForbiddenIdentifiers(relativePath) { return (isCodeSource(relativePath) && !isTestSource(relativePath)) || isRuntimeAsset(relativePath); } @@ -151,6 +165,19 @@ export function scanFileContent(relativePath, content) { } } + if (scansForConcreteDialectImportBoundaries(normalizedPath)) { + for (const pattern of concreteDialectImportPatterns) { + if (pattern.test(content)) { + violations.push({ + file: normalizedPath, + kind: 'dialect-boundary', + message: + 'Forbidden concrete connector dialect import; use getDialectForDriver() from context/connections/dialects.ts', + }); + } + } + } + if ( scansForForbiddenIdentifiers(normalizedPath) && !skipsIdentifierScan(normalizedPath) && diff --git a/scripts/check-boundaries.test.mjs b/scripts/check-boundaries.test.mjs index 25cd0f85..f279313d 100644 --- a/scripts/check-boundaries.test.mjs +++ b/scripts/check-boundaries.test.mjs @@ -68,8 +68,8 @@ describe('scanFileContent', () => { it('allows product identifiers in test fixtures', () => { const name = lowerProductName(); - assert.equal(scanFileContent('packages/cli/src/setup.test.ts', `project: ${name}-dev`).length, 0); - assert.equal(scanFileContent('packages/cli/src/context/ingest/importer.test.ts', `email: system@${name}.dev`).length, 0); + assert.equal(scanFileContent('packages/cli/test/setup.test.ts', `project: ${name}-dev`).length, 0); + assert.equal(scanFileContent('packages/cli/test/context/ingest/importer.test.ts', `email: system@${name}.dev`).length, 0); assert.equal(scanFileContent('python/ktx-daemon/tests/test_package.py', `${name}-ktx`).length, 0); }); @@ -112,6 +112,43 @@ describe('scanFileContent', () => { ); }); + it('rejects concrete connector dialect imports from scan workflow and connector classes', () => { + const violations = [ + ...scanFileContent( + 'packages/cli/src/context/scan/relationship-profiling.ts', + "import { KtxPostgresDialect } from '../../connectors/postgres/dialect.js';", + ), + ...scanFileContent( + 'packages/cli/src/connectors/postgres/connector.ts', + "import { KtxPostgresDialect } from './dialect.js';", + ), + ]; + + assert.deepEqual( + violations.map((violation) => violation.kind), + ['dialect-boundary', 'dialect-boundary'], + ); + assert.equal( + violations[0]?.message, + 'Forbidden concrete connector dialect import; use getDialectForDriver() from context/connections/dialects.ts', + ); + + assert.deepEqual( + scanFileContent( + 'packages/cli/src/context/connections/dialects.ts', + "import { KtxPostgresDialect } from '../../connectors/postgres/dialect.js';", + ), + [], + ); + assert.deepEqual( + scanFileContent( + 'packages/cli/test/connectors/postgres/dialect.test.ts', + "import { KtxPostgresDialect } from './dialect.js';", + ), + [], + ); + }); + it('rejects old KTX LLM port declarations in context', () => { const violations = [ ...scanFileContent('packages/cli/src/context/agent/agent-runner.service.ts', 'export interface LlmProviderPort {}'), @@ -150,7 +187,7 @@ describe('scanFileContent', () => { assert.deepEqual( scanFileContent( - 'packages/cli/src/context/ingest/page-triage/page-triage.service.test.ts', + 'packages/cli/test/context/ingest/page-triage/page-triage.service.test.ts', "const model = this.deps.llmProvider.getModelByName('test-model');", ), [], diff --git a/scripts/examples-docs.test.mjs b/scripts/examples-docs.test.mjs index 41b6d346..53413ea5 100644 --- a/scripts/examples-docs.test.mjs +++ b/scripts/examples-docs.test.mjs @@ -309,10 +309,10 @@ describe('standalone example docs', () => { it('runs the example smoke in the cli smoke script', async () => { const packageJson = JSON.parse(await readText('packages/cli/package.json')); - assert.match(packageJson.scripts.smoke, /src\/standalone-smoke\.test\.ts/); - assert.match(packageJson.scripts.smoke, /src\/example-smoke\.test\.ts/); - assert.match(packageJson.scripts.test, /--exclude src\/standalone-smoke\.test\.ts/); - assert.match(packageJson.scripts.test, /--exclude src\/example-smoke\.test\.ts/); + assert.match(packageJson.scripts.smoke, /test\/standalone-smoke\.test\.ts/); + assert.match(packageJson.scripts.smoke, /test\/example-smoke\.test\.ts/); + assert.match(packageJson.scripts.test, /--exclude test\/standalone-smoke\.test\.ts/); + assert.match(packageJson.scripts.test, /--exclude test\/example-smoke\.test\.ts/); }); it('documents daemon HTTP database, source generation, LookML, embedding, and code execution support', async () => { diff --git a/scripts/relationship-benchmark-report.mjs b/scripts/relationship-benchmark-report.mjs index 7af82d57..9901d77c 100644 --- a/scripts/relationship-benchmark-report.mjs +++ b/scripts/relationship-benchmark-report.mjs @@ -14,7 +14,7 @@ import { const scriptDir = dirname(fileURLToPath(import.meta.url)); const ktxRoot = resolve(scriptDir, '..'); -const fixtureRoot = join(ktxRoot, 'packages/cli/src/test/fixtures/relationship-benchmarks'); +const fixtureRoot = join(ktxRoot, 'packages/cli/test/fixtures/relationship-benchmarks'); async function buildDetector() { const backend = process.env.KTX_BENCHMARK_LLM_BACKEND; diff --git a/scripts/test-tiering.test.mjs b/scripts/test-tiering.test.mjs index bd43ce71..a54c6a16 100644 --- a/scripts/test-tiering.test.mjs +++ b/scripts/test-tiering.test.mjs @@ -14,41 +14,41 @@ function assertScriptContainsAll(script, expected) { describe('test tiering', () => { const cliSlowTests = [ - 'src/setup-databases.test.ts', - 'src/scan.test.ts', - 'src/commands/connection-metabase-setup.test.ts', - 'src/setup-models.test.ts', - 'src/setup-sources.test.ts', - 'src/setup.test.ts', - 'src/connection.test.ts', - 'src/setup-embeddings.test.ts', - 'src/ingest.test.ts', - 'src/commands/connection-mapping.test.ts', - 'src/ingest-viz.test.ts', - 'src/demo.test.ts', - 'src/setup-project.test.ts', - 'src/sl.test.ts', - 'src/local-scan-connectors.test.ts', - 'src/commands/connection-notion.test.ts', + 'test/setup-databases.test.ts', + 'test/scan.test.ts', + 'test/commands/connection-metabase-setup.test.ts', + 'test/setup-models.test.ts', + 'test/setup-sources.test.ts', + 'test/setup.test.ts', + 'test/connection.test.ts', + 'test/setup-embeddings.test.ts', + 'test/ingest.test.ts', + 'test/commands/connection-mapping.test.ts', + 'test/ingest-viz.test.ts', + 'test/demo.test.ts', + 'test/setup-project.test.ts', + 'test/sl.test.ts', + 'test/local-scan-connectors.test.ts', + 'test/commands/connection-notion.test.ts', ]; const contextSlowTests = [ - 'src/context/scan/local-scan.test.ts', - 'src/context/mcp/local-project-ports.test.ts', - 'src/context/ingest/local-stage-ingest.test.ts', - 'src/context/sl/pglite-sl-search-prototype.test.ts', - 'src/context/core/git.service.test.ts', - 'src/context/ingest/local-adapters.test.ts', - 'src/context/ingest/local-bundle-ingest.test.ts', - 'src/context/ingest/local-metabase-ingest.test.ts', - 'src/context/sl/local-sl.test.ts', - 'src/context/search/pglite-owner-process.test.ts', - 'src/context/scan/local-enrichment-artifacts.test.ts', - 'src/context/search/pglite-spike.test.ts', - 'src/context/wiki/local-knowledge.test.ts', - 'src/context/sl/local-query.test.ts', - 'src/context/scan/relationship-review-decisions.test.ts', - 'src/context/scan/relationship-profiling.test.ts', + 'test/context/scan/local-scan.test.ts', + 'test/context/mcp/local-project-ports.test.ts', + 'test/context/ingest/local-stage-ingest.test.ts', + 'test/context/sl/pglite-sl-search-prototype.test.ts', + 'test/context/core/git.service.test.ts', + 'test/context/ingest/local-adapters.test.ts', + 'test/context/ingest/local-bundle-ingest.test.ts', + 'test/context/ingest/local-metabase-ingest.test.ts', + 'test/context/sl/local-sl.test.ts', + 'test/context/search/pglite-owner-process.test.ts', + 'test/context/scan/local-enrichment-artifacts.test.ts', + 'test/context/search/pglite-spike.test.ts', + 'test/context/wiki/local-knowledge.test.ts', + 'test/context/sl/local-query.test.ts', + 'test/context/scan/relationship-review-decisions.test.ts', + 'test/context/scan/relationship-profiling.test.ts', ]; it('keeps slow package tests out of default local package test scripts', async () => { From 2a6fb19ba425a06f89e14621b1b7934c9b175bf6 Mon Sep 17 00:00:00 2001 From: ARYAN <57013028+Aryan1718@users.noreply.github.com> Date: Tue, 26 May 2026 03:16:53 -0700 Subject: [PATCH 08/74] fix(scripts): make package artifacts pnpm launch work on Windows Fix Windows package artifact script invocation under pnpm. --- scripts/package-artifacts.mjs | 299 ++++++++++++++++++----------- scripts/package-artifacts.test.mjs | 52 +++-- 2 files changed, 226 insertions(+), 125 deletions(-) diff --git a/scripts/package-artifacts.mjs b/scripts/package-artifacts.mjs index 627850b4..e1ff8c6c 100644 --- a/scripts/package-artifacts.mjs +++ b/scripts/package-artifacts.mjs @@ -28,6 +28,20 @@ export const NPM_ARTIFACT_PACKAGES = [{ name: PUBLIC_NPM_PACKAGE_NAME, packageRo export const CLI_PYTHON_ASSET_MANIFEST = 'manifest.json'; +function pnpmCommand(args) { + if (process.platform === 'win32') { + return { + command: 'cmd.exe', + args: ['/d', '/s', '/c', 'pnpm', ...args], + }; + } + + return { + command: 'pnpm', + args, + }; +} + function scriptRootDir() { return resolve(dirname(fileURLToPath(import.meta.url)), '..'); } @@ -70,8 +84,7 @@ export function packageArtifactLayout(rootDir = scriptRootDir(), version = publi export function buildArtifactCommands(layout) { return [ { - command: 'pnpm', - args: ['--filter', PUBLIC_NPM_PACKAGE_NAME, 'run', 'build'], + ...pnpmCommand(['--filter', PUBLIC_NPM_PACKAGE_NAME, 'run', 'build']), cwd: layout.rootDir, }, { @@ -80,8 +93,7 @@ export function buildArtifactCommands(layout) { cwd: layout.rootDir, }, { - command: 'pnpm', - args: ['pack', '--out', layout.cliTarball], + ...pnpmCommand(['pack', '--out', layout.cliTarball]), cwd: join(layout.rootDir, 'packages', 'cli'), }, ]; @@ -460,11 +472,19 @@ import { createRequire } from 'node:module'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { DatabaseSync } from 'node:sqlite'; +import { setTimeout as delay } from 'node:timers/promises'; import { promisify } from 'node:util'; const execFileAsync = promisify(execFile); const require = createRequire(import.meta.url); +function pnpmCommand(args) { + if (process.platform === 'win32') { + return { command: 'cmd.exe', args: ['/d', '/s', '/c', 'pnpm', ...args] }; + } + return { command: 'pnpm', args }; +} + async function run(command, args, options = {}) { process.stdout.write('$ ' + command + ' ' + args.join(' ') + '\\n'); try { @@ -551,6 +571,21 @@ function requireIncludes(values, expected, label) { assert.ok(values.includes(expected), label + ' did not include ' + expected + ': ' + values.join(', ')); } +async function rmWithRetry(path) { + for (let attempt = 0; ; attempt += 1) { + try { + await rm(path, { recursive: true, force: true }); + return; + } catch (error) { + const code = typeof error?.code === 'string' ? error.code : ''; + if (attempt >= 4 || !['EBUSY', 'ENOTEMPTY', 'EPERM'].includes(code)) { + throw error; + } + await delay(500); + } + } +} + async function writeSqliteWarehouse(projectDir) { const database = new DatabaseSync(join(projectDir, 'warehouse.db')); try { @@ -575,50 +610,58 @@ let daemonStarted = false; try { const projectDir = join(root, 'project'); - const version = await run('pnpm', ['exec', 'ktx', '--version']); + const version = await run(...Object.values(pnpmCommand(['exec', 'ktx', '--version']))); requireSuccess('ktx public package version', version); requireOutput('ktx public package version', version, await installedPackageVersionPattern()); const runtimeStatusBefore = parseJsonResultWithExitCode( 'ktx admin runtime status missing', - await run('pnpm', ['exec', 'ktx', 'admin', 'runtime', 'status', '--json']), + await run(...Object.values(pnpmCommand(['exec', 'ktx', 'admin', 'runtime', 'status', '--json']))), 1, ); assert.equal(runtimeStatusBefore.kind, 'missing'); assert.equal(runtimeStatusBefore.layout.runtimeRoot, process.env.KTX_RUNTIME_ROOT); process.stdout.write('ktx managed runtime starts missing in isolated root\\n'); - const init = await run('pnpm', [ - 'exec', - 'ktx', - 'setup', - '--project-dir', - projectDir, - '--no-input', - '--yes', - '--skip-llm', - '--skip-embeddings', - '--skip-databases', - '--skip-sources', - '--skip-agents', - ]); + const init = await run( + ...Object.values( + pnpmCommand([ + 'exec', + 'ktx', + 'setup', + '--project-dir', + projectDir, + '--no-input', + '--yes', + '--skip-llm', + '--skip-embeddings', + '--skip-databases', + '--skip-sources', + '--skip-agents', + ]), + ), + ); requireSuccess('ktx setup', init); const emptyProjectDir = join(root, 'empty-project'); - const emptyInit = await run('pnpm', [ - 'exec', - 'ktx', - 'setup', - '--project-dir', - emptyProjectDir, - '--no-input', - '--yes', - '--skip-llm', - '--skip-embeddings', - '--skip-databases', - '--skip-sources', - '--skip-agents', - ]); + const emptyInit = await run( + ...Object.values( + pnpmCommand([ + 'exec', + 'ktx', + 'setup', + '--project-dir', + emptyProjectDir, + '--no-input', + '--yes', + '--skip-llm', + '--skip-embeddings', + '--skip-databases', + '--skip-sources', + '--skip-agents', + ]), + ), + ); requireSuccess('ktx setup empty project', emptyInit); await writeFile( join(projectDir, 'ktx.yaml'), @@ -658,17 +701,21 @@ try { 'utf-8', ); - const wikiSearch = await run('pnpm', [ - 'exec', - 'ktx', - 'wiki', - 'revenue', - '--json', - '--limit', - '5', - '--project-dir', - projectDir, - ]); + const wikiSearch = await run( + ...Object.values( + pnpmCommand([ + 'exec', + 'ktx', + 'wiki', + 'revenue', + '--json', + '--limit', + '5', + '--project-dir', + projectDir, + ]), + ), + ); const wikiSearchJson = parseJsonResult('ktx wiki search', wikiSearch); assert.equal(wikiSearchJson.kind, 'list'); assert.equal(wikiSearchJson.data.items.length, 1); @@ -700,17 +747,21 @@ try { await mkdir(join(projectDir, 'semantic-layer', 'warehouse'), { recursive: true }); await writeFile(join(projectDir, 'semantic-layer', 'warehouse', 'orders.yaml'), slYaml, 'utf-8'); - const slSearch = await run('pnpm', [ - 'exec', - 'ktx', - 'sl', - 'orders', - '--json', - '--connection-id', - 'warehouse', - '--project-dir', - projectDir, - ]); + const slSearch = await run( + ...Object.values( + pnpmCommand([ + 'exec', + 'ktx', + 'sl', + 'orders', + '--json', + '--connection-id', + 'warehouse', + '--project-dir', + projectDir, + ]), + ), + ); const slSearchJson = parseJsonResult('ktx sl search', slSearch); assert.equal(slSearchJson.kind, 'list'); assert.equal(slSearchJson.data.items.length, 1); @@ -720,17 +771,25 @@ try { requireIncludes(slSearchJson.data.items[0].matchReasons, 'lexical', 'sl search match reasons'); process.stdout.write('ktx sl search hybrid metadata verified\\n'); - const slQuery = await run('pnpm', ['exec', 'ktx', 'sl', 'query', - '--connection-id', - 'warehouse', - '--measure', - 'orders.order_count', - '--format', - 'json', - '--yes', - '--project-dir', - projectDir, - ]); + const slQuery = await run( + ...Object.values( + pnpmCommand([ + 'exec', + 'ktx', + 'sl', + 'query', + '--connection-id', + 'warehouse', + '--measure', + 'orders.order_count', + '--format', + 'json', + '--yes', + '--project-dir', + projectDir, + ]), + ), + ); requireSuccessWithStderr( 'ktx sl query first managed runtime install', slQuery, @@ -741,27 +800,35 @@ try { const runtimeStatusAfter = parseJsonResult( 'ktx admin runtime status ready', - await run('pnpm', ['exec', 'ktx', 'admin', 'runtime', 'status', '--json']), + await run(...Object.values(pnpmCommand(['exec', 'ktx', 'admin', 'runtime', 'status', '--json']))), ); assert.equal(runtimeStatusAfter.kind, 'ready'); assert.deepEqual(runtimeStatusAfter.manifest.features, ['core']); assert.equal(runtimeStatusAfter.layout.runtimeRoot, process.env.KTX_RUNTIME_ROOT); process.stdout.write('ktx managed runtime lazy install verified\\n'); - const sqliteSlQuery = await run('pnpm', ['exec', 'ktx', 'sl', 'query', - '--connection-id', - 'warehouse', - '--measure', - 'orders.order_count', - '--format', - 'json', - '--execute', - '--max-rows', - '100', - '--yes', - '--project-dir', - projectDir, - ]); + const sqliteSlQuery = await run( + ...Object.values( + pnpmCommand([ + 'exec', + 'ktx', + 'sl', + 'query', + '--connection-id', + 'warehouse', + '--measure', + 'orders.order_count', + '--format', + 'json', + '--execute', + '--max-rows', + '100', + '--yes', + '--project-dir', + projectDir, + ]), + ), + ); requireSuccess('ktx sl query sqlite execute', sqliteSlQuery); requireOutput('ktx sl query sqlite execute', sqliteSlQuery, /"dialect": "sqlite"/); requireOutput('ktx sl query sqlite execute', sqliteSlQuery, /"mode": "executed"/); @@ -769,36 +836,35 @@ try { requireOutput('ktx sl query sqlite execute', sqliteSlQuery, /"rows": \\[\\s*\\[\\s*3\\s*\\]\\s*\\]/); process.stdout.write('ktx sl query sqlite execute verified\\n'); - const runtimeDoctor = await run('pnpm', ['exec', 'ktx', 'admin', 'runtime', 'status']); + const runtimeDoctor = await run(...Object.values(pnpmCommand(['exec', 'ktx', 'admin', 'runtime', 'status']))); requireSuccess('ktx admin runtime status', runtimeDoctor); requireOutput('ktx admin runtime status', runtimeDoctor, /KTX Python runtime/); requireOutput('ktx admin runtime status', runtimeDoctor, /status: ready/); process.stdout.write('ktx admin runtime status verified\\n'); - const runtimeStart = await run('pnpm', ['exec', 'ktx', 'admin', 'runtime', 'start']); + const runtimeStart = await run(...Object.values(pnpmCommand(['exec', 'ktx', 'admin', 'runtime', 'start']))); requireSuccess('ktx admin runtime start', runtimeStart); daemonStarted = true; requireOutput('ktx admin runtime start', runtimeStart, /Started KTX daemon/); requireOutput('ktx admin runtime start', runtimeStart, /url: http:\\/\\/127\\.0\\.0\\.1:\\d+/); requireOutput('ktx admin runtime start', runtimeStart, /features: core/); - const runtimeStartReuse = await run('pnpm', ['exec', 'ktx', 'admin', 'runtime', 'start']); + const runtimeStartReuse = await run(...Object.values(pnpmCommand(['exec', 'ktx', 'admin', 'runtime', 'start']))); requireSuccess('ktx admin runtime start reuse', runtimeStartReuse); requireOutput('ktx admin runtime start reuse', runtimeStartReuse, /Using existing KTX daemon/); requireOutput('ktx admin runtime start reuse', runtimeStartReuse, /features: core/); - const runtimeStop = await run('pnpm', ['exec', 'ktx', 'admin', 'runtime', 'stop']); + const runtimeStop = await run(...Object.values(pnpmCommand(['exec', 'ktx', 'admin', 'runtime', 'stop']))); requireSuccess('ktx admin runtime stop', runtimeStop); daemonStarted = false; requireOutput('ktx admin runtime stop', runtimeStop, /Stopped KTX daemon/); process.stdout.write('ktx admin runtime daemon lifecycle verified\\n'); - const structuralScan = await run('pnpm', ['exec', 'ktx', 'ingest', 'warehouse', - '--project-dir', - projectDir, - '--fast', - '--no-input', - ]); + const structuralScan = await run( + ...Object.values( + pnpmCommand(['exec', 'ktx', 'ingest', 'warehouse', '--project-dir', projectDir, '--fast', '--no-input']), + ), + ); requireSuccessWithProjectStderr('ktx ingest fast', structuralScan, projectDir); requireOutput('ktx ingest fast', structuralScan, /Ingest finished/); requireOutput('ktx ingest fast', structuralScan, /Database schema/); @@ -806,12 +872,11 @@ try { await access(join(projectDir, 'semantic-layer', 'warehouse', '_schema', 'public.yaml')); process.stdout.write('ktx ingest fast verified\\n'); - const enrichedScan = await run('pnpm', ['exec', 'ktx', 'ingest', 'warehouse', - '--project-dir', - projectDir, - '--deep', - '--no-input', - ]); + const enrichedScan = await run( + ...Object.values( + pnpmCommand(['exec', 'ktx', 'ingest', 'warehouse', '--project-dir', projectDir, '--deep', '--no-input']), + ), + ); requireExitCodeWithProjectStderr('ktx ingest deep readiness guard', enrichedScan, projectDir, 1); requireOutput('ktx ingest deep readiness guard', enrichedScan, /Ingest finished with partial failures/); requireOutput('ktx ingest deep readiness guard', enrichedScan, /requires deep ingest readiness/); @@ -821,14 +886,15 @@ try { process.stdout.write('ktx ingest state verified\\n'); } finally { if (daemonStarted) { - await run('pnpm', ['exec', 'ktx', 'admin', 'runtime', 'stop']); + await run(...Object.values(pnpmCommand(['exec', 'ktx', 'admin', 'runtime', 'stop']))); + await delay(500); } if (previousRuntimeRoot === undefined) { delete process.env.KTX_RUNTIME_ROOT; } else { process.env.KTX_RUNTIME_ROOT = previousRuntimeRoot; } - await rm(root, { recursive: true, force: true }); + await rmWithRetry(root); } `; } @@ -844,6 +910,13 @@ import { promisify } from 'node:util'; const execFileAsync = promisify(execFile); +function pnpmCommand(args) { + if (process.platform === 'win32') { + return { command: 'cmd.exe', args: ['/d', '/s', '/c', 'pnpm', ...args] }; + } + return { command: 'pnpm', args }; +} + async function run(command, args, options = {}) { process.stdout.write('$ ' + command + ' ' + args.join(' ') + '\\n'); try { @@ -880,17 +953,17 @@ try { const packageJson = JSON.parse(await readFile(join(process.cwd(), 'package.json'), 'utf8')); assert.deepEqual(Object.keys(packageJson.dependencies), ['@kaelio/ktx']); - const help = await run('pnpm', ['exec', 'ktx', '--help']); + const help = await run(...Object.values(pnpmCommand(['exec', 'ktx', '--help']))); requireSuccess('ktx --help', help); requireStdout('ktx --help', help, /Usage: ktx/); requireStdout('ktx --help', help, /setup/); - const setupHelp = await run('pnpm', ['exec', 'ktx', 'setup', '--help']); + const setupHelp = await run(...Object.values(pnpmCommand(['exec', 'ktx', 'setup', '--help']))); requireSuccess('ktx setup --help', setupHelp); requireStdout('ktx setup --help', setupHelp, /Usage: ktx setup/); requireStdout('ktx setup --help', setupHelp, /--no-input/); - const doctor = await run('pnpm', ['exec', 'ktx', 'status', '--verbose', '--no-input']); + const doctor = await run(...Object.values(pnpmCommand(['exec', 'ktx', 'status', '--verbose', '--no-input']))); assert.ok([0, 1].includes(doctor.code), 'ktx status setup exit code must be 0 or 1'); requireStdout('ktx status setup', doctor, /KTX status/); requireStdout('ktx status setup', doctor, /No project here yet\\./); @@ -949,10 +1022,19 @@ async function verifyNpmArtifacts(layout, tmpRoot) { await writeFile(join(projectDir, 'verify-installed-cli.mjs'), npmRuntimeSmokeSource()); await writeFile(join(projectDir, 'verify-installed-cli-commands.mjs'), npmCliSmokeSource()); - await runCommand('pnpm', ['install'], { cwd: projectDir }); - await runCommand('pnpm', ['rebuild', 'better-sqlite3'], { cwd: projectDir }); + { + const pnpmInstall = pnpmCommand(['install']); + await runCommand(pnpmInstall.command, pnpmInstall.args, { cwd: projectDir }); + } + { + const pnpmRebuild = pnpmCommand(['rebuild', 'better-sqlite3']); + await runCommand(pnpmRebuild.command, pnpmRebuild.args, { cwd: projectDir }); + } await runCommand('node', ['verify-npm.mjs'], { cwd: projectDir }); - await runCommand('pnpm', ['exec', 'ktx', '--version'], { cwd: projectDir }); + { + const pnpmExecVersion = pnpmCommand(['exec', 'ktx', '--version']); + await runCommand(pnpmExecVersion.command, pnpmExecVersion.args, { cwd: projectDir }); + } await runCommand('node', ['verify-installed-cli.mjs'], { cwd: projectDir }); await runCommand('node', ['verify-installed-cli-commands.mjs'], { cwd: projectDir }); } @@ -968,7 +1050,10 @@ async function verifyNpmCliArtifacts(layout, tmpRoot) { await writeFile(join(projectDir, 'pnpm-workspace.yaml'), npmSmokePnpmWorkspaceYaml()); await writeFile(join(projectDir, 'verify-installed-cli-commands.mjs'), npmCliSmokeSource()); - await runCommand('pnpm', ['install'], { cwd: projectDir }); + { + const pnpmInstall = pnpmCommand(['install']); + await runCommand(pnpmInstall.command, pnpmInstall.args, { cwd: projectDir }); + } await runCommand('node', ['verify-installed-cli-commands.mjs'], { cwd: projectDir }); } diff --git a/scripts/package-artifacts.test.mjs b/scripts/package-artifacts.test.mjs index 7ea9339b..a1d2489d 100644 --- a/scripts/package-artifacts.test.mjs +++ b/scripts/package-artifacts.test.mjs @@ -97,12 +97,12 @@ describe('packageArtifactLayout', () => { it('uses stable artifact paths under ktx/dist/artifacts', () => { const layout = packageArtifactLayout('/repo/ktx', PUBLIC_NPM_PACKAGE_VERSION); - assert.equal(layout.artifactDir, '/repo/ktx/dist/artifacts'); - assert.equal(layout.npmDir, '/repo/ktx/dist/artifacts/npm'); - assert.equal(layout.pythonDir, '/repo/ktx/dist/artifacts/python'); + assert.equal(layout.artifactDir, join('/repo/ktx', 'dist', 'artifacts')); + assert.equal(layout.npmDir, join('/repo/ktx', 'dist', 'artifacts', 'npm')); + assert.equal(layout.pythonDir, join('/repo/ktx', 'dist', 'artifacts', 'python')); assert.equal( layout.cliTarball, - `/repo/ktx/dist/artifacts/npm/kaelio-ktx-${PUBLIC_NPM_PACKAGE_VERSION}.tgz`, + join('/repo/ktx', 'dist', 'artifacts', 'npm', `kaelio-ktx-${PUBLIC_NPM_PACKAGE_VERSION}.tgz`), ); assert.deepEqual(Object.keys(layout.npmTarballs), ['@kaelio/ktx']); }); @@ -112,17 +112,21 @@ describe('buildArtifactCommands', () => { it('builds the CLI package, then the runtime wheel, then packs the npm tarball directly', () => { const layout = packageArtifactLayout('/repo/ktx', PUBLIC_NPM_PACKAGE_VERSION); const commands = buildArtifactCommands(layout); + const expectedBuildCommand = + process.platform === 'win32' + ? ['cmd.exe', ['/d', '/s', '/c', 'pnpm', '--filter', '@kaelio/ktx', 'run', 'build'], layout.rootDir] + : ['pnpm', ['--filter', '@kaelio/ktx', 'run', 'build'], layout.rootDir]; + const expectedPackCommand = + process.platform === 'win32' + ? ['cmd.exe', ['/d', '/s', '/c', 'pnpm', 'pack', '--out', layout.cliTarball], join('/repo/ktx', 'packages', 'cli')] + : ['pnpm', ['pack', '--out', layout.cliTarball], join('/repo/ktx', 'packages', 'cli')]; assert.deepEqual( commands.map((command) => [command.command, command.args, command.cwd]), [ - ['pnpm', ['--filter', '@kaelio/ktx', 'run', 'build'], '/repo/ktx'], - [process.execPath, ['scripts/build-python-runtime-wheel.mjs'], '/repo/ktx'], - [ - 'pnpm', - ['pack', '--out', `/repo/ktx/dist/artifacts/npm/kaelio-ktx-${PUBLIC_NPM_PACKAGE_VERSION}.tgz`], - '/repo/ktx/packages/cli', - ], + expectedBuildCommand, + [process.execPath, ['scripts/build-python-runtime-wheel.mjs'], layout.rootDir], + expectedPackCommand, ], ); }); @@ -476,18 +480,27 @@ describe('verification snippets', () => { it('runs installed CLI commands through the public package runtime', () => { const source = npmRuntimeSmokeSource(); + assert.match(source, /function pnpmCommand\(args\)/); + assert.match(source, /process\.platform === 'win32'/); + assert.match(source, /command: 'cmd\.exe'/); + assert.match(source, /args: \['\/d', '\/s', '\/c', 'pnpm', \.\.\.args\]/); + assert.match(source, /import \{ setTimeout as delay \} from 'node:timers\/promises';/); + assert.match(source, /async function rmWithRetry\(path\)/); + assert.match(source, /await delay\(500\)/); + assert.match(source, /await rmWithRetry\(root\)/); assert.match(source, /ktx public package version/); assert.match(source, /installedPackageVersionPattern/); assert.doesNotMatch(source, /@kaelio\\\/ktx 0\\\.1\\\.0/); - assert.match(source, /'ktx', 'sl', 'query'/); + assert.match(source, /pnpmCommand\(\[\s*'exec',\s*'ktx',\s*'sl',\s*'query'/); assert.doesNotMatch(source, /@ktx\/context/); assert.doesNotMatch(source, /@modelcontextprotocol/); assert.doesNotMatch(source, /startSemanticDaemon/); - assert.match(source, /run\('pnpm', \[\s*'exec',\s*'ktx',\s*'setup'/); + assert.doesNotMatch(source, /run\('pnpm',/); + assert.match(source, /pnpmCommand\(\[\s*'exec',\s*'ktx',\s*'setup'/); assert.match(source, /wiki', 'global', 'revenue\.md'/); - assert.match(source, /run\('pnpm', \[\s*'exec',\s*'ktx',\s*'wiki',\s*'revenue'/); + assert.match(source, /pnpmCommand\(\[\s*'exec',\s*'ktx',\s*'wiki',\s*'revenue'/); assert.match(source, /semantic-layer', 'warehouse', 'orders\.yaml'/); - assert.match(source, /run\('pnpm', \[\s*'exec',\s*'ktx',\s*'sl',\s*'orders'/); + assert.match(source, /pnpmCommand\(\[\s*'exec',\s*'ktx',\s*'sl',\s*'orders'/); assert.match(source, /orders\.order_count/); assert.match(source, /node:sqlite/); assert.match(source, /driver: sqlite/); @@ -516,7 +529,7 @@ describe('verification snippets', () => { assert.match(source, /ktx admin runtime stop/); assert.doesNotMatch(source, /ktx admin runtime prune/); assert.doesNotMatch(source, /staleRuntimeDir/); - assert.match(source, /run\('pnpm', \[\s*'exec',\s*'ktx',\s*'ingest',\s*'warehouse'/); + assert.match(source, /pnpmCommand\(\['exec', 'ktx', 'ingest', 'warehouse'/); assert.match(source, /'--deep'/); assert.doesNotMatch(source, /'--enrich'/); assert.match(source, /ktx ingest fast verified/); @@ -534,8 +547,11 @@ describe('verification snippets', () => { it('exercises supported public package CLI commands', () => { const source = npmCliSmokeSource(); - assert.match(source, /pnpm', \['exec', 'ktx', '--help'\]/); - assert.match(source, /pnpm', \['exec', 'ktx', 'setup', '--help'\]/); + assert.match(source, /function pnpmCommand\(args\)/); + assert.match(source, /process\.platform === 'win32'/); + assert.doesNotMatch(source, /run\('pnpm',/); + assert.match(source, /pnpmCommand\(\['exec', 'ktx', '--help'\]\)/); + assert.match(source, /pnpmCommand\(\['exec', 'ktx', 'setup', '--help'\]\)/); assert.match(source, /Usage: ktx setup/); assert.doesNotMatch(source, new RegExp(["'demo'", "'--mode'", "'deterministic'"].join(', '))); assert.match(source, /'status', '--verbose', '--no-input'/); From 1071f9d1c9b2c8ddc1b3aa74972aecf36d7eb236 Mon Sep 17 00:00:00 2001 From: Andrey Avtomonov Date: Tue, 26 May 2026 12:21:53 +0200 Subject: [PATCH 09/74] fix(ingest): attribute historic-sql evidence writes in bundle report (#220) The emit_historic_sql_evidence tool took rawPath as LLM-supplied input, so projection actions frequently lacked defensible raw paths and every row in bundle_ingest_reports fell through as actionType: 'skipped' with null artifact metadata, hiding the wiki pages and SL merges the run had actually produced (KLO-698). The tool now reads the work unit's rawFiles from session.allowedRawPaths and stores them on the evidence envelope; the projection emits actions with those paths, and stale/archive actions are anchored to manifest.json so they also surface as non-skipped provenance rows. --- .../adapters/historic-sql/chunk-unified.ts | 2 +- .../adapters/historic-sql/evidence-tool.ts | 14 +++-- .../ingest/adapters/historic-sql/evidence.ts | 4 +- .../adapters/historic-sql/projection.ts | 7 ++- .../src/skills/historic_sql_patterns/SKILL.md | 4 +- .../skills/historic_sql_table_digest/SKILL.md | 1 - .../historic-sql/evidence-tool.test.ts | 51 +++++++++++++++++-- .../adapters/historic-sql/evidence.test.ts | 4 +- .../local-ingest-acceptance.test.ts | 30 +++++++++-- .../adapters/historic-sql/projection.test.ts | 12 ++--- .../ingest/local-bundle-ingest.test.ts | 1 - 11 files changed, 99 insertions(+), 31 deletions(-) diff --git a/packages/cli/src/context/ingest/adapters/historic-sql/chunk-unified.ts b/packages/cli/src/context/ingest/adapters/historic-sql/chunk-unified.ts index 4e6dfeda..4477e753 100644 --- a/packages/cli/src/context/ingest/adapters/historic-sql/chunk-unified.ts +++ b/packages/cli/src/context/ingest/adapters/historic-sql/chunk-unified.ts @@ -60,7 +60,7 @@ export async function chunkHistoricSqlUnifiedStagedDir(stagedDir: string, diffSe dependencyPaths: ['manifest.json'], peerFileIndex: files.filter((file) => file !== path && file !== 'manifest.json').sort(), notes: - `Use historic_sql_patterns. Read ${path} and emit pattern objects with emit_historic_sql_evidence using rawPath "${path}". Do not call wiki_write or sl_write_source.`, + `Use historic_sql_patterns. Read ${path} and emit pattern objects with emit_historic_sql_evidence. Do not call wiki_write or sl_write_source.`, }); } diff --git a/packages/cli/src/context/ingest/adapters/historic-sql/evidence-tool.ts b/packages/cli/src/context/ingest/adapters/historic-sql/evidence-tool.ts index 29d66cb2..1b03e1c6 100644 --- a/packages/cli/src/context/ingest/adapters/historic-sql/evidence-tool.ts +++ b/packages/cli/src/context/ingest/adapters/historic-sql/evidence-tool.ts @@ -10,7 +10,6 @@ const emitHistoricSqlEvidenceInputSchema = z .object({ kind: z.enum(['table_usage', 'pattern']), table: z.string().min(1).optional(), - rawPath: z.string().min(1), usage: tableUsageOutputSchema.optional(), pattern: patternOutputSchema.optional(), }) @@ -46,6 +45,7 @@ interface EmitHistoricSqlEvidenceToolContext { connectionId?: string | null; session?: { ingest?: { runId: string; sourceKey: string }; + allowedRawPaths?: ReadonlySet; configService?: { writeFile( path: string, @@ -66,7 +66,7 @@ function unitKeyForEvidence(input: EmitHistoricSqlEvidenceInput): string { return `historic-sql-pattern-${String(input.pattern?.slug).replace(/[^a-zA-Z0-9]+/g, '-').replace(/^-+|-+$/g, '')}`; } -function evidenceEnvelope(input: EmitHistoricSqlEvidenceInput, connectionId: string) { +function evidenceEnvelope(input: EmitHistoricSqlEvidenceInput, connectionId: string, rawPaths: string[]) { if (input.kind === 'table_usage') { if (!input.table || !input.usage) { throw new Error('Invalid historic-SQL table usage evidence input.'); @@ -75,7 +75,7 @@ function evidenceEnvelope(input: EmitHistoricSqlEvidenceInput, connectionId: str kind: 'table_usage' as const, connectionId, table: input.table, - rawPath: input.rawPath, + rawPaths, usage: input.usage, }; } @@ -85,7 +85,7 @@ function evidenceEnvelope(input: EmitHistoricSqlEvidenceInput, connectionId: str return { kind: 'pattern' as const, connectionId, - rawPath: input.rawPath, + rawPaths, pattern: input.pattern, }; } @@ -102,9 +102,13 @@ export function createEmitHistoricSqlEvidenceTool(defaultContext?: EmitHistoricS if (!ingest || ingest.sourceKey !== 'historic-sql' || !configService || !context?.connectionId) { return 'Error: emit_historic_sql_evidence is only available during historic-sql ingest.'; } + const rawPaths = context.session?.allowedRawPaths ? [...context.session.allowedRawPaths].sort() : []; + if (rawPaths.length === 0) { + return 'Error: emit_historic_sql_evidence requires a WorkUnit context with at least one raw file.'; + } const unitKey = unitKeyForEvidence(input); - const evidence = evidenceEnvelope(input, context.connectionId); + const evidence = evidenceEnvelope(input, context.connectionId, rawPaths); const content = serializeHistoricSqlEvidence(evidence); await configService.writeFile( historicSqlEvidencePath(ingest.runId, unitKey), diff --git a/packages/cli/src/context/ingest/adapters/historic-sql/evidence.ts b/packages/cli/src/context/ingest/adapters/historic-sql/evidence.ts index ddf26aed..6445decf 100644 --- a/packages/cli/src/context/ingest/adapters/historic-sql/evidence.ts +++ b/packages/cli/src/context/ingest/adapters/historic-sql/evidence.ts @@ -14,7 +14,7 @@ export const historicSqlTableUsageEvidenceSchema = z.object({ kind: z.literal('table_usage'), connectionId: z.string().min(1), table: z.string().min(1), - rawPath: z.string().min(1), + rawPaths: z.array(z.string().min(1)).min(1), usage: tableUsageOutputSchema, }); @@ -22,7 +22,7 @@ export const historicSqlTableUsageEvidenceSchema = z.object({ export const historicSqlPatternEvidenceSchema = z.object({ kind: z.literal('pattern'), connectionId: z.string().min(1), - rawPath: z.string().min(1), + rawPaths: z.array(z.string().min(1)).min(1), pattern: patternOutputSchema, }); diff --git a/packages/cli/src/context/ingest/adapters/historic-sql/projection.ts b/packages/cli/src/context/ingest/adapters/historic-sql/projection.ts index 272a96dc..2c7830a2 100644 --- a/packages/cli/src/context/ingest/adapters/historic-sql/projection.ts +++ b/packages/cli/src/context/ingest/adapters/historic-sql/projection.ts @@ -278,7 +278,7 @@ export async function projectHistoricSqlEvidence(input: HistoricSqlProjectionInp key: sourceName, targetConnectionId: input.connectionId, detail: `Merged historic-SQL usage for ${matchingEvidence.table}`, - rawPaths: [matchingEvidence.rawPath], + rawPaths: matchingEvidence.rawPaths, }); } } else if (entry.usage && !currentTables.has(tableRef)) { @@ -298,6 +298,7 @@ export async function projectHistoricSqlEvidence(input: HistoricSqlProjectionInp key: sourceName, targetConnectionId: input.connectionId, detail: `Marked historic-SQL usage stale for ${tableRef}`, + rawPaths: ['manifest.json'], }); } } @@ -341,7 +342,7 @@ export async function projectHistoricSqlEvidence(input: HistoricSqlProjectionInp type: reusable ? 'updated' : 'created', key, detail: `Projected historic-SQL pattern ${pattern.pattern.title}`, - rawPaths: [pattern.rawPath], + rawPaths: pattern.rawPaths, }); } @@ -361,6 +362,7 @@ export async function projectHistoricSqlEvidence(input: HistoricSqlProjectionInp type: 'updated', key: page.key, detail: `Archived stale historic-SQL pattern page ${page.key}`, + rawPaths: ['manifest.json'], }); continue; } @@ -377,6 +379,7 @@ export async function projectHistoricSqlEvidence(input: HistoricSqlProjectionInp type: 'updated', key: page.key, detail: `Marked historic-SQL pattern page ${page.key} stale`, + rawPaths: ['manifest.json'], }); } diff --git a/packages/cli/src/skills/historic_sql_patterns/SKILL.md b/packages/cli/src/skills/historic_sql_patterns/SKILL.md index 057a7c78..fc7096a1 100644 --- a/packages/cli/src/skills/historic_sql_patterns/SKILL.md +++ b/packages/cli/src/skills/historic_sql_patterns/SKILL.md @@ -15,8 +15,7 @@ Use this skill when the WorkUnit raw file is a `patterns-input/part-0001.json` s 3. Call `read_raw_file` for that exact raw file path. 4. Identify recurring analytical intents that span at least two tables and have repeated usage signal. 5. Emit one `pattern` evidence object per durable cross-table intent by calling `emit_historic_sql_evidence`. -6. Set each evidence object's `rawPath` to the exact raw file path read in step 3. -7. Stop after all pattern evidence has been emitted. +6. Stop after all pattern evidence has been emitted. Every join column mentioned in pattern descriptions must be verified via entity_details for both sides of the join. @@ -56,7 +55,6 @@ Each call to `emit_historic_sql_evidence` must use this shape: ```json { "kind": "pattern", - "rawPath": "patterns-input/part-0001.json", "pattern": { "slug": "order-lifecycle-analysis", "title": "Order Lifecycle Analysis", diff --git a/packages/cli/src/skills/historic_sql_table_digest/SKILL.md b/packages/cli/src/skills/historic_sql_table_digest/SKILL.md index 99cf6936..1710f21c 100644 --- a/packages/cli/src/skills/historic_sql_table_digest/SKILL.md +++ b/packages/cli/src/skills/historic_sql_table_digest/SKILL.md @@ -53,7 +53,6 @@ Call `emit_historic_sql_evidence` with this shape: { "kind": "table_usage", "table": "public.orders", - "rawPath": "tables/public.orders.json", "usage": { "narrative": "Orders are repeatedly queried for paid/refunded lifecycle analysis and customer-level rollups.", "frequencyTier": "high", diff --git a/packages/cli/test/context/ingest/adapters/historic-sql/evidence-tool.test.ts b/packages/cli/test/context/ingest/adapters/historic-sql/evidence-tool.test.ts index 0185798b..3638bb1b 100644 --- a/packages/cli/test/context/ingest/adapters/historic-sql/evidence-tool.test.ts +++ b/packages/cli/test/context/ingest/adapters/historic-sql/evidence-tool.test.ts @@ -11,15 +11,14 @@ describe('emit_historic_sql_evidence tool', () => { }); }); - it('writes table usage evidence to the ignored run evidence directory', async () => { - const writeFile = vi.fn(async () => ({ success: true, commitHash: null })); + it('writes table usage evidence using the work unit allowed raw paths', async () => { + const writeFile = vi.fn(async (_path: string, _body: string) => ({ success: true, commitHash: null })); const tool = createEmitHistoricSqlEvidenceTool(); const result = await tool.execute!( { kind: 'table_usage', table: 'public.orders', - rawPath: 'tables/public.orders.json', usage: { narrative: 'Orders are repeatedly queried by paid status.', frequencyTier: 'high', @@ -36,6 +35,7 @@ describe('emit_historic_sql_evidence tool', () => { connectionId: 'warehouse', session: { ingest: { runId: 'run-1', jobId: 'job-1', syncId: 'sync-1', sourceKey: 'historic-sql' }, + allowedRawPaths: new Set(['tables/public.orders.json']), configService: { writeFile }, }, }, @@ -45,12 +45,53 @@ describe('emit_historic_sql_evidence tool', () => { expect(result).toBe('Recorded historic-SQL table_usage evidence for public.orders.'); expect(writeFile).toHaveBeenCalledWith( '.ktx/ingest-evidence/historic-sql/run-1/historic-sql-table-public-orders.json', - expect.stringContaining('"kind": "table_usage"'), + expect.stringContaining('"rawPaths"'), 'System User', 'system@example.com', 'Record historic-SQL evidence: historic-sql-table-public-orders', { skipLock: true }, ); + expect(writeFile).toHaveBeenCalledWith( + expect.any(String), + expect.stringContaining('tables/public.orders.json'), + expect.any(String), + expect.any(String), + expect.any(String), + expect.any(Object), + ); + }); + + it('rejects calls without a WorkUnit raw file context', async () => { + const tool = createEmitHistoricSqlEvidenceTool(); + + await expect( + tool.execute!( + { + kind: 'pattern', + pattern: { + slug: 'orders', + title: 'Orders', + narrative: 'Orders pattern.', + definitionSql: 'select * from public.orders', + tablesInvolved: ['public.orders'], + slRefs: ['orders'], + constituentTemplateIds: ['pg:1'], + }, + }, + { + toolCallId: 'call-1', + messages: [], + abortSignal: new AbortController().signal, + experimental_context: { + connectionId: 'warehouse', + session: { + ingest: { runId: 'run-1', jobId: 'job-1', syncId: 'sync-1', sourceKey: 'historic-sql' }, + configService: { writeFile: vi.fn() }, + }, + }, + } as never, + ), + ).resolves.toContain('emit_historic_sql_evidence requires a WorkUnit context'); }); it('rejects non-historic ingest sessions', async () => { @@ -60,7 +101,6 @@ describe('emit_historic_sql_evidence tool', () => { tool.execute!( { kind: 'pattern', - rawPath: 'patterns-input.json', pattern: { slug: 'orders', title: 'Orders', @@ -79,6 +119,7 @@ describe('emit_historic_sql_evidence tool', () => { connectionId: 'warehouse', session: { ingest: { runId: 'run-1', jobId: 'job-1', syncId: 'sync-1', sourceKey: 'notion' }, + allowedRawPaths: new Set(['patterns-input/part-0001.json']), configService: { writeFile: vi.fn() }, }, }, diff --git a/packages/cli/test/context/ingest/adapters/historic-sql/evidence.test.ts b/packages/cli/test/context/ingest/adapters/historic-sql/evidence.test.ts index 1f188a95..1d1d7f6c 100644 --- a/packages/cli/test/context/ingest/adapters/historic-sql/evidence.test.ts +++ b/packages/cli/test/context/ingest/adapters/historic-sql/evidence.test.ts @@ -12,7 +12,7 @@ describe('historic-sql evidence contracts', () => { kind: 'table_usage', connectionId: 'warehouse', table: 'public.orders', - rawPath: 'tables/public.orders.json', + rawPaths: ['tables/public.orders.json'], usage: { narrative: 'Orders are repeatedly queried for paid/refunded lifecycle analysis.', frequencyTier: 'high', @@ -32,7 +32,7 @@ describe('historic-sql evidence contracts', () => { historicSqlEvidenceEnvelopeSchema.parse({ kind: 'pattern', connectionId: 'warehouse', - rawPath: 'patterns-input.json', + rawPaths: ['patterns-input/part-0001.json'], pattern: { slug: 'order-lifecycle-analysis', title: 'Order Lifecycle Analysis', diff --git a/packages/cli/test/context/ingest/adapters/historic-sql/local-ingest-acceptance.test.ts b/packages/cli/test/context/ingest/adapters/historic-sql/local-ingest-acceptance.test.ts index 1e626edd..48e5744b 100644 --- a/packages/cli/test/context/ingest/adapters/historic-sql/local-ingest-acceptance.test.ts +++ b/packages/cli/test/context/ingest/adapters/historic-sql/local-ingest-acceptance.test.ts @@ -57,7 +57,6 @@ class HistoricSqlAcceptanceAgentRunner implements AgentRunnerPort { const result = await emitEvidence.execute({ kind: 'table_usage', table: 'public.orders', - rawPath: 'tables/public.orders.json', usage: { narrative: 'Analysts repeatedly inspect paid order lifecycle by customer segment.', frequencyTier: 'high', @@ -76,7 +75,6 @@ class HistoricSqlAcceptanceAgentRunner implements AgentRunnerPort { const result = await emitEvidence.execute({ kind: 'table_usage', table: 'public.customers', - rawPath: 'tables/public.customers.json', usage: { narrative: 'Customers provide segment context for paid order lifecycle analysis.', frequencyTier: 'mid', @@ -94,7 +92,6 @@ class HistoricSqlAcceptanceAgentRunner implements AgentRunnerPort { if (params.telemetryTags.unitKey === 'historic-sql-patterns-part-0001') { const result = await emitEvidence.execute({ kind: 'pattern', - rawPath: 'patterns-input/part-0001.json', pattern: { slug: 'paid-order-lifecycle', title: 'Paid Order Lifecycle', @@ -257,6 +254,33 @@ describe('historic-SQL local ingest retrieval acceptance', () => { ]), ); + // Regression for KLO-698: the bundle report's provenance rows must + // attribute the table-usage merges and pattern-page writes back to + // their raw files instead of falling through as `actionType: 'skipped'` + // with null artifact metadata. + const provenanceRows = result.report.body.provenanceRows; + const nonSkipped = provenanceRows.filter((row) => row.actionType !== 'skipped'); + expect(nonSkipped).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + rawPath: 'tables/public.orders.json', + artifactKind: 'sl', + artifactKey: 'orders', + }), + expect.objectContaining({ + rawPath: 'tables/public.customers.json', + artifactKind: 'sl', + artifactKey: 'customers', + }), + expect.objectContaining({ + rawPath: 'patterns-input/part-0001.json', + artifactKind: 'wiki', + artifactKey: 'historic-sql-paid-order-lifecycle', + actionType: 'wiki_written', + }), + ]), + ); + await expect(readFile(join(project.projectDir, 'semantic-layer/warehouse/_schema/public.yaml'), 'utf-8')).resolves .toContain('Analysts repeatedly inspect paid order lifecycle by customer segment.'); await expect(readFile(join(project.projectDir, 'wiki/global/historic-sql-paid-order-lifecycle.md'), 'utf-8')) diff --git a/packages/cli/test/context/ingest/adapters/historic-sql/projection.test.ts b/packages/cli/test/context/ingest/adapters/historic-sql/projection.test.ts index 722d7156..8487cdfc 100644 --- a/packages/cli/test/context/ingest/adapters/historic-sql/projection.test.ts +++ b/packages/cli/test/context/ingest/adapters/historic-sql/projection.test.ts @@ -60,7 +60,7 @@ describe('projectHistoricSqlEvidence', () => { kind: 'table_usage', connectionId: 'warehouse', table: 'public.orders', - rawPath: 'tables/public.orders.json', + rawPaths: ['tables/public.orders.json'], usage: { narrative: 'Orders are repeatedly queried for lifecycle analysis.', frequencyTier: 'high', @@ -158,7 +158,7 @@ describe('projectHistoricSqlEvidence', () => { await writeJson(workdir, '.ktx/ingest-evidence/historic-sql/run-1/pattern.json', { kind: 'pattern', connectionId: 'warehouse', - rawPath: 'patterns-input.json', + rawPaths: ['patterns-input/part-0001.json'], pattern: { slug: 'order-lifecycle-analysis', title: 'Order Lifecycle Analysis', @@ -179,7 +179,7 @@ describe('projectHistoricSqlEvidence', () => { expect.objectContaining({ target: 'wiki', key: 'historic-sql-old-order-lifecycle', - rawPaths: ['patterns-input.json'], + rawPaths: ['patterns-input/part-0001.json'], }), ]), ); @@ -234,7 +234,7 @@ describe('projectHistoricSqlEvidence', () => { await writeJson(workdir, '.ktx/ingest-evidence/historic-sql/run-1/pattern.json', { kind: 'pattern', connectionId: 'warehouse', - rawPath: 'patterns-input.json', + rawPaths: ['patterns-input/part-0001.json'], pattern: { slug: 'order-lifecycle-analysis', title: 'Order Lifecycle Analysis', @@ -343,7 +343,7 @@ describe('projectHistoricSqlEvidence', () => { kind: 'table_usage', connectionId: 'warehouse', table: 'public.customers', - rawPath: 'tables/public.customers.json', + rawPaths: ['tables/public.customers.json'], usage: { narrative: 'Customers were queried.', frequencyTier: 'low', @@ -380,7 +380,7 @@ describe('projectHistoricSqlEvidence', () => { expect(result.touchedSources).toEqual([{ connectionId: 'warehouse', sourceName: 'orders' }]); const staleAction = result.actions.find((action) => action.target === 'sl' && action.key === 'orders'); expect(staleAction).toEqual(expect.objectContaining({ target: 'sl', key: 'orders' })); - expect(staleAction?.rawPaths).toBeUndefined(); + expect(staleAction?.rawPaths).toEqual(['manifest.json']); const shard = YAML.parse(await readFile(join(workdir, 'semantic-layer/warehouse/_schema/public.yaml'), 'utf-8')); expect(shard.tables.orders.usage).toEqual({ ownerNote: 'keep analyst annotation', diff --git a/packages/cli/test/context/ingest/local-bundle-ingest.test.ts b/packages/cli/test/context/ingest/local-bundle-ingest.test.ts index 744af5be..6140dd10 100644 --- a/packages/cli/test/context/ingest/local-bundle-ingest.test.ts +++ b/packages/cli/test/context/ingest/local-bundle-ingest.test.ts @@ -139,7 +139,6 @@ class HistoricSqlEvidenceAgentRunner implements AgentRunnerPort { const result = await emitEvidence.execute({ kind: 'table_usage', table: 'public.orders', - rawPath: 'tables/public.orders.json', usage: { narrative: 'Orders are repeatedly queried by lifecycle status.', frequencyTier: 'high', From 62699bfe9d6e77abeeddfe81ccd544b2feb8893c Mon Sep 17 00:00:00 2001 From: Andrey Avtomonov Date: Tue, 26 May 2026 13:42:52 +0200 Subject: [PATCH 10/74] feat(cli): surface docs and demo-warehouse links in ktx setup (#221) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a Clack note pointing to https://docs.kaelio.com/ktx right after the setup intro, and a second note pointing to https://kaelio.com/start above the database driver multiselect — mirroring the docs-site CTA wording. Closes KLO-715 and KLO-716. --- packages/cli/src/setup-databases.ts | 6 ++++++ packages/cli/src/setup.ts | 1 + 2 files changed, 7 insertions(+) diff --git a/packages/cli/src/setup-databases.ts b/packages/cli/src/setup-databases.ts index 0704ecd2..4014d689 100644 --- a/packages/cli/src/setup-databases.ts +++ b/packages/cli/src/setup-databases.ts @@ -35,6 +35,7 @@ import { isDemoConnection } from './telemetry/demo-detect.js'; import { emitTelemetryEvent } from './telemetry/index.js'; import { createKtxSetupPromptAdapter, + createKtxSetupUiAdapter, type KtxSetupPromptOption, } from './setup-prompts.js'; @@ -1780,6 +1781,11 @@ async function chooseDrivers( return 'missing-input'; } const initialValues = unique(options?.initialDrivers ?? []); + createKtxSetupUiAdapter().note( + 'Get demo credentials at https://kaelio.com/start', + '🎁 Need a warehouse to play with?', + io, + ); const choices = await prompts.multiselect({ message: withMultiselectNavigation('Which databases should KTX connect to?'), options: [...DRIVER_OPTIONS], diff --git a/packages/cli/src/setup.ts b/packages/cli/src/setup.ts index 422f95c5..9c523902 100644 --- a/packages/cli/src/setup.ts +++ b/packages/cli/src/setup.ts @@ -586,6 +586,7 @@ export async function runKtxSetup(args: KtxSetupArgs, io: KtxCliIo, deps: KtxSet async function runKtxSetupInner(args: KtxSetupArgs, io: KtxCliIo, deps: KtxSetupDeps = {}): Promise { const setupUi = deps.setupUi ?? createKtxSetupUiAdapter(); setupUi.intro('KTX setup', io); + setupUi.note('https://docs.kaelio.com/ktx', '📚 Docs', io); let entryAction: KtxSetupEntryAction | undefined; let projectResult: Awaited>; let agentNextActions: string | undefined; From 0eeac6f980f5df8acba9d50607eb4f85a8f30b8f Mon Sep 17 00:00:00 2001 From: Andrey Avtomonov Date: Tue, 26 May 2026 14:10:12 +0200 Subject: [PATCH 11/74] docs(readme): restructure for clarity and add FAQ + comparison table (#222) * docs(readme): restructure for clarity and add FAQ + comparison table Restructure the README: trim Common Commands to the 6 essentials and link to the CLI Reference, add a "How ktx compares" table and "Who is ktx for" qualifier, introduce a small FAQ, wrap key prompts in GitHub callouts, merge the duplicate workspace-layout section into Development, move Telemetry next to License, and add a Star History chart. * docs(readme): tighten Skip-ktx list and convert FAQ to bullets --- README.md | 216 +++++++++++++++++++++++++++++------------------------- 1 file changed, 116 insertions(+), 100 deletions(-) diff --git a/README.md b/README.md index 8dadd3e1..2e034677 100644 --- a/README.md +++ b/README.md @@ -16,17 +16,26 @@ Y Combinator P25

+

+ Quickstart · + CLI Reference · + Agent Setup · + Slack +

+ --- **ktx** is a self-improving context layer that teaches agents how to query your warehouse accurately - from approved metric definitions, joinable columns, and business knowledge it builds and maintains for you. -Works with PostgreSQL, Snowflake, BigQuery, ClickHouse, MySQL, SQL Server, and -SQLite. Integrates with dbt, MetricFlow, LookML, Looker, Metabase, and Notion. +> [!NOTE] +> Run **ktx** with your own LLM API keys or a **Claude Pro/Max** subscription. +> No extra usage billing from **ktx**. -Runs with your own LLM API keys or a **Claude -Pro/Max subscription - no extra usage billing from** **ktx**. +

+ ktx ingestion flow from source systems through validation to wiki and semantic-layer outputs +

## Why ktx @@ -51,23 +60,35 @@ upkeep and don't absorb the rest of your company's knowledge. - **Serves agents at execution.** Exposes CLI and MCP tools with combined full-text and semantic search across wiki and semantic-layer entities. -Agents can run raw SQL when they need it, or compose semantic-layer queries -when they want approved metrics with reliable joins. +## How ktx compares -

- ktx ingestion flow from source systems through validation to wiki and semantic-layer outputs -

+| | General-purpose agent | Traditional semantic layer | **ktx** | +| --- | :---: | :---: | :---: | +| Builds warehouse context automatically | — | — | ✓ | +| Detects joinable columns + resolves fan/chasm traps | — | Manual | ✓ | +| Approved, reusable metric definitions | — | ✓ | ✓ | +| Absorbs wiki / Notion / team knowledge | — | — | ✓ | +| Flags contradictions across sources | — | — | ✓ | +| Ships CLI + MCP for agent execution | Partial | — | ✓ | +| Read-only by design | n/a | n/a | ✓ | -## Agent Setup +## Who is ktx for -Ask an agent such as Claude Code, Codex, Cursor, or OpenCode to install and -configure **ktx** from your project directory: +**Use ktx if you:** -```text -Follow instructions from -https://docs.kaelio.com/ktx/docs/agents-setup.md -to install and configure ktx -``` +- Want agents like Claude Code, Codex, Cursor, or OpenCode to query your + warehouse with approved metric definitions +- Have business knowledge scattered across dbt, Looker, Metabase, Notion, and + team wikis +- Need agents to reuse canonical SQL instead of inventing it on every prompt + +**Skip ktx if you:** + +- You don't have a SQL warehouse - **ktx** sits on top of one +- You only need one ad-hoc query - `psql` or a notebook will do + +Works with PostgreSQL, Snowflake, BigQuery, ClickHouse, MySQL, SQL Server, and +SQLite. Integrates with dbt, MetricFlow, LookML, Looker, Metabase, and Notion. ## Quick Start @@ -77,10 +98,10 @@ ktx setup ktx status ``` -`ktx setup` creates or resumes a local **ktx** project, configures providers and -connections, builds context, and installs agent integration. +`ktx setup` creates or resumes a local **ktx** project, configures providers +and connections, builds context, and installs agent integration. -Example `ktx status` output after setup: +Example `ktx status` after setup: ```text ktx project: /home/user/analytics @@ -93,38 +114,33 @@ ktx context built: yes Agent integration ready: yes (codex:project) ``` -## Telemetry +> [!TIP] +> Already using an agent? Ask Claude Code, Codex, Cursor, or OpenCode from +> your project directory: +> +> ```text +> Follow instructions from +> https://docs.kaelio.com/ktx/docs/agents-setup.md +> to install and configure ktx +> ``` -**ktx** collects anonymous usage telemetry from interactive CLI runs to improve -setup, command reliability, and data-agent workflows. See -[Telemetry](https://docs.kaelio.com/ktx/docs/community/telemetry) for the event -catalog, privacy details, and opt-out options. +> [!IMPORTANT] +> If `ktx status` prints `ktx mcp start --project-dir ...`, run it before +> opening your agent client. -## Common Commands +## First commands | Command | Purpose | -|---------|---------| +| --- | --- | | `ktx setup` | Create, resume, or update a **ktx** project | | `ktx status` | Check project readiness | -| `ktx connection` | List configured connections | -| `ktx connection test` | Test every configured connection | -| `ktx connection test ` | Test one connection | | `ktx ingest` | Build context for every configured connection | -| `ktx ingest ` | Build context for one connection | -| `ktx ingest --text "..."` | Capture free-form notes into memory | -| `ktx ingest --file notes.md --connection-id ` | Capture a text file into memory | -| `ktx sl` | List semantic sources | | `ktx sl "revenue"` | Search semantic sources | -| `ktx sl validate --connection-id ` | Validate a semantic source | -| `ktx sl query --measure --format sql` | Compile semantic-layer SQL | -| `ktx sql --connection "select 1"` | Execute read-only SQL | -| `ktx wiki` | List local wiki pages | -| `ktx wiki "revenue definition"` | Search local wiki pages | -| `ktx mcp` | Show MCP daemon status | -| `ktx mcp start` | Start the local MCP server for agent clients | +| `ktx wiki "refund policy"` | Search local wiki pages | +| `ktx mcp start` | Start the MCP server for agent clients | -Project resolution defaults to `KTX_PROJECT_DIR`, then the nearest `ktx.yaml`, -then the current directory. Pass `--project-dir ` when scripting. +See the [CLI Reference](https://docs.kaelio.com/ktx/docs/cli-reference/ktx) +for every command, flag, and option. ## Project Layout @@ -140,46 +156,43 @@ my-project/ Commit `ktx.yaml`, `semantic-layer/`, and `wiki/`. Keep `.ktx/` local. -## Agent Usage +Project resolution defaults to `KTX_PROJECT_DIR`, then the nearest `ktx.yaml`, +then the current directory. Pass `--project-dir ` when scripting. -Install **ktx** integration for Claude Code, Claude Desktop, Codex, Cursor, -OpenCode, and generic `.agents` clients: +## FAQ -```bash -ktx setup --agents -``` +- **Does ktx send my schema or query results to a hosted service?** + No. **ktx** runs locally. The only data leaving your machine is what you + send to the LLM provider you configured. +- **Which LLM backends are supported?** + Anthropic API, Google Vertex AI, AI Gateway, and the local Claude Code + session through the Claude Agent SDK. See + [LLM configuration](https://docs.kaelio.com/ktx/docs/guides/llm-configuration). +- **How is ktx different from a dbt or MetricFlow semantic layer?** + **ktx** *ingests* those layers and combines them with raw-table + introspection and wiki content. Agents get one searchable surface instead + of three disconnected ones - and **ktx** flags contradictions across + sources. +- **Does ktx need a running server?** + There is no hosted service. The local MCP daemon runs on demand via + `ktx mcp start` when an agent client needs it. +- **Is my warehouse safe?** + Yes. Connections are read-only - **ktx** never writes to your database. -Pass `--target ` to install or repair one specific integration. +## Docs -A typical agent workflow combines wiki and semantic-layer search before -querying: +- [Quickstart](https://docs.kaelio.com/ktx/docs/getting-started/quickstart) +- [The Context Layer](https://docs.kaelio.com/ktx/docs/concepts/the-context-layer) +- [Building Context](https://docs.kaelio.com/ktx/docs/guides/building-context) +- [CLI Reference](https://docs.kaelio.com/ktx/docs/cli-reference/ktx) +- [Agent Quickstart](https://docs.kaelio.com/ktx/docs/ai-resources/agent-quickstart) +- [Community & Support](https://docs.kaelio.com/ktx/docs/community/support) -```bash -ktx sl "revenue" --json -ktx wiki "refund policy" --json -ktx sl query --connection-id warehouse --measure orders.revenue --format sql -``` +## Community -During setup, choose **Ask data questions with ktx MCP** for agent clients. -Choose **Ask data questions + manage ktx with CLI commands** when an operator -agent also needs pinned `ktx` admin commands. Choose **Skip agent setup for -now** to leave agent integration incomplete and run `ktx setup --agents` later. - -After setup, **ktx** prints **Required before using agents** with the exact -commands to run. If the output includes `ktx mcp start --project-dir ...`, run -it before opening your agent. Claude Desktop gets a stdio MCP config entry and -prints separate skill upload steps under `.ktx/agents/claude/`. - -## Workspace layout - -| Path | Purpose | -|------|---------| -| `packages/cli` | TypeScript CLI package and published npm package source | -| `packages/cli/src/context` | Core context engine | -| `packages/cli/src/llm` | LLM and embedding providers | -| `packages/cli/src/connectors` | Database scan connectors | -| `python/ktx-sl` | Semantic-layer query planning | -| `python/ktx-daemon` | Portable compute service | +- **[Slack](https://join.slack.com/t/ktxcommunity/shared_invite/zt-3y9b44m1x-LVyNNJD5nwaZHq4XS29LMQ)** — ask questions, share what you're building, and chat with maintainers. +- **[GitHub Issues](https://github.com/Kaelio/ktx/issues)** — report bugs and request features. +- **[Contributing](https://docs.kaelio.com/ktx/docs/community/contributing)** — set up the repo, run tests, and open a PR. ## Development @@ -192,7 +205,18 @@ pnpm run build pnpm run check ``` -Use the development CLI locally: +**ktx** is a pnpm + uv workspace: + +| Path | Purpose | +| --- | --- | +| `packages/cli` | TypeScript CLI and published npm package source | +| `packages/cli/src/context` | Core context engine | +| `packages/cli/src/llm` | LLM and embedding providers | +| `packages/cli/src/connectors` | Database scan connectors | +| `python/ktx-sl` | Semantic-layer query planning | +| `python/ktx-daemon` | Portable compute service | + +Local development CLI: ```bash pnpm run setup:dev @@ -200,13 +224,6 @@ pnpm run link:dev ktx-dev --help ``` -**ktx** is a pnpm + uv workspace: - -- TypeScript packages live in `packages/*` -- CLI source lives in `packages/cli` -- Python runtime source lives in `python/ktx-sl` and `python/ktx-daemon` -- Public docs live in `docs-site/content/docs` - Useful checks: ```bash @@ -216,23 +233,22 @@ pnpm run dead-code uv run pytest -q ``` -## Docs +## Telemetry -- [Quickstart](docs-site/content/docs/getting-started/quickstart.mdx) -- [CLI Reference](docs-site/content/docs/cli-reference/ktx.mdx) -- [Building Context](docs-site/content/docs/guides/building-context.mdx) -- [Community & Support](docs-site/content/docs/community/support.mdx) -- [Contributing](docs-site/content/docs/community/contributing.mdx) - -## Community - -- **[Slack](https://join.slack.com/t/ktxcommunity/shared_invite/zt-3y9b44m1x-LVyNNJD5nwaZHq4XS29LMQ)** — ask questions, share what you're building, and chat with maintainers and other users. -- **[GitHub Issues](https://github.com/Kaelio/ktx/issues)** — report bugs and request features. -- **[Contributing guide](docs-site/content/docs/community/contributing.mdx)** — set up the repo, run tests, and open a PR. - -See [Community & Support](docs-site/content/docs/community/support.mdx) for the -full guide on where to ask what. +**ktx** collects anonymous usage telemetry from interactive CLI runs to +improve setup, command reliability, and data-agent workflows. No file paths, +hostnames, SQL, schema names, error messages, or argv are recorded. See +[Telemetry](https://docs.kaelio.com/ktx/docs/community/telemetry) for the +event catalog and opt-out options. ## License **ktx** is licensed under the Apache License, Version 2.0. See `LICENSE`. + +## Star History + +

+ + ktx Star History Chart + +

From bc7373fa8e791295dcd6b4fca20b358470c0890e Mon Sep 17 00:00:00 2001 From: Andrey Avtomonov Date: Tue, 26 May 2026 23:03:47 +0200 Subject: [PATCH 12/74] fix: update ktx CI boundary checks (#223) --- packages/cli/src/setup-databases.ts | 3 +- packages/cli/src/setup.ts | 3 +- scripts/check-boundaries.mjs | 41 ------------------ scripts/check-boundaries.test.mjs | 66 ----------------------------- scripts/examples-docs.test.mjs | 3 +- 5 files changed, 6 insertions(+), 110 deletions(-) diff --git a/packages/cli/src/setup-databases.ts b/packages/cli/src/setup-databases.ts index 4014d689..eb364228 100644 --- a/packages/cli/src/setup-databases.ts +++ b/packages/cli/src/setup-databases.ts @@ -40,6 +40,7 @@ import { } from './setup-prompts.js'; const HISTORIC_SQL_WORK_UNIT_MAX_CONCURRENCY = 6; +const KTX_QUICKSTART_URL = 'https://docs.kaelio.com/ktx/docs/getting-started/quickstart'; const execFileAsync = promisify(execFileCallback); export type KtxSetupDatabaseDriver = @@ -1782,7 +1783,7 @@ async function chooseDrivers( } const initialValues = unique(options?.initialDrivers ?? []); createKtxSetupUiAdapter().note( - 'Get demo credentials at https://kaelio.com/start', + `Get demo credentials from the Quickstart: ${KTX_QUICKSTART_URL}`, '🎁 Need a warehouse to play with?', io, ); diff --git a/packages/cli/src/setup.ts b/packages/cli/src/setup.ts index 9c523902..6b3442ca 100644 --- a/packages/cli/src/setup.ts +++ b/packages/cli/src/setup.ts @@ -170,6 +170,7 @@ export interface KtxSetupDeps { } const SOURCE_DRIVERS = new Set(['dbt', 'metricflow', 'metabase', 'looker', 'lookml', 'notion']); +const KTX_DOCS_URL = 'https://docs.kaelio.com/ktx'; type KtxSetupEntryAction = 'setup' | 'new-project' | 'agents' | 'status' | 'demo' | 'exit'; type KtxSetupFlowStep = 'models' | 'embeddings' | 'databases' | 'sources' | 'runtime' | 'context' | 'agents'; @@ -586,7 +587,7 @@ export async function runKtxSetup(args: KtxSetupArgs, io: KtxCliIo, deps: KtxSet async function runKtxSetupInner(args: KtxSetupArgs, io: KtxCliIo, deps: KtxSetupDeps = {}): Promise { const setupUi = deps.setupUi ?? createKtxSetupUiAdapter(); setupUi.intro('KTX setup', io); - setupUi.note('https://docs.kaelio.com/ktx', '📚 Docs', io); + setupUi.note(KTX_DOCS_URL, '📚 Docs', io); let entryAction: KtxSetupEntryAction | undefined; let projectResult: Awaited>; let agentNextActions: string | undefined; diff --git a/scripts/check-boundaries.mjs b/scripts/check-boundaries.mjs index 97ffef64..b47d0db0 100644 --- a/scripts/check-boundaries.mjs +++ b/scripts/check-boundaries.mjs @@ -5,15 +5,6 @@ import path from 'node:path'; import { fileURLToPath, pathToFileURL } from 'node:url'; const codeExtensions = new Set(['.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs', '.py']); -const runtimeAssetPatterns = [/^packages\/cli\/src\/prompts\/.+\.md$/, /^packages\/cli\/src\/skills\/.+\.md$/]; -const identifierSkipPrefixes = ['docs/', 'docs-site/', 'examples/', 'python/ktx-sl/plans/', 'python/ktx-sl/openspec/']; -const identifierAllowPatterns = [ - /^packages\/cli\/src\/(?:index|managed-local-embeddings|managed-python-command|managed-python-daemon|managed-python-runtime|release-version|runtime)(?:\.test)?\.ts$/, - /^python\/ktx-daemon\/src\/ktx_daemon\/__init__\.py$/, - /^scripts\/(?:build-python-runtime-wheel|local-embeddings-runtime-smoke|package-artifacts|public-npm-release-metadata|published-package-smoke|release-readiness)(?:\.test)?\.mjs$/, - /^scripts\/semantic-release-config\.cjs$/, -]; -const forbiddenIdentifierTerms = ['kae' + 'lio', 'Kae' + 'lio', 'KAE' + 'LIO_']; const appImportPatterns = [ { @@ -83,10 +74,6 @@ function isCodeSource(relativePath) { return codeExtensions.has(path.extname(relativePath)); } -function isRuntimeAsset(relativePath) { - return runtimeAssetPatterns.some((pattern) => pattern.test(relativePath)); -} - function scansForAppImports(relativePath) { return isCodeSource(relativePath); } @@ -113,18 +100,6 @@ function scansForConcreteDialectImportBoundaries(relativePath) { ); } -function scansForForbiddenIdentifiers(relativePath) { - return (isCodeSource(relativePath) && !isTestSource(relativePath)) || isRuntimeAsset(relativePath); -} - -function skipsIdentifierScan(relativePath) { - return identifierSkipPrefixes.some((prefix) => relativePath.startsWith(prefix)); -} - -function allowsForbiddenIdentifier(relativePath) { - return identifierAllowPatterns.some((pattern) => pattern.test(relativePath)); -} - export function scanFileContent(relativePath, content) { const normalizedPath = normalizePath(relativePath); const violations = []; @@ -178,22 +153,6 @@ export function scanFileContent(relativePath, content) { } } - if ( - scansForForbiddenIdentifiers(normalizedPath) && - !skipsIdentifierScan(normalizedPath) && - !allowsForbiddenIdentifier(normalizedPath) - ) { - for (const term of forbiddenIdentifierTerms) { - if (content.includes(term)) { - violations.push({ - file: normalizedPath, - kind: 'identifier', - message: `Forbidden product identifier "${term}"`, - }); - } - } - } - return violations; } diff --git a/scripts/check-boundaries.test.mjs b/scripts/check-boundaries.test.mjs index f279313d..e6dd4bb8 100644 --- a/scripts/check-boundaries.test.mjs +++ b/scripts/check-boundaries.test.mjs @@ -3,14 +3,6 @@ import { describe, it } from 'node:test'; import { scanFileContent } from './check-boundaries.mjs'; -function productName() { - return ['Kae', 'lio'].join(''); -} - -function lowerProductName() { - return ['kae', 'lio'].join(''); -} - describe('scanFileContent', () => { it('rejects source imports from application directories', () => { const serverAlias = '@' + 'server/contracts'; @@ -27,64 +19,6 @@ describe('scanFileContent', () => { ); }); - it('rejects forbidden product identifiers in code source files', () => { - const violations = scanFileContent('packages/cli/src/context/index.ts', `export const owner = '${lowerProductName()}';`); - - assert.equal(violations.length, 1); - assert.equal(violations[0]?.kind, 'identifier'); - }); - - it('rejects forbidden product identifiers in shipped runtime prompt assets', () => { - const violations = scanFileContent( - 'packages/cli/src/prompts/memory_agent_bundle_ingest_work_unit.md', - `Write output for ${productName()}.`, - ); - - assert.equal(violations.length, 1); - assert.equal(violations[0]?.kind, 'identifier'); - assert.equal(violations[0]?.file, 'packages/cli/src/prompts/memory_agent_bundle_ingest_work_unit.md'); - }); - - it('rejects forbidden product identifiers in shipped runtime skill assets', () => { - const violations = scanFileContent( - 'packages/cli/src/skills/metabase_ingest/SKILL.md', - `Use ${productName()} project conventions.`, - ); - - assert.equal(violations.length, 1); - assert.equal(violations[0]?.kind, 'identifier'); - assert.equal(violations[0]?.file, 'packages/cli/src/skills/metabase_ingest/SKILL.md'); - }); - - it('allows product identifiers in docs, examples, and transition metadata', () => { - const name = productName(); - - assert.equal(scanFileContent('docs/transition.md', name).length, 0); - assert.equal(scanFileContent('examples/transition.md', name).length, 0); - assert.equal(scanFileContent('python/ktx-sl/plans/brainstorm.md', name).length, 0); - assert.equal(scanFileContent('python/ktx-sl/openspec/specs/semantic-layer/spec.md', name).length, 0); - }); - - it('allows product identifiers in test fixtures', () => { - const name = lowerProductName(); - - assert.equal(scanFileContent('packages/cli/test/setup.test.ts', `project: ${name}-dev`).length, 0); - assert.equal(scanFileContent('packages/cli/test/context/ingest/importer.test.ts', `email: system@${name}.dev`).length, 0); - assert.equal(scanFileContent('python/ktx-daemon/tests/test_package.py', `${name}-ktx`).length, 0); - }); - - it('allows public package identifiers in release packaging and managed runtime source', () => { - const name = lowerProductName(); - - assert.equal(scanFileContent('scripts/local-embeddings-runtime-smoke.mjs', `@${name}/ktx`).length, 0); - assert.equal(scanFileContent('scripts/package-artifacts.test.mjs', `${name}-ktx`).length, 0); - assert.equal(scanFileContent('scripts/public-npm-release-metadata.mjs', `@${name}/ktx`).length, 0); - assert.equal(scanFileContent('scripts/semantic-release-config.cjs', `${name}-ktx-`).length, 0); - assert.equal(scanFileContent('packages/cli/src/release-version.ts', `@${name}/ktx`).length, 0); - assert.equal(scanFileContent('packages/cli/src/managed-python-runtime.ts', `${name}_ktx`).length, 0); - assert.equal(scanFileContent('python/ktx-daemon/src/ktx_daemon/__init__.py', `${name}-ktx`).length, 0); - }); - it('allows clean source files and clean runtime prompt assets', () => { assert.deepEqual( scanFileContent('packages/cli/src/context/index.ts', "export const packageName = 'ktx';"), diff --git a/scripts/examples-docs.test.mjs b/scripts/examples-docs.test.mjs index 53413ea5..b196aaa1 100644 --- a/scripts/examples-docs.test.mjs +++ b/scripts/examples-docs.test.mjs @@ -255,7 +255,8 @@ describe('standalone example docs', () => { assert.match(reviewingContext, /ktx ingest --all --no-input/); assert.match(quickstart, /schema context/); assert.match(primarySources, /context:\n queryHistory:/); - assert.match(rootReadme, /`ktx ingest ` \| Build context for one connection/); + assert.match(rootReadme, /`ktx ingest` \| Build context for every configured connection/); + assert.doesNotMatch(rootReadme, /`ktx ingest `/); assert.match(quickstart, /Databases:\n warehouse: deep context complete/); assert.match(quickstart, /Databases configured: yes \(warehouse\)/); assert.match(setupReference, /Databases configured: yes \(postgres-warehouse\)/); From 5d74bd35dec678d104e0af5b893d4495ad684960 Mon Sep 17 00:00:00 2001 From: semantic-release-bot Date: Tue, 26 May 2026 21:19:07 +0000 Subject: [PATCH 13/74] chore(release): 0.6.0 [skip ci] ## [0.6.0](https://github.com/Kaelio/ktx/compare/v0.5.0...v0.6.0) (2026-05-26) ### Features * **cli:** skip-context-sources menu + clack-style tree picker UX ([#213](https://github.com/Kaelio/ktx/issues/213)) ([cfd1749](https://github.com/Kaelio/ktx/commit/cfd1749ab91afa5834e578c99d6047d04639b7d9)) * **cli:** surface docs and demo-warehouse links in ktx setup ([#221](https://github.com/Kaelio/ktx/issues/221)) ([62699bf](https://github.com/Kaelio/ktx/commit/62699bfe9d6e77abeeddfe81ccd544b2feb8893c)) * **connectors:** generalize readiness and constraint handling ([#212](https://github.com/Kaelio/ktx/issues/212)) ([78b8a0c](https://github.com/Kaelio/ktx/commit/78b8a0c025d62696c56e945a30b72c1f34fe816e)) ### Bug Fixes * **ingest:** attribute historic-sql evidence writes in bundle report ([#220](https://github.com/Kaelio/ktx/issues/220)) ([1071f9d](https://github.com/Kaelio/ktx/commit/1071f9d1c9b2c8ddc1b3aa74972aecf36d7eb236)) * **scripts:** make package artifacts pnpm launch work on Windows ([2a6fb19](https://github.com/Kaelio/ktx/commit/2a6fb19ba425a06f89e14621b1b7934c9b175bf6)) * update ktx CI boundary checks ([#223](https://github.com/Kaelio/ktx/issues/223)) ([bc7373f](https://github.com/Kaelio/ktx/commit/bc7373fa8e791295dcd6b4fca20b358470c0890e)) ### Documentation * ban ktx compatibility shims ([#214](https://github.com/Kaelio/ktx/issues/214)) ([a9db379](https://github.com/Kaelio/ktx/commit/a9db3797e6701fb9a629196506cc50b2de5c7fb7)) * **readme:** restructure for clarity and add FAQ + comparison table ([#222](https://github.com/Kaelio/ktx/issues/222)) ([0eeac6f](https://github.com/Kaelio/ktx/commit/0eeac6f980f5df8acba9d50607eb4f85a8f30b8f)) * standardize fanout terminology ([#218](https://github.com/Kaelio/ktx/issues/218)) ([9248688](https://github.com/Kaelio/ktx/commit/924868841deefa69ac86f694e8ad3c92c27d63f4)) ### Code Refactoring * remove legacy ktx compatibility shims ([#211](https://github.com/Kaelio/ktx/issues/211)) ([96952fb](https://github.com/Kaelio/ktx/commit/96952fb43cfba4efc26bc91f347d99a9ad2dc03b)) ### Tests * split cli tests from source tree ([#216](https://github.com/Kaelio/ktx/issues/216)) ([56985b7](https://github.com/Kaelio/ktx/commit/56985b7e098ca06cb134f9ea8fd44976d23b8134)) ### Continuous Integration * disable telemetry in workflows ([#217](https://github.com/Kaelio/ktx/issues/217)) ([4827437](https://github.com/Kaelio/ktx/commit/4827437f3a12042f681f0f4a908252cf57d825e2)) --- package.json | 2 +- packages/cli/package.json | 2 +- python/ktx-daemon/pyproject.toml | 2 +- python/ktx-sl/pyproject.toml | 2 +- release-policy.json | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/package.json b/package.json index 52776d50..b60c1b82 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "ktx-workspace", - "version": "0.5.0", + "version": "0.6.0", "description": "Workspace root for ktx packages", "private": true, "type": "module", diff --git a/packages/cli/package.json b/packages/cli/package.json index 5e5a585a..81a699a6 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@kaelio/ktx", - "version": "0.5.0", + "version": "0.6.0", "description": "Standalone ktx context layer for data agents", "type": "module", "engines": { diff --git a/python/ktx-daemon/pyproject.toml b/python/ktx-daemon/pyproject.toml index d7168d01..8f2204c0 100644 --- a/python/ktx-daemon/pyproject.toml +++ b/python/ktx-daemon/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "ktx-daemon" -version = "0.5.0" +version = "0.6.0" description = "Portable compute package for KTX semantic-layer operations" readme = "README.md" requires-python = ">=3.13" diff --git a/python/ktx-sl/pyproject.toml b/python/ktx-sl/pyproject.toml index 69dfd2d9..f66af2e6 100644 --- a/python/ktx-sl/pyproject.toml +++ b/python/ktx-sl/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "ktx-sl" -version = "0.5.0" +version = "0.6.0" description = "Agent-first semantic layer engine with aggregate locality" readme = "README.md" requires-python = ">=3.13" diff --git a/release-policy.json b/release-policy.json index 0acaaeb5..34119ad6 100644 --- a/release-policy.json +++ b/release-policy.json @@ -19,7 +19,7 @@ }, "publishedPackageSmoke": { "packageName": "@kaelio/ktx", - "version": "0.5.0", + "version": "0.6.0", "registry": null }, "runtimeInstaller": { From a94f35800a6d6af52c46c71d8991c79e4a054e58 Mon Sep 17 00:00:00 2001 From: Andrey Avtomonov Date: Wed, 27 May 2026 18:20:51 +0200 Subject: [PATCH 14/74] feat(docs-site): redirect ktx.sh/slack to Slack community invite (#224) Add a host-scoped redirect for /slack on ktx.sh before the existing catch-all so the path resolves to the community invite link instead of docs.kaelio.com/ktx/slack. --- docs-site/next.config.mjs | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/docs-site/next.config.mjs b/docs-site/next.config.mjs index 30a96741..b82803be 100644 --- a/docs-site/next.config.mjs +++ b/docs-site/next.config.mjs @@ -34,6 +34,14 @@ const config = { permanent: true, basePath: false, }, + { + source: "/slack", + has: [{ type: "host", value: "ktx.sh" }], + destination: + "https://join.slack.com/t/ktxcommunity/shared_invite/zt-3y9b44m1x-LVyNNJD5nwaZHq4XS29LMQ", + permanent: false, + basePath: false, + }, { source: "/:path*", has: [{ type: "host", value: "ktx.sh" }], From 6837ab253d6173d3270a8fdc08cff066d1137d1b Mon Sep 17 00:00:00 2001 From: Andrey Avtomonov Date: Thu, 28 May 2026 02:09:53 +0200 Subject: [PATCH 15/74] fix(cli): align ingest step counter with SDK num_turns (#225) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Claude Code runtime counted every SDKAssistantMessage with parent_tool_use_id === null as a step, but the SDK emits extra messages within a single num_turns round-trip — `stop_reason: 'pause_turn'` continuations and errored partials it retries internally. The local counter then outran maxTurns and the ingest HUD rendered confusing ratios like `step 69/40`. Filter both cases in collectResult so stepIndex tracks num_turns and stays bounded by the work-unit stepBudget. --- .../src/context/llm/claude-code-runtime.ts | 18 +++++- .../context/llm/claude-code-runtime.test.ts | 58 +++++++++++++++++++ uv.lock | 4 +- 3 files changed, 77 insertions(+), 3 deletions(-) diff --git a/packages/cli/src/context/llm/claude-code-runtime.ts b/packages/cli/src/context/llm/claude-code-runtime.ts index 0eb3eadb..22055c28 100644 --- a/packages/cli/src/context/llm/claude-code-runtime.ts +++ b/packages/cli/src/context/llm/claude-code-runtime.ts @@ -58,6 +58,22 @@ function isResult(message: SDKMessage): message is SDKResultMessage { return message.type === 'result'; } +// Skip emissions the SDK does not count toward `num_turns`: `pause_turn` continuations and +// errored partials (e.g. `max_output_tokens`) it retries internally. Without this, the +// runtime's step counter outruns `maxTurns` and the HUD renders e.g. `step 69/40`. +function countsAsAssistantTurn(message: SDKMessage): boolean { + if (message.type !== 'assistant' || message.parent_tool_use_id !== null) { + return false; + } + if (message.error !== undefined) { + return false; + } + if (message.message.stop_reason === 'pause_turn') { + return false; + } + return true; +} + function resultError(result: SDKResultMessage): Error | undefined { if (result.subtype === 'success') { return undefined; @@ -190,7 +206,7 @@ async function collectResult(params: { let result: SDKResultMessage | undefined; for await (const message of params.query({ prompt: params.prompt, options: params.options })) { assertInitIsolation(message, params.allowedToolIds, params.expectedMcpServerNames); - if (message.type === 'assistant' && message.parent_tool_use_id === null) { + if (countsAsAssistantTurn(message)) { await params.onAssistantTurn?.(); } if (isResult(message)) { diff --git a/packages/cli/test/context/llm/claude-code-runtime.test.ts b/packages/cli/test/context/llm/claude-code-runtime.test.ts index 205c74e0..706d5d55 100644 --- a/packages/cli/test/context/llm/claude-code-runtime.test.ts +++ b/packages/cli/test/context/llm/claude-code-runtime.test.ts @@ -415,6 +415,64 @@ describe('ClaudeCodeKtxLlmRuntime', () => { ); }); + it('counts only assistant turns the SDK counts toward num_turns', async () => { + const assistantMessage = ( + overrides: Partial> & { uuid: string }, + ): SDKMessage => + ({ + type: 'assistant', + message: { role: 'assistant', content: [], stop_reason: 'end_turn' }, + parent_tool_use_id: null, + session_id: 'session-id', + ...overrides, + }) as unknown as SDKMessage; + + const query = vi.fn((_input: any) => + stream([ + initMessage(), + assistantMessage({ + uuid: '00000000-0000-4000-8000-0000000000a1', + error: 'max_output_tokens', + }), + assistantMessage({ + uuid: '00000000-0000-4000-8000-0000000000a2', + message: { role: 'assistant', content: [], stop_reason: 'pause_turn' } as never, + }), + assistantMessage({ uuid: '00000000-0000-4000-8000-0000000000a3' }), + { + type: 'assistant', + message: { role: 'assistant', content: [], stop_reason: 'end_turn' }, + parent_tool_use_id: 'tool-use-1', + uuid: '00000000-0000-4000-8000-0000000000a4', + session_id: 'session-id', + } as unknown as SDKMessage, + resultMessage({ subtype: 'success', terminal_reason: 'completed' }), + ]), + ); + const runtime = new ClaudeCodeKtxLlmRuntime({ + projectDir: '/tmp/project', + modelSlots: { default: 'sonnet' }, + query, + env: {}, + }); + const onStepFinish = vi.fn(); + + await expect( + runtime.runAgentLoop({ + modelRole: 'default', + systemPrompt: 'system', + userPrompt: 'user', + toolSet: {}, + stepBudget: 40, + telemetryTags: { operationName: 'test' }, + onStepFinish, + }), + ).resolves.toEqual({ stopReason: 'natural' }); + + expect(onStepFinish).toHaveBeenCalledTimes(1); + expect(onStepFinish).toHaveBeenCalledWith({ stepIndex: 1, stepBudget: 40 }); + }); + it('logs and ignores onStepFinish callback errors', async () => { const query = vi.fn((_input: any) => stream([ diff --git a/uv.lock b/uv.lock index 7c2c368f..25a8fab6 100644 --- a/uv.lock +++ b/uv.lock @@ -458,7 +458,7 @@ wheels = [ [[package]] name = "ktx-daemon" -version = "0.5.0" +version = "0.6.0" source = { editable = "python/ktx-daemon" } dependencies = [ { name = "fastapi" }, @@ -515,7 +515,7 @@ dev = [ [[package]] name = "ktx-sl" -version = "0.5.0" +version = "0.6.0" source = { editable = "python/ktx-sl" } dependencies = [ { name = "pydantic" }, From 27842e14a91233818715d792f798032a70b0cfe4 Mon Sep 17 00:00:00 2001 From: Luca Martial <48870843+luca-martial@users.noreply.github.com> Date: Thu, 28 May 2026 05:58:08 -0400 Subject: [PATCH 16/74] docs: add context layer terminology (#226) --- AGENTS.md | 20 ++++++++++++++++++++ docs/terminology.md | 38 +++++++++++++++++++++++++++++++++++++- 2 files changed, 57 insertions(+), 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index 64ec2d4a..b5eccd67 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -323,6 +323,26 @@ use `PascalCase` without the suffix. source-code identifier, package/API name, or other literal value that must match the implementation. +### Product Category Naming + +- **MUST**: Use **context layer** as the primary public category for **ktx**. + Preferred phrase: `context layer for data agents`. +- **MUST**: Use **context engine** only as the secondary mechanism term for the + active system that builds, reconciles, validates, searches, and serves the + context layer. +- **MUST**: Keep **semantic layer** as the narrower term for executable metric + definitions, semantic sources, joins, measures, and SQL compilation. +- **MUST NOT**: Replace every `semantic layer` occurrence with `context layer`; + the semantic layer is one pillar inside the broader context layer. + +Preferred pattern: + +```md +**ktx** is an open-source context layer for data agents. Its context engine +ingests warehouse metadata, BI definitions, query history, docs, and approved +metrics, then turns them into reviewable files agents can search and execute. +``` + ### Terminology For canonical vocabulary used across docs, code, comments, CLI strings, and diff --git a/docs/terminology.md b/docs/terminology.md index 00be75e6..9da59456 100644 --- a/docs/terminology.md +++ b/docs/terminology.md @@ -21,6 +21,41 @@ in prose when ambiguity is possible. Always qualify: Bare `source` is allowed only inside a section that has already established its referent (e.g., body of a `Semantic sources` page, or `sourceName` as a CLI arg). +## Context Layer and Context Engine + +Use **context layer** as the primary category term for what **ktx** provides to +data agents. + +Use **context engine** as the secondary mechanism term for how **ktx** builds, +maintains, validates, and serves that layer. + +| Concept | Use | Do not use | +|---|---|---| +| The whole **ktx** product category | **context layer** / **context layer for data agents** | knowledge layer, agent memory | +| The active system that builds and maintains context | **context engine** | context layer when describing ingest/reconciliation internals | +| The durable reviewed surface agents use | **context layer** | context engine | +| The compiler pillar for executable metrics and joins | **semantic layer** | context layer when specifically discussing SQL compilation | +| Prose/business knowledge files | **wiki** / **wiki pages** | wiki context | + +### Usage rules + +- Use **context layer** in taglines, page titles, meta descriptions, docs + introductions, comparison pages, and first-paragraph definitions. +- Use **context engine** when describing active behavior: ingesting evidence, + reconciling changes, validating references, maintaining files, search, CLI, + and MCP serving. +- Keep **semantic layer** for the narrower YAML/compiler surface: semantic + sources, measures, joins, dimensions, filters, SQL compilation, and semantic + queries. +- Do not use **context engine** as the primary replacement for the whole + product. It sounds like runtime infrastructure; **context layer** better + describes the durable YAML and Markdown surface users review in git. +- Do not use **context layer** when the sentence is specifically about the + compiler. Example: write "the semantic layer compiles semantic queries to + SQL," not "the context layer compiles semantic queries to SQL." +- Default lowercase in prose: `context layer`, `context engine`, `semantic + layer`. Title case only in page titles, headings, nav labels, and UI labels. + ## Canonical vocabulary | Concept | Use | Do not use | @@ -31,7 +66,8 @@ referent (e.g., body of a `Semantic sources` page, or `sourceName` as a CLI arg) | The connected database | **primary source** / **database connection** | data source | | Analytics-tooling integration | **context source** / **context-source connection** | BI source, BI model, metadata source, source tool | | YAML file describing a table | **semantic source** | semantic-layer source, model file, bare "source file" | -| The whole **ktx** surface | **context layer** (lowercase in prose) | "Context Layer" in prose | +| The whole **ktx** surface | **context layer** / **context layer for data agents** (lowercase in prose) | "Context Layer" in prose, knowledge layer, agent memory | +| The active system that builds and maintains context | **context engine** (lowercase in prose) | context layer when describing ingest/reconciliation internals | | The compiler pillar | **semantic layer** (lowercase in prose) | "Semantic Layer" in prose | | The query payload | **semantic query** (lowercase in prose) | "Semantic Query" | | The MCP layer | **MCP server** (the server), **MCP tools** (the functions) | "ktx MCP" as a standalone noun | From 39f94f39ffce81ad7b5a635fb3327897533aea75 Mon Sep 17 00:00:00 2001 From: Andrey Avtomonov Date: Thu, 28 May 2026 12:28:10 +0200 Subject: [PATCH 17/74] docs: add ktx skills.sh setup skill (#227) --- README.md | 5 +- .../app/llms.mdx/docs/[[...slug]]/route.ts | 18 +- docs-site/content/agents-setup.md | 201 ------------------ .../docs/ai-resources/prompt-recipes.mdx | 3 +- .../docs/getting-started/quickstart.mdx | 21 +- docs-site/lib/agent-setup-markdown.ts | 12 -- docs-site/lib/llm-docs.ts | 3 +- skills.sh.json | 11 + skills/ktx/SKILL.md | 142 +++++++++++++ skills/ktx/agents/openai.yaml | 7 + 10 files changed, 177 insertions(+), 246 deletions(-) delete mode 100644 docs-site/content/agents-setup.md delete mode 100644 docs-site/lib/agent-setup-markdown.ts create mode 100644 skills.sh.json create mode 100644 skills/ktx/SKILL.md create mode 100644 skills/ktx/agents/openai.yaml diff --git a/README.md b/README.md index 2e034677..23b2fa0a 100644 --- a/README.md +++ b/README.md @@ -119,9 +119,8 @@ Agent integration ready: yes (codex:project) > your project directory: > > ```text -> Follow instructions from -> https://docs.kaelio.com/ktx/docs/agents-setup.md -> to install and configure ktx +> Run npx skills add Kaelio/ktx --skill ktx and use the ktx skill to install +> and configure ktx in this project. > ``` > [!IMPORTANT] diff --git a/docs-site/app/llms.mdx/docs/[[...slug]]/route.ts b/docs-site/app/llms.mdx/docs/[[...slug]]/route.ts index 87dcbd42..1372d556 100644 --- a/docs-site/app/llms.mdx/docs/[[...slug]]/route.ts +++ b/docs-site/app/llms.mdx/docs/[[...slug]]/route.ts @@ -3,11 +3,6 @@ import { getLlmDocsPages, getPageMarkdown, } from "@/lib/llm-docs"; -import { - agentSetupSlug, - isAgentSetupSlug, - readAgentSetupMarkdown, -} from "@/lib/agent-setup-markdown"; export const dynamic = "force-static"; @@ -16,14 +11,6 @@ export async function GET( props: { params: Promise<{ slug?: string[] }> }, ) { const params = await props.params; - if (isAgentSetupSlug(params.slug)) { - return new Response(await readAgentSetupMarkdown(), { - headers: { - "Content-Type": "text/markdown; charset=utf-8", - }, - }); - } - const page = getLlmDocsPage(params.slug); if (!page) { return new Response("Documentation page not found.\n", { @@ -42,8 +29,5 @@ export async function GET( } export function generateStaticParams() { - return [ - ...getLlmDocsPages().map((page) => ({ slug: page.slug })), - { slug: [...agentSetupSlug] }, - ]; + return getLlmDocsPages().map((page) => ({ slug: page.slug })); } diff --git a/docs-site/content/agents-setup.md b/docs-site/content/agents-setup.md deleted file mode 100644 index 4933ff10..00000000 --- a/docs-site/content/agents-setup.md +++ /dev/null @@ -1,201 +0,0 @@ -# Goal - -Set up **ktx** from scratch end-to-end as a fully autonomous, agent-driven replacement for the interactive `ktx setup` wizard. Detect the environment, install missing prerequisites, ask the user only for information you genuinely need (which connections to add, credentials), write a valid configuration, verify it works, and run a fast ingest. Keep the user updated throughout. - -# Operating principles - -- **Be autonomous.** Detect, decide, and act. Only ask the user when you need information that only they can provide: project location, which databases/sources to connect, credentials, and similar choices. -- **Stream short status updates.** Before each major phase ("Checking prerequisites…", "Installing uv…", "Configuring warehouse connection…", "Running fast ingest…") print a one-line update. Not chatty - just enough that the user can see what's happening. -- **Verify against docs, never guess.** CLI flags, config keys, and command names must come from the docs or from `ktx --help`. If something looks wrong or missing, say so explicitly. -- **Print every command you run and its exit code.** Terse, not silent. -- **Fail loudly with cause + fix.** When a command fails: capture the exact error, identify the cause, change something, retry. Never retry an unchanged command. Exceptions for *known soft-failures* are listed in Phase 4 - handle those without retrying. -- **No LLM-based ingestion in this flow.** Only `--fast` ingest. The user can run `--deep` later. -- **Platform-agnostic.** Detect the host OS first and pick the right install commands / path syntax. Anything path- or shell-specific must branch on OS. - -# Authoritative docs - -**ktx** docs are served at `https://docs.kaelio.com/ktx/`. **Start by fetching `https://docs.kaelio.com/ktx/llms.txt`** to discover the docs map. Scan it for a "troubleshooting" entry - if one exists, read it **before** running install/setup so you can apply known fixes preemptively rather than after failing. If no troubleshooting page is listed (current state of the docs), proceed. Then fetch any other `.md` pages you need (setup, ingest, status, connection types). **Never invent CLI flags or config keys** - verify against the docs or `ktx --help` / `ktx --help`. - -> **Note on the `ktx status` JSON example in the docs.** The docs page for `ktx status` shows an example shaped like `{"title": "...", "checks": [...]}`. That example is outdated. The real CLI output uses a top-level `verdict` field plus a `connections[]` array - see Phase 5 for the canonical success criteria. Trust the shape in this prompt over the docs example. - -# Workflow - -## Phase 1 - Detect environment - -Determine the host OS (e.g. via `uname -s`, `process.platform`, or `$env:OS`). Use the right install commands per OS for the rest of this flow. - -| Tool | macOS / Linux | Windows (PowerShell) | -|------|---------------|----------------------| -| `uv` | `curl -LsSf https://astral.sh/uv/install.sh \| sh` then re-source shell env | `irm https://astral.sh/uv/install.ps1 \| iex` | -| Node.js | use system / fnm / nvm - **do not** auto-install | use system / nvm-windows - **do not** auto-install | -| **ktx** CLI | `npm install -g …` (see Phase 2) | `npm install -g …` (see Phase 2) | - -If Node.js is missing, **stop and ask the user** to install it (https://nodejs.org/). Do not attempt to auto-install Node. - -## Phase 2 - Verify and install prerequisites - -Check each tool in order; install only if missing. - -1. **Node.js** - run `node --version`. Require >= 22. If missing or older, stop and instruct the user. -2. **`uv`** - run `uv --version`. If missing, run the OS-appropriate install command, then re-source the shell environment (`export PATH="$HOME/.local/bin:$PATH"` on Linux/macOS) so `uv` is on `PATH`. -3. **ktx CLI** - - - Install ktx with `npm install -g @kaelio/ktx` - - Verify with `ktx --version`. - -Print one status line per tool ("✓ uv 0.11.15 found", "Installing uv…", "✓ ktx 0.x.y installed"). - -## Phase 3 - Gather user choices - -Ask the user (grouped if your harness supports it; otherwise sequentially): - -1. **Project directory.** Default: current working directory. Confirm before continuing. -2. **LLM provider.** Default: `claude-code` with model `sonnet` (the user is already inside Claude Code; no extra API key needed). Offer `anthropic` (paste API key, stored as `env:` or `file:` ref) and `vertex` (GCP project + location) as alternatives. Skip if defaults are accepted. -3. **Embeddings backend.** Default: `sentence-transformers` (local, no API key, managed Python runtime). Offer `openai` only if the user has a key. -4. **Database connections.** Ask how many to add, then loop. For each, collect: - - Connection name (e.g. `warehouse`, `analytics`). - - Driver: one of `sqlite`, `postgres`, `mysql`, `sqlserver`, `bigquery`, `snowflake`. - - Connection URL/DSN (or service-account file for BigQuery). Accept `env:VAR_NAME` or `file:/abs/path` to avoid pasting raw secrets. - - **Heads-up for the user**: even if they paste a literal URL, **ktx** will silently relocate it into `/.ktx/secrets/-url` and rewrite `ktx.yaml` to `url: file:…` - this is correct, secure behavior and not a bug. - - Schemas / datasets to include (postgres / sqlserver / snowflake / bigquery only). - - Optional `enabled_tables` allowlist if the user wants to scope ingest to specific tables. -5. **Context sources** (dbt, Metabase, Looker, LookML, MetricFlow, Notion). Default: none. Ask only if the user mentions them. - -## Phase 4 - Configure the project - -Drive the existing wizard non-interactively (verify exact flag names with `ktx setup --help` and the docs - the automation flags are hidden from help but accepted): - -``` -ktx setup \ - --project-dir \ - --no-input --yes \ - --llm-backend --llm-model \ - [--anthropic-api-key-env ANTHROPIC_API_KEY | --anthropic-api-key-file ] \ - [--vertex-project

--vertex-location ] \ - --embedding-backend \ - [--embedding-api-key-env OPENAI_API_KEY] \ - --skip-sources \ - --database --database-connection-id --database-url \ - [--database-schema …] -``` - -Notes on the flags above: -- **Project creation is automatic with `--no-input --yes`.** When - `ktx.yaml` exists, setup resumes it. When it doesn't exist, setup creates it - at `--project-dir`. -- **`--database-connection-id` is dual-purpose.** With `--database` or - `--database-url`, it names the new connection. Without those flags, it - selects an existing connection id. -- **Configure one new database connection per setup command.** If the user - wants multiple new connections, run setup again for each connection. -- **You don't need `--skip-agents` in this flow.** The agent integration step - is opt-in: setup leaves it alone unless you pass `--agents --target - `. -- **`--skip-sources`** is correct and is the documented way to leave context sources unconfigured. - -### Known soft-failure: `ktx setup` exits 1 after a successful fast build - -When you select a configuration that only does fast ingest, `ktx setup`'s final readiness verification fails with: - -``` -ktx context build did not pass agent-readiness verification. - : deep database context has not completed. -``` - -This is **expected** and **does not mean setup failed**. Treat the exit code as a soft-failure **only if all of the following hold**: - -- The build log shows the fast ingest reached `[100%] Scan completed` for every configured connection. -- `ktx connection test ` (run next) exits 0 for every connection. -- `ktx status --json --no-input` reports `verdict: "ready"`. - -If those three conditions hold, proceed to Phase 5 without retrying setup, and **do not** switch to `--deep` to "fix" the readiness gate - deep ingest is explicitly out of scope. Mention this in the final report under "Docs / CLI gaps" so the user is aware. - -If any of those three conditions do not hold, this is a real failure - capture the error, fetch the relevant docs page, fix the cause, retry. - -After `ktx setup` writes `ktx.yaml`, edit it directly for anything flags don't cover: -- Per-connection `enabled_tables` allowlist (snake_case, under `connections..enabled_tables`). -- Any advanced settings the user requested. - -Use a YAML-aware editor (e.g. `uv run python -c "import yaml; …"`) - do not hand-edit blindly. - -## Phase 5 - Verify - -`ktx setup` already runs a fast ingest of every database connection it configures, so you do not need to re-ingest by default. For each configured connection: - -``` -ktx connection test # must exit 0 -``` - -Only re-run ingest if setup's build log did **not** reach 100% for that connection: - -``` -ktx ingest --fast --no-input -``` - -**Mutex warning on `ktx ingest`**: passing both `--yes` and `--no-input` fails with `Choose only one runtime install mode: --yes or --no-input`. Setup already installed the managed Python runtime, so pass **only `--no-input`** to `ktx ingest`. (`--yes` is only needed when an ingest invocation has to install the runtime itself, which is not the case here.) - -Then run the global health check: - -``` -ktx status --json --no-input -``` - -Success requires (canonical shape - supersedes the example in the docs): -- `verdict: "ready"` at the top of the JSON. -- Every `connections[].status === "ok"`. -- `ktx connection test ` exited 0 for every connection. - -Do **not** run `--deep` ingest in this flow - that requires LLM time and is out of scope. - -### Optional: directly probe the ktx daemon - -If the user asks for stronger verification that `sentence-transformers` is actually serving (not just that setup said "ok"), do all of: - -1. `ktx admin runtime status --json` → expect `"kind": "ready"` and `"features": [..., "local-embeddings"]`. -2. `pgrep -fa ktx-daemon` → expect a process running `ktx-daemon serve-http`. -3. `curl -sS http://127.0.0.1:/health` → expect HTTP 200 with `{"status":"healthy",…}`. -4. `curl -sS -X POST http://127.0.0.1:/embeddings/compute -H 'content-type: application/json' -d '{"text":"hello"}'` → expect `{"embedding": [...384 floats...]}`. - -Discover the port from setup's log line `Started ktx daemon: http://127.0.0.1:` or from the daemon's OpenAPI at `GET /openapi.json`. Note: the routes are `/health` and `/embeddings/compute` - not `/healthz` or `/embeddings`. - -## Phase 6 - Final report - -Print a structured report: - -``` -ktx SETUP COMPLETE - -Project: -LLM: / -Embeddings: / -Runtime: managed Python ✓ (if the ktx daemon was started) - -Connections: - - () status=ok schemas=[…] tables= - - … - -Sources: -Verdict: ready -``` - -Then **Next steps** (copy-pasteable): -1. Enrich with AI descriptions and embeddings: `ktx ingest --deep` (several minutes per connection). -2. Add more connections later by rerunning this setup or via `ktx setup --database … --database-connection-id …`. -3. Configure context sources (dbt, Metabase, Looker, LookML, MetricFlow, Notion) - see `ktx setup --help` for `--source …` flags. -4. Install agent integration: `ktx setup --agents --target ` (with optional `--global` for `claude-code`/`codex`). -5. Connect the agent / MCP: see docs at `https://docs.kaelio.com/ktx/`. - -Under **Docs / CLI gaps to flag** include any of these that applied during your run: -- `ktx setup` exits non-zero after a successful fast build (deep-readiness gate); status reports ready. -- `ktx ingest` rejects `--yes` and `--no-input` together; docs don't note the conflict. -- `ktx status --json` real shape (`verdict`, `connections[]`) doesn't match the example in the docs page. -- The pasted DB URL was moved to `.ktx/secrets/-url` automatically. - -End with a single line: `RESULT: PASS` or `RESULT: FAIL - `. - -# Operating rules (recap) - -- Print every command you run and its exit code. Status updates may be terse, but never silent. -- On failure: capture the error, fetch the relevant docs page, fix the cause, retry. Never retry an unchanged command. -- Known soft-failures (listed in Phase 4 and Phase 5) are not real failures - handle them as documented; do not retry or escalate. -- If you find a docs/CLI gap ("docs say X but CLI does Y"), call it out in the final report. -- Never commit credentials - **ktx** accepts `env:` and `file:` references; prefer those. **ktx** will also auto-relocate literal URLs into `.ktx/secrets/`, but that does not protect anyone who pasted the URL into chat history. diff --git a/docs-site/content/docs/ai-resources/prompt-recipes.mdx b/docs-site/content/docs/ai-resources/prompt-recipes.mdx index c2a9f282..9ba8e3b8 100644 --- a/docs-site/content/docs/ai-resources/prompt-recipes.mdx +++ b/docs-site/content/docs/ai-resources/prompt-recipes.mdx @@ -14,7 +14,8 @@ Read https://docs.kaelio.com/ktx/llms.txt first. Then fetch only the ktx Markdow ## Set up a project ```text -Set up ktx in this repository. Start by reading /docs/ai-resources/agent-quickstart.md and /docs/getting-started/quickstart.md. Install the published CLI with npm; use pnpm only when working from a ktx source checkout. After setup, run ktx status and summarize which steps are complete, which files changed, and what still needs credentials or user input. +Run npx skills add Kaelio/ktx --skill ktx and use the ktx skill to install +and configure ktx in this project. ``` ## Find a command diff --git a/docs-site/content/docs/getting-started/quickstart.mdx b/docs-site/content/docs/getting-started/quickstart.mdx index f2ef3c7c..56b0389e 100644 --- a/docs-site/content/docs/getting-started/quickstart.mdx +++ b/docs-site/content/docs/getting-started/quickstart.mdx @@ -103,11 +103,8 @@ If you're a coding assistant choosing a docs route, start with the

You can ask an agent such as Claude Code, Codex, Cursor, or OpenCode to - install and configure **ktx** for you. The{' '} - - agent setup Markdown prompt - {' '} - tells the agent how to check prerequisites, ask only for credentials or + install and configure **ktx** for you. The installable **ktx** skill tells + the agent how to check prerequisites, ask only for credentials or connection choices, run ktx setup, verify connections, and report the result.
@@ -120,16 +117,18 @@ If you're a coding assistant choosing a docs route, start with the Prompt
-
Follow instructions from
-
https://docs.kaelio.com/ktx/docs/agents-setup.md
-
to install and configure ktx
+
+ Run npx skills add Kaelio/ktx --skill ktx and use the ktx skill to + install and configure ktx in this project. +
diff --git a/docs-site/lib/agent-setup-markdown.ts b/docs-site/lib/agent-setup-markdown.ts deleted file mode 100644 index 5a42ea1f..00000000 --- a/docs-site/lib/agent-setup-markdown.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { readFile } from "node:fs/promises"; -import { join } from "node:path"; - -export const agentSetupSlug = ["agents-setup"] as const; - -export function isAgentSetupSlug(slug: string[] | undefined) { - return slug?.length === 1 && slug[0] === agentSetupSlug[0]; -} - -export function readAgentSetupMarkdown() { - return readFile(join(process.cwd(), "content/agents-setup.md"), "utf8"); -} diff --git a/docs-site/lib/llm-docs.ts b/docs-site/lib/llm-docs.ts index 7ed338a0..fd6c8dd1 100644 --- a/docs-site/lib/llm-docs.ts +++ b/docs-site/lib/llm-docs.ts @@ -52,8 +52,9 @@ ktx provides semantic-layer files, warehouse scans, wiki pages, provenance, and ## Agent Entry Points +- Installable setup skill: run \`npx skills add Kaelio/ktx --skill ktx\` from + the project you want to configure. ${link("/docs/ai-resources/agent-quickstart", "Agent Quickstart", "Task-first route for coding assistants using ktx")} -${link("/docs/agents-setup", "Agent Setup", "Copy-pasteable prompt for agents installing and configuring ktx")} ${link("/docs/ai-resources/markdown-access", "Markdown Access", "Fetch ktx docs as llms.txt, llms-full.txt, or per-page Markdown")} ${link("/docs/ai-resources/agent-instructions", "Agent Instructions", "Suggested instructions for coding assistants that need to read and cite ktx docs")} diff --git a/skills.sh.json b/skills.sh.json new file mode 100644 index 00000000..6bc144ae --- /dev/null +++ b/skills.sh.json @@ -0,0 +1,11 @@ +{ + "$schema": "https://skills.sh/schemas/skills.sh.schema.json", + "notGrouped": "bottom", + "groupings": [ + { + "title": "ktx", + "description": "Skills for installing, configuring, and operating ktx.", + "skills": ["ktx"] + } + ] +} diff --git a/skills/ktx/SKILL.md b/skills/ktx/SKILL.md new file mode 100644 index 00000000..4a2b48a3 --- /dev/null +++ b/skills/ktx/SKILL.md @@ -0,0 +1,142 @@ +--- +name: ktx +description: Use when installing, configuring, verifying, or debugging ktx in a project, including ktx setup, ktx.yaml, database connectors, embeddings, agent integration, ingest, and ktx status checks. +--- + +# ktx + +Install and configure **ktx**, the open-source context layer for data agents. +Use this skill when a user wants an agent to add **ktx** to a project, connect +data sources, build initial context, install agent rules, or troubleshoot a +local **ktx** setup. + +## Operating rules + +- Act autonomously when the user asks you to install or configure **ktx**. +- Ask only for choices or values you cannot infer: project directory, + connection targets, credentials, account identifiers, and source selections. +- Never ask the user to paste secrets when an `env:VAR_NAME` or `file:/path` + reference would work. +- Do not commit `.ktx/secrets/*` or pasted credentials. +- Verify CLI flags and config keys with `ktx --help`, `ktx --help`, + or the docs at `https://docs.kaelio.com/ktx/` before using unfamiliar + options. +- Print or report each command you run and its result when doing setup work. +- If a command fails, identify the cause and change something before retrying. + +## Install workflow + +Use this workflow for a new or resumed project setup: + +1. Confirm the project directory. Default to the current working directory. +2. Check prerequisites: + - Node.js with `node --version`; require Node 22 or newer. + - `uv` with `uv --version`; install it only if missing and local Python + runtime features are needed. + - **ktx** with `ktx --version`; install the published CLI if missing. +3. Install the published CLI when needed: + + ```bash + npm install -g @kaelio/ktx + ``` + +4. Run interactive setup when the user is present: + + ```bash + ktx setup + ``` + +5. For scripted setup, prefer `ktx setup --no-input --yes` with explicit flags. + Verify exact flags with `ktx setup --help` and the docs first. +6. Configure one new database connection per scripted setup command. For + multiple connections, rerun setup once per connection. +7. Run fast ingest by default. Do not run deep ingest unless the user asks for + LLM-backed enrichment. +8. Install or repair agent integration after project setup: + + ```bash + ktx setup --agents + ``` + +9. Verify readiness: + + ```bash + ktx status + ``` + + Use `ktx status --json` when you need structured success criteria. + +## Common setup choices + +Default choices are usually: + +- LLM: `claude-code` if the user is already running Claude Code, otherwise ask. +- Embeddings: `sentence-transformers` for local embeddings with no API key, or + `openai` when the user wants hosted embeddings and has an API key. +- Databases: SQLite, PostgreSQL, MySQL, SQL Server, BigQuery, Snowflake, or + ClickHouse. +- Context sources: dbt, MetricFlow, LookML, Looker, Metabase, or Notion. + +Use `env:` or `file:` references for credentials: + +```bash +ktx setup \ + --project-dir ./analytics \ + --no-input \ + --yes \ + --database postgres \ + --database-connection-id warehouse \ + --database-url env:DATABASE_URL \ + --database-schema public +``` + +Then build or refresh fast context if setup did not already do it: + +```bash +ktx ingest warehouse --fast --no-input +``` + +## Files to inspect + +- `ktx.yaml`: project configuration. +- `.ktx/secrets/*`: local secret files. Never commit them. +- `semantic-layer//*.yaml`: semantic sources for SQL + compilation. +- `wiki/**/*.md`: project context pages for agents. +- `.claude/skills/ktx/`, `.agents/skills/ktx/`, `.cursor/rules/ktx.mdc`, and + `.opencode/commands/ktx.md`: generated agent integration files. + +## Verification + +After setup, run the smallest checks that cover the configured surface: + +```bash +ktx connection test +ktx status --json +``` + +Success means the project is ready, configured connections report healthy, and +the agent integration target requested by the user is installed. If fast setup +completed but deep context readiness is still missing, report that as the next +optional enrichment step rather than retrying setup unchanged. + +## Final report + +End setup work with a concise report: + +```text +ktx SETUP COMPLETE + +Project: +LLM: / +Embeddings: / +Connections: () status= +Sources: +Verdict: + +Next: +1. +2. + +RESULT: PASS +``` diff --git a/skills/ktx/agents/openai.yaml b/skills/ktx/agents/openai.yaml new file mode 100644 index 00000000..41eb75d2 --- /dev/null +++ b/skills/ktx/agents/openai.yaml @@ -0,0 +1,7 @@ +interface: + display_name: "ktx" + short_description: "Install and configure ktx for data agents" + default_prompt: "Use $ktx to install and configure ktx in this project." + +policy: + allow_implicit_invocation: true From 2a85346613e780c3b337872e9642d5dbdd7ed339 Mon Sep 17 00:00:00 2001 From: Andrey Avtomonov Date: Thu, 28 May 2026 12:51:17 +0200 Subject: [PATCH 18/74] fix(docs-site): disable Geist Mono ligatures on every font-mono surface (#228) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Geist Mono fuses `--` into an em-dash glyph that visually swallows the adjacent space, so prompts like `npx skills add Kaelio/ktx --skill ktx` rendered as `Kaelio/ktx--skill ktx` on the quickstart page. The existing ligature-off rule only covered /
 and the .ktx-code wrapper —
quickstart.mdx puts the prompt in a plain 
, so the rule didn't apply. Extend the selector to also match the .font-mono Tailwind utility and any inline-style opt-in via the mono font CSS variable. Document the convention in AGENTS.md so future docs additions keep ligatures off on any new monospace container. --- AGENTS.md | 16 ++++++++++++++++ docs-site/app/global.css | 8 ++++++-- 2 files changed, 22 insertions(+), 2 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index b5eccd67..3d8c1725 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -375,6 +375,22 @@ that do not change user-facing behavior. When you do update docs, follow the warrants docs but you are out of scope, call it out in your final summary rather than silently skipping it. +#### Monospace ligatures in `docs-site/` + +- **MUST**: Disable monospace ligatures on every surface that uses the + `var(--font-mono)` family (Geist Mono). Geist Mono fuses `--` into an + em-dash glyph that visually eats the adjacent space, so prompts like + `npx skills add Kaelio/ktx --skill ktx` render as `Kaelio/ktx--skill ktx`. +- **MUST**: When adding a new container that renders user-visible monospace + text outside `` / `
` (e.g. a styled `
` + for a copyable prompt), verify the global ligature-off rule in + `docs-site/app/global.css` covers its selector. Either use Tailwind's + `font-mono` utility (already covered) or extend the rule to match the new + class — do not silently rely on Geist Mono's defaults. +- **SHOULD**: Prefer `` / `
` (or a `font-mono` wrapper) for any
+  string that contains CLI flags, paths, or other tokens with `--`, `->`,
+  `>=`, `!=`, `==`, `//` so ligatures never alter intent.
+
 ## LLM and Prompt Development
 
 When creating or modifying agent prompts, system prompts, tool descriptions, or
diff --git a/docs-site/app/global.css b/docs-site/app/global.css
index a4cebc55..929e06b4 100644
--- a/docs-site/app/global.css
+++ b/docs-site/app/global.css
@@ -166,12 +166,16 @@ pre {
 }
 
 /* Disable monospace ligatures so `--flag` keeps a visible space and double
-   dashes don't fuse into an em-dash glyph. */
+   dashes don't fuse into an em-dash glyph. Covers every monospace surface:
+   raw /
, the ktx-code wrapper, Tailwind's `font-mono` utility,
+   and anything that opts in via the `var(--font-mono)` family directly. */
 code,
 pre,
 pre code,
 .ktx-code,
-.ktx-code code {
+.ktx-code code,
+.font-mono,
+[style*="--font-mono"] {
   font-variant-ligatures: none !important;
   font-feature-settings: "liga" 0, "calt" 0 !important;
 }

From b687167bc16d44a144210cf5d5b5b8080e045ba7 Mon Sep 17 00:00:00 2001
From: Andrey Avtomonov 
Date: Thu, 28 May 2026 13:00:49 +0200
Subject: [PATCH 19/74] Route ktx stars dashboard

---
 docs-site/next.config.mjs | 32 ++++++++++++++++++++++++--------
 1 file changed, 24 insertions(+), 8 deletions(-)

diff --git a/docs-site/next.config.mjs b/docs-site/next.config.mjs
index b82803be..380dba85 100644
--- a/docs-site/next.config.mjs
+++ b/docs-site/next.config.mjs
@@ -6,12 +6,28 @@ const withMDX = createMDX();
 const config = {
   basePath: "/ktx",
   async rewrites() {
-    return [
-      {
-        source: "/docs/:path*.md",
-        destination: "/llms.mdx/docs/:path*",
-      },
-    ];
+    return {
+      beforeFiles: [
+        {
+          source: "/stars",
+          has: [{ type: "host", value: "ktx.sh" }],
+          destination: "https://ktx-stars.vercel.app/stars",
+          basePath: false,
+        },
+        {
+          source: "/stars/:path*",
+          has: [{ type: "host", value: "ktx.sh" }],
+          destination: "https://ktx-stars.vercel.app/stars/:path*",
+          basePath: false,
+        },
+      ],
+      afterFiles: [
+        {
+          source: "/docs/:path*.md",
+          destination: "/llms.mdx/docs/:path*",
+        },
+      ],
+    };
   },
   async redirects() {
     return [
@@ -43,9 +59,9 @@ const config = {
         basePath: false,
       },
       {
-        source: "/:path*",
+        source: "/:path((?!stars(?:/|$)).*)",
         has: [{ type: "host", value: "ktx.sh" }],
-        destination: "https://docs.kaelio.com/ktx/:path*",
+        destination: "https://docs.kaelio.com/ktx/:path",
         permanent: true,
         basePath: false,
       },

From c1ed5eedced10c468d55cad90c4d00f4d8688922 Mon Sep 17 00:00:00 2001
From: Andrey Avtomonov 
Date: Thu, 28 May 2026 15:17:06 +0200
Subject: [PATCH 20/74] fix(cli): preserve project artifacts when ktx setup
 steps fail (#229)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

ktx setup wiped ktx.yaml, .ktx/setup/state.json, wiki/, semantic-layer/,
raw-sources/, and .git/ — or removed the entire project dir — whenever any
single source in the context-build step failed, destroying hours of ingest
work and the persisted resume state. The cleanup hint was designed for an
"early abort, leave no trace" semantic but was applied indiscriminately to
every later step failure, in direct conflict with the .ktx/setup/state.json
resume mechanism.

Drop the cleanup mechanism entirely (KtxSetupCreatedProjectCleanup,
cleanupForFolderState, createProjectWithCleanup, cleanupCreatedProjectScaffold,
and the createdProjectCleanup plumbing through KtxSetupProjectResult). Step
failures now return non-zero without touching the filesystem, so re-running
ktx setup continues from completed steps and only re-attempts failed sources.

Rewrites the two tests that documented the wipe behavior to assert
preservation, and adds a regression test that simulates partial context-build
artifacts (state.json, wiki/, semantic-layer/) and verifies all survive a
failed context step.

Refs KLO-719
---
 packages/cli/src/setup-project.ts | 45 ++-------------------
 packages/cli/src/setup.ts         | 25 +-----------
 packages/cli/test/setup.test.ts   | 65 ++++++++++++++++++++++++++++---
 3 files changed, 63 insertions(+), 72 deletions(-)

diff --git a/packages/cli/src/setup-project.ts b/packages/cli/src/setup-project.ts
index d7d189e1..08f935e6 100644
--- a/packages/cli/src/setup-project.ts
+++ b/packages/cli/src/setup-project.ts
@@ -24,17 +24,12 @@ export interface KtxSetupProjectArgs {
   allowBack?: boolean;
 }
 
-export type KtxSetupCreatedProjectCleanup =
-  | { kind: 'remove-project-dir'; projectDir: string }
-  | { kind: 'remove-ktx-scaffold'; projectDir: string };
-
 export type KtxSetupProjectResult =
   | {
       status: 'ready';
       projectDir: string;
       project: KtxLocalProject;
       confirmedCreation?: boolean;
-      createdProjectCleanup?: KtxSetupCreatedProjectCleanup;
     }
   | { status: 'back'; projectDir: string }
   | { status: 'cancelled'; projectDir: string }
@@ -59,7 +54,6 @@ type PromptProjectDirResult =
       status: 'selected';
       projectDir: string;
       confirmedCreation: boolean;
-      createdProjectCleanup?: KtxSetupCreatedProjectCleanup;
     }
   | { status: 'cancelled'; projectDir: string }
   | { status: 'missing-input'; projectDir: string }
@@ -106,26 +100,12 @@ type ConfirmProjectDirResult =
   | {
       status: 'confirmed';
       confirmedCreation: boolean;
-      createdProjectCleanup?: KtxSetupCreatedProjectCleanup;
     }
   | { status: 'choose-another' }
   | { status: 'back' }
   | { status: 'cancelled' }
   | { status: 'not-directory' };
 
-function cleanupForFolderState(
-  projectDir: string,
-  state: Awaited>,
-): KtxSetupCreatedProjectCleanup | undefined {
-  if (state === 'missing') {
-    return { kind: 'remove-project-dir', projectDir };
-  }
-  if (state === 'empty-directory') {
-    return { kind: 'remove-ktx-scaffold', projectDir };
-  }
-  return undefined;
-}
-
 async function confirmProjectDir(
   selectedDir: string,
   io: KtxCliIo,
@@ -165,7 +145,7 @@ async function confirmProjectDir(
   if (action === 'choose-another') return { status: 'choose-another' };
   if (action === 'back') return { status: 'back' };
   if (action !== 'create') return { status: 'cancelled' };
-  return { status: 'confirmed', confirmedCreation: true, createdProjectCleanup: cleanupForFolderState(selectedDir, state) };
+  return { status: 'confirmed', confirmedCreation: true };
 }
 
 async function normalizeSetupGitignore(projectDir: string): Promise {
@@ -252,24 +232,10 @@ async function promptForNewProjectDir(
       status: 'selected',
       projectDir: selectedDir,
       confirmedCreation: confirmed.confirmedCreation,
-      ...(confirmed.createdProjectCleanup ? { createdProjectCleanup: confirmed.createdProjectCleanup } : {}),
     };
   }
 }
 
-async function createProjectWithCleanup(
-  projectDir: string,
-  deps: KtxSetupProjectDeps,
-): Promise<{ project: KtxLocalProject; createdProjectCleanup?: KtxSetupCreatedProjectCleanup }> {
-  const state = await existingFolderState(projectDir);
-  const project = await createProject(projectDir, deps);
-  const createdProjectCleanup = cleanupForFolderState(projectDir, state);
-  return {
-    project,
-    ...(createdProjectCleanup ? { createdProjectCleanup } : {}),
-  };
-}
-
 export async function runKtxSetupProjectStep(
   args: KtxSetupProjectArgs,
   io: KtxCliIo,
@@ -307,7 +273,6 @@ export async function runKtxSetupProjectStep(
       projectDir: selected.projectDir,
       project,
       confirmedCreation: selected.confirmedCreation,
-      ...(selected.createdProjectCleanup ? { createdProjectCleanup: selected.createdProjectCleanup } : {}),
     };
   }
 
@@ -322,13 +287,12 @@ export async function runKtxSetupProjectStep(
       io.stderr.write('Missing setup choice: pass --yes to create a project in non-interactive setup.\n');
       return { status: 'missing-input', projectDir };
     }
-    const { project, createdProjectCleanup } = await createProjectWithCleanup(projectDir, deps);
+    const project = await createProject(projectDir, deps);
     printProjectSummary(io, projectDir);
     return {
       status: 'ready',
       projectDir,
       project,
-      ...(createdProjectCleanup ? { createdProjectCleanup } : {}),
     };
   }
 
@@ -368,13 +332,12 @@ export async function runKtxSetupProjectStep(
     }
 
     if (choice === 'current') {
-      const { project, createdProjectCleanup } = await createProjectWithCleanup(projectDir, deps);
+      const project = await createProject(projectDir, deps);
       printProjectSummary(io, projectDir);
       return {
         status: 'ready',
         projectDir,
         project,
-        ...(createdProjectCleanup ? { createdProjectCleanup } : {}),
       };
     }
 
@@ -390,7 +353,6 @@ export async function runKtxSetupProjectStep(
         projectDir: defaultProjectDir,
         project,
         confirmedCreation: confirmed.confirmedCreation,
-        ...(confirmed.createdProjectCleanup ? { createdProjectCleanup: confirmed.createdProjectCleanup } : {}),
       };
     }
 
@@ -419,7 +381,6 @@ export async function runKtxSetupProjectStep(
         projectDir: customDir,
         project,
         confirmedCreation: confirmed.confirmedCreation,
-        ...(confirmed.createdProjectCleanup ? { createdProjectCleanup: confirmed.createdProjectCleanup } : {}),
       };
     }
 
diff --git a/packages/cli/src/setup.ts b/packages/cli/src/setup.ts
index 6b3442ca..74056542 100644
--- a/packages/cli/src/setup.ts
+++ b/packages/cli/src/setup.ts
@@ -1,5 +1,4 @@
 import { existsSync } from 'node:fs';
-import { rm } from 'node:fs/promises';
 import { basename, join, resolve } from 'node:path';
 import { getLatestLocalIngestStatus } from './context/ingest/local-ingest.js';
 import { savedMemoryCountsForReport } from './context/ingest/reports.js';
@@ -32,11 +31,7 @@ import {
   isKtxSetupLlmConfigReady,
   runKtxSetupAnthropicModelStep,
 } from './setup-models.js';
-import {
-  type KtxSetupCreatedProjectCleanup,
-  type KtxSetupProjectDeps,
-  runKtxSetupProjectStep,
-} from './setup-project.js';
+import { type KtxSetupProjectDeps, runKtxSetupProjectStep } from './setup-project.js';
 import {
   isKtxPreAgentSetupReady,
   isKtxSetupReady,
@@ -556,23 +551,6 @@ async function commitSetupConfigChanges(projectDir: string): Promise {
   await project.git.commitFile('ktx.yaml', 'setup: update KTX project config', 'ktx setup', 'setup@ktx.local');
 }
 
-const KTX_SETUP_SCAFFOLD_PATHS = ['ktx.yaml', '.ktx', 'wiki', 'semantic-layer', 'raw-sources', '.git'];
-
-async function cleanupCreatedProjectScaffold(cleanup: KtxSetupCreatedProjectCleanup | undefined): Promise {
-  if (!cleanup) {
-    return;
-  }
-  if (cleanup.kind === 'remove-project-dir') {
-    await rm(cleanup.projectDir, { recursive: true, force: true });
-    return;
-  }
-  await Promise.all(
-    KTX_SETUP_SCAFFOLD_PATHS.map((relativePath) =>
-      rm(join(cleanup.projectDir, relativePath), { recursive: true, force: true }),
-    ),
-  );
-}
-
 export async function runKtxSetup(args: KtxSetupArgs, io: KtxCliIo, deps: KtxSetupDeps = {}): Promise {
   try {
     return await runKtxSetupInner(args, io, deps);
@@ -869,7 +847,6 @@ async function runKtxSetupInner(args: KtxSetupArgs, io: KtxCliIo, deps: KtxSetup
       });
 
       if (stepResult.status === 'failed') {
-        await cleanupCreatedProjectScaffold(projectResult.createdProjectCleanup);
         return 1;
       }
       if (stepResult.status === 'missing-input') {
diff --git a/packages/cli/test/setup.test.ts b/packages/cli/test/setup.test.ts
index 6c928033..0bc00919 100644
--- a/packages/cli/test/setup.test.ts
+++ b/packages/cli/test/setup.test.ts
@@ -1,5 +1,5 @@
 import { execFile } from 'node:child_process';
-import { mkdir, mkdtemp, readFile, readdir, rm, stat, writeFile } from 'node:fs/promises';
+import { mkdir, mkdtemp, readFile, rm, stat, writeFile } from 'node:fs/promises';
 import { tmpdir } from 'node:os';
 import { join } from 'node:path';
 import { promisify } from 'node:util';
@@ -602,7 +602,7 @@ describe('setup status', () => {
     expect(testIo.stderr()).toBe('');
   });
 
-  it('removes a newly created missing project directory when a later runtime step fails', async () => {
+  it('preserves a newly created missing project directory when a later setup step fails', async () => {
     const projectDir = join(tempDir, 'missing-project');
     const testIo = makeIo();
 
@@ -634,10 +634,12 @@ describe('setup status', () => {
       ),
     ).resolves.toBe(1);
 
-    await expect(stat(projectDir)).rejects.toThrow();
+    await expect(stat(projectDir)).resolves.toBeDefined();
+    await expect(stat(join(projectDir, 'ktx.yaml'))).resolves.toBeDefined();
+    await expect(stat(join(projectDir, '.ktx'))).resolves.toBeDefined();
   });
 
-  it('removes KTX scaffold files from an initially empty project directory when runtime setup fails', async () => {
+  it('preserves KTX scaffold files in an initially empty project directory when setup fails', async () => {
     const testIo = makeIo();
 
     await expect(
@@ -668,8 +670,59 @@ describe('setup status', () => {
       ),
     ).resolves.toBe(1);
 
-    await expect(stat(tempDir)).resolves.toBeDefined();
-    expect(await readdir(tempDir)).toEqual([]);
+    await expect(stat(join(tempDir, 'ktx.yaml'))).resolves.toBeDefined();
+    await expect(stat(join(tempDir, '.ktx'))).resolves.toBeDefined();
+  });
+
+  it('preserves partial context-build artifacts and resume state when the context step fails', async () => {
+    const projectDir = join(tempDir, 'partial-context');
+    const testIo = makeIo();
+
+    await expect(
+      runKtxSetup(
+        {
+          command: 'run',
+          projectDir,
+          mode: 'auto',
+          agents: false,
+          skipAgents: true,
+          inputMode: 'disabled',
+          yes: true,
+          cliVersion: '0.2.0',
+          skipLlm: true,
+          skipEmbeddings: true,
+          databaseSchemas: [],
+          skipDatabases: true,
+          skipSources: true,
+        },
+        testIo.io,
+        {
+          model: async () => ({ status: 'skipped', projectDir }),
+          embeddings: async () => ({ status: 'skipped', projectDir }),
+          databases: async () => ({ status: 'skipped', projectDir }),
+          sources: async () => ({ status: 'skipped', projectDir }),
+          runtime: async () => runtimeReady(projectDir),
+          context: async () => {
+            await mkdir(join(projectDir, '.ktx', 'setup'), { recursive: true });
+            await writeFile(
+              join(projectDir, '.ktx', 'setup', 'state.json'),
+              JSON.stringify({ status: 'failed', retryableFailedTargets: [{ source: 'metabase' }] }),
+              'utf-8',
+            );
+            await mkdir(join(projectDir, 'wiki'), { recursive: true });
+            await writeFile(join(projectDir, 'wiki', 'postgres-warehouse.md'), '# warehouse\n', 'utf-8');
+            await mkdir(join(projectDir, 'semantic-layer'), { recursive: true });
+            await writeFile(join(projectDir, 'semantic-layer', 'orders.yaml'), 'name: orders\n', 'utf-8');
+            return { status: 'failed', projectDir };
+          },
+        },
+      ),
+    ).resolves.toBe(1);
+
+    await expect(stat(join(projectDir, 'ktx.yaml'))).resolves.toBeDefined();
+    await expect(readFile(join(projectDir, '.ktx', 'setup', 'state.json'), 'utf-8')).resolves.toContain('"status":"failed"');
+    await expect(readFile(join(projectDir, 'wiki', 'postgres-warehouse.md'), 'utf-8')).resolves.toContain('warehouse');
+    await expect(readFile(join(projectDir, 'semantic-layer', 'orders.yaml'), 'utf-8')).resolves.toContain('orders');
   });
 
   it('preserves a pre-existing non-empty project directory when runtime setup fails', async () => {

From 35cecdf65d6554f03ef8dcd2382c2610f12854f0 Mon Sep 17 00:00:00 2001
From: Andrey Avtomonov 
Date: Thu, 28 May 2026 15:30:51 +0200
Subject: [PATCH 21/74] docs(docs-site): tidy agent setup prompt copy and
 sizing

Co-Authored-By: Claude Opus 4.7 (1M context) 
---
 docs-site/content/docs/getting-started/quickstart.mdx | 8 ++++----
 1 file changed, 4 insertions(+), 4 deletions(-)

diff --git a/docs-site/content/docs/getting-started/quickstart.mdx b/docs-site/content/docs/getting-started/quickstart.mdx
index 56b0389e..5d49cfb3 100644
--- a/docs-site/content/docs/getting-started/quickstart.mdx
+++ b/docs-site/content/docs/getting-started/quickstart.mdx
@@ -105,7 +105,7 @@ If you're a coding assistant choosing a docs route, start with the
     You can ask an agent such as Claude Code, Codex, Cursor, or OpenCode to
     install and configure **ktx** for you. The installable **ktx** skill tells
     the agent how to check prerequisites, ask only for credentials or
-    connection choices, run ktx setup, verify connections, and
+    connection choices, run ktx setup, verify connections, and
     report the result.
   
@@ -119,15 +119,15 @@ If you're a coding assistant choosing a docs route, start with the
-
+
Run npx skills add Kaelio/ktx --skill ktx and use the ktx skill to - install and configure ktx in this project. + install and configure ktx
From 6c6a3e7bafccdc5c66873be6790882ba46f45f81 Mon Sep 17 00:00:00 2001 From: Andrey Avtomonov Date: Thu, 28 May 2026 15:36:56 +0200 Subject: [PATCH 22/74] docs(skills): correct ktx setup skill against agent-trial findings (#230) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An external agent ran the skill end-to-end against `ktx setup` and reported seven concrete failures, all verified against the CLI source: - All useful setup flags are `.hideHelp()`, so the skill's "verify with --help" rule led the agent to conclude its own examples were wrong (setup-commands.ts:208-332). - The non-interactive LLM default is `anthropic` (and requires a key), not `claude-code` as the skill claimed (setup-models.ts:505-507). - `ktx status` exits 1 whenever the LLM is `none`, even with healthy embeddings and connections (status-project.ts:204-211, doctor.ts:647). - `ktx ingest` rejects `--yes`+`--no-input` while `ktx setup` accepts both (managed-python-command.ts:23-24). - `--database-url ` auto-externalizes to `.ktx/secrets/-url` — worth telling the agent (setup-databases.ts:671-683). - Resuming setup with only `--llm-backend` fails on missing DB flags even when `ktx.yaml` already has one (setup-databases.ts:1778-1782). - The `--agents` step prints `Required before using agents: ktx mcp start` but the skill never told agents to run it (setup-agents.ts:989,1227). Rewrite SKILL.md to: lead with the scripted (non-interactive) path; add a single "gather inputs once" checklist; correct the LLM default; document `--skip-*` flags and resumability; warn that `status` exit code ≠ readiness; fix the `ktx ingest` example to use `--no-input` only; require `ktx mcp start` after `--agents`; add a ktx-monorepo branch that avoids `npm install -g`. Add skills/ktx/troubleshooting.md (one level deep, per Anthropic's progressive-disclosure guidance) covering the five real failure signatures the agent hit: invalid ELF header, missing native CLI binary, missing Anthropic key, claude-code probe failure, and the resume-without-DB error. Description rewritten to combine what + when per the official skill authoring guidelines. --- skills/ktx/SKILL.md | 164 ++++++++++++++++++++-------------- skills/ktx/troubleshooting.md | 79 ++++++++++++++++ 2 files changed, 174 insertions(+), 69 deletions(-) create mode 100644 skills/ktx/troubleshooting.md diff --git a/skills/ktx/SKILL.md b/skills/ktx/SKILL.md index 4a2b48a3..85028de7 100644 --- a/skills/ktx/SKILL.md +++ b/skills/ktx/SKILL.md @@ -1,100 +1,113 @@ --- name: ktx -description: Use when installing, configuring, verifying, or debugging ktx in a project, including ktx setup, ktx.yaml, database connectors, embeddings, agent integration, ingest, and ktx status checks. +description: Installs and configures ktx, the open-source context layer for data agents — runs ktx setup non-interactively with hidden CLI flags, configures database connections and embeddings, installs agent integration, and verifies readiness. Use when the user asks an agent to add ktx to a project, connect data sources, install agent rules, ingest schema, or troubleshoot a local ktx install. --- # ktx Install and configure **ktx**, the open-source context layer for data agents. Use this skill when a user wants an agent to add **ktx** to a project, connect -data sources, build initial context, install agent rules, or troubleshoot a -local **ktx** setup. +data sources, build initial context, install agent integration, or troubleshoot +a local **ktx** setup. ## Operating rules - Act autonomously when the user asks you to install or configure **ktx**. -- Ask only for choices or values you cannot infer: project directory, - connection targets, credentials, account identifiers, and source selections. + The non-interactive scripted flow below is the canonical path — bare + `ktx setup` is interactive (clack prompts) and an agent cannot drive it. +- Setup's non-interactive flags are intentionally hidden from `--help`. Use the + flags listed below; verify uncommon flags against the docs at + `https://docs.kaelio.com/ktx/` or this skill — not against `--help` output. +- Ask only for values you cannot infer: project directory, connection targets, + credentials, account identifiers, and source selections. - Never ask the user to paste secrets when an `env:VAR_NAME` or `file:/path` - reference would work. -- Do not commit `.ktx/secrets/*` or pasted credentials. -- Verify CLI flags and config keys with `ktx --help`, `ktx --help`, - or the docs at `https://docs.kaelio.com/ktx/` before using unfamiliar - options. -- Print or report each command you run and its result when doing setup work. + reference would work. Pasting a literal URL is also safe — `ktx setup` + auto-externalizes URLs into `.ktx/secrets/-url` (see workflow step 2). +- Do not commit `.ktx/secrets/*`. +- Print each command you run and its result. - If a command fails, identify the cause and change something before retrying. +## Gather inputs once + +Before invoking `ktx setup`, collect in one round: + +1. Project directory (default: current working directory). +2. LLM backend and key strategy. In `--no-input` mode the CLI defaults to + `anthropic` and **requires an API key**. When the user is inside Claude + Code, pass `--llm-backend claude-code` explicitly; otherwise pass + `--llm-backend anthropic --anthropic-api-key-env ANTHROPIC_API_KEY`. +3. Embedding backend (`sentence-transformers` is the local default and needs + no key; use `openai` only if the user already has a key, then pass + `--embedding-api-key-env OPENAI_API_KEY`). +4. Database: driver, connection id, URL (or `env:` / `file:` ref), and one or + more schemas. +5. Optional context sources (dbt, Metabase, Looker, LookML, MetricFlow, + Notion). Skip with `--skip-sources` if the user has none. + +Do not discover these inputs across multiple setup runs. + ## Install workflow -Use this workflow for a new or resumed project setup: - -1. Confirm the project directory. Default to the current working directory. -2. Check prerequisites: - - Node.js with `node --version`; require Node 22 or newer. - - `uv` with `uv --version`; install it only if missing and local Python - runtime features are needed. - - **ktx** with `ktx --version`; install the published CLI if missing. -3. Install the published CLI when needed: +1. **Detect the install path.** If the working directory contains + `packages/cli/dist/bin.js` or `pnpm-workspace.yaml` referencing + `@kaelio/ktx` you are inside the **ktx** monorepo — build and link the + local CLI with `pnpm` and do **not** run `npm install -g`. Otherwise: ```bash - npm install -g @kaelio/ktx + node --version # require >= 22; stop and ask the user if older + ktx --version || npm install -g @kaelio/ktx ``` -4. Run interactive setup when the user is present: +2. **Run scripted setup** (canonical path): ```bash - ktx setup + ktx setup --no-input --yes \ + --project-dir \ + --llm-backend claude-code \ + --embedding-backend sentence-transformers \ + --database --database-connection-id \ + --database-url '' \ + --database-schema \ + --skip-sources ``` -5. For scripted setup, prefer `ktx setup --no-input --yes` with explicit flags. - Verify exact flags with `ktx setup --help` and the docs first. -6. Configure one new database connection per scripted setup command. For - multiple connections, rerun setup once per connection. -7. Run fast ingest by default. Do not run deep ingest unless the user asks for - LLM-backed enrichment. -8. Install or repair agent integration after project setup: + - Configure one new database connection per setup invocation. For multiple + connections, rerun setup once per connection. + - Pasting a literal `--database-url` is safe: the CLI relocates the URL + into `.ktx/secrets/-url` and rewrites `ktx.yaml` to a + `file:` ref automatically. + +3. **Resumability and `--skip-*`.** Re-running `ktx setup` against an existing + project resumes its config. Use `--skip-llm`, `--skip-databases`, + `--skip-sources`, or `--skip-embeddings` to leave a slice unconfigured but + let the rest complete instead of aborting on the first failure. **When + resuming an existing project to change one slice (e.g. only LLM), still + pass the database flags from the previous run** — setup validates current + flags, not persisted `ktx.yaml` state. + +4. **Run fast ingest** if setup did not already complete one: ```bash - ktx setup --agents + ktx ingest --fast --no-input ``` -9. Verify readiness: + Note: `ktx ingest` rejects `--yes` together with `--no-input` + (*Choose only one runtime install mode*); `ktx setup` accepts both. Use + `--no-input` only for ingest. Do not run `--deep` ingest unless the user + explicitly asks for LLM-backed enrichment. + +5. **Install agent integration:** ```bash - ktx status + ktx setup --agents --target + ktx mcp start --project-dir ``` - Use `ktx status --json` when you need structured success criteria. + Agent integration is **not usable until `ktx mcp start` is running**. The + `--agents` step prints this requirement as `Required before using agents`. -## Common setup choices - -Default choices are usually: - -- LLM: `claude-code` if the user is already running Claude Code, otherwise ask. -- Embeddings: `sentence-transformers` for local embeddings with no API key, or - `openai` when the user wants hosted embeddings and has an API key. -- Databases: SQLite, PostgreSQL, MySQL, SQL Server, BigQuery, Snowflake, or - ClickHouse. -- Context sources: dbt, MetricFlow, LookML, Looker, Metabase, or Notion. - -Use `env:` or `file:` references for credentials: - -```bash -ktx setup \ - --project-dir ./analytics \ - --no-input \ - --yes \ - --database postgres \ - --database-connection-id warehouse \ - --database-url env:DATABASE_URL \ - --database-schema public -``` - -Then build or refresh fast context if setup did not already do it: - -```bash -ktx ingest warehouse --fast --no-input -``` +6. **Fall back to bare `ktx setup` only when a human is at the keyboard** — + it uses interactive prompts an agent cannot answer. ## Files to inspect @@ -108,17 +121,30 @@ ktx ingest warehouse --fast --no-input ## Verification -After setup, run the smallest checks that cover the configured surface: +After setup, run: ```bash ktx connection test -ktx status --json +ktx status --json --no-input ``` -Success means the project is ready, configured connections report healthy, and -the agent integration target requested by the user is installed. If fast setup -completed but deep context readiness is still missing, report that as the next -optional enrichment step rather than retrying setup unchanged. +**Judge readiness from `ktx status --json` fields, not the exit code.** +`ktx status` exits 1 whenever the LLM is `none`, even when embeddings and +every database connection are healthy. Treat success as: + +- `verdict: "ready"` at the top of the JSON, and +- every `connections[].status === "ok"`, and +- every `ktx connection test ` exited 0. + +A non-zero exit with only the LLM unconfigured is still a usable context +layer — report it as "ready, LLM optional" rather than retrying setup. + +## Troubleshooting + +For known failure signatures (`invalid ELF header`, +`Native CLI binary for not found`, `Missing Anthropic API key`, +`claude-code` probe failure, `KTX cannot work without a database` on resume), +see [troubleshooting.md](troubleshooting.md). ## Final report diff --git a/skills/ktx/troubleshooting.md b/skills/ktx/troubleshooting.md new file mode 100644 index 00000000..812b45fc --- /dev/null +++ b/skills/ktx/troubleshooting.md @@ -0,0 +1,79 @@ +# ktx setup troubleshooting + +Known failure signatures hit by agent-driven `ktx setup` runs. Match the +error string in the left column, apply the fix in the right column. + +## `Error: invalid ELF header` from `better-sqlite3` + +Native module compiled for a different platform or architecture (e.g. +installed under Rosetta then run under native arm64). + +Fix: + +```bash +# Inside the ktx monorepo: +pnpm rebuild better-sqlite3 + +# Or for a global install: +npm rebuild --global better-sqlite3 +``` + +## `Native CLI binary for not found` + +The platform-specific optional dependency that ships the native CLI binary +was skipped during install (npm/pnpm "optional dep not for this platform"). + +Fix: + +```bash +npm install -g @kaelio/ktx --force +``` + +## `Missing Anthropic API key: pass --anthropic-api-key-env or --anthropic-api-key-file` + +`--no-input` mode defaulted the LLM backend to `anthropic` because no +`--llm-backend` flag was supplied. The CLI then required a key. + +Fix — pick one: + +```bash +# Inside Claude Code, prefer the local backend: +ktx setup --no-input --llm-backend claude-code ...other flags... + +# Otherwise point at an existing env var: +ktx setup --no-input --llm-backend anthropic \ + --anthropic-api-key-env ANTHROPIC_API_KEY ...other flags... +``` + +## `claude-code` LLM probe fails (auth or binary not found) + +The `claude` CLI is not on the agent's `PATH`, or the user has not run +`claude` interactively at least once to log in. + +Fix: + +```bash +which claude # confirm the binary resolves +claude --version # confirm it runs +# If auth probe still fails, the user must run `claude` once interactively +# to complete login; agents cannot do this step. +``` + +If `claude-code` cannot be made to work, fall back to `--skip-llm` and let +the rest of setup complete; the project is still a usable context layer +without an LLM. + +## `KTX cannot work without a database` when resuming setup + +`ktx setup` validates the **current invocation's flags**, not the persisted +`ktx.yaml`. Resuming setup with only `--llm-backend …` fails even when the +project already has a healthy database connection. + +Fix — re-pass the database flags from the original setup run, even when +only changing one slice: + +```bash +ktx setup --no-input \ + --database --database-connection-id \ + --llm-backend claude-code +``` From 57b607169f92d4555e8c921a2fa5fe81dbe0b654 Mon Sep 17 00:00:00 2001 From: Andrey Avtomonov Date: Thu, 28 May 2026 16:05:19 +0200 Subject: [PATCH 23/74] docs(docs-site): collapse agent setup explainer into a hover overlay (#231) --- .../docs/getting-started/quickstart.mdx | 81 +++++++++++++++---- 1 file changed, 65 insertions(+), 16 deletions(-) diff --git a/docs-site/content/docs/getting-started/quickstart.mdx b/docs-site/content/docs/getting-started/quickstart.mdx index 5d49cfb3..4251c57f 100644 --- a/docs-site/content/docs/getting-started/quickstart.mdx +++ b/docs-site/content/docs/getting-started/quickstart.mdx @@ -98,18 +98,70 @@ If you're a coding assistant choosing a docs route, start with the background: 'color-mix(in oklch, var(--color-fd-primary) 8%, transparent)', }} > -
- Run setup from an agent -
-
- You can ask an agent such as Claude Code, Codex, Cursor, or OpenCode to - install and configure **ktx** for you. The installable **ktx** skill tells - the agent how to check prerequisites, ask only for credentials or - connection choices, run ktx setup, verify connections, and - report the result. -
-
- Use a prompt like this from the project you want to configure: +
+
+ Or, ask an AI agent to install and configure **ktx** for you. +
+
+ + +
@@ -125,10 +177,7 @@ If you're a coding assistant choosing a docs route, start with the />
-
- Run npx skills add Kaelio/ktx --skill ktx and use the ktx skill to - install and configure ktx -
+ Run npx skills add Kaelio/ktx --skill ktx and use the ktx skill to install and configure ktx
From 00d5fd1b0f4c771cedae89d3db6432405bd691ae Mon Sep 17 00:00:00 2001 From: Andrey Avtomonov Date: Thu, 28 May 2026 16:09:03 +0200 Subject: [PATCH 24/74] docs(docs-site): show setup prompt command in backticks --- docs-site/content/docs/getting-started/quickstart.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs-site/content/docs/getting-started/quickstart.mdx b/docs-site/content/docs/getting-started/quickstart.mdx index 4251c57f..6f65d6ec 100644 --- a/docs-site/content/docs/getting-started/quickstart.mdx +++ b/docs-site/content/docs/getting-started/quickstart.mdx @@ -177,7 +177,7 @@ If you're a coding assistant choosing a docs route, start with the />
- Run npx skills add Kaelio/ktx --skill ktx and use the ktx skill to install and configure ktx + Run {'`npx skills add Kaelio/ktx --skill ktx`'} and use the ktx skill to install and configure ktx
From ed8f523362be816af94e2d9daeda2f11766a5bc9 Mon Sep 17 00:00:00 2001 From: semantic-release-bot Date: Thu, 28 May 2026 15:21:40 +0000 Subject: [PATCH 25/74] chore(release): 0.7.0 [skip ci] ## [0.7.0](https://github.com/Kaelio/ktx/compare/v0.6.0...v0.7.0) (2026-05-28) ### Features * **docs-site:** redirect ktx.sh/slack to Slack community invite ([#224](https://github.com/Kaelio/ktx/issues/224)) ([a94f358](https://github.com/Kaelio/ktx/commit/a94f35800a6d6af52c46c71d8991c79e4a054e58)) ### Bug Fixes * **cli:** align ingest step counter with SDK num_turns ([#225](https://github.com/Kaelio/ktx/issues/225)) ([6837ab2](https://github.com/Kaelio/ktx/commit/6837ab253d6173d3270a8fdc08cff066d1137d1b)) * **cli:** preserve project artifacts when ktx setup steps fail ([#229](https://github.com/Kaelio/ktx/issues/229)) ([c1ed5ee](https://github.com/Kaelio/ktx/commit/c1ed5eedced10c468d55cad90c4d00f4d8688922)) * **docs-site:** disable Geist Mono ligatures on every font-mono surface ([#228](https://github.com/Kaelio/ktx/issues/228)) ([2a85346](https://github.com/Kaelio/ktx/commit/2a85346613e780c3b337872e9642d5dbdd7ed339)) ### Documentation * add context layer terminology ([#226](https://github.com/Kaelio/ktx/issues/226)) ([27842e1](https://github.com/Kaelio/ktx/commit/27842e14a91233818715d792f798032a70b0cfe4)) * add ktx skills.sh setup skill ([#227](https://github.com/Kaelio/ktx/issues/227)) ([39f94f3](https://github.com/Kaelio/ktx/commit/39f94f39ffce81ad7b5a635fb3327897533aea75)) * **docs-site:** collapse agent setup explainer into a hover overlay ([#231](https://github.com/Kaelio/ktx/issues/231)) ([57b6071](https://github.com/Kaelio/ktx/commit/57b607169f92d4555e8c921a2fa5fe81dbe0b654)) * **docs-site:** show setup prompt command in backticks ([00d5fd1](https://github.com/Kaelio/ktx/commit/00d5fd1b0f4c771cedae89d3db6432405bd691ae)) * **docs-site:** tidy agent setup prompt copy and sizing ([35cecdf](https://github.com/Kaelio/ktx/commit/35cecdf65d6554f03ef8dcd2382c2610f12854f0)) * **skills:** correct ktx setup skill against agent-trial findings ([#230](https://github.com/Kaelio/ktx/issues/230)) ([6c6a3e7](https://github.com/Kaelio/ktx/commit/6c6a3e7bafccdc5c66873be6790882ba46f45f81)) --- package.json | 2 +- packages/cli/package.json | 2 +- python/ktx-daemon/pyproject.toml | 2 +- python/ktx-sl/pyproject.toml | 2 +- release-policy.json | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/package.json b/package.json index b60c1b82..b8c538e1 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "ktx-workspace", - "version": "0.6.0", + "version": "0.7.0", "description": "Workspace root for ktx packages", "private": true, "type": "module", diff --git a/packages/cli/package.json b/packages/cli/package.json index 81a699a6..4d6bd33b 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@kaelio/ktx", - "version": "0.6.0", + "version": "0.7.0", "description": "Standalone ktx context layer for data agents", "type": "module", "engines": { diff --git a/python/ktx-daemon/pyproject.toml b/python/ktx-daemon/pyproject.toml index 8f2204c0..0c6c95e4 100644 --- a/python/ktx-daemon/pyproject.toml +++ b/python/ktx-daemon/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "ktx-daemon" -version = "0.6.0" +version = "0.7.0" description = "Portable compute package for KTX semantic-layer operations" readme = "README.md" requires-python = ">=3.13" diff --git a/python/ktx-sl/pyproject.toml b/python/ktx-sl/pyproject.toml index f66af2e6..02cfd06a 100644 --- a/python/ktx-sl/pyproject.toml +++ b/python/ktx-sl/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "ktx-sl" -version = "0.6.0" +version = "0.7.0" description = "Agent-first semantic layer engine with aggregate locality" readme = "README.md" requires-python = ">=3.13" diff --git a/release-policy.json b/release-policy.json index 34119ad6..1cf1529b 100644 --- a/release-policy.json +++ b/release-policy.json @@ -19,7 +19,7 @@ }, "publishedPackageSmoke": { "packageName": "@kaelio/ktx", - "version": "0.6.0", + "version": "0.7.0", "registry": null }, "runtimeInstaller": { From d53cdac36666b647189576caefe66d79033292e8 Mon Sep 17 00:00:00 2001 From: Andrey Avtomonov Date: Fri, 29 May 2026 11:56:55 +0200 Subject: [PATCH 26/74] chore: upgrade dependencies and tooling (#232) * chore: upgrade dependencies and tooling * chore: upgrade dependencies and tooling --- .github/workflows/triage-issues.yml | 2 +- .pre-commit-config.yaml | 6 + docs-site/package.json | 4 +- package.json | 9 +- packages/cli/package.json | 36 +- pnpm-lock.yaml | 3021 ++++++++++++------------- pnpm-workspace.yaml | 1 + pyproject.toml | 16 +- python/ktx-daemon/pyproject.toml | 32 +- python/ktx-sl/pyproject.toml | 26 +- scripts/upgrade-dependencies.mjs | 111 + scripts/upgrade-dependencies.test.mjs | 123 + tombi.toml | 5 + uv.lock | 2055 ++++++++--------- 14 files changed, 2737 insertions(+), 2710 deletions(-) create mode 100644 scripts/upgrade-dependencies.mjs create mode 100644 scripts/upgrade-dependencies.test.mjs create mode 100644 tombi.toml diff --git a/.github/workflows/triage-issues.yml b/.github/workflows/triage-issues.yml index 41d2f048..e5817e2c 100644 --- a/.github/workflows/triage-issues.yml +++ b/.github/workflows/triage-issues.yml @@ -22,7 +22,7 @@ jobs: github.event.issue.author_association != 'COLLABORATOR' steps: - name: Apply needs-triage label - uses: actions/github-script@v7 + uses: actions/github-script@v9 with: script: | await github.rest.issues.addLabels({ diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index cc2f483d..ec730d50 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -14,6 +14,12 @@ repos: - id: check-case-conflict - id: mixed-line-ending + - repo: https://github.com/tombi-toml/tombi-pre-commit + rev: v1.1.0 + hooks: + - id: tombi-format + args: ["--offline"] + - repo: https://github.com/asottile/pyupgrade rev: v3.21.2 hooks: diff --git a/docs-site/package.json b/docs-site/package.json index 4cf896ff..2af1c19d 100644 --- a/docs-site/package.json +++ b/docs-site/package.json @@ -12,7 +12,7 @@ "dependencies": { "@xyflow/react": "^12.10.2", "fumadocs-core": "16.8.10", - "fumadocs-mdx": "15.0.4", + "fumadocs-mdx": "15.0.7", "fumadocs-ui": "16.8.10", "next": "^16", "react": "19.2.6", @@ -20,7 +20,7 @@ }, "devDependencies": { "@tailwindcss/postcss": "^4", - "@types/node": "^25.7.0", + "@types/node": "^25.9.1", "@types/react": "^19", "@types/react-dom": "^19", "tailwindcss": "^4", diff --git a/package.json b/package.json index b8c538e1..5ffb93d6 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,7 @@ "description": "Workspace root for ktx packages", "private": true, "type": "module", - "packageManager": "pnpm@11.1.1", + "packageManager": "pnpm@11.4.0", "engines": { "node": ">=22.0.0", "pnpm": ">=10.20.0" @@ -24,6 +24,7 @@ "dead-code:fix": "biome check . --formatter-enabled=false --assist-enabled=false --write && knip --fix --format", "dead-code:knip": "knip --reporter compact", "dead-code:knip:production": "knip --production --reporter compact", + "deps:upgrade": "node scripts/upgrade-dependencies.mjs", "docs": "kill $(lsof -ti:3000) 2>/dev/null; pnpm --filter ktx-docs run dev", "ktx": "node scripts/run-ktx.mjs", "link:dev": "node scripts/link-dev-cli.mjs", @@ -58,11 +59,11 @@ "@semantic-release/github": "^12.0.8", "@semantic-release/npm": "^13.1.5", "@semantic-release/release-notes-generator": "^14.1.1", - "@types/node": "^25.7.0", + "@types/node": "^25.9.1", "better-sqlite3": "^12.10.0", "conventional-changelog-conventionalcommits": "^9.3.1", - "knip": "^6.12.2", - "pg": "^8.20.0", + "knip": "^6.14.1", + "pg": "^8.21.0", "semantic-release": "^25.0.3", "typescript": "^6.0.3", "yaml": "^2.9.0" diff --git a/packages/cli/package.json b/packages/cli/package.json index 4d6bd33b..f3dfaec2 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -43,36 +43,36 @@ "search:pglite-sl-prototype": "node ../../scripts/pglite-sl-search-prototype.mjs" }, "dependencies": { - "@ai-sdk/anthropic": "3.0.77", - "@ai-sdk/devtools": "0.0.17", - "@ai-sdk/google-vertex": "^4.0.128", - "@anthropic-ai/claude-agent-sdk": "0.3.142", + "@ai-sdk/anthropic": "3.0.78", + "@ai-sdk/devtools": "0.0.18", + "@ai-sdk/google-vertex": "^4.0.134", + "@anthropic-ai/claude-agent-sdk": "0.3.146", "@clack/prompts": "1.4.0", - "@clickhouse/client": "^1.18.4", + "@clickhouse/client": "^1.18.5", "@commander-js/extra-typings": "14.0.0", "@google-cloud/bigquery": "^8.3.1", "@looker/sdk": "^26.8.0", "@looker/sdk-node": "^26.8.0", "@looker/sdk-rtl": "^21.6.5", "@modelcontextprotocol/sdk": "^1.29.0", - "@notionhq/client": "^5.21.0", - "ai": "^6.0.180", + "@notionhq/client": "^5.22.0", + "ai": "^6.0.188", "better-sqlite3": "^12.10.0", "commander": "14.0.3", - "fflate": "^0.8.2", + "fflate": "^0.8.3", "handlebars": "^4.7.9", - "ink": "^7.0.2", + "ink": "^7.0.3", "lookml-parser": "7.1.0", "minimatch": "^10.2.5", - "mssql": "^12.5.2", + "mssql": "^12.5.4", "mysql2": "^3.22.3", - "openai": "^6.37.0", + "openai": "^6.38.0", "p-limit": "^7.3.0", - "pg": "^8.20.0", - "posthog-node": "^5.0.0", + "pg": "^8.21.0", + "posthog-node": "^5.34.9", "react": "^19.2.6", "simple-git": "3.36.0", - "snowflake-sdk": "^2.4.1", + "snowflake-sdk": "^2.4.2", "yaml": "^2.9.0", "zod": "^4.4.3" }, @@ -81,14 +81,14 @@ "@electric-sql/pglite-socket": "^0.1.5", "@types/better-sqlite3": "^7.6.13", "@types/mssql": "^12.3.0", - "@types/node": "^25.7.0", + "@types/node": "^25.9.1", "@types/pg": "^8.20.0", - "@types/react": "^19.2.14", - "@vitest/coverage-v8": "^4.1.6", + "@types/react": "^19.2.15", + "@vitest/coverage-v8": "^4.1.7", "ajv": "8.20.0", "ink-testing-library": "^4.0.0", "typescript": "^6.0.3", - "vitest": "^4.1.6" + "vitest": "^4.1.7" }, "license": "Apache-2.0", "repository": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index de0d2c24..bf496066 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -48,7 +48,7 @@ importers: version: 14.1.1(semantic-release@25.0.3(typescript@6.0.3)) '@types/node': specifier: ^24.3.0 - version: 24.12.2 + version: 24.12.4 better-sqlite3: specifier: ^12.10.0 version: 12.10.0 @@ -56,11 +56,11 @@ importers: specifier: ^9.3.1 version: 9.3.1 knip: - specifier: ^6.12.2 - version: 6.12.2(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0) + specifier: ^6.14.1 + version: 6.14.1(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0) pg: - specifier: ^8.20.0 - version: 8.20.0 + specifier: ^8.21.0 + version: 8.21.0 semantic-release: specifier: ^25.0.3 version: 25.0.3(typescript@6.0.3) @@ -75,19 +75,19 @@ importers: dependencies: '@xyflow/react': specifier: ^12.10.2 - version: 12.10.2(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + version: 12.10.2(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) fumadocs-core: specifier: 16.8.10 - version: 16.8.10(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.4)(@types/mdast@4.0.4)(@types/react@19.2.14)(lucide-react@1.14.0(react@19.2.6))(next@16.2.6(@opentelemetry/api@1.9.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(zod@4.4.3) + version: 16.8.10(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.4)(@types/mdast@4.0.4)(@types/react@19.2.15)(lucide-react@1.16.0(react@19.2.6))(next@16.2.6(@opentelemetry/api@1.9.1)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(zod@4.4.3) fumadocs-mdx: - specifier: 15.0.4 - version: 15.0.4(@types/mdast@4.0.4)(@types/mdx@2.0.13)(@types/react@19.2.14)(fumadocs-core@16.8.10(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.4)(@types/mdast@4.0.4)(@types/react@19.2.14)(lucide-react@1.14.0(react@19.2.6))(next@16.2.6(@opentelemetry/api@1.9.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(zod@4.4.3))(next@16.2.6(@opentelemetry/api@1.9.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react@19.2.6)(vite@8.0.10(@types/node@24.12.2)(esbuild@0.28.0)(jiti@2.7.0)(yaml@2.9.0)) + specifier: 15.0.7 + version: 15.0.7(@types/mdast@4.0.4)(@types/mdx@2.0.13)(@types/react@19.2.15)(fumadocs-core@16.8.10(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.4)(@types/mdast@4.0.4)(@types/react@19.2.15)(lucide-react@1.16.0(react@19.2.6))(next@16.2.6(@opentelemetry/api@1.9.1)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(zod@4.4.3))(next@16.2.6(@opentelemetry/api@1.9.1)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react@19.2.6)(rolldown@1.0.2)(vite@8.0.14(@types/node@24.12.4)(esbuild@0.28.0)(jiti@2.7.0)(yaml@2.9.0)) fumadocs-ui: specifier: 16.8.10 - version: 16.8.10(@tailwindcss/oxide@4.3.0)(@types/mdx@2.0.13)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(fumadocs-core@16.8.10(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.4)(@types/mdast@4.0.4)(@types/react@19.2.14)(lucide-react@1.14.0(react@19.2.6))(next@16.2.6(@opentelemetry/api@1.9.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(zod@4.4.3))(next@16.2.6(@opentelemetry/api@1.9.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(tailwindcss@4.3.0) + version: 16.8.10(@tailwindcss/oxide@4.3.0)(@types/mdx@2.0.13)(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(fumadocs-core@16.8.10(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.4)(@types/mdast@4.0.4)(@types/react@19.2.15)(lucide-react@1.16.0(react@19.2.6))(next@16.2.6(@opentelemetry/api@1.9.1)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(zod@4.4.3))(next@16.2.6(@opentelemetry/api@1.9.1)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(tailwindcss@4.3.0) next: specifier: ^16 - version: 16.2.6(@opentelemetry/api@1.9.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + version: 16.2.6(@opentelemetry/api@1.9.1)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) react: specifier: 19.2.6 version: 19.2.6 @@ -100,13 +100,13 @@ importers: version: 4.3.0 '@types/node': specifier: ^24.3.0 - version: 24.12.2 + version: 24.12.4 '@types/react': specifier: ^19 - version: 19.2.14 + version: 19.2.15 '@types/react-dom': specifier: ^19 - version: 19.2.3(@types/react@19.2.14) + version: 19.2.3(@types/react@19.2.15) tailwindcss: specifier: ^4 version: 4.3.0 @@ -117,23 +117,23 @@ importers: packages/cli: dependencies: '@ai-sdk/anthropic': - specifier: 3.0.77 - version: 3.0.77(zod@4.4.3) + specifier: 3.0.78 + version: 3.0.78(zod@4.4.3) '@ai-sdk/devtools': - specifier: 0.0.17 - version: 0.0.17 + specifier: 0.0.18 + version: 0.0.18 '@ai-sdk/google-vertex': - specifier: ^4.0.128 - version: 4.0.128(zod@4.4.3) + specifier: ^4.0.134 + version: 4.0.134(zod@4.4.3) '@anthropic-ai/claude-agent-sdk': - specifier: 0.3.142 - version: 0.3.142(zod@4.4.3) + specifier: 0.3.146 + version: 0.3.146(@anthropic-ai/sdk@0.97.1(zod@4.4.3))(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(zod@4.4.3) '@clack/prompts': specifier: 1.4.0 version: 1.4.0 '@clickhouse/client': - specifier: ^1.18.4 - version: 1.18.4 + specifier: ^1.18.5 + version: 1.18.5 '@commander-js/extra-typings': specifier: 14.0.0 version: 14.0.0(commander@14.0.3) @@ -153,11 +153,11 @@ importers: specifier: ^1.29.0 version: 1.29.0(zod@4.4.3) '@notionhq/client': - specifier: ^5.21.0 - version: 5.21.0 + specifier: ^5.22.0 + version: 5.22.0 ai: - specifier: ^6.0.180 - version: 6.0.180(zod@4.4.3) + specifier: ^6.0.188 + version: 6.0.188(zod@4.4.3) better-sqlite3: specifier: ^12.10.0 version: 12.10.0 @@ -165,14 +165,14 @@ importers: specifier: 14.0.3 version: 14.0.3 fflate: - specifier: ^0.8.2 - version: 0.8.2 + specifier: ^0.8.3 + version: 0.8.3 handlebars: specifier: ^4.7.9 version: 4.7.9 ink: - specifier: ^7.0.2 - version: 7.0.2(@types/react@19.2.14)(react@19.2.6) + specifier: ^7.0.3 + version: 7.0.3(@types/react@19.2.15)(react@19.2.6) lookml-parser: specifier: 7.1.0 version: 7.1.0(js-yaml@4.1.1) @@ -180,23 +180,23 @@ importers: specifier: ^10.2.5 version: 10.2.5 mssql: - specifier: ^12.5.2 - version: 12.5.2(@azure/core-client@1.10.1) + specifier: ^12.5.4 + version: 12.5.4(@azure/core-client@1.10.1) mysql2: specifier: ^3.22.3 - version: 3.22.3(@types/node@24.12.2) + version: 3.22.3(@types/node@24.12.4) openai: - specifier: ^6.37.0 - version: 6.37.0(ws@8.20.1)(zod@4.4.3) + specifier: ^6.38.0 + version: 6.38.0(ws@8.20.1)(zod@4.4.3) p-limit: specifier: ^7.3.0 version: 7.3.0 pg: - specifier: ^8.20.0 - version: 8.20.0 + specifier: ^8.21.0 + version: 8.21.0 posthog-node: - specifier: ^5.0.0 - version: 5.0.0 + specifier: ^5.34.9 + version: 5.34.9 react: specifier: ^19.2.6 version: 19.2.6 @@ -204,8 +204,8 @@ importers: specifier: 3.36.0 version: 3.36.0 snowflake-sdk: - specifier: ^2.4.1 - version: 2.4.1(asn1.js@5.4.1) + specifier: ^2.4.2 + version: 2.4.2(asn1.js@5.4.1) yaml: specifier: ^2.9.0 version: 2.9.0 @@ -227,28 +227,28 @@ importers: version: 12.3.0(@azure/core-client@1.10.1) '@types/node': specifier: ^24.3.0 - version: 24.12.2 + version: 24.12.4 '@types/pg': specifier: ^8.20.0 version: 8.20.0 '@types/react': - specifier: ^19.2.14 - version: 19.2.14 + specifier: ^19.2.15 + version: 19.2.15 '@vitest/coverage-v8': - specifier: ^4.1.6 - version: 4.1.6(vitest@4.1.6) + specifier: ^4.1.7 + version: 4.1.7(vitest@4.1.7) ajv: specifier: 8.20.0 version: 8.20.0 ink-testing-library: specifier: ^4.0.0 - version: 4.0.0(@types/react@19.2.14) + version: 4.0.0(@types/react@19.2.15) typescript: specifier: ^6.0.3 version: 6.0.3 vitest: - specifier: ^4.1.6 - version: 4.1.6(@opentelemetry/api@1.9.0)(@types/node@24.12.2)(@vitest/coverage-v8@4.1.6)(vite@8.0.10(@types/node@24.12.2)(esbuild@0.28.0)(jiti@2.7.0)(yaml@2.9.0)) + specifier: ^4.1.7 + version: 4.1.7(@opentelemetry/api@1.9.1)(@types/node@24.12.4)(@vitest/coverage-v8@4.1.7)(vite@8.0.14(@types/node@24.12.4)(esbuild@0.28.0)(jiti@2.7.0)(yaml@2.9.0)) packages: @@ -264,31 +264,31 @@ packages: '@actions/io@3.0.2': resolution: {integrity: sha512-nRBchcMM+QK1pdjO7/idu86rbJI5YHUKCvKs0KxnSYbVe3F51UfGxuZX4Qy/fWlp6l7gWFwIkrOzN+oUK03kfw==} - '@ai-sdk/anthropic@3.0.77': - resolution: {integrity: sha512-ML8C2M1YvPA1ulEx4TiyF0k1xvC2ikEiPBIC1PPQ0a5xELUGrO2lAaEzsTEoJ+eCeDd8PSBuFJjs+r+9yIwQXA==} + '@ai-sdk/anthropic@3.0.78': + resolution: {integrity: sha512-0OY12G20cUt6iU6htpEA1491Oz++NVxZxlmWGX4B7rSbeZ5pnDmOu6YtW9BKzdZlNx5Gn23i6WMxyZFoMKNcgA==} engines: {node: '>=18'} peerDependencies: zod: ^3.25.76 || ^4.1.8 - '@ai-sdk/devtools@0.0.17': - resolution: {integrity: sha512-CJgo+3DMHOJbxxq1qTgnW4vpFXgBW1pHePMimBW4Go5FPU7iLqppoGX/UC798IXqlD3hncQRPfyBLZjbsJC91w==} + '@ai-sdk/devtools@0.0.18': + resolution: {integrity: sha512-0J25Q7occrkMJM1MrP0KeR8XNdGGKNgzxhOLfxBe/qZBQP6yXgV4H5Gf2DnDC3UgXDBJBskH9nh23doCo2Pebw==} engines: {node: '>=18'} hasBin: true - '@ai-sdk/gateway@3.0.114': - resolution: {integrity: sha512-MqkZ5sd+qiq6RgIxELkoFQXg2/JwK+WCMaot7U+rtrZpWJl3fSyYvc28SC03b256o4F7OXjQtdjTqs81B2w+dA==} + '@ai-sdk/gateway@3.0.118': + resolution: {integrity: sha512-XYPbVoDo1TDMVLe5Eg42gIjdOyxaizh9H0kiSSnTXr+AdrqZvutk/ypLOiqBXPV3D1K3+BSm/sbFeomZJlM64A==} engines: {node: '>=18'} peerDependencies: zod: ^3.25.76 || ^4.1.8 - '@ai-sdk/google-vertex@4.0.128': - resolution: {integrity: sha512-jK8fixb4km2yfgvb9DUFQRpV/jiDB0v9gyxHoHfPydaQvz+CpAz8DTt1quyaM+Wg9G2R8Zo68CYmHbIkUqW2AA==} + '@ai-sdk/google-vertex@4.0.134': + resolution: {integrity: sha512-EaVwzHk7P/Pj1JQtOfN3uLj+zKY6MQOn2hcEEACpbbjdVgBkpWxDjMRIpyYbVlXCLMUeWSYL0qUM54lmis/1BQ==} engines: {node: '>=18'} peerDependencies: zod: ^3.25.76 || ^4.1.8 - '@ai-sdk/google@3.0.73': - resolution: {integrity: sha512-o2MuIeyvZrFIeIbnbA8Thrr63irdyUBh0uWBZ2lY6yFeXuE/tcwyXF74bDKS4KvTu84uFpQfpbS/LXHGKKXz+g==} + '@ai-sdk/google@3.0.78': + resolution: {integrity: sha512-iPkZHiaaBNreaVX2fLpc+SAa7OJPV6f7pZRK98lWTI4vf0D546+9eEQ6T2FagJAHO0K0gEyzx5zogCoHbJnhQg==} engines: {node: '>=18'} peerDependencies: zod: ^3.25.76 || ^4.1.8 @@ -317,58 +317,60 @@ packages: resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==} engines: {node: '>=10'} - '@anthropic-ai/claude-agent-sdk-darwin-arm64@0.3.142': - resolution: {integrity: sha512-yBHOiRqJ8JcD9OAMGJALbypaD3u3K8hyUmcnZ+91AHJtymzWxuMkVi4IY1qp8L5jzkKeTnvYfCspzkbiHLuYWg==} + '@anthropic-ai/claude-agent-sdk-darwin-arm64@0.3.146': + resolution: {integrity: sha512-0IIvlEaenq2CRSVx5Bo5BaCtHQXS87GancM35WKEYveGVLn6DI+5G7ikYuTE4AKRPkMnogFtY4BJt6LulWGj+A==} cpu: [arm64] os: [darwin] - '@anthropic-ai/claude-agent-sdk-darwin-x64@0.3.142': - resolution: {integrity: sha512-/a/bVMjvAl3gNzWiPIgynYktTYckTcp4YAacV/2F4Jd8XeCV0+DMQW7OFeR+3fnPcBg/8kcOAVYfLZXDExqO1w==} + '@anthropic-ai/claude-agent-sdk-darwin-x64@0.3.146': + resolution: {integrity: sha512-Dk5xJ03Ff1JXbMRP1t2wc/TyfY6xF/2Ysp31wMhFPjoNiKSPHMWaIg242+T3CHdxLWmJ8plWHL1HL5cyZ/LCkw==} cpu: [x64] os: [darwin] - '@anthropic-ai/claude-agent-sdk-linux-arm64-musl@0.3.142': - resolution: {integrity: sha512-KZuwSupNJovnMJ7MZxjp1Qq0yu7rAmbzO4Zlmr3jtKDU95t2kgs3c6j4evzQDCgTQMlwH8QTSV4mItDGxlYEbg==} + '@anthropic-ai/claude-agent-sdk-linux-arm64-musl@0.3.146': + resolution: {integrity: sha512-QlCid0ucdrmhUAOewfQjaofN2wlokWcfFTxSFePTSj1umk35JO7TDFP700F7jU49r1fPWIdvJpPwWGyB0DeFPA==} cpu: [arm64] os: [linux] libc: [musl] - '@anthropic-ai/claude-agent-sdk-linux-arm64@0.3.142': - resolution: {integrity: sha512-QKG553PSbIcQ5KLvnl2ekfy5lTyU3dW/X5fDQlRLv4YHNHnqf2o7scJ6eUdfaVTQdIZ+Pa7SNN3bsvVs4bNjQw==} + '@anthropic-ai/claude-agent-sdk-linux-arm64@0.3.146': + resolution: {integrity: sha512-mzBXDDWWBAC/vDtAYpO1G/dq5QvJtYSPXsqcb+sNdcDhiuf4IYnYp7ytRncYlsUNDkLmX6Gk2jkWAHUUA2Lozg==} cpu: [arm64] os: [linux] libc: [glibc] - '@anthropic-ai/claude-agent-sdk-linux-x64-musl@0.3.142': - resolution: {integrity: sha512-QkDwLMsdYO7n/i1zPCt5YZIet5u+Eo07UpF9UX5yD7bnwRZKDe22L6LVVwiLLjeTO0fTz+uNY7w9/XOYQMlxUQ==} + '@anthropic-ai/claude-agent-sdk-linux-x64-musl@0.3.146': + resolution: {integrity: sha512-E3coK1ThQT08KIX80RLcsq7DWXFllCKOzoOe32it/bdtY56TBgPY9xemwXhIJ+cVBHTI9/MpBSIlKBcFCt+yQA==} cpu: [x64] os: [linux] libc: [musl] - '@anthropic-ai/claude-agent-sdk-linux-x64@0.3.142': - resolution: {integrity: sha512-o1QZmCNRL5BFTc14KEvT23Fxm1jNv0aa0e9T0OZUjua0oW8DRpri3HKvDEM36qEGWUOANBG7h6Ca/KNqxaTnYg==} + '@anthropic-ai/claude-agent-sdk-linux-x64@0.3.146': + resolution: {integrity: sha512-B2baXU1tCBT5CVlD7jJMKjpC4xdO45NUIWpqImmwuOfKvlM/PITjyTXyTY662mGZf1dBmdqBBsqirwFH/jhi8Q==} cpu: [x64] os: [linux] libc: [glibc] - '@anthropic-ai/claude-agent-sdk-win32-arm64@0.3.142': - resolution: {integrity: sha512-x8lbY1m7E/BiFF0Gu/Mx9lkD/zW3vBr3viw0GYNuqY9GYHfLOX9+l9H8C+INeGzB4+ibG8+xD2pnRhWdQxuvUg==} + '@anthropic-ai/claude-agent-sdk-win32-arm64@0.3.146': + resolution: {integrity: sha512-CIwQxGX2r/yWpjCJ6ahB3smKXhghWgGTxL98+LGW52TUwqTiBnlNrH9DPqqgv1/+Hyquw6xfLrKU+StyfMgiLw==} cpu: [arm64] os: [win32] - '@anthropic-ai/claude-agent-sdk-win32-x64@0.3.142': - resolution: {integrity: sha512-NpNxdiCEUNjjwvBltpDnkgdjVQ+nRsALpfM1Pe4GhnYiOkTk/TvjMZUuA2qGh0F8KyF0FbqzUsi0uXIgojJT5w==} + '@anthropic-ai/claude-agent-sdk-win32-x64@0.3.146': + resolution: {integrity: sha512-qmxrsyaqA8s4HShqJls7ZCRjdoqN66Jo/hbjQNB3uHepD8tEO1iD19aPV4+osdLT7feMkhDBfLT07Q30R2NB5w==} cpu: [x64] os: [win32] - '@anthropic-ai/claude-agent-sdk@0.3.142': - resolution: {integrity: sha512-k1xBon6ov0PT/vZNf+Z+SuAqmylGJU/+a+h/k04MW5cBbzOIwiVcGFRTGJ/qbY5pcboJbLtts/yBwSu9AvSipg==} + '@anthropic-ai/claude-agent-sdk@0.3.146': + resolution: {integrity: sha512-hK9/Ng+hOyexUemTxdIUsSWJ9o2LFi2YNWzHwz8/YMCohUYOnFMZkBiENvUAb0WIc5hieOyBZrOIlg5OewuJMg==} engines: {node: '>=18.0.0'} peerDependencies: + '@anthropic-ai/sdk': '>=0.93.0' + '@modelcontextprotocol/sdk': ^1.29.0 zod: ^4.0.0 - '@anthropic-ai/sdk@0.93.0': - resolution: {integrity: sha512-q9vaSZQVFx6B/gPxetGYfLXSJD5v0sOmh0OpZDq7yCrTSA+Rscvrtyol7JJTW40wEpQB4U1B4JXzxQitbQ3CAA==} + '@anthropic-ai/sdk@0.97.1': + resolution: {integrity: sha512-wOf7AUeJPitcVpvKO4UMu63mWH5SaVipkGd7OOQJt/G6VYGlV8D2Gp9dLxOrttDJh/9gqPqdaBwDGcBevumeAg==} hasBin: true peerDependencies: zod: ^3.25.0 || ^4.0.0 @@ -407,136 +409,127 @@ packages: resolution: {integrity: sha512-oDJJ7rM1osvfBdfZuhQ5DM6lHD9iuypL9m2LsEiA/lB8xuE5uPYsftNDcS0J9VRXFSvYTqC14K7Y5vMMKMg0vw==} engines: {node: '>=20.0.0'} - '@aws-sdk/core@3.974.8': - resolution: {integrity: sha512-njR2qoG6ZuB0kvAS2FyICsFZJ6gmCcf2X/7JcD14sUvGDm26wiZ5BrA6LOiUxKFEF+IVe7kdroxyE00YlkiYsw==} + '@aws-sdk/core@3.974.12': + resolution: {integrity: sha512-qrqgioqYFjwR6LatVNS1L2Vk++EwRIxqSQXPKNv5Ofux2D8UNgqMQ1znnMyEImXquVPTtbf71fc128pvmU6y9A==} engines: {node: '>=20.0.0'} - '@aws-sdk/crc64-nvme@3.972.7': - resolution: {integrity: sha512-QUagVVBbC8gODCF6e1aV0mE2TXWB9Opz4k8EJFdNrujUVQm5R4AjJa1mpOqzwOuROBzqJU9zawzig7M96L8Ejg==} + '@aws-sdk/crc64-nvme@3.972.8': + resolution: {integrity: sha512-fVfUCL/Xh2zINYMPZvj+iBn6XWouQf0DAnjaWCI9MkmqXzL2Iy5FoQB8O7syFe6gN6AH1ecDDU58T51Ou0kFkA==} engines: {node: '>=20.0.0'} - '@aws-sdk/credential-provider-env@3.972.34': - resolution: {integrity: sha512-XT0jtf8Fw9JE6ppsQeoNnZRiG+jqRixMT1v1ZR17G60UvVdsQmTG8nbEyHuEPfMxDXEhfdARaM/XiEhca4lGHQ==} + '@aws-sdk/credential-provider-env@3.972.38': + resolution: {integrity: sha512-m3WjZEgPtioMhPmwqUt+DhlTJ2i9ufR6DhfkyXojb9puEvfR+ur2U5shavu5/Cc9WHHsDCvALi6UFHgcqjhQ5w==} engines: {node: '>=20.0.0'} - '@aws-sdk/credential-provider-http@3.972.36': - resolution: {integrity: sha512-DPoGWfy7J7RKxvbf5kOKIGQkD2ek3dbKgzKIGrnLuvZBz5myU+Im/H6pmc14QcnFbqHMqxvtWSgRDSJW3qXLQg==} + '@aws-sdk/credential-provider-http@3.972.40': + resolution: {integrity: sha512-D78L/m2Dr6cJnnSvWoAudPhQmCwmJ7j6APXsPYmFpPaKfQTfCSu0rdm8j14Np+VmXF9z8Aj8HE3xFpsrwtfgeg==} engines: {node: '>=20.0.0'} - '@aws-sdk/credential-provider-ini@3.972.38': - resolution: {integrity: sha512-oDzUBu2MGJFgoar05sPMCwSrhw44ASyccrHzj66vO69OZqi7I6hZZxXfuPLC8OCzW7C+sU+bI73XHij41yekgQ==} + '@aws-sdk/credential-provider-ini@3.972.42': + resolution: {integrity: sha512-Mu5ESvFXeinafVM8jTIvRqcvK2Ehj4kz3auT39yUcHwu1Vfxo6xRlmUafdKLW4tusjAJukQwK09sCSMgOm7OKg==} engines: {node: '>=20.0.0'} - '@aws-sdk/credential-provider-login@3.972.38': - resolution: {integrity: sha512-g1NosS8qe4OF++G2UFCM5ovSkgipC7YYor5KCWatG0UoMSO5YFj9C8muePlyVmOBV/WTI16Jo3/s1NUo/o1Bww==} + '@aws-sdk/credential-provider-login@3.972.42': + resolution: {integrity: sha512-O6WkZga3kf0yqyJYd1dbeJqVhEgJx/x1UaLgtbR+XuL/YP+K5y6QTxQKL7ka9z3jnQASESKGAPnRyt4D5hQrxA==} engines: {node: '>=20.0.0'} - '@aws-sdk/credential-provider-node@3.972.39': - resolution: {integrity: sha512-HEswDQyxUtadoZ/bJsPPENHg7R0Lzym5LuMksJeHvqhCOpP+rtkDLKI4/ZChH4w3cf5kG8n6bZuI8PzajoiqMg==} + '@aws-sdk/credential-provider-node@3.972.43': + resolution: {integrity: sha512-D/DJmbrWRP5BXEO3FH+ar4el+2n6OlGofiud7dQun2jES+AQEJjczenp1jBb4MBN7CpGpS8nsWGQLtuzc9tQbA==} engines: {node: '>=20.0.0'} - '@aws-sdk/credential-provider-process@3.972.34': - resolution: {integrity: sha512-T3IFs4EVmVi1dVN5RciFnklCANSzvrQd/VuHY9ThHSQmYkTogjcGkoJEr+oNUPQZnso52183088NqysMPji1/Q==} + '@aws-sdk/credential-provider-process@3.972.38': + resolution: {integrity: sha512-EnbYVajGgbkb24s0K1eo4VNAPV5mHIET7LSvirTaFCwkfrfaOJxtSE+wY/tJdKDS21cEYkZs2ruCaAm+W4iblg==} engines: {node: '>=20.0.0'} - '@aws-sdk/credential-provider-sso@3.972.38': - resolution: {integrity: sha512-5ZxG+t0+3Q3QPh8KEjX6syskhgNf7I0MN7oGioTf6Lm1NTjfP7sIcYGNsthXC2qR8vcD3edNZwCr2ovfSSWuRA==} + '@aws-sdk/credential-provider-sso@3.972.42': + resolution: {integrity: sha512-RVV/9NbFwI8ZHEH5dn39lGyFmSbSVj1+orZdr6QsOe1mW9DCglmlen0cFaNZmCcqkqc7erNRHNBduxbeZuHAnw==} engines: {node: '>=20.0.0'} - '@aws-sdk/credential-provider-web-identity@3.972.38': - resolution: {integrity: sha512-lYHFF30DGI20jZcYX8cm6Ns0V7f1dDN6g/MBDLTyD/5iw+bXs3yBr2iAiHDkx4RFU5JgsnZvCHYKiRVPRdmOgw==} + '@aws-sdk/credential-provider-web-identity@3.972.42': + resolution: {integrity: sha512-/67fXX0ddllD4u2Nujc5PvT4byHgpMUfz6+RxIKi/0nFIckeorm7JvXgzBuDyVKw0s58EbofmETDWUf9vTEuHQ==} engines: {node: '>=20.0.0'} '@aws-sdk/ec2-metadata-service@3.1045.0': resolution: {integrity: sha512-cYjEbjbGScw9l8TmI9AFYde1hIu5c9Wt0Qp7/cbWBHBiOzMfLwmjGhd5+4AUm1RsnmC5HZ/WOA9iGJHfHL4cuA==} engines: {node: '>=20.0.0'} - '@aws-sdk/middleware-bucket-endpoint@3.972.10': - resolution: {integrity: sha512-Vbc2frZH7wXlMNd+ZZSXUEs/l1Sv8Jj4zUnIfwrYF5lwaLdXHZ9xx4U3rjUcaye3HRhFVc+E5DbBxpRAbB16BA==} + '@aws-sdk/middleware-bucket-endpoint@3.972.14': + resolution: {integrity: sha512-Aaj0d+xbo1jJquBWJP0/9V/XZRYukO3LWIRp3dOLHmoFrYKb4YZ0aLefgVHfGcNOVBS2ZTq7L/n5JcrE7DaC+Q==} engines: {node: '>=20.0.0'} - '@aws-sdk/middleware-expect-continue@3.972.10': - resolution: {integrity: sha512-2Yn0f1Qiq/DjxYR3wfI3LokXnjOhFM7Ssn4LTdFDIxRMCE6I32MAsVnhPX1cUZsuVA9tiZtwwhlSLAtFGxAZlQ==} + '@aws-sdk/middleware-expect-continue@3.972.12': + resolution: {integrity: sha512-dA5pKTom/Ls9mgeyeaRBNQrRIVOLVjv4AmKOB0/e4yaiXEUy0gSz2d3liP8JHtYoCAEWySU1jWnyzwLOREN+4g==} engines: {node: '>=20.0.0'} - '@aws-sdk/middleware-flexible-checksums@3.974.16': - resolution: {integrity: sha512-6ru8doI0/XzszqLIPXf0E/V7HhAw1Pu94010XCKYtBUfD0LxF0BuOzrUf8OQGR6j2o6wgKTHUniOmndQycHwCA==} + '@aws-sdk/middleware-flexible-checksums@3.974.20': + resolution: {integrity: sha512-NdnMVQCR1YjIcqFAiNLdBiOwr2DyQDB2IiXQrBhzolKOv32ae4d4Ll7IzLMi04eMHiq/o/Y/GjFuVjF9HuG0QA==} engines: {node: '>=20.0.0'} - '@aws-sdk/middleware-host-header@3.972.10': - resolution: {integrity: sha512-IJSsIMeVQ8MMCPbuh1AbltkFhLBLXn7aejzfX5YKT/VLDHn++Dcz8886tXckE+wQssyPUhaXrJhdakO2VilRhg==} + '@aws-sdk/middleware-host-header@3.972.13': + resolution: {integrity: sha512-EA3+u2LD3kGcfRNmCSjyJuzX4XvG4zYv57i4ZksH+1IEciuSyHQGvzivEz7vZ+jbRPdAAe7WWKy/4M8InCKDcw==} engines: {node: '>=20.0.0'} '@aws-sdk/middleware-location-constraint@3.972.10': resolution: {integrity: sha512-rI3NZvJcEvjoD0+0PI0iUAwlPw2IlSlhyvgBK/3WkKJQE/YiKFedd9dMN2lVacdNxPNhxL/jzQaKQdrGtQagjQ==} engines: {node: '>=20.0.0'} - '@aws-sdk/middleware-logger@3.972.10': - resolution: {integrity: sha512-OOuGvvz1Dm20SjZo5oEBePFqxt5nf8AwkNDSyUHvD9/bfNASmstcYxFAHUowy4n6Io7mWUZ04JURZwSBvyQanQ==} + '@aws-sdk/middleware-logger@3.972.12': + resolution: {integrity: sha512-NxB2dS4/mV3380hNkC72TkhMaLLjWGGBeTAEucqlJptVVovTbNmQWZLwaMC74ICo9NZHmFiBVVTHzDfAh/3y6Q==} engines: {node: '>=20.0.0'} - '@aws-sdk/middleware-recursion-detection@3.972.11': - resolution: {integrity: sha512-+zz6f79Kj9V5qFK2P+D8Ehjnw4AhphAlCAsPjUqEcInA9umtSSKMrHbSagEeOIsDNuvVrH98bjRHcyQukTrhaQ==} + '@aws-sdk/middleware-recursion-detection@3.972.14': + resolution: {integrity: sha512-bqL+upATpOJ/7px4IVfMVxcM6Lyt9uRizmEx3mNg4N6+IQlnOaYXXOJ4TNX6P0mKPPW0lwn9ZW8QEhXwQuCH9A==} engines: {node: '>=20.0.0'} - '@aws-sdk/middleware-sdk-s3@3.972.37': - resolution: {integrity: sha512-Km7M+i8DrLArVzrid1gfxeGhYHBd3uxvE77g0s5a52zPSVosxzQBnJ0gwWb6NIp/DOk8gsBMhi7V+cpJG0ndTA==} + '@aws-sdk/middleware-sdk-s3@3.972.41': + resolution: {integrity: sha512-M4T2I2WPuH5WQpU8Tsp+u2bcO29zGRkU14ATzuqb9I4xh8tzsLqtp4hzaJM5aO2dhMZnHDzyQwSFVgc3XbnoGg==} engines: {node: '>=20.0.0'} '@aws-sdk/middleware-ssec@3.972.10': resolution: {integrity: sha512-Gli9A0u8EVVb+5bFDGS/QbSVg28w/wpEidg1ggVcSj65BDTdGR6punsOcVjqdiu1i42WHWo51MCvARPIIz9juw==} engines: {node: '>=20.0.0'} - '@aws-sdk/middleware-user-agent@3.972.38': - resolution: {integrity: sha512-iz+B29TXcAZsJpwB+AwG/TTGA5l/VnmMZ2UxtiySOZjI6gCdmviXPwdgzcmuazMy16rXoPY4mYCGe7zdNKfx5A==} + '@aws-sdk/middleware-user-agent@3.972.42': + resolution: {integrity: sha512-U7jjlJKQnuUlI2swC2umFLFzLAxMLudSRFv+Bqk2F8ORmr5bG25qsFxGm4GEFwoZeGaFFnAFmTY0xReVRfyl2A==} engines: {node: '>=20.0.0'} - '@aws-sdk/nested-clients@3.997.6': - resolution: {integrity: sha512-WBDnqatJl+kGObpfmfSxqnXeYTu3Me8wx8WCtvoxX3pfWrrTv8I4WTMSSs7PZqcRcVh8WeUKMgGFjMG+52SR1w==} + '@aws-sdk/nested-clients@3.997.10': + resolution: {integrity: sha512-FtQ/Bt327peZJuyo4WZSOLVUTw9ujRxntepiC7L65FxA2P82Xlq0g14T22BuqBUeMjDoxa9nvwiMHjLIfP3eUg==} engines: {node: '>=20.0.0'} - '@aws-sdk/region-config-resolver@3.972.13': - resolution: {integrity: sha512-CvJ2ZIjK/jVD/lbOpowBVElJyC1YxLTIJ13yM0AEo0t2v7swOzGjSA6lJGH+DwZXQhcjUjoYwc8bVYCX5MDr1A==} + '@aws-sdk/region-config-resolver@3.972.16': + resolution: {integrity: sha512-/YaivCvKUkEeMN9VTKBSvBn5w/4osAM1YboM58DKaLF/vqFGf/FdJCLmppqiPPJWZaXcASqByVjc3evE7KHKdA==} engines: {node: '>=20.0.0'} - '@aws-sdk/signature-v4-multi-region@3.996.25': - resolution: {integrity: sha512-+CMIt3e1VzlklAECmG+DtP1sV8iKq25FuA0OKpnJ4KA0kxUtd7CgClY7/RU6VzJBQwbN4EJ9Ue6plvqx1qGadw==} + '@aws-sdk/signature-v4-multi-region@3.996.27': + resolution: {integrity: sha512-0Phbz4t6HI3D3skxvG2uI+VWU034/nSIw1T8d+FPzzQG9EQTrw94o9mOKO2Gv3n3Oc8P7JD7RAUxkoneLWv5Eg==} engines: {node: '>=20.0.0'} - '@aws-sdk/token-providers@3.1041.0': - resolution: {integrity: sha512-Th7kPI6YPtvJUcdznooXJMy+9rQWjmEF81LxaJssngBzuysK4a/x+l8kjm1zb7nYsUPbndnBdUnwng/3PLvtGw==} + '@aws-sdk/token-providers@3.1049.0': + resolution: {integrity: sha512-r7+d0lQMTHKypkmaF5jRTBYLYHCUHzt3gaVoN9SidLhQeWhCmHk3AKrboDTpPF5b7Pt7vKu3+oeMjznM2Eu1ow==} engines: {node: '>=20.0.0'} '@aws-sdk/types@3.973.8': resolution: {integrity: sha512-gjlAdtHMbtR9X5iIhVUvbVcy55KnznpC6bkDUWW9z915bi0ckdUr5cjf16Kp6xq0bP5HBD2xzgbL9F9Quv5vUw==} engines: {node: '>=20.0.0'} - '@aws-sdk/util-arn-parser@3.972.3': - resolution: {integrity: sha512-HzSD8PMFrvgi2Kserxuff5VitNq2sgf3w9qxmskKDiDTThWfVteJxuCS9JXiPIPtmCrp+7N9asfIaVhBFORllA==} - engines: {node: '>=20.0.0'} - - '@aws-sdk/util-endpoints@3.996.8': - resolution: {integrity: sha512-oOZHcRDihk5iEe5V25NVWg45b3qEA8OpHWVdU/XQh8Zj4heVPAJqWvMphQnU7LkufmUo10EpvFPZuQMiFLJK3g==} + '@aws-sdk/util-endpoints@3.996.11': + resolution: {integrity: sha512-BUMJ6VoL54r6Udj/wKy8uKRIndL04rGbaS/wTIV0dM1ewxSrR8yARBHdvZKQsK55ZSW2JrmAPk3KP15kBDxJMw==} engines: {node: '>=20.0.0'} '@aws-sdk/util-locate-window@3.965.5': resolution: {integrity: sha512-WhlJNNINQB+9qtLtZJcpQdgZw3SCDCpXdUJP7cToGwHbCWCnRckGlc6Bx/OhWwIYFNAn+FIydY8SZ0QmVu3xTQ==} engines: {node: '>=20.0.0'} - '@aws-sdk/util-user-agent-browser@3.972.10': - resolution: {integrity: sha512-FAzqXvfEssGdSIz8ejatan0bOdx1qefBWKF/gWmVBXIP1HkS7v/wjjaqrAGGKvyihrXTXW00/2/1nTJtxpXz7g==} + '@aws-sdk/util-user-agent-browser@3.972.13': + resolution: {integrity: sha512-wfk9ZdVwh187gdGXB1EyAoprwjSMt/bSfVtva+OaZx+LyNdKD7smlZf611yMd42UpfQ9vaS8NOftjSajgpdd+w==} - '@aws-sdk/util-user-agent-node@3.973.24': - resolution: {integrity: sha512-ZWwlkjcIp7cEL8ZfTpTAPNkwx25p7xol0xlKoWVVf22+nsjwmLcHYtTPjIV1cSpmB/b6DaK4cb1fSkvCXHgRdw==} + '@aws-sdk/util-user-agent-node@3.973.28': + resolution: {integrity: sha512-A2l/PTRzsOS9L8dmZbXtDyJQgeeX+qjqLJ+fr0UU5Dz0AUQMuxgZCPSLKZgUDlHAmLFuk34owdMEvJxmDTBgRg==} engines: {node: '>=20.0.0'} - peerDependencies: - aws-crt: '>=1.0.0' - peerDependenciesMeta: - aws-crt: - optional: true - '@aws-sdk/xml-builder@3.972.22': - resolution: {integrity: sha512-PMYKKtJd70IsSG0yHrdAbxBr+ZWBKLvzFZfD3/urxgf6hXVMzuU5M+3MJ5G67RpOmLBu1fAUN65SbWuKUCOlAA==} + '@aws-sdk/xml-builder@3.972.24': + resolution: {integrity: sha512-V8z5YcDPfsvzrBlj0xR1vhRtocblhYbqdreCJB/voGd4Sr5zjNAeWxexbnqVtskTJe0vFb5KMqbSL++ePl+zRw==} engines: {node: '>=20.0.0'} '@aws/lambda-invoke-store@0.2.4': @@ -606,16 +599,16 @@ packages: resolution: {integrity: sha512-fCqPIfOcLE+CGqGPd66c8bZpwAji98tZ4JI9i/mlTNTlsIWslCfpg48s/ypyLxZTump5sypjrKn2/kY7q8oAbA==} engines: {node: '>=20.0.0'} - '@azure/msal-browser@5.9.0': - resolution: {integrity: sha512-CzE+4PefDSJWj26zU7G1bKchlGRRHMBFreG4tAlGuzyI8hAPiYGobaJvZBgZBf6L63iphX7VH+ityL8VgEQz9Q==} + '@azure/msal-browser@5.11.0': + resolution: {integrity: sha512-zkGNYS3TwY8lUpPIafAmsFCYZbgFixY9y/LZB9GUg0IILoHTqpN26j5OrkL1AQThh/YdZsawe4iWXfp85lFVxg==} engines: {node: '>=0.8.0'} - '@azure/msal-common@16.5.2': - resolution: {integrity: sha512-GkDEL6TYo3HgT3UuqakdgE9PZfc1hMki6+Hwgy1uddb/EauvAKfu85vVhuofRSo22D1xTnWt8Ucwfg4vSCVwvA==} + '@azure/msal-common@16.6.2': + resolution: {integrity: sha512-hQjjsekAjB00cM1EmatWJlzhEoK2Qhz7Rj5gvM6tYf8iL7RM3tkxlpU9fG0+ofkulzg9AEEA6dIEnSmDr5ZqUA==} engines: {node: '>=0.8.0'} - '@azure/msal-node@5.1.5': - resolution: {integrity: sha512-ObTeMoNPmq19X3z40et9Xvs4ZoWVeJg43PZMRLG5iwVL+2nCtAerG3YTDItqPp1CfXNwmCXBbg8jn1DOx65c3g==} + '@azure/msal-node@5.2.2': + resolution: {integrity: sha512-toS+2AePxqyzb0YOKttDOOiSl3jrkK9aiqIvpurpis0O34QcIS5gToqrgT39p04Dpxw3YoUU0lxJKTpSFFfA6Q==} engines: {node: '>=20'} '@azure/storage-blob@12.26.0': @@ -716,11 +709,11 @@ packages: resolution: {integrity: sha512-S0My7XPGIgpRWMDG8uRqalbgT+a6FmCUdOW+HaIOVVpUPHOb7RrpvjTjiODadKp06fsrVDJZlIzc6yCTp4AnxA==} engines: {node: '>= 20.12.0'} - '@clickhouse/client-common@1.18.4': - resolution: {integrity: sha512-kPPtv8yQmplNAxfrAJvwBJq5dd+IWRewEbXSpUvtyEJXlrB8lt/ZH63jUS81Nmd+lK5MRvpOFXPoN3iogkvg+A==} + '@clickhouse/client-common@1.18.5': + resolution: {integrity: sha512-g9LwcS1dvkatKDsIjT1PwUHldsiYzwdKAB0nXfd9APLd+t4PrNJa+my+dXcqJdmcWyhWjKLP/2/ztBwgxp+sbQ==} - '@clickhouse/client@1.18.4': - resolution: {integrity: sha512-jjCrddI+e2OVXGh/MQY92K9r8Z/iwqaZtUXNI/MfZ/y9VGYwfbQsXRzp4Jv6w4Hgxvr4sLcz9YwIvkCBQ6X/mw==} + '@clickhouse/client@1.18.5': + resolution: {integrity: sha512-4FfoyMkFWhsdNMuXsoEL6l3c12svA63BBJBtDo9SrxRZ14RdmN6jLr/rF3f84BK8cFoxETZCSeKlsbk6NNYebw==} engines: {node: '>=16'} '@colors/colors@1.5.0': @@ -1240,8 +1233,8 @@ packages: '@nodable/entities@2.1.0': resolution: {integrity: sha512-nyT7T3nbMyBI/lvr6L5TyWbFJAI9FTgVRakNoBqCD+PmID8DzFrrNdLLtHMwMszOtqZa8PAOV24ZqDnQrhQINA==} - '@notionhq/client@5.21.0': - resolution: {integrity: sha512-X+T+hzaQFleOUGm4xUOUm51pOpdZ1+6T4BsRjGlcdEOTJLNkUEv8nZATq9O3ZY4NQEgICc0qwQ0I25OdYprX0w==} + '@notionhq/client@5.22.0': + resolution: {integrity: sha512-lZ3JGBCd6O6MNHWn/58QcUqX1FgmlcODcx/EaUEEpuxLXF5tSi+v29Vzoz8mZ6JgDWDn5pMzzjB69QevYjQQZA==} engines: {node: '>=18'} '@octokit/auth-token@6.0.0': @@ -1292,146 +1285,146 @@ packages: '@octokit/types@16.0.0': resolution: {integrity: sha512-sKq+9r1Mm4efXW1FCk7hFSeJo4QKreL/tTbR0rz/qx/r1Oa2VV83LTA/H/MuCOX7uCIJmQVRKBcbmWoySjAnSg==} - '@opentelemetry/api@1.9.0': - resolution: {integrity: sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==} + '@opentelemetry/api@1.9.1': + resolution: {integrity: sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q==} engines: {node: '>=8.0.0'} '@orama/orama@3.1.18': resolution: {integrity: sha512-a61ljmRVVyG5MC/698C8/FfFDw5a8LOIvyOLW5fztgUXqUpc1jOfQzOitSCbge657OgXXThmY3Tk8fpiDb4UcA==} engines: {node: '>= 20.0.0'} - '@oxc-parser/binding-android-arm-eabi@0.128.0': - resolution: {integrity: sha512-aca6ZvzmCBUGOANQRiRQRZuRKYI3ENhcit6GisnknOOmcezfQc7xJ4dxlPU7MV7mOvrC7RNR1u3LAD7xyaiCxA==} + '@oxc-parser/binding-android-arm-eabi@0.130.0': + resolution: {integrity: sha512-h/xYU8/7ADWzVSf5I+YalLpj33LOy9CI/zgbJNIZ5eunRBG+Czqa3lZsvuPHHf3rOt6z1c5+UzoxjbAzAvhwVw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [android] - '@oxc-parser/binding-android-arm64@0.128.0': - resolution: {integrity: sha512-BbeDmuohoJ7Rz/it5wnkj69i/OsCPS3Z51nLEzwO/Y6YshtC4JU+15oNwhY8v4LRKRYclRc7ggOikwrsJ/eOEQ==} + '@oxc-parser/binding-android-arm64@0.130.0': + resolution: {integrity: sha512-oFWFJrsGv9siFM4HjMqKNB7IuIZD/SMmZdCXl8xyx7lDplGvPKyewpOo272rSWgMXe2Wx7bWI0Yj+gkHv4qbeg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [android] - '@oxc-parser/binding-darwin-arm64@0.128.0': - resolution: {integrity: sha512-tRUHPt80417QmvNpoSslJT1VY8NUbWdrWR+L14Zn+RbOTcaqB8E6PYE/ZGN8jjWBzqporiA/H4MfO50ew/NCNA==} + '@oxc-parser/binding-darwin-arm64@0.130.0': + resolution: {integrity: sha512-sGUzupdTplK9jQg7eJZ878HfEgQjJNBc6dAYVWJ9W5aU+J8rLfRJhTVsKThiu1pNwm6Y1qKCcbC6WhNWSXR3Ig==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [darwin] - '@oxc-parser/binding-darwin-x64@0.128.0': - resolution: {integrity: sha512-rWI2Hb1Nt3U/vKsjyNvZzDC8i/l144U20DKjhzaTmwIhIiSRGeroPWWiImwypmKLqrw8GuIixbWJkpGWLbkzrQ==} + '@oxc-parser/binding-darwin-x64@0.130.0': + resolution: {integrity: sha512-PsB4cdCISbC00Uy8eiD8bc2AkGWjZqrSrJnkBFuG2ptrrf6mZ2F5gLFSjOAVMMgZPg8B1D7OydJwLWSfyI2Plg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [darwin] - '@oxc-parser/binding-freebsd-x64@0.128.0': - resolution: {integrity: sha512-hhpdVMaNCLgQxjgNPeeFzSeJMmZPc5lKfv0NGSI3egZq9EdnEGqeC8JsYsQjK7PoQgbvZ17xlj0SO5ziH5Obkg==} + '@oxc-parser/binding-freebsd-x64@0.130.0': + resolution: {integrity: sha512-DgABp3l38hS77JbXCV4qk1+n6DPym5u8zzwuweokezm2tX194nDSJDENbDRECxVsiNbprKATLbk+Z5wlHT0OHw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [freebsd] - '@oxc-parser/binding-linux-arm-gnueabihf@0.128.0': - resolution: {integrity: sha512-093zNw0zZ/e/obML+rhlSdmnzR0mVZluPcAkxunEc5E3F0yBVsFn24Y1ILfsEte11Ud041qn/gp2OJ1jxNqUng==} + '@oxc-parser/binding-linux-arm-gnueabihf@0.130.0': + resolution: {integrity: sha512-4Kn3CTEmwFrzhTSC/JuUW16qovmaMdX7jeSKbL8w0pLtLww7To1a2XJi9Z5uD8QWUkfUHhqfV+VD6dVzBnWzoA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [linux] - '@oxc-parser/binding-linux-arm-musleabihf@0.128.0': - resolution: {integrity: sha512-fq7DmKmfC+dvD97IXrgbph6Jzwe0EDu+PYMofmzZ6fv5X1k9vtaqLpDGMuICO9MmUnyKAQmVl+wIv2RNy4Dz8g==} + '@oxc-parser/binding-linux-arm-musleabihf@0.130.0': + resolution: {integrity: sha512-D35KZM3F4rRu1uAFKyBlg3Gaf/ybCjyaPR1hfgvk5ex8NtcTmRgc0JgSighEyNg96TPrFhemFba68SZuxaha8w==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [linux] - '@oxc-parser/binding-linux-arm64-gnu@0.128.0': - resolution: {integrity: sha512-Xvm48jJah8TlIrURIjNOP/gNiGe6aKvCB+r06VliflFo8Kq7VOLE8PxtgShJzZIqubrgdMdYfvuPPozn7F6MbQ==} + '@oxc-parser/binding-linux-arm64-gnu@0.130.0': + resolution: {integrity: sha512-Q9o7oVlo955KHwS8l1u0bCzIx+JsZUA3XToLXC+MsMhye/9LeBQbt84nh120cl2XLy+TEzvugYDiHShg5yaX6Q==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] libc: [glibc] - '@oxc-parser/binding-linux-arm64-musl@0.128.0': - resolution: {integrity: sha512-M7iwBGmYJTx+pKOYFjI0buop4gJvlmcVzFGaXPt21DKpQkbQZG1f63Yg7LloIYT/t9yLxCw0Lhfx/RFlAlMSjA==} + '@oxc-parser/binding-linux-arm64-musl@0.130.0': + resolution: {integrity: sha512-EiJ/gC0ljbcwVpycC8YWw6ggMbtsPX8XMOt0mPx0aqWeMsNR+L9m05Flbvd5T+GlivG+GkSWQL7tM9SRFpM/dw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] libc: [musl] - '@oxc-parser/binding-linux-ppc64-gnu@0.128.0': - resolution: {integrity: sha512-21LGNIZb1Pcfk5/EGsqabrxv4yqQOWis1407JJrClS7XpFCrbvr74YAB1V+m54cYbwvO6UWwQqS4WecxiyfCRg==} + '@oxc-parser/binding-linux-ppc64-gnu@0.130.0': + resolution: {integrity: sha512-b+h/lsLLurp756dMGizNs5uPaJfyEdWrTcV5t8M609jWm1DEHB1StpRXCkyvwtkJx3m+qL5BNQ0dEKan/4yGFA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ppc64] os: [linux] libc: [glibc] - '@oxc-parser/binding-linux-riscv64-gnu@0.128.0': - resolution: {integrity: sha512-gyHjOTFpg9bTTYjxPmQirvufb89+VdZwVfcMtAUyPr6F5H8ZswvCQshK4qOW+Q+2Xyb33hduRgY/eFHJQjU/vQ==} + '@oxc-parser/binding-linux-riscv64-gnu@0.130.0': + resolution: {integrity: sha512-O19Cil83XAyjEFfo8WhkMwY58ALqZ7ckjGL+25mjMIuF84urWBeANH0FC8B8BsSSygWU3/1aY3ADdDbp+wlBnw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [riscv64] os: [linux] libc: [glibc] - '@oxc-parser/binding-linux-riscv64-musl@0.128.0': - resolution: {integrity: sha512-X6Q2oKUrP5GyDd2xniuEBLk6aFQCZ97W2+aVXGgJXdjx5t4/oFuA9ri0wLOUrBIX+qdSuK581snMBio4z910eA==} + '@oxc-parser/binding-linux-riscv64-musl@0.130.0': + resolution: {integrity: sha512-BgXRVC0+83n3YzCscLQjj6nbyeBIVeZYPTI4fFMAE4WNm2+4RXhWp03IVizL7esIz36kgmT48aebk1iM+cs8sw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [riscv64] os: [linux] libc: [musl] - '@oxc-parser/binding-linux-s390x-gnu@0.128.0': - resolution: {integrity: sha512-BdzTmqxfxoYkpgokoLaSnOX6T+R3/goL42klre2tnG+kHbG2TXS0VN+P5BPofH1axdKOHy5ei4ENZrjmCOt2lA==} + '@oxc-parser/binding-linux-s390x-gnu@0.130.0': + resolution: {integrity: sha512-6tJz0xvnGhsokE7N1WlUSBXibpYmT9xSJFS1Ce41Km/+8gQvdlW8MLhRv8PD0L7ix8vRG0FDDepp3jdOFzdVdw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [s390x] os: [linux] libc: [glibc] - '@oxc-parser/binding-linux-x64-gnu@0.128.0': - resolution: {integrity: sha512-OO1nW2Q7sSYYvJZpDHdvyFSdRaVcQqRijZSSmWVMqFxPYy8cEF45zJ9fcdIYuzIT3jYq6YRhEFm/VMWNWhE22Q==} + '@oxc-parser/binding-linux-x64-gnu@0.130.0': + resolution: {integrity: sha512-9aCWj83dp3heTQGmGnZGdIWgxjZrr/7VQ0TGFHH5PKByxJKF2Hcr4qvaSUHhhGEa3MSsDjTL1YDP8RAgdL5/Cg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] libc: [glibc] - '@oxc-parser/binding-linux-x64-musl@0.128.0': - resolution: {integrity: sha512-4NehAe404MRdoZVS9DW8C5XbJwbXIc/KfVlYdpi5vE4081zc9Y0YzKVqyOYj/Puye7/Do+ohaONBFWlEHYl9hw==} + '@oxc-parser/binding-linux-x64-musl@0.130.0': + resolution: {integrity: sha512-afXt87aZBqrUVli8TB/I8H1G50RDWcwirjWtXGXYqJ2ZqWEiErH7V72j3LUSDZaivmtu2OLX0KQ/mbhP81mr7A==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] libc: [musl] - '@oxc-parser/binding-openharmony-arm64@0.128.0': - resolution: {integrity: sha512-kVbqgW9xLL8bh8oc7aYOJilRKXE5G33+tE0jan+duo/9OriaFRpijcCwT2waWs2oqYROYq0GlE7/p3ywoshVeg==} + '@oxc-parser/binding-openharmony-arm64@0.130.0': + resolution: {integrity: sha512-I0NCrZV/YZuCGWgqwNN/GO/iXlLF2z+Wgc7u+Aa9N4P51oYeIa0XT+zVBUne4csO9GqxskXgI4g8JzzWGRpfOw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [openharmony] - '@oxc-parser/binding-wasm32-wasi@0.128.0': - resolution: {integrity: sha512-L38ojghJYHmgiz6fJd7jwLB/ESDBpB02NdFxh+smqVM6P2anCEvHn0jhaSrt5eVNR1Ak8+moOeftUlofeyvniA==} + '@oxc-parser/binding-wasm32-wasi@0.130.0': + resolution: {integrity: sha512-sJgQkGaBX0WJvPUDfwciex6IcTk5O5NLQ1bhEb6f3nBruh1GshKMRSMt2bxZlYrgBzjyBbJzsnO+InPG0bg+fA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [wasm32] - '@oxc-parser/binding-win32-arm64-msvc@0.128.0': - resolution: {integrity: sha512-xgvO35GyHBtjlQ5AEpaYr7Rll1rvY7zqIhT6ty8E3ezBW2J1SFLjIDEvI/tcgDg6oaseDAqVcM+jU1HuCekgZw==} + '@oxc-parser/binding-win32-arm64-msvc@0.130.0': + resolution: {integrity: sha512-bjcma99sQrNh6RY4mPO9yTkfxql6TDFoN3HWdK31RCKXwNhcDgJXW/l8PUtzKNiQ+9vpKJfJtQq+LklBuxSOBA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [win32] - '@oxc-parser/binding-win32-ia32-msvc@0.128.0': - resolution: {integrity: sha512-OY+3eM2SN72prHKRB22mPz8o5A/7dJ+f5DFLBVvggyZhEaNDAH9IB+ElMjmOkOIwf5MDCUAowCK7pAncNxzpBA==} + '@oxc-parser/binding-win32-ia32-msvc@0.130.0': + resolution: {integrity: sha512-hRYbv6HhpSTzT4xTiIkadLI7upLQxuOdLPR/9nL1fTjwhgutBTPXrwaAPb/jTFVx6/8C7Jb5HcUKhmNwloTbFA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ia32] os: [win32] - '@oxc-parser/binding-win32-x64-msvc@0.128.0': - resolution: {integrity: sha512-NE9ny+cPUCCObXa0IKLfj0tCdPd7pe/dz9ZpkxpUOymB3miNeMPybdlYYTBSGJUalMWeBM85/4JcCErCNTqOXw==} + '@oxc-parser/binding-win32-x64-msvc@0.130.0': + resolution: {integrity: sha512-RBpA9TsRucJq6HNVNCFF1iKg+QeTkLdZf7hi4xaOGCPvMZWvDHjQgSOEZMUpuW4JNciHbxNhLEYmz5CVygjVGQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [win32] - '@oxc-project/types@0.127.0': - resolution: {integrity: sha512-aIYXQBo4lCbO4z0R3FHeucQHpF46l2LbMdxRvqvuRuW2OxdnSkcng5B8+K12spgLDj93rtN3+J2Vac/TIO+ciQ==} + '@oxc-project/types@0.130.0': + resolution: {integrity: sha512-ibD2usx9JRu7f5pu2tMKMI4cpA4NgXJQoYRP4pQ7Pxmn1l6k/53qWtQWZayhYy3X4QZkt90Ot+mJEaeXouio6Q==} - '@oxc-project/types@0.128.0': - resolution: {integrity: sha512-huv1Y/LzBJkBVHt3OlC7u0zHBW9qXf1FdD7sGmc1rXc2P1mTwHssYv7jyGx5KAACSCH+9B3Bhn6Z9luHRvf7pQ==} + '@oxc-project/types@0.132.0': + resolution: {integrity: sha512-FESMOxil5Se014ui/Eq8fT5uHJo6nIRwH0PfJrZJXs6Gek3ZVFOrpUv3YIZT20m+extU98Hg1Ym72U58rlsxUQ==} '@oxc-resolver/binding-android-arm-eabi@11.19.1': resolution: {integrity: sha512-aUs47y+xyXHUKlbhqHUjBABjvycq6YSD7bpxSW7vplUmdzAlJ93yXY6ZR0c1o1x5A/QKbENCvs3+NlY8IpIVzg==} @@ -1553,6 +1546,12 @@ packages: resolution: {integrity: sha512-h104Kh26rR8tm+a3Qkc5S4VLYint3FE48as7+/5oCEcKR2idC/pF1G6AhIXKI+eHPJa/3J9i5z0Al47IeGHPkA==} engines: {node: '>=12'} + '@posthog/core@1.29.7': + resolution: {integrity: sha512-WcBD9/YQVGI9r/5+/IGeaPgsmTIg0YfyzaTei5TNlhmAeFOccnhs269rhtQJcAXngZFpvWSj+RTxX2ONdgxBDQ==} + + '@posthog/types@1.374.4': + resolution: {integrity: sha512-OHBo+gReFwPJtt/yLY6xxa1EYMp7Ti07O1C1KE9ZXXyyuLNqekRaHZxJ/SKUfEvt1LhFV/9sioz8O0xfsSffsQ==} + '@radix-ui/number@1.1.1': resolution: {integrity: sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g==} @@ -1918,103 +1917,103 @@ packages: '@radix-ui/rect@1.1.1': resolution: {integrity: sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==} - '@rolldown/binding-android-arm64@1.0.0-rc.17': - resolution: {integrity: sha512-s70pVGhw4zqGeFnXWvAzJDlvxhlRollagdCCKRgOsgUOH3N1l0LIxf83AtGzmb5SiVM4Hjl5HyarMRfdfj3DaQ==} + '@rolldown/binding-android-arm64@1.0.2': + resolution: {integrity: sha512-ZS4D1JPGn/MYQN/SYDWftIE/nVsM8j/AFOYEzAoOE2O3NktQOZru+/vYXGbR/qtdLdIfGCP0lcoJiYVzsEz+iQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [android] - '@rolldown/binding-darwin-arm64@1.0.0-rc.17': - resolution: {integrity: sha512-4ksWc9n0mhlZpZ9PMZgTGjeOPRu8MB1Z3Tz0Mo02eWfWCHMW1zN82Qz/pL/rC+yQa+8ZnutMF0JjJe7PjwasYw==} + '@rolldown/binding-darwin-arm64@1.0.2': + resolution: {integrity: sha512-vdFA9+C/rekyGce7WqHs/xoT0ioZEWaOFyZLIV1mEeNFaFDUQrPIo8Vs2GvJ6eetb3rzDUtUBgzto3ExpXJB3w==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [darwin] - '@rolldown/binding-darwin-x64@1.0.0-rc.17': - resolution: {integrity: sha512-SUSDOI6WwUVNcWxd02QEBjLdY1VPHvlEkw6T/8nYG322iYWCTxRb1vzk4E+mWWYehTp7ERibq54LSJGjmouOsw==} + '@rolldown/binding-darwin-x64@1.0.2': + resolution: {integrity: sha512-BewSOwTHazv77DTYiAZXSqqKZ4KP/KonFisDMVU7PImxoWfB2aepnPhd2E4SWz3zDzYgDNbs6jBmTdgNnF02GA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [darwin] - '@rolldown/binding-freebsd-x64@1.0.0-rc.17': - resolution: {integrity: sha512-hwnz3nw9dbJ05EDO/PvcjaaewqqDy7Y1rn1UO81l8iIK1GjenME75dl16ajbvSSMfv66WXSRCYKIqfgq2KCfxw==} + '@rolldown/binding-freebsd-x64@1.0.2': + resolution: {integrity: sha512-m41o7M0YWtUdqk61Tb+jnKb2rN++iRdIASlExkUoKfIAH30DOHCB8fVLzSUpbWHHU8esmEioY62PxzexE8MBuA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [freebsd] - '@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.17': - resolution: {integrity: sha512-IS+W7epTcwANmFSQFrS1SivEXHtl1JtuQA9wlxrZTcNi6mx+FDOYrakGevvvTwgj2JvWiK8B29/qD9BELZPyXQ==} + '@rolldown/binding-linux-arm-gnueabihf@1.0.2': + resolution: {integrity: sha512-jcojB9H7W/jS29pMKWAK1N+fU99vXodHDTatS3b3y/XSOCiHo0kkA74pL3jJmkoQtYpOCxDvaKs1fo2Ij/1X5w==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [linux] - '@rolldown/binding-linux-arm64-gnu@1.0.0-rc.17': - resolution: {integrity: sha512-e6usGaHKW5BMNZOymS1UcEYGowQMWcgZ71Z17Sl/h2+ZziNJ1a9n3Zvcz6LdRyIW5572wBCTH/Z+bKuZouGk9Q==} + '@rolldown/binding-linux-arm64-gnu@1.0.2': + resolution: {integrity: sha512-1jn6qDU5iiOgFgygDzKUuKP0maTi0/f1+sBLgvij/76C77Nm3ts6ufz9Bjg5q5dduxiUIxtq86JIoBvo1xQ4Ig==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] libc: [glibc] - '@rolldown/binding-linux-arm64-musl@1.0.0-rc.17': - resolution: {integrity: sha512-b/CgbwAJpmrRLp02RPfhbudf5tZnN9nsPWK82znefso832etkem8H7FSZwxrOI9djcdTP7U6YfNhbRnh7djErg==} + '@rolldown/binding-linux-arm64-musl@1.0.2': + resolution: {integrity: sha512-QVLO/czFMdoMFSqlX3bcswcJNm/23r+qoa/jgtmFc/qEp6/jXmIkDjF/XIo8dPfGaiwy1xfQn8o77L79GeXFgw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] libc: [musl] - '@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.17': - resolution: {integrity: sha512-4EII1iNGRUN5WwGbF/kOh/EIkoDN9HsupgLQoXfY+D1oyJm7/F4t5PYU5n8SWZgG0FEwakyM8pGgwcBYruGTlA==} + '@rolldown/binding-linux-ppc64-gnu@1.0.2': + resolution: {integrity: sha512-hgO5Abm0w5UL6FEa2iFnZqo2KlK7TQ5QhV5x09hujBf7t5KzHQ1VmfPuTpqRy/rNlSxua3eWH374xxiVrP+lcA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ppc64] os: [linux] libc: [glibc] - '@rolldown/binding-linux-s390x-gnu@1.0.0-rc.17': - resolution: {integrity: sha512-AH8oq3XqQo4IibpVXvPeLDI5pzkpYn0WiZAfT05kFzoJ6tQNzwRdDYQ45M8I/gslbodRZwW8uxLhbSBbkv96rA==} + '@rolldown/binding-linux-s390x-gnu@1.0.2': + resolution: {integrity: sha512-fy8rXxuYEu602abC8MUNaPjYLIFzReOaEIEMKMUa0rFEUxNpVXhs15KSSQ4qlqSaM7B6rcj9rDZgADh/IGDzLQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [s390x] os: [linux] libc: [glibc] - '@rolldown/binding-linux-x64-gnu@1.0.0-rc.17': - resolution: {integrity: sha512-cLnjV3xfo7KslbU41Z7z8BH/E1y5mzUYzAqih1d1MDaIGZRCMqTijqLv76/P7fyHuvUcfGsIpqCdddbxLLK9rA==} + '@rolldown/binding-linux-x64-gnu@1.0.2': + resolution: {integrity: sha512-0+bOkiQ779+r1WpoHOWHqncvyySci0vKph+myNDYb+im6meJAzHQXay6oEgnkHuUGouM1LKTZwqKpBow6Kj7CQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] libc: [glibc] - '@rolldown/binding-linux-x64-musl@1.0.0-rc.17': - resolution: {integrity: sha512-0phclDw1spsL7dUB37sIARuis2tAgomCJXAHZlpt8PXZ4Ba0dRP1e+66lsRqrfhISeN9bEGNjQs+T/Fbd7oYGw==} + '@rolldown/binding-linux-x64-musl@1.0.2': + resolution: {integrity: sha512-mjSkrzZK5Qsl0a9d1JgILOiuZOSDTVdKENcSXBoqbzSrspLR/4/IRVDo5wd2GgZjNss/viBFJdeq+j7qH2nypw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] libc: [musl] - '@rolldown/binding-openharmony-arm64@1.0.0-rc.17': - resolution: {integrity: sha512-0ag/hEgXOwgw4t8QyQvUCxvEg+V0KBcA6YuOx9g0r02MprutRF5dyljgm3EmR02O292UX7UeS6HzWHAl6KgyhA==} + '@rolldown/binding-openharmony-arm64@1.0.2': + resolution: {integrity: sha512-1v5vHasdfQAZoEHakBV72LIFAC9JjnymsiKxp+GEr/ma3+NJCPSaYK+qavInOovJkgwFrs7GccX2d6IgDA3Z5w==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [openharmony] - '@rolldown/binding-wasm32-wasi@1.0.0-rc.17': - resolution: {integrity: sha512-LEXei6vo0E5wTGwpkJ4KoT3OZJRnglwldt5ziLzOlc6qqb55z4tWNq2A+PFqCJuvWWdP53CVhG1Z9NtToDPJrA==} + '@rolldown/binding-wasm32-wasi@1.0.2': + resolution: {integrity: sha512-mb1VobWn6NheziTk5/WEaR6AKVbrwT5sOi6C7zk3gy/pD1qtJfU1j4PgTo2NJnOtbL9Dl3Aeei8w9jJ7qC2jZQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [wasm32] - '@rolldown/binding-win32-arm64-msvc@1.0.0-rc.17': - resolution: {integrity: sha512-gUmyzBl3SPMa6hrqFUth9sVfcLBlYsbMzBx5PlexMroZStgzGqlZ26pYG89rBb45Mnia+oil6YAIFeEWGWhoZA==} + '@rolldown/binding-win32-arm64-msvc@1.0.2': + resolution: {integrity: sha512-SqKonF56vA/L2yHwHYcEp2P34URpOZ7d1fS635cTkpDnUtEGdUbhI6NzsPdqeSWvAAeGDrxjWjNmibDIdFf9/A==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [win32] - '@rolldown/binding-win32-x64-msvc@1.0.0-rc.17': - resolution: {integrity: sha512-3hkiolcUAvPB9FLb3UZdfjVVNWherN1f/skkGWJP/fgSQhYUZpSIRr0/I8ZK9TkF3F7kxvJAk0+IcKvPHk9qQg==} + '@rolldown/binding-win32-x64-msvc@1.0.2': + resolution: {integrity: sha512-v7qRI7gXLRINcOGXt+7YmAZ6iFuyZVMIoXAxhd8oP+DR9dLfL9GfNIx7PLMxmhZdvq8waUJBQiWN9EKNy+TRBQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [win32] - '@rolldown/pluginutils@1.0.0-rc.17': - resolution: {integrity: sha512-n8iosDOt6Ig1UhJ2AYqoIhHWh/isz0xpicHTzpKBeotdVsTEcxsSA/i3EVM7gQAj0rU27OLAxCjzlj15IWY7bg==} + '@rolldown/pluginutils@1.0.1': + resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==} '@sec-ant/readable-stream@0.4.1': resolution: {integrity: sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg==} @@ -2063,32 +2062,32 @@ packages: peerDependencies: semantic-release: '>=20.1.0' - '@shikijs/core@4.0.2': - resolution: {integrity: sha512-hxT0YF4ExEqB8G/qFdtJvpmHXBYJ2lWW7qTHDarVkIudPFE6iCIrqdgWxGn5s+ppkGXI0aEGlibI0PAyzP3zlw==} + '@shikijs/core@4.1.0': + resolution: {integrity: sha512-jLJtSJeuFffqX6/inRE1zqU5aFv2hrszvYgq3OjbAgFRZiWv7abKMDdQzYxuSDfmUPQozZvI/kuy6VMTvnvqTQ==} engines: {node: '>=20'} - '@shikijs/engine-javascript@4.0.2': - resolution: {integrity: sha512-7PW0Nm49DcoUIQEXlJhNNBHyoGMjalRETTCcjMqEaMoJRLljy1Bi/EGV3/qLBgLKQejdspiiYuHGQW6dX94Nag==} + '@shikijs/engine-javascript@4.1.0': + resolution: {integrity: sha512-YquhawCUgaBfhsS72e2Y/dI59gCBNPHu3fEO/tvLaXrTssxZrY5ddjtNLTwndrMgPo8b3IscE+xoICDzpTmlFQ==} engines: {node: '>=20'} - '@shikijs/engine-oniguruma@4.0.2': - resolution: {integrity: sha512-UpCB9Y2sUKlS9z8juFSKz7ZtysmeXCgnRF0dlhXBkmQnek7lAToPte8DkxmEYGNTMii72zU/lyXiCB6StuZeJg==} + '@shikijs/engine-oniguruma@4.1.0': + resolution: {integrity: sha512-axLpjVs45YBvvINa+dJF+NPW+KtFkNXsFr4SDw2BMj9GdeMnGxVB9PQb2xXlJYovslt/nz6giedAyOANkfc7hg==} engines: {node: '>=20'} - '@shikijs/langs@4.0.2': - resolution: {integrity: sha512-KaXby5dvoeuZzN0rYQiPMjFoUrz4hgwIE+D6Du9owcHcl6/g16/yT5BQxSW5cGt2MZBz6Hl0YuRqf12omRfUUg==} + '@shikijs/langs@4.1.0': + resolution: {integrity: sha512-nwOMruEkbgdZfQ/b8CgpNBVOpvG1k0N5tbmgiFeqsan401+x3ILqlzZJowSla4Agmq4hG2Uf2wh5jLTEhR8VSg==} engines: {node: '>=20'} - '@shikijs/primitive@4.0.2': - resolution: {integrity: sha512-M6UMPrSa3fN5ayeJwFVl9qWofl273wtK1VG8ySDZ1mQBfhCpdd8nEx7nPZ/tk7k+TYcpqBZzj/AnwxT9lO+HJw==} + '@shikijs/primitive@4.1.0': + resolution: {integrity: sha512-zx2/2Uwj2q9X3KSyYREEhXO23xBw5WUhP4orK2lE4r+t9JGITmEe0JH+wPmJhqHpOT2bRRs6lAL945+LDvOAGw==} engines: {node: '>=20'} - '@shikijs/themes@4.0.2': - resolution: {integrity: sha512-mjCafwt8lJJaVSsQvNVrJumbnnj1RI8jbUKrPKgE6E3OvQKxnuRoBaYC51H4IGHePsGN/QtALglWBU7DoKDFnA==} + '@shikijs/themes@4.1.0': + resolution: {integrity: sha512-emCcTnUM7yO2wltYbaxm+yLvcCI4+h8XBKc4KmJ7EZUXoSGjcCHifkI//R4OFit9ewpg7H2/9tjOuXrT2v/Knw==} engines: {node: '>=20'} - '@shikijs/types@4.0.2': - resolution: {integrity: sha512-qzbeRooUTPnLE+sHD/Z8DStmaDgnbbc/pMrU203950aRqjX/6AFHeDYT+j00y2lPdz0ywJKx7o/7qnqTivtlXg==} + '@shikijs/types@4.1.0': + resolution: {integrity: sha512-3EQWX54fMpniOrDblzAhiwiJwpiTMW6+B9DWyUd9ska483tbayFYuw47UxwuPknI31bKnySfVQ/QW+jFL4rFdA==} engines: {node: '>=20'} '@shikijs/vscode-textmate@10.0.2': @@ -2112,222 +2111,164 @@ packages: resolution: {integrity: sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ==} engines: {node: '>=18'} - '@smithy/chunked-blob-reader-native@4.2.3': - resolution: {integrity: sha512-jA5k5Udn7Y5717L86h4EIv06wIr3xn8GM1qHRi/Nf31annXcXHJjBKvgztnbn2TxH3xWrPBfgwHsOwZf0UmQWw==} + '@smithy/config-resolver@4.5.3': + resolution: {integrity: sha512-TpS6Am5zSEtx3ow7VynThEL7UwRM06zZZcmFaP6Ij9hqKPfsFhTYCLcgU7gjFjw9QAI2kzwXrfS7InH8BivJTA==} engines: {node: '>=18.0.0'} - '@smithy/chunked-blob-reader@5.2.2': - resolution: {integrity: sha512-St+kVicSyayWQca+I1rGitaOEH6uKgE8IUWoYnnEX26SWdWQcL6LvMSD19Lg+vYHKdT9B2Zuu7rd3i6Wnyb/iw==} + '@smithy/core@3.24.3': + resolution: {integrity: sha512-Ep/7tPamGY8mgESE3LyLKtxJyy6U52WWAqr/3wial47Sj4u3PiIF73AOGI27UyLy9duTkhZbgzodOfLV4TduZg==} engines: {node: '>=18.0.0'} - '@smithy/config-resolver@4.4.17': - resolution: {integrity: sha512-TzDZcAnhTyAHbXVxWZo7/tEcrIeFq20IBk8So3OLOetWpR8EwY/yEqBMBFaJMeyEiREDq4NfEl+qO3OAUD+vbQ==} + '@smithy/credential-provider-imds@4.3.3': + resolution: {integrity: sha512-I2Bti0DKFo2IJyN28ijCsx51BAumEYR4/1yZ1FXyBygy9MqbnMqCev4JPth/MbpRfBSRAX35hITSnAdJRo1u5w==} engines: {node: '>=18.0.0'} - '@smithy/core@3.23.17': - resolution: {integrity: sha512-x7BlLbUFL8NWCGjMF9C+1N5cVCxcPa7g6Tv9B4A2luWx3be3oU8hQ96wIwxe/s7OhIzvoJH73HAUSg5JXVlEtQ==} + '@smithy/eventstream-serde-browser@4.3.3': + resolution: {integrity: sha512-LXg5yYJPYnVSrpa6LOZ+/wqpI2OlIccy7j5F16EFNYDbXWmnhry/PFRRPyM30H+hJeqfVgckFuvNGnAGCt56cA==} engines: {node: '>=18.0.0'} - '@smithy/credential-provider-imds@4.2.14': - resolution: {integrity: sha512-Au28zBN48ZAoXdooGUHemuVBrkE+Ie6RPmGNIAJsFqj33Vhb6xAgRifUydZ2aY+M+KaMAETAlKk5NC5h1G7wpg==} + '@smithy/eventstream-serde-config-resolver@4.4.3': + resolution: {integrity: sha512-MdQxEX5SFNc3QmpiLXtcZXsWk4imCfGVN7Ikz9I/XvavypvHT4mqxwo5JHdr/LBKCfAv89+8193ZWlUwDp8YXQ==} engines: {node: '>=18.0.0'} - '@smithy/eventstream-codec@4.2.14': - resolution: {integrity: sha512-erZq0nOIpzfeZdCyzZjdJb4nVSKLUmSkaQUVkRGQTXs30gyUGeKnrYEg+Xe1W5gE3aReS7IgsvANwVPxSzY6Pw==} + '@smithy/eventstream-serde-node@4.3.3': + resolution: {integrity: sha512-54RbRsw9eVaVnqYUXi3F6nMAPgUyKsBvAKBY2lf+81mIgM7N+yS9V5LYk7yUGbrM789b2e1qBuyDSjX1/Axxcw==} engines: {node: '>=18.0.0'} - '@smithy/eventstream-serde-browser@4.2.14': - resolution: {integrity: sha512-8IelTCtTctWRbb+0Dcy+C0aICh1qa0qWXqgjcXDmMuCvPJRnv26hiDZoAau2ILOniki65mCPKqOQs/BaWvO4CQ==} + '@smithy/fetch-http-handler@5.4.3': + resolution: {integrity: sha512-F+DRf8IJazRJgYog2A/yJK7eYVc0rqTlRzO+5ZxjJd4WkZoKz0IJRncf7G6t1pdVT3kryJcwuTFhN1c5m6N47A==} engines: {node: '>=18.0.0'} - '@smithy/eventstream-serde-config-resolver@4.3.14': - resolution: {integrity: sha512-sqHiHpYRYo3FJlaIxD1J8PhbcmJAm7IuM16mVnwSkCToD7g00IBZzKuiLNMGmftULmEUX6/UAz8/NN5uMP8bVA==} + '@smithy/hash-blob-browser@4.3.3': + resolution: {integrity: sha512-TkGfDlYeWOGwYvAunHHHmKgvFtD7DFAl6gWxATI4pv4B6w0Wnx6RK5zCMoXTTqMVd+zPcWm7w8RPTgHytoCDJA==} engines: {node: '>=18.0.0'} - '@smithy/eventstream-serde-node@4.2.14': - resolution: {integrity: sha512-Ht/8BuGlKfFTy0H3+8eEu0vdpwGztCnaLLXtpXNdQqiR7Hj4vFScU3T436vRAjATglOIPjJXronY+1WxxNLSiw==} + '@smithy/hash-node@4.3.3': + resolution: {integrity: sha512-tSUA38sM7kzMoLhqQ2aCGTwLXovjurz3jjG+a0sxqD4qT/4FhQr/wxMdhCumT70giM+axC1pPjimAHLlEQCfzw==} engines: {node: '>=18.0.0'} - '@smithy/eventstream-serde-universal@4.2.14': - resolution: {integrity: sha512-lWyt4T2XQZUZgK3tQ3Wn0w3XBvZsK/vjTuJl6bXbnGZBHH0ZUSONTYiK9TgjTTzU54xQr3DRFwpjmhp0oLm3gg==} + '@smithy/hash-stream-node@4.3.3': + resolution: {integrity: sha512-ZyDAlpKKc7BKHUp+kDBiTwNhiHrOf3syQdvQadvnwWs0QJhYMHMg6QSarlhpzN6qr+KBFM/oF/xP/bvzR6KI9w==} engines: {node: '>=18.0.0'} - '@smithy/fetch-http-handler@5.3.17': - resolution: {integrity: sha512-bXOvQzaSm6MnmLaWA1elgfQcAtN4UP3vXqV97bHuoOrHQOJiLT3ds6o9eo5bqd0TJfRFpzdGnDQdW3FACiAVdw==} - engines: {node: '>=18.0.0'} - - '@smithy/hash-blob-browser@4.2.15': - resolution: {integrity: sha512-0PJ4Al3fg2nM4qKrAIxyNcApgqHAXcBkN8FeizOz69z0rb26uZ6lMESYtxegaTlXB5Hj84JfwMPavMrwDMjucA==} - engines: {node: '>=18.0.0'} - - '@smithy/hash-node@4.2.14': - resolution: {integrity: sha512-8ZBDY2DD4wr+GGjTpPtiglEsqr0lUP+KHqgZcWczFf6qeZ/YRjMIOoQWVQlmwu7EtxKTd8YXD8lblmYcpBIA1g==} - engines: {node: '>=18.0.0'} - - '@smithy/hash-stream-node@4.2.14': - resolution: {integrity: sha512-tw4GANWkZPb6+BdD4Fgucqzey2+r73Z/GRo9zklsCdwrnxxumUV83ZIaBDdudV4Ylazw3EPTiJZhpX42105ruQ==} - engines: {node: '>=18.0.0'} - - '@smithy/invalid-dependency@4.2.14': - resolution: {integrity: sha512-c21qJiTSb25xvvOp+H2TNZzPCngrvl5vIPqPB8zQ/DmJF4QWXO19x1dWfMJZ6wZuuWUPPm0gV8C0cU3+ifcWuw==} + '@smithy/invalid-dependency@4.3.3': + resolution: {integrity: sha512-wUWowbCm7DGczl6bfLI6wGGtoxwN5Pon8DhF0Q8AA4NvgLwYfLo3h2DWI7sHr33lLcEsyTLQKeUeTHydqSfQ5Q==} engines: {node: '>=18.0.0'} '@smithy/is-array-buffer@2.2.0': resolution: {integrity: sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA==} engines: {node: '>=14.0.0'} - '@smithy/is-array-buffer@4.2.2': - resolution: {integrity: sha512-n6rQ4N8Jj4YTQO3YFrlgZuwKodf4zUFs7EJIWH86pSCWBaAtAGBFfCM7Wx6D2bBJ2xqFNxGBSrUWswT3M0VJow==} + '@smithy/md5-js@4.3.3': + resolution: {integrity: sha512-pFw8gEMrHw9BbRwNm//UU4WgnVO7+dhfFRaSAkFPfwslWU2LXt0mM+oap3iFwGbdD8kuAWIeOAxqSiamOcM3Dw==} engines: {node: '>=18.0.0'} - '@smithy/md5-js@4.2.14': - resolution: {integrity: sha512-V2v0vx+h0iUSNG1Alt+GNBMSLGCrl9iVsdd+Ap67HPM9PN479x12V8LkuMoKImNZxn3MXeuyUjls+/7ZACZghA==} + '@smithy/middleware-content-length@4.3.3': + resolution: {integrity: sha512-Up1XAYnj6oxFBypWpkhNpgX+yReQxkKAV/iLaeP0KVLb2oTkmA9X+UJuGBVvEA9uZIN06y0irDi7sBMuTZMVJg==} engines: {node: '>=18.0.0'} - '@smithy/middleware-content-length@4.2.14': - resolution: {integrity: sha512-xhHq7fX4/3lv5NHxLUk3OeEvl0xZ+Ek3qIbWaCL4f9JwgDZEclPBElljaZCAItdGPQl/kSM4LPMOpy1MYgprpw==} + '@smithy/middleware-endpoint@4.5.3': + resolution: {integrity: sha512-p60HGFflWsJC6V9GAYeFgbfORn+9ILx8FqgMa/8PzA0rhIUxF57EKoOR4Irs6oe1oy8RLzhjhcGS8CBtPv/t+Q==} engines: {node: '>=18.0.0'} - '@smithy/middleware-endpoint@4.4.32': - resolution: {integrity: sha512-ZZkgyjnJppiZbIm6Qbx92pbXYi1uzenIvGhBSCDlc7NwuAkiqSgS75j1czAD25ZLs2FjMjYy1q7gyRVWG6JA0Q==} + '@smithy/middleware-retry@4.6.3': + resolution: {integrity: sha512-MnfYnJs3cBXK3ZBqbPzXRPHIp+QtgpkX5NogcUOWHPU5GbgTAQSIfPLi91lTcEbkFDcH2YbgjLPQjWeyQ689rA==} engines: {node: '>=18.0.0'} - '@smithy/middleware-retry@4.5.7': - resolution: {integrity: sha512-bRt6ZImqVSeTk39Nm81K20ObIiAZ3WefY7G6+iz/0tZjs4dgRRjvRX2sgsH+zi6iDCRR/aQvQofLKxxz4rPBZg==} + '@smithy/middleware-serde@4.3.3': + resolution: {integrity: sha512-RUVCZgn92izDAARs5OJSM2+KWSfTRvQWwN9t0MmiybT3pquRgDx9vD9t/YZjd/5lwcFbsNuPojJSddYQEZGeWw==} engines: {node: '>=18.0.0'} - '@smithy/middleware-serde@4.2.20': - resolution: {integrity: sha512-Lx9JMO9vArPtiChE3wbEZ5akMIDQpWQtlu90lhACQmNOXcGXRbaDywMHDzuDZ2OkZzP+9wQfZi3YJT9F67zTQQ==} + '@smithy/middleware-stack@4.3.3': + resolution: {integrity: sha512-+BPabWluqxo3EfMMvOgnAmPtWnCSzj+gf5mJ27wTZUbvS0hpdUIU1g80R01bEGKZx4JCi8P58jAXD9FUGMjhwA==} engines: {node: '>=18.0.0'} - '@smithy/middleware-stack@4.2.14': - resolution: {integrity: sha512-2dvkUKLuFdKsCRmOE4Mn63co0Djtsm+JMh0bYZQupN1pJwMeE8FmQmRLLzzEMN0dnNi7CDCYYH8F0EVwWiPBeA==} + '@smithy/node-config-provider@4.4.3': + resolution: {integrity: sha512-vDtz5OuytrjP4o9GtAOz1JloN003p94utJIQeO0WAjorhpafFFjpbDOrP6btPoCN3UxaU/U84OIEt5dM7ZRRLA==} engines: {node: '>=18.0.0'} - '@smithy/node-config-provider@4.3.14': - resolution: {integrity: sha512-S+gFjyo/weSVL0P1b9Ts8C/CwIfNCgUPikk3sl6QVsfE/uUuO+QsF+NsE/JkpvWqqyz1wg7HFdiaZuj5CoBMRg==} + '@smithy/node-http-handler@4.7.3': + resolution: {integrity: sha512-/jPhevcTFPMVl6KNjbaI47iOg1zxC7IsnX4PQDGVZKMFceOXtB8IEYaB7a9VvkP/3oC60WzTeKocvSI7vLT0vA==} engines: {node: '>=18.0.0'} - '@smithy/node-http-handler@4.6.1': - resolution: {integrity: sha512-iB+orM4x3xrr57X3YaXazfKnntl0LHlZB1kcXSGzMV1Tt0+YwEjGlbjk/44qEGtBzXAz6yFDzkYTKSV6Pj2HUg==} + '@smithy/protocol-http@5.4.3': + resolution: {integrity: sha512-P16TBD/d8ZcD9MHQ0ubQ9BbOYSd5HZKbHOLsyFWxKk2oBEoghbRFPfGOoqToZX1yrfLITXRylL16EyPP4IzLPg==} engines: {node: '>=18.0.0'} - '@smithy/property-provider@4.2.14': - resolution: {integrity: sha512-WuM31CgfsnQ/10i7NYr0PyxqknD72Y5uMfUMVSniPjbEPceiTErb4eIqJQ+pdxNEAUEWrewrGjIRjVbVHsxZiQ==} + '@smithy/signature-v4@5.4.3': + resolution: {integrity: sha512-53+75QuPl6DL+ct6vVEB51FDO5oulXr20TPV46VvJZg76lIlXNWfxi8j+G2V/t0I2qxCBOa3vX/8bmjrpFVo9g==} engines: {node: '>=18.0.0'} - '@smithy/protocol-http@5.3.14': - resolution: {integrity: sha512-dN5F8kHx8RNU0r+pCwNmFZyz6ChjMkzShy/zup6MtkRmmix4vZzJdW+di7x//b1LiynIev88FM18ie+wwPcQtQ==} + '@smithy/smithy-client@4.13.3': + resolution: {integrity: sha512-Z8mQ+YryjP5krDadV6unnp5035L4S1brafXpTiRmjPweKSaQ6X9CYDYWvmEggXjDIa1oufX/2a/bdwu8EIz/lw==} engines: {node: '>=18.0.0'} - '@smithy/querystring-builder@4.2.14': - resolution: {integrity: sha512-XYA5Z0IqTeF+5XDdh4BBmSA0HvbgVZIyv4cmOoUheDNR57K1HgBp9ukUMx3Cr3XpDHHpLBnexPE3LAtDsZkj2A==} + '@smithy/types@4.14.2': + resolution: {integrity: sha512-P+otAxbV4CqBybp7EkcJCrig63yE2E7PuNVOmilVMRcx/O+QDzGULTrKsq4DV13gSfak9ObPrWaHl/9bL5YcWw==} engines: {node: '>=18.0.0'} - '@smithy/querystring-parser@4.2.14': - resolution: {integrity: sha512-hr+YyqBD23GVvRxGGrcc/oOeNlK3PzT5Fu4dzrDXxzS1LpFiuL2PQQqKPs87M79aW7ziMs+nvB3qdw77SqE7Lw==} + '@smithy/url-parser@4.3.3': + resolution: {integrity: sha512-TsMTAOnjuMOv1zJBw8cfYGWhopyc3og8tZX/KuyCPjg7V3ji3f4YjFOVu843UjBmrfS/+X6kwFv5ZKg7sSm1bQ==} engines: {node: '>=18.0.0'} - '@smithy/service-error-classification@4.3.1': - resolution: {integrity: sha512-aUQuDGh760ts/8MU+APjIZhlLPKhIIfqyzZaJikLEIMrdxFvxuLYD0WxWzaYWpmLbQlXDe9p7EWM3HsBe0K6Gw==} + '@smithy/util-base64@4.4.3': + resolution: {integrity: sha512-91lxjhFpAktA9yPBxniqVR/NSH9zyjMjLmoa+jbQHQFR9WiJA+n61T7HBrfh5APdEoAledJwGq8l4cS+ZJFUnQ==} engines: {node: '>=18.0.0'} - '@smithy/shared-ini-file-loader@4.4.9': - resolution: {integrity: sha512-495/V2I15SHgedSJoDPD23JuSfKAp726ZI1V0wtjB07Wh7q/0tri/0e0DLefZCHgxZonrGKt/OCTpAtP1wE1kQ==} + '@smithy/util-body-length-browser@4.3.3': + resolution: {integrity: sha512-/M6Ya1Fjq8hg3rYjiwwqTen6s1bAa3U3g/2eicBaBQfaoa4ymLUke/x4T8mwb9dSq/L8TQ4YgndS0MaB9ShgmA==} engines: {node: '>=18.0.0'} - '@smithy/signature-v4@5.3.14': - resolution: {integrity: sha512-1D9Y/nmlVjCeSivCbhZ7hgEpmHyY1h0GvpSZt3l0xcD9JjmjVC1CHOozS6+Gh+/ldMH8JuJ6cujObQqfayAVFA==} - engines: {node: '>=18.0.0'} - - '@smithy/smithy-client@4.12.13': - resolution: {integrity: sha512-y/Pcj1V9+qG98gyu1gvftHB7rDpdh+7kIBIggs55yGm3JdtBV8GT8IFF3a1qxZ79QnaJHX9GXzvBG6tAd+czJA==} - engines: {node: '>=18.0.0'} - - '@smithy/types@4.14.1': - resolution: {integrity: sha512-59b5HtSVrVR/eYNei3BUj3DCPKD/G7EtDDe7OEJE7i7FtQFugYo6MxbotS8mVJkLNVf8gYaAlEBwwtJ9HzhWSg==} - engines: {node: '>=18.0.0'} - - '@smithy/url-parser@4.2.14': - resolution: {integrity: sha512-p06BiBigJ8bTA3MgnOfCtDUWnAMY0YfedO/GRpmc7p+wg3KW8vbXy1xwSu5ASy0wV7rRYtlfZOIKH4XqfhjSQQ==} - engines: {node: '>=18.0.0'} - - '@smithy/util-base64@4.3.2': - resolution: {integrity: sha512-XRH6b0H/5A3SgblmMa5ErXQ2XKhfbQB+Fm/oyLZ2O2kCUrwgg55bU0RekmzAhuwOjA9qdN5VU2BprOvGGUkOOQ==} - engines: {node: '>=18.0.0'} - - '@smithy/util-body-length-browser@4.2.2': - resolution: {integrity: sha512-JKCrLNOup3OOgmzeaKQwi4ZCTWlYR5H4Gm1r2uTMVBXoemo1UEghk5vtMi1xSu2ymgKVGW631e2fp9/R610ZjQ==} - engines: {node: '>=18.0.0'} - - '@smithy/util-body-length-node@4.2.3': - resolution: {integrity: sha512-ZkJGvqBzMHVHE7r/hcuCxlTY8pQr1kMtdsVPs7ex4mMU+EAbcXppfo5NmyxMYi2XU49eqaz56j2gsk4dHHPG/g==} + '@smithy/util-body-length-node@4.3.3': + resolution: {integrity: sha512-M+zdSrevWj0grtZx2RBULPUyjTq1aB+n+13Hrm9owiGpow6DqY/WqiSj6sHVQy/rKp0j7NzV3TNf2LrwDel8JQ==} engines: {node: '>=18.0.0'} '@smithy/util-buffer-from@2.2.0': resolution: {integrity: sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA==} engines: {node: '>=14.0.0'} - '@smithy/util-buffer-from@4.2.2': - resolution: {integrity: sha512-FDXD7cvUoFWwN6vtQfEta540Y/YBe5JneK3SoZg9bThSoOAC/eGeYEua6RkBgKjGa/sz6Y+DuBZj3+YEY21y4Q==} + '@smithy/util-defaults-mode-browser@4.4.3': + resolution: {integrity: sha512-Q60hxKkMEkmBsOEzxlMWEymBWov0dtWGgoJhOUs6mE8k2FDPjK8NlsRdMkmO80n2pwzreHtrYcX5jiRP7ZkP3w==} engines: {node: '>=18.0.0'} - '@smithy/util-config-provider@4.2.2': - resolution: {integrity: sha512-dWU03V3XUprJwaUIFVv4iOnS1FC9HnMHDfUrlNDSh4315v0cWyaIErP8KiqGVbf5z+JupoVpNM7ZB3jFiTejvQ==} + '@smithy/util-defaults-mode-node@4.3.3': + resolution: {integrity: sha512-RYj+8gr95WiiBqvVghoRvL12NS9ryvLyufp7FOs7EzKwGX0W5gOVlXdCrFkJScSf8gxdjQMRyIZ3Y82/MvXQ3Q==} engines: {node: '>=18.0.0'} - '@smithy/util-defaults-mode-browser@4.3.49': - resolution: {integrity: sha512-a5bNrdiONYB/qE2BuKegvUMd/+ZDwdg4vsNuuSzYE8qs2EYAdK9CynL+Rzn29PbPiUqoz/cbpRbcLzD5lEevHw==} + '@smithy/util-endpoints@3.5.3': + resolution: {integrity: sha512-2JqSmzQtKDKqBckLl/9NXTL1fY+zQBU5fNGMpud7AT65vql0tVFhb2UEZNZmLSHayLeD+X/Qzn84oXw5KS+KSQ==} engines: {node: '>=18.0.0'} - '@smithy/util-defaults-mode-node@4.2.54': - resolution: {integrity: sha512-g1cvrJvOnzeJgEdf7AE4luI7gp6L8weE0y9a9wQUSGtjb8QRHDbCJYuE4Sy0SD9N8RrnNPFsPltAz/OSoBR9Zw==} + '@smithy/util-middleware@4.3.3': + resolution: {integrity: sha512-8NZwlQ+nyAIWn9YZxH14FC8ca0i6ZGW1aJyPjD+zMZz3k9jOhXXKhdCSRvjmcSYLW42uhbrxavXqMkrTKHyY3A==} engines: {node: '>=18.0.0'} - '@smithy/util-endpoints@3.4.2': - resolution: {integrity: sha512-a55Tr+3OKld4TTtnT+RhKOQHyPxm3j/xL4OR83WBUhLJaKDS9dnJ7arRMOp3t31dcLhApwG9bgvrRXBHlLdIkg==} + '@smithy/util-retry@4.4.3': + resolution: {integrity: sha512-8RJXeU5lEhdNfXm4XAuHlf6VtNzd279Z2FJZSR7VaELYCR46ffgjJBSjc+3UAy7V1YqBOLV0G9gWhLB/nA44nA==} engines: {node: '>=18.0.0'} - '@smithy/util-hex-encoding@4.2.2': - resolution: {integrity: sha512-Qcz3W5vuHK4sLQdyT93k/rfrUwdJ8/HZ+nMUOyGdpeGA1Wxt65zYwi3oEl9kOM+RswvYq90fzkNDahPS8K0OIg==} - engines: {node: '>=18.0.0'} - - '@smithy/util-middleware@4.2.14': - resolution: {integrity: sha512-1Su2vj9RYNDEv/V+2E+jXkkwGsgR7dc4sfHn9Z7ruzQHJIEni9zzw5CauvRXlFJfmgcqYP8fWa0dkh2Q2YaQyw==} - engines: {node: '>=18.0.0'} - - '@smithy/util-retry@4.3.6': - resolution: {integrity: sha512-p6/FO1n2KxMeQyna067i0uJ6TSbb165ZhnRtCpWh4Foxqbfc6oW+XITaL8QkFJj3KFnDe2URt4gOhgU06EP9ew==} - engines: {node: '>=18.0.0'} - deprecated: '@smithy/util-retry v4.3.6 contains a bug in Adaptive Retry, see https://github.com/smithy-lang/smithy-typescript/issues/1993. Upgrade to 4.3.7+' - - '@smithy/util-stream@4.5.25': - resolution: {integrity: sha512-/PFpG4k8Ze8Ei+mMKj3oiPICYekthuzePZMgZbCqMiXIHHf4n2aZ4Ps0aSRShycFTGuj/J6XldmC0x0DwednIA==} - engines: {node: '>=18.0.0'} - - '@smithy/util-uri-escape@4.2.2': - resolution: {integrity: sha512-2kAStBlvq+lTXHyAZYfJRb/DfS3rsinLiwb+69SstC9Vb0s9vNWkRwpnj918Pfi85mzi42sOqdV72OLxWAISnw==} + '@smithy/util-stream@4.6.3': + resolution: {integrity: sha512-DSpJpPg0rQwjZk9/CSlOTplD6xSUu+bz8eDJQkq/Fmy9JlSD4ZGhXG/qFl0aRHmouDbBF75tnZ00lPxiL/sgRQ==} engines: {node: '>=18.0.0'} '@smithy/util-utf8@2.3.0': resolution: {integrity: sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A==} engines: {node: '>=14.0.0'} - '@smithy/util-utf8@4.2.2': - resolution: {integrity: sha512-75MeYpjdWRe8M5E3AW0O4Cx3UadweS+cwdXjwYGBW5h/gxxnbeZ877sLPX/ZJA9GVTlL/qG0dXP29JWFCD1Ayw==} + '@smithy/util-utf8@4.3.3': + resolution: {integrity: sha512-c1QpRBn3aMsoqE64dd4Imgjy8Pynfw+eR7GkjElquxUFSnezwYVaOFm8JcYa+Bo/5ssbEyPKcT3+4bmrWYh6eQ==} engines: {node: '>=18.0.0'} - '@smithy/util-waiter@4.3.0': - resolution: {integrity: sha512-JyjYmLAfS+pdxF92o4yLgEoy0zhayKTw73FU1aofLWwLcJw7iSqIY2exGmMTrl/lmZugP5p/zxdFSippJDfKWA==} - engines: {node: '>=18.0.0'} - - '@smithy/uuid@1.1.2': - resolution: {integrity: sha512-O/IEdcCUKkubz60tFbGA7ceITTAJsty+lBjNoorP4Z6XRqaFb/OjQjZODophEcuq68nKm6/0r+6/lLQ+XVpk8g==} + '@smithy/util-waiter@4.4.3': + resolution: {integrity: sha512-WSHSF865zDGFGtJdMmYPI2Blq/MbUrn5CB4bLDg4ARbQ9z7oA87ZZ/FSiwNZbQrU/EiVyl9lpINswALgI4lZXA==} engines: {node: '>=18.0.0'} '@so-ric/colorspace@1.1.6': resolution: {integrity: sha512-/KiKkpHNOBgkFJwu9sh48LkHSMYGyuTcSFK/qMBdnOAlrRJzRSXAOFB5qwzaVQuDl8wAvHVMkaASQDReTahxuw==} + '@stablelib/base64@1.0.1': + resolution: {integrity: sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==} + '@standard-schema/spec@1.1.0': resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} @@ -2432,8 +2373,8 @@ packages: '@tediousjs/connection-string@1.1.0': resolution: {integrity: sha512-z9ZBWEG+8pIB5V1zYzlRPXx0oRJ5H7coPnMQK8EZOw03UTPI9Umn6viL36f5w+CuqkKsnCM50RVStpjZmR0Bng==} - '@tybys/wasm-util@0.10.1': - resolution: {integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==} + '@tybys/wasm-util@0.10.2': + resolution: {integrity: sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==} '@types/better-sqlite3@7.6.13': resolution: {integrity: sha512-NMv9ASNARoKksWtsq/SHakpYAYnhBrQgGD8zkLYk/jaK8jUGn08CfEdTRgYhMypUQAfzSP8W6gNLe0q19/t4VA==} @@ -2468,8 +2409,8 @@ packages: '@types/estree-jsx@1.0.5': resolution: {integrity: sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg==} - '@types/estree@1.0.8': - resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} + '@types/estree@1.0.9': + resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} '@types/hast@3.0.4': resolution: {integrity: sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==} @@ -2486,8 +2427,8 @@ packages: '@types/mssql@12.3.0': resolution: {integrity: sha512-+jy+AJtfuTDI5+nhh0hDNcir1p8P+pf+qsHXpUpYvg7EikxUUePBe+a+Kr6j/Xs89o4EbHlVzrh0HOxbqWM31Q==} - '@types/node@24.12.2': - resolution: {integrity: sha512-A1sre26ke7HDIuY/M23nd9gfB+nrmhtYyMINbjI1zHJxYteKR6qSMX56FsmjMcDb3SMcjJg5BiRRgOCC/yBD0g==} + '@types/node@24.12.4': + resolution: {integrity: sha512-GUUEShf+PBCGW2KaXwcIt3Yk+e3pkKwWKb9GSyM9WQVE+ep2jzmHdGsHzu4wgcZy5fN9FBdVzjpBQsYlpfpgLA==} '@types/normalize-package-data@2.4.4': resolution: {integrity: sha512-37i+OaWTh9qeK4LSHPsyRC7NahnGotNuZvjLSgcPzblpHB3rrCJxAOgI5gCdKm7coonsaX1Of0ILiTcnZjbfxA==} @@ -2500,8 +2441,8 @@ packages: peerDependencies: '@types/react': ^19.2.0 - '@types/react@19.2.14': - resolution: {integrity: sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==} + '@types/react@19.2.15': + resolution: {integrity: sha512-eRwcGNHve+E8qtEQSSRl6urh+rFop4v8gm6O8rGv25CodbvFdLjA1vVQ1KkiFE0w0UPOnb8tDiFKL5lp0rtY5Q==} '@types/readable-stream@4.0.23': resolution: {integrity: sha512-wwXrtQvbMHxCbBgjHaMGEmImFTQxxpfMOR/ZoQnXxB1woqkUbdLGFDgauo00Py9IudiaqSeiBiulSV9i6XIPig==} @@ -2526,20 +2467,20 @@ packages: resolution: {integrity: sha512-UycprH3T6n3jH0k44NHMa7pnFHGu/N05MjojYr+Mc6I7obkoLIJujSWwin1pCvdy/eOxrI/l3uDLQsmcrOb4ug==} engines: {node: '>= 20'} - '@vitest/coverage-v8@4.1.6': - resolution: {integrity: sha512-36l628fQ/9a/8ihy97eOtEnvWQEdqULQOJtcaxtoNq0G1w3Mxd4szSahOaMM9/NGyZ+hyKcMtIW/WIxq0XQViQ==} + '@vitest/coverage-v8@4.1.7': + resolution: {integrity: sha512-qsYPeXc5Q9dFLd1i8Ap+Bx8sQgcp+rFVQo4R0dDsWNBzl26ldVF1qOO+RL24K7FDrR6pA+50XedRLSoSG24bVQ==} peerDependencies: - '@vitest/browser': 4.1.6 - vitest: 4.1.6 + '@vitest/browser': 4.1.7 + vitest: 4.1.7 peerDependenciesMeta: '@vitest/browser': optional: true - '@vitest/expect@4.1.6': - resolution: {integrity: sha512-7EHDquPthALSV0jhhjgEW8FXaviMx7rSqu8W6oqCoAuOhKov814P99QDV1pxMA3QPv21YudvJngIhjrNI4opLg==} + '@vitest/expect@4.1.7': + resolution: {integrity: sha512-1R+tw0ortHEbZDGMymm+pN7/AFQ/RkFFdtd7EN+VBpynKmLbP8A3rpEXdshBJ7+8hQ9zBJh/i1s0yKNtxAnU7w==} - '@vitest/mocker@4.1.6': - resolution: {integrity: sha512-MCFc63czMjEInOlcY2cpQCvCN+KgbAn+60xu9cMgP4sKaLC5JNAKw7JH8QdAnoAC88hW1IiSNZ+GgVXlN1UcMQ==} + '@vitest/mocker@4.1.7': + resolution: {integrity: sha512-vY7nuamKgfvpA1Koa3oYIw/k7D6kZnpGyNMZW8loow2bsBYla1TFdqTaXncWdRn4pgwNs+90RhnXhJScDwQeJA==} peerDependencies: msw: ^2.4.9 vite: ^6.0.0 || ^7.0.0 || ^8.0.0 @@ -2549,20 +2490,20 @@ packages: vite: optional: true - '@vitest/pretty-format@4.1.6': - resolution: {integrity: sha512-h5SxD/IzNhZYnrSZRsUZQIC+vD0GY8cUvq0iwsmkFKixRCKLLWqCXa/FIQ4S1R+sI+PGoojkHsdNrbZiM9Qpgw==} + '@vitest/pretty-format@4.1.7': + resolution: {integrity: sha512-umgCarTOYQWIaDMvGDRZij+6b9oVeLIyJzfN+AS88e0ZOU3QTgNNSTtjQOpcvWr3np1N0j4WgZj+sb3oYBDscw==} - '@vitest/runner@4.1.6': - resolution: {integrity: sha512-nOPCmn2+yD0ZNmKdsXGv/UxMMWbMuKeD6GyYncNwdkYDxpQvrPSKYj2rWuDjC2Y4b6w6hjip5dBKFzEUuZe3vA==} + '@vitest/runner@4.1.7': + resolution: {integrity: sha512-BapjmAQ2aI78WdMEfeUWivnfVzB+VPGwWRQcJE0OUq7qEeEcBsCSf+0T5iREBNE5nBb4wA5Ya0W6IA+sghdEFw==} - '@vitest/snapshot@4.1.6': - resolution: {integrity: sha512-YhsdE6xAVfTDmzjxL2ZDUvjj+ZsgyOKe+TdQzqkD72wIOmHka8NuGQ6NpTNZv9D2Z63fbwWKJPeVpEw4EQgYxw==} + '@vitest/snapshot@4.1.7': + resolution: {integrity: sha512-ZacLzja+TmJeZ1h14xW2FB/WpeimUD3haBXQPyJqxvo8jQTmfeA8zv58mtjN2C7EHXZDYVcVYdYmAxjkWVvKCw==} - '@vitest/spy@4.1.6': - resolution: {integrity: sha512-JFKxMx6udhwKh/Ldo270e17QX710vgunMkuPAvXjHSvC6oqLWAHhVhjg/I71q0u0CBSErIODV1Kjv0FQNSWjdg==} + '@vitest/spy@4.1.7': + resolution: {integrity: sha512-kbkI5LMWakyuTIvs6fUJ5qdIVb1XVKsYJAT4OJ938cHMROYMSfmoQdZy0aaAnjbbc8F61vkoTqz/Az+/HiIu5Q==} - '@vitest/utils@4.1.6': - resolution: {integrity: sha512-FxIY+U81R3LGKCxaHHFRQ5+g6/iRgGLmeHWdp2Amj4ljQRrEIWHmZyDfDYBRZlpyqA7qKxtS9DD1dhk8RnRIVQ==} + '@vitest/utils@4.1.7': + resolution: {integrity: sha512-T532WBu791cBxJlCl6SO+J14l81DQx6uQHm1bQbmCDY7nqlEIgkza/UFnSBNaUtSf41unldDFjdOBYEQC4b5Hw==} '@xyflow/react@12.10.2': resolution: {integrity: sha512-CgIi6HwlcHXwlkTpr0fxLv/0sRVNZ8IdwKLzzeCscaYBwpvfcH1QFOCeaTCuEn1FQEs/B8CjnTSjhs8udgmBgQ==} @@ -2591,6 +2532,10 @@ packages: engines: {node: '>=0.4.0'} hasBin: true + agent-base@6.0.2: + resolution: {integrity: sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==} + engines: {node: '>= 6.0.0'} + agent-base@7.1.4: resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==} engines: {node: '>= 14'} @@ -2607,8 +2552,8 @@ packages: resolution: {integrity: sha512-gOsf2YwSlleG6IjRYG2A7k0HmBMEo6qVNk9Bp/EaLgAJT5ngH6PXbqa4ItvnEwCm/velL5jAnQgsHsWnjhGmvw==} engines: {node: '>=18'} - ai@6.0.180: - resolution: {integrity: sha512-tOyRbwD0PEjMZKGvYQcTsv95K2zktwwNhQ49QOUAh0g8MNprO7ELIO1SgANMuPc0BFtkP6Ny6OAdjq3TtxLCbQ==} + ai@6.0.188: + resolution: {integrity: sha512-kNwIl1MM4ESzeOPDYuN+FidJ2QY5kGWHLtTMru6HHPW/JJ6nPuvHRhJ8tMX/Y2Tijx9DCiv2W7y5IBouuB712g==} engines: {node: '>=18'} peerDependencies: zod: ^3.25.76 || ^4.1.8 @@ -2708,8 +2653,8 @@ packages: resolution: {integrity: sha512-NZKeq9AfyQvEeNlN0zSYAaWrmBffJh3IELMZfRpJVWgrpEbtEpnjvzqBPf+mxoI287JohRDoa+/nsfqqiZmF6g==} engines: {node: '>= 6.0.0'} - axios@1.15.2: - resolution: {integrity: sha512-wLrXxPtcrPTsNlJmKjkPnNPK2Ihe0hn0wGSaTEiHRPxwjvJwT3hKmXF4dpqxmPO9SoNb2FsYXj/xEo0gHN+D5A==} + axios@1.16.1: + resolution: {integrity: sha512-caYkukvroVPO8KrzuJEb50Hm07KwfBZPEC3VeFHTsqWHvKTsy54hjJz9BS/cdaypROE2rH6xvm9mHX4fgWkr3A==} bail@2.0.2: resolution: {integrity: sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==} @@ -2724,8 +2669,8 @@ packages: base64-js@1.5.1: resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} - baseline-browser-mapping@2.10.29: - resolution: {integrity: sha512-Asa2krT+XTPZINCS+2QcyS8WTkObE77RwkydwF7h6DmnKqbvlalz93m/dnphUyCa6SWSP51VgtEUf2FN+gelFQ==} + baseline-browser-mapping@2.10.31: + resolution: {integrity: sha512-MujYO3eP72uvmSE0i4wltsodRfIpZATP3jvzRNRGGxgzId7aVocVJJV3nf01qnzzKFGxQVC9bpWxl5cjxTr/7Q==} engines: {node: '>=6.0.0'} hasBin: true @@ -2811,8 +2756,8 @@ packages: resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} engines: {node: '>=6'} - caniuse-lite@1.0.30001792: - resolution: {integrity: sha512-hVLMUZFgR4JJ6ACt1uEESvQN1/dBVqPAKY0hgrV70eN3391K6juAfTjKZLKvOMsx8PxA7gsY1/tLMMTcfFLLpw==} + caniuse-lite@1.0.30001793: + resolution: {integrity: sha512-iwSsYWaCOoh26cV8NwNRViHlrfUvYsHDfRVcbtmw0Kg6PJIZZXwMkj1442FYLBGkeUf1juAsU3DTfxW579mrPA==} ccount@2.0.1: resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==} @@ -3191,8 +3136,8 @@ packages: end-of-stream@1.4.5: resolution: {integrity: sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==} - enhanced-resolve@5.21.2: - resolution: {integrity: sha512-xe9vQb5kReirPUxgQrXA3ihgbCqssmTiM7cOZ+Gzu+VeGWgpV98lLZvp0dl4yriyAePcewxGUs9UpKD8PET9KQ==} + enhanced-resolve@5.21.6: + resolution: {integrity: sha512-aNnGCvbJ/RIyWo1IuhNdVjnNF+EjH9wpzpNHt+ci/m9He9LJvUN8wrCcXjp9cWsGNAuvSpVFTx/vraAFQ8qGjQ==} engines: {node: '>=10.13.0'} entities@6.0.1: @@ -3334,8 +3279,8 @@ packages: resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} engines: {node: '>=12.0.0'} - express-rate-limit@8.4.1: - resolution: {integrity: sha512-NGVYwQSAyEQgzxX1iCM978PP9AdO/hW93gMcF6ZwQCm+rFvLsBH6w4xcXWTcliS8La5EPRN3p9wzItqBwJrfNw==} + express-rate-limit@8.5.2: + resolution: {integrity: sha512-5Kb34ipNX694DH48vN9irak1Qx30nb0PLYHXfJgw4YEjiC3ZEmZJhwOp+VfiCYwFzvFTdB9QkArYS5kXa2cx2A==} engines: {node: '>= 16'} peerDependencies: express: '>= 4.11' @@ -3353,6 +3298,9 @@ packages: fast-deep-equal@3.1.3: resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + fast-sha256@1.3.0: + resolution: {integrity: sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==} + fast-string-truncated-width@3.0.3: resolution: {integrity: sha512-0jjjIEL6+0jag3l2XWWizO64/aZVtpiGE3t0Zgqxv0DPuxiMjvB3M24fCyhZUO4KomJQPj3LTSUnDP3GpdwC0g==} @@ -3362,14 +3310,18 @@ packages: fast-uri@3.1.2: resolution: {integrity: sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==} - fast-wrap-ansi@0.2.0: - resolution: {integrity: sha512-rLV8JHxTyhVmFYhBJuMujcrHqOT2cnO5Zxj37qROj23CP39GXubJRBUFF0z8KFK77Uc0SukZUf7JZhsVEQ6n8w==} + fast-wrap-ansi@0.2.2: + resolution: {integrity: sha512-7F2Fl+TjRSenLqlU3UjSH0iyqopqoZIu7eZVpEirP2g1GtWa2G/ecEmBdgz31+Mxr+ELclgg6sokpSFIQiZ02Q==} fast-xml-builder@1.1.7: resolution: {integrity: sha512-Yh7/7rQuMXICNr0oMYDR2yHP6oUvmQsTToFeOWj/kIDhAwQ+c4Ol/lbcwOmEM5OHYQmh6S6EQSQ1sljCKP36bQ==} - fast-xml-parser@5.7.2: - resolution: {integrity: sha512-P7oW7tLbYnhOLQk/Gv7cZgzgMPP/XN03K02/Jy6Y/NHzyIAIpxuZIM/YqAkfiXFPxA2CTm7NtCijK9EDu09u2w==} + fast-xml-parser@5.7.3: + resolution: {integrity: sha512-C0AaNuC+mscy6vrAQKAc/rMq+zAPHodfHGZu4sGVehvAQt/JLG1O5zEcYcXSY5zSqr4YVgxsB+pHXTq0i7eDlg==} + hasBin: true + + fast-xml-parser@5.8.0: + resolution: {integrity: sha512-6bIM7fsJxeo3uXv7OncQYsBAMPJ7V16Slahl/6M98C/i2q+vB1+4a0MtrvYwDFEUrwDSbAmeLDRXsOBwrL7yAg==} hasBin: true fastest-levenshtein@1.0.16: @@ -3395,8 +3347,8 @@ packages: resolution: {integrity: sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==} engines: {node: ^12.20 || >= 14.13} - fflate@0.8.2: - resolution: {integrity: sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==} + fflate@0.8.3: + resolution: {integrity: sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==} figures@2.0.0: resolution: {integrity: sha512-Oa2M9atig69ZkfwiApY8F2Yy+tzMbazyvqv21R0NsSC8floSOC09BbT1ITWAdoMGQvJ/aZnR1KMwdx9tvHnTNA==} @@ -3458,8 +3410,8 @@ packages: resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==} engines: {node: '>= 0.6'} - framer-motion@12.38.0: - resolution: {integrity: sha512-rFYkY/pigbcswl1XQSb7q424kSTQ8q6eAC+YUsSKooHQYuLdzdHjrt6uxUC+PRAO++q5IS7+TamgIw1AphxR+g==} + framer-motion@12.40.0: + resolution: {integrity: sha512-uaBd3qC1v3KQqBEjwTUd183K6PbS+j0yR9w9VmEOLWA/tnUcSn8Xa3uck7t4dgpDoUss8xQTcj8W2L07lrnLFg==} peerDependencies: '@emotion/is-prop-valid': '*' react: ^18.0.0 || ^19.0.0 @@ -3550,8 +3502,8 @@ packages: zod: optional: true - fumadocs-mdx@15.0.4: - resolution: {integrity: sha512-swJ7VB9x8fPjVAcH4xMg7qplgm7m6Hy3woB5s/yssBWVPkvET7wGOHoYZ05iW9TDIWruk5vyqwEDrwq3t4PZUw==} + fumadocs-mdx@15.0.7: + resolution: {integrity: sha512-3ffG5th20eL3CvEH9YSYfmn0MPeaJkdnu1MMXNsR1zduhn6tW5ieQ8YX+EC4+BfnpwcB3y2wwAlGM0PWl/YPNg==} hasBin: true peerDependencies: '@types/mdast': '*' @@ -3627,8 +3579,8 @@ packages: resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} engines: {node: 6.* || 8.* || >= 10.*} - get-east-asian-width@1.5.0: - resolution: {integrity: sha512-CQ+bEO+Tva/qlmw24dCejulK5pMzVnUOFOijVogd3KQs07HnRIgp8TGipvCCRT06xeYEbpbgwaCxglFyiuIcmA==} + get-east-asian-width@1.6.0: + resolution: {integrity: sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==} engines: {node: '>=18'} get-intrinsic@1.3.0: @@ -3785,6 +3737,10 @@ packages: resolution: {integrity: sha512-FcF8VhXYLQcxWCnt/cCpT2apKsRDUGeVEeMqGu4HSTu29U8Yw0TLOjdYIlDsYk3IkUh+taX4IDWpPcCqKDhCjA==} engines: {node: '>= 20'} + https-proxy-agent@5.0.1: + resolution: {integrity: sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==} + engines: {node: '>= 6'} + https-proxy-agent@7.0.6: resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==} engines: {node: '>= 14'} @@ -3858,8 +3814,8 @@ packages: '@types/react': optional: true - ink@7.0.2: - resolution: {integrity: sha512-cnkE2SsDC/gieJ+BD8+gWpXrZPMInv7agBYN5gcKVlQZYp+IKa/FKM5bp1OIuJFp3ZIuRK7ZNxY4MZR3tUzyfQ==} + ink@7.0.3: + resolution: {integrity: sha512-5kxHkIj9+RuqCU3zyvP4qvYWNOSHP2TW/SHayHGHOmk87KwfVcZwvJGemi9ch+ci2gXUqerK/Eh2DGEDt5q45g==} engines: {node: '>=22'} peerDependencies: '@types/react': '>=19.2.0' @@ -3997,8 +3953,8 @@ packages: resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==} hasBin: true - jose@6.2.2: - resolution: {integrity: sha512-d7kPDd34KO/YnzaDOlikGpOurfF0ByC2sEV4cANCtdqLlTfBlw2p14O/5d/zv40gJPbIQxfES3nSx1/oYNyuZQ==} + jose@6.2.3: + resolution: {integrity: sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==} js-md4@0.3.2: resolution: {integrity: sha512-/GDnfQYsltsjRswQhN9fhv3EMw2sCpUdrdxyWDOUK7eyD++r3gRhzgiQgc/x4MAv2i1iuQ4lxO5mvqM3vj4bwA==} @@ -4051,8 +4007,8 @@ packages: jws@4.0.1: resolution: {integrity: sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==} - knip@6.12.2: - resolution: {integrity: sha512-RcZpT1sVziKZgDk1F0hAcp+bq71VJAF8vg1Y9ZLXc1+UXQaMm1rjiUqpJQTIj+lqwmiBQT19/u7ikgazs23cvA==} + knip@6.14.1: + resolution: {integrity: sha512-SN3Ly0ixzj5CQkY/rc4OPHpWrCC0XRIIjgdP76G9Cni5k72ur5jBYOyvJuF5oPTM14v8eHcMUgPbElHa+lnR0g==} engines: {node: ^20.19.0 || >=22.12.0} hasBin: true @@ -4202,24 +4158,24 @@ packages: lru-cache@10.4.3: resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} - lru-cache@11.3.6: - resolution: {integrity: sha512-Gf/KoL3C/MlI7Bt0PGI9I+TeTC/I6r/csU58N4BSNc4lppLBeKsOdFYkK+dX0ABDUMJNfCHTyPpzwwO21Awd3A==} + lru-cache@11.5.0: + resolution: {integrity: sha512-5YgH9UJd7wVb9hIouI2adWpgqrrICkt070Dnj8EUY1+B4B2P9eRLPAkAAo6NICA7CEhOIeBHl46u9zSNpNu7zA==} engines: {node: 20 || >=22} lru.min@1.1.4: resolution: {integrity: sha512-DqC6n3QQ77zdFpCMASA1a3Jlb64Hv2N2DciFGkO/4L9+q/IpIAuRlKOvCXabtRW6cQf8usbmM6BE/TOPysCdIA==} engines: {bun: '>=1.0.0', deno: '>=1.30.0', node: '>=8.0.0'} - lucide-react@1.14.0: - resolution: {integrity: sha512-+1mdWcfSJVUsaTIjN9zoezmUhfXo5l0vP7ekBMPo3jcS/aIkxHnXqAPsByszMZx/Y8oQBRJxJx5xg+RH3urzxA==} + lucide-react@1.16.0: + resolution: {integrity: sha512-dYwyPzb4MEKpGUmNYk3WKWPnMrHs3FKM+q94kAnJrcDIqqn1hq2xY8scaS2ovsOCM5D51ey2gaRG3PBb1vgoYQ==} peerDependencies: react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0 magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} - magicast@0.5.2: - resolution: {integrity: sha512-E3ZJh4J3S9KfwdjZhe2afj6R9lGIN5Pher1pF39UGrXRqq/VDaGVIGN13BjHd2u8B61hArAGOnso7nBOouW3TQ==} + magicast@0.5.3: + resolution: {integrity: sha512-pVKE4UdSQ7DvHzivsCIFx2BJn1mHG6KsyrFcaxFx6tONdneEuThrDx0Cj3AMg58KyN4pzYT+LHOotxDQDjNvkw==} make-asynchronous@1.1.0: resolution: {integrity: sha512-ayF7iT+44LXdxJLTrTd3TLQpFDDvPCBxXxbv+pMUSuHA5Q8zyAfwkRP6aHHwNVFBUFWtxAHqwNJxF8vMZLAbVg==} @@ -4478,14 +4434,14 @@ packages: moment@2.30.1: resolution: {integrity: sha512-uEmtNhbDOrWPFS+hdjFCBfy9f2YoyzRpwcl+DqpC6taX21FzsTLQVbMV/W7PzNSX6x/bhC1zA3c2UQ5NzH6how==} - motion-dom@12.38.0: - resolution: {integrity: sha512-pdkHLD8QYRp8VfiNLb8xIBJis1byQ9gPT3Jnh2jqfFtAsWUA3dEepDlsWe/xMpO8McV+VdpKVcp+E+TGJEtOoA==} + motion-dom@12.40.0: + resolution: {integrity: sha512-HxU3ZaBwNPVQUBQf1xxgq+7JrPNZvjLVxgbpEZL7RrWJnsxOf0/OM+yrHG9ogLQ31Do/r57Oz2gQWPK+6q62mg==} - motion-utils@12.36.0: - resolution: {integrity: sha512-eHWisygbiwVvf6PZ1vhaHCLamvkSbPIeAYxWUuL3a2PD/TROgE7FvfHWTIH4vMl798QLfMw15nRqIaRDXTlYRg==} + motion-utils@12.39.0: + resolution: {integrity: sha512-8nadJAJjTtqRkmRF36FoJTrywK9nnFmnPwnSMyxaOCU7GDjN9RTMJIxx9De8ErM+vpPhMccr/6fo5WciyQLnMQ==} - motion@12.38.0: - resolution: {integrity: sha512-uYfXzeHlgThchzwz5Te47dlv5JOUC7OB4rjJ/7XTUgtBZD8CchMN8qEJ4ZVsUmTyYA44zjV0fBwsiktRuFnn+w==} + motion@12.40.0: + resolution: {integrity: sha512-yjrHUrBFW6kQvjJwRsoiPSAhC5tRwRqNGJWmiJ4CrGnbKp0V88AdzkhBmDoqIsIPfarOe0Uddd37Xq43/gIocA==} peerDependencies: '@emotion/is-prop-valid': '*' react: ^18.0.0 || ^19.0.0 @@ -4501,8 +4457,8 @@ packages: ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} - mssql@12.5.2: - resolution: {integrity: sha512-rPXZdvYGCayb4+pRmqZ3oymDJB4ZrMpjnZfs3/EZYg8cXfYMWHZo8Kh5zVxny0GyRfPOCslcGuEEMUIyCWRRxg==} + mssql@12.5.4: + resolution: {integrity: sha512-f8UzhpO1STCYhxBybEgT4kaPa2Pda+nucQcMMad7RqOmmTZu3tjkvpPeI9h0RdSK//eua4ybRsLcalz/ttagwQ==} engines: {node: '>=18.19.0'} hasBin: true @@ -4519,8 +4475,8 @@ packages: resolution: {integrity: sha512-Tz09sEL2EEuv5fFowm419c1+a/jSMiBjI9gHxVLrVdbUkkNUUfjsVYs9pVZu5oCon/kmRh9TfLEObFtkVxmY0w==} engines: {node: '>=8.0.0'} - nanoid@3.3.11: - resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==} + nanoid@3.3.12: + resolution: {integrity: sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true @@ -4567,8 +4523,8 @@ packages: sass: optional: true - node-abi@3.89.0: - resolution: {integrity: sha512-6u9UwL0HlAl21+agMN3YAMXcKByMqwGx+pq+P76vii5f7hTPtKDp08/H9py6DY+cfDw7kQNTGEj/rly3IgbNQA==} + node-abi@3.92.0: + resolution: {integrity: sha512-KdHvFWZjEKDf0cakgFjebl371GPsISX2oZHcuyKqM7DtogIsHrqKeLTo8wBHxaXRAQlY2PsPlZmfo+9ZCxEREQ==} engines: {node: '>=10'} node-domexception@1.0.0: @@ -4592,8 +4548,8 @@ packages: resolution: {integrity: sha512-RWk+PI433eESQ7ounYxIp67CYuVsS1uYSonX3kA6ps/3LWfjVQa/ptEg6Y3T6uAMq1mWpX9PQ+qx+QaHpsc7gQ==} engines: {node: ^20.17.0 || >=22.9.0} - normalize-url@9.0.0: - resolution: {integrity: sha512-z9nC87iaZXXySbWWtTHfCFJyFvKaUAW6lODhikG7ILSbVgmwuFjUqkgnheHvAUcGedO29e2QGBRXMUD64aurqQ==} + normalize-url@9.0.1: + resolution: {integrity: sha512-ARftfC5HdUNu9jJeL8pHj8debUIHA2b91FizCoMzY4lG6dDX13jdvTK0TBe24IBDRf2HvJSzzwEPvmbkQWHRSg==} engines: {node: '>=20'} npm-run-path@4.0.1: @@ -4608,8 +4564,8 @@ packages: resolution: {integrity: sha512-9qny7Z9DsQU8Ou39ERsPU4OZQlSTP47ShQzuKZ6PRXpYLtIFgl/DEBYEXKlvcEa+9tHVcK8CF81Y2V72qaZhWA==} engines: {node: '>=18'} - npm@11.14.1: - resolution: {integrity: sha512-aopNZ0eEl6LbxoFcrXLmTEPzNBNxfiQnVgR9RmJBqzm+5h5pFoOmRljpRJbsXxocBeSl7GLcx3MoDf2UlEOjZw==} + npm@11.15.0: + resolution: {integrity: sha512-+k0tk7lRnpMUPnC7kTuU/yrV/mnFoPhJQ75VfLtZ6fwbzOVXaPsTE/Il9Pn1DHi482byMyqkHv/XsQ76mNjXLw==} engines: {node: ^20.17.0 || >=22.9.0} hasBin: true bundledDependencies: @@ -4725,8 +4681,8 @@ packages: resolution: {integrity: sha512-MVHddDVweXZF3awtlAS+6pgKLlm/JgxZ90+/NBurBoQctVOOB/zDdVjcyPzQ+0laDGbsWgrRkflI65sQeOgT9Q==} engines: {node: '>=8'} - openai@6.37.0: - resolution: {integrity: sha512-0H5dEGFmmLv6KSd0W1w2nyL8WsLkX6yoLeQpU+dZAOuGcany5qkYQMmj35ZrKgb6yiyYqpUzFOpR8mZQkgqeEQ==} + openai@6.38.0: + resolution: {integrity: sha512-AoMplt2UalrpgUDMh3L09QWjNRlgJPipclQvA6sYAaeF6nHNBMgmikAZGmcYLn8on4d9sQY9Q8bOLfrBS7Lc8g==} hasBin: true peerDependencies: ws: 8.20.1 @@ -4737,8 +4693,8 @@ packages: zod: optional: true - oxc-parser@0.128.0: - resolution: {integrity: sha512-XkOw3eiIxAgQ19WRew/Bq9wc5Ga/guaWIzDBzq80z1PyuDNGvWBpPby9k6YGwV8A8uMw+Nlq3xqlzuDYmUFYUw==} + oxc-parser@0.130.0: + resolution: {integrity: sha512-X0PJ+NmOok8qP3vK9uaW431ngkdM9UPEK7KG466urtIL2+EYTEgbZK2yqe2MWKJKBjRlFweP/pJPx0x9muMEVw==} engines: {node: ^20.19.0 || >=22.12.0} oxc-resolver@11.19.1: @@ -4870,30 +4826,30 @@ packages: engines: {node: '>=0.10'} hasBin: true - pg-cloudflare@1.3.0: - resolution: {integrity: sha512-6lswVVSztmHiRtD6I8hw4qP/nDm1EJbKMRhf3HCYaqud7frGysPv7FYJ5noZQdhQtN2xJnimfMtvQq21pdbzyQ==} + pg-cloudflare@1.4.0: + resolution: {integrity: sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A==} - pg-connection-string@2.12.0: - resolution: {integrity: sha512-U7qg+bpswf3Cs5xLzRqbXbQl85ng0mfSV/J0nnA31MCLgvEaAo7CIhmeyrmJpOr7o+zm0rXK+hNnT5l9RHkCkQ==} + pg-connection-string@2.13.0: + resolution: {integrity: sha512-EMnU9E2fSULdsbErBbMaXJvFeD9B4+nPcM3f+4lsiCR0BHLPrLVjv3DbyM2hgQQviKJaTWIRRTjKjWlHg3p2ig==} pg-int8@1.0.1: resolution: {integrity: sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==} engines: {node: '>=4.0.0'} - pg-pool@3.13.0: - resolution: {integrity: sha512-gB+R+Xud1gLFuRD/QgOIgGOBE2KCQPaPwkzBBGC9oG69pHTkhQeIuejVIk3/cnDyX39av2AxomQiyPT13WKHQA==} + pg-pool@3.14.0: + resolution: {integrity: sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==} peerDependencies: pg: '>=8.0' - pg-protocol@1.13.0: - resolution: {integrity: sha512-zzdvXfS6v89r6v7OcFCHfHlyG/wvry1ALxZo4LqgUoy7W9xhBDMaqOuMiF3qEV45VqsN6rdlcehHrfDtlCPc8w==} + pg-protocol@1.14.0: + resolution: {integrity: sha512-n5taZ1kO3s9ngDTVxsEznOqCyToTgz0FLuPq0B33COy5pPpuWJpY3/2oRBVETuOgzdqRXfWpM9HIhp2LBBT1BA==} pg-types@2.2.0: resolution: {integrity: sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==} engines: {node: '>=4'} - pg@8.20.0: - resolution: {integrity: sha512-ldhMxz2r8fl/6QkXnBD3CR9/xg694oT6DZQ2s6c/RI28OjtSOpxnPrUCGOBJ46RCUxcWdx3p6kw/xnDHjKvaRA==} + pg@8.21.0: + resolution: {integrity: sha512-AUP1EYJuHraQGsVoCQVIcM7TEJVGtDzxWtGFZd8rds9d+CCXlU5Js1rYgfLNvxy9iJrpHjGrRjoi/3BT9fRyiA==} engines: {node: '>= 16.0.0'} peerDependencies: pg-native: '>=3.0.1' @@ -4947,9 +4903,14 @@ packages: resolution: {integrity: sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==} engines: {node: '>=0.10.0'} - posthog-node@5.0.0: - resolution: {integrity: sha512-gontigBt1pGHGXZme3+ojDdCYL66h/vvo+6KaQ6A51xqUOYgRvyzCLkS9Xv816jNBesRO8ouRjG428SDb2fFkg==} - engines: {node: '>=20'} + posthog-node@5.34.9: + resolution: {integrity: sha512-vOH+71q/Cb68ILXj58M2fV3GcE2sHimVFn2JQJYpe2wxAdZjWyt7sGhY1NI2/87DVjJ0zisEboVCX9/WXxXlMg==} + engines: {node: ^20.20.0 || >=22.22.0} + peerDependencies: + rxjs: ^7.0.0 + peerDependenciesMeta: + rxjs: + optional: true prebuild-install@7.1.3: resolution: {integrity: sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==} @@ -4985,8 +4946,8 @@ packages: pump@3.0.4: resolution: {integrity: sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==} - qs@6.15.1: - resolution: {integrity: sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg==} + qs@6.15.2: + resolution: {integrity: sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==} engines: {node: '>=0.6'} range-parser@1.2.1: @@ -5155,8 +5116,8 @@ packages: resolution: {integrity: sha512-JzFPAfklk1kjR1w76f0QOIhoDkNkSqW8wYKT08n9yysTmZfB+RQ2QoXoTAeOi1HD9ZipTyTAZg3c4pM/jeqgSw==} engines: {node: '>=18'} - rolldown@1.0.0-rc.17: - resolution: {integrity: sha512-ZrT53oAKrtA4+YtBWPQbtPOxIbVDbxT0orcYERKd63VJTF13zPcgXTvD4843L8pcsI7M6MErt8QtON6lrB9tyA==} + rolldown@1.0.2: + resolution: {integrity: sha512-oZx5zVDtVB44AW3eaifgDml1gWRDZGvjcfdxonE4swNPG98PrrXjaO/KrnUjzlMnztCCRVlUueA1kCXhARGk6g==} engines: {node: ^20.19.0 || >=22.12.0} hasBin: true @@ -5196,8 +5157,8 @@ packages: resolution: {integrity: sha512-hunMQrEy1T6Jr2uEVjrAIqjwWcQTgOAcIM52C8MY1EZSD3DDNft04XzvYKPqjED65bNVVko0YI38nYeEHCX3yw==} engines: {node: '>=12'} - semver@7.7.4: - resolution: {integrity: sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==} + semver@7.8.0: + resolution: {integrity: sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA==} engines: {node: '>=10'} hasBin: true @@ -5224,8 +5185,8 @@ packages: resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} engines: {node: '>=8'} - shiki@4.0.2: - resolution: {integrity: sha512-eAVKTMedR5ckPo4xne/PjYQYrU3qx78gtJZ+sHlXEg5IHhhoQhMfZVzetTYuaJS0L2Ef3AcCRzCHV8T0WI6nIQ==} + shiki@4.1.0: + resolution: {integrity: sha512-l/ABZPUR5v70jI10EzqfMS/I96vjSGv2y0ihUV+WYFzv0EfvW4s54m0Lg8wCrrL+2IkwBzFTuxkZjPf8b2NX9Q==} engines: {node: '>=20'} side-channel-list@1.0.1: @@ -5285,8 +5246,8 @@ packages: resolution: {integrity: sha512-dWUG8F5sIIARXih1DTaQAX4SsiTXhInKf1buxdY9DIg4ZYPZK5nGM1VRIYmEbDbsHt7USo99xSLFu5Q1IqTmsg==} engines: {node: '>= 18'} - snowflake-sdk@2.4.1: - resolution: {integrity: sha512-JIdqz9ed2FzkU8oEstf06hTJRoX9+PRRG9LJT1vfGTXN3A52kGxhGoWzmK0GtFTUnxTMxMoMYgD5QdoQbckyag==} + snowflake-sdk@2.4.2: + resolution: {integrity: sha512-sN9683tRetlGC1rFGLUSkwpUVaVwRbATho7DcHUwft76rg2EHq8ooiMs6iHHtXLSG6IGmly51oajAyc7/nOOhg==} engines: {node: '>=18'} peerDependencies: asn1.js: ^5.4.1 @@ -5345,6 +5306,9 @@ packages: stackback@0.0.2: resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + standardwebhooks@1.0.0: + resolution: {integrity: sha512-BbHGOQK9olHPMvQNHWul6MYlrRTAOKn03rOe4A8O3CLWhNf4YHBqq2HJKKC+sfqpxiBY52pNeesD6jIiLDz8jg==} + statuses@2.0.2: resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==} engines: {node: '>= 0.8'} @@ -5386,8 +5350,8 @@ packages: resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} engines: {node: '>=8'} - strip-ansi@7.1.2: - resolution: {integrity: sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==} + strip-ansi@7.2.0: + resolution: {integrity: sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==} engines: {node: '>=12'} strip-bom@3.0.0: @@ -5414,8 +5378,8 @@ packages: resolution: {integrity: sha512-1tB5mhVo7U+ETBKNf92xT4hrQa3pm0MZ0PQvuDnWgAAGHDsfp4lPSpiS6psrSiet87wyGPh9ft6wmhOMQ0hDiw==} engines: {node: '>=14.16'} - strnum@2.2.3: - resolution: {integrity: sha512-oKx6RUCuHfT3oyVjtnrmn19H1SiCqgJSg+54XqURKp5aCMbrXrhLjRN9TjuwMjiYstZ0MzDrHqkGZ5dFTKd+zg==} + strnum@2.3.0: + resolution: {integrity: sha512-ums3KNd42PGyx5xaoVTO1mjU1bH3NpY4vsrVlnv9PNGqQj8wd7rJ6nEypLrJ7z5vxK5RP0yMLo6J/Gsm62DI5Q==} stubs@3.0.0: resolution: {integrity: sha512-PdHt7hHUJKxvTCgbKX9C1V/ftOcjJQgz8BZwNfV5c4B6dcGqlpelTbJ999jBGZ2jYiPAwcX5dP6oBwVlBlUbxw==} @@ -5520,10 +5484,6 @@ packages: tinybench@2.9.0: resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} - tinyexec@1.1.1: - resolution: {integrity: sha512-VKS/ZaQhhkKFMANmAOhhXVoIfBXblQxGX1myCQ2faQrfmobMftXeJPcZGp0gS07ocvGJWDLZGyOZDadDBqYIJg==} - engines: {node: '>=18'} - tinyexec@1.1.2: resolution: {integrity: sha512-dAqSqE/RabpBKI8+h26GfLq6Vb3JVXs30XYQjdMjaj/c2tS8IYYMbIzP599KtRj7c57/wYApb3QjgRgXmrCukA==} engines: {node: '>=18'} @@ -5590,9 +5550,9 @@ packages: resolution: {integrity: sha512-8ZiHFm91orbSAe2PSAiSVBVko18pbhbiB3U9GglSzF/zCGkR+rxpHx6sEMCUm4kxY4LjDIUGgCfUMtwfZfjfUA==} engines: {node: '>=20'} - type-is@2.0.1: - resolution: {integrity: sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==} - engines: {node: '>= 0.6'} + type-is@2.1.0: + resolution: {integrity: sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==} + engines: {node: '>= 18'} typescript@6.0.3: resolution: {integrity: sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==} @@ -5722,13 +5682,13 @@ packages: vfile@6.0.3: resolution: {integrity: sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==} - vite@8.0.10: - resolution: {integrity: sha512-rZuUu9j6J5uotLDs+cAA4O5H4K1SfPliUlQwqa6YEwSrWDZzP4rhm00oJR5snMewjxF5V/K3D4kctsUTsIU9Mw==} + vite@8.0.14: + resolution: {integrity: sha512-s4BJJ+5y1pYL6Otw51FHhVJQhPnuRinKig64g/1+EUNaJsd3gCKdD31IPFvswUgW9/60QT9oFHbZHbQK5imcxw==} engines: {node: ^20.19.0 || >=22.12.0} hasBin: true peerDependencies: '@types/node': ^24.3.0 - '@vitejs/devtools': ^0.1.0 + '@vitejs/devtools': ^0.1.18 esbuild: ^0.27.0 || ^0.28.0 jiti: '>=1.21.0' less: ^4.0.0 @@ -5765,20 +5725,20 @@ packages: yaml: optional: true - vitest@4.1.6: - resolution: {integrity: sha512-6lvjbS3p9b4CrdCmguzbh2/4uoXhGE2q71R4OX5sqF9R1bo9Xd6fGrMAfvp5wnCzlBnFVdCOp6onuTQVbo8iUQ==} + vitest@4.1.7: + resolution: {integrity: sha512-flYyaFd2CgoCoU+0UKt3pxksgC+S02iTDN0n3LtqaMeXsI9SBcdNujc2k0DeFLzUn/0k538yNjOSdwgCqcrwJA==} engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} hasBin: true peerDependencies: '@edge-runtime/vm': '*' '@opentelemetry/api': ^1.9.0 '@types/node': ^24.3.0 - '@vitest/browser-playwright': 4.1.6 - '@vitest/browser-preview': 4.1.6 - '@vitest/browser-webdriverio': 4.1.6 - '@vitest/coverage-istanbul': 4.1.6 - '@vitest/coverage-v8': 4.1.6 - '@vitest/ui': 4.1.6 + '@vitest/browser-playwright': 4.1.7 + '@vitest/browser-preview': 4.1.7 + '@vitest/browser-webdriverio': 4.1.7 + '@vitest/coverage-istanbul': 4.1.7 + '@vitest/coverage-v8': 4.1.7 + '@vitest/ui': 4.1.7 happy-dom: '*' jsdom: '*' vite: ^6.0.0 || ^7.0.0 || ^8.0.0 @@ -5876,6 +5836,10 @@ packages: resolution: {integrity: sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw==} engines: {node: '>=18'} + xml-naming@0.1.0: + resolution: {integrity: sha512-k8KO9hrMyNk6tUWqUfkTEZbezRRpONVOzUTnc97VnCvyj6Tf9lyUR9EDAIeiVLv56jsMcoXEwjW8Kv5yPY52lw==} + engines: {node: '>=16.0.0'} + xtend@4.0.2: resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==} engines: {node: '>=0.4'} @@ -5960,29 +5924,29 @@ snapshots: '@actions/io@3.0.2': {} - '@ai-sdk/anthropic@3.0.77(zod@4.4.3)': + '@ai-sdk/anthropic@3.0.78(zod@4.4.3)': dependencies: '@ai-sdk/provider': 3.0.10 '@ai-sdk/provider-utils': 4.0.27(zod@4.4.3) zod: 4.4.3 - '@ai-sdk/devtools@0.0.17': + '@ai-sdk/devtools@0.0.18': dependencies: '@ai-sdk/provider': 3.0.10 '@hono/node-server': 1.19.14(hono@4.12.18) hono: 4.12.18 - '@ai-sdk/gateway@3.0.114(zod@4.4.3)': + '@ai-sdk/gateway@3.0.118(zod@4.4.3)': dependencies: '@ai-sdk/provider': 3.0.10 '@ai-sdk/provider-utils': 4.0.27(zod@4.4.3) '@vercel/oidc': 3.2.0 zod: 4.4.3 - '@ai-sdk/google-vertex@4.0.128(zod@4.4.3)': + '@ai-sdk/google-vertex@4.0.134(zod@4.4.3)': dependencies: - '@ai-sdk/anthropic': 3.0.77(zod@4.4.3) - '@ai-sdk/google': 3.0.73(zod@4.4.3) + '@ai-sdk/anthropic': 3.0.78(zod@4.4.3) + '@ai-sdk/google': 3.0.78(zod@4.4.3) '@ai-sdk/openai-compatible': 2.0.47(zod@4.4.3) '@ai-sdk/provider': 3.0.10 '@ai-sdk/provider-utils': 4.0.27(zod@4.4.3) @@ -5991,7 +5955,7 @@ snapshots: transitivePeerDependencies: - supports-color - '@ai-sdk/google@3.0.73(zod@4.4.3)': + '@ai-sdk/google@3.0.78(zod@4.4.3)': dependencies: '@ai-sdk/provider': 3.0.10 '@ai-sdk/provider-utils': 4.0.27(zod@4.4.3) @@ -6021,51 +5985,49 @@ snapshots: '@alloc/quick-lru@5.2.0': {} - '@anthropic-ai/claude-agent-sdk-darwin-arm64@0.3.142': + '@anthropic-ai/claude-agent-sdk-darwin-arm64@0.3.146': optional: true - '@anthropic-ai/claude-agent-sdk-darwin-x64@0.3.142': + '@anthropic-ai/claude-agent-sdk-darwin-x64@0.3.146': optional: true - '@anthropic-ai/claude-agent-sdk-linux-arm64-musl@0.3.142': + '@anthropic-ai/claude-agent-sdk-linux-arm64-musl@0.3.146': optional: true - '@anthropic-ai/claude-agent-sdk-linux-arm64@0.3.142': + '@anthropic-ai/claude-agent-sdk-linux-arm64@0.3.146': optional: true - '@anthropic-ai/claude-agent-sdk-linux-x64-musl@0.3.142': + '@anthropic-ai/claude-agent-sdk-linux-x64-musl@0.3.146': optional: true - '@anthropic-ai/claude-agent-sdk-linux-x64@0.3.142': + '@anthropic-ai/claude-agent-sdk-linux-x64@0.3.146': optional: true - '@anthropic-ai/claude-agent-sdk-win32-arm64@0.3.142': + '@anthropic-ai/claude-agent-sdk-win32-arm64@0.3.146': optional: true - '@anthropic-ai/claude-agent-sdk-win32-x64@0.3.142': + '@anthropic-ai/claude-agent-sdk-win32-x64@0.3.146': optional: true - '@anthropic-ai/claude-agent-sdk@0.3.142(zod@4.4.3)': + '@anthropic-ai/claude-agent-sdk@0.3.146(@anthropic-ai/sdk@0.97.1(zod@4.4.3))(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(zod@4.4.3)': dependencies: - '@anthropic-ai/sdk': 0.93.0(zod@4.4.3) + '@anthropic-ai/sdk': 0.97.1(zod@4.4.3) '@modelcontextprotocol/sdk': 1.29.0(zod@4.4.3) zod: 4.4.3 optionalDependencies: - '@anthropic-ai/claude-agent-sdk-darwin-arm64': 0.3.142 - '@anthropic-ai/claude-agent-sdk-darwin-x64': 0.3.142 - '@anthropic-ai/claude-agent-sdk-linux-arm64': 0.3.142 - '@anthropic-ai/claude-agent-sdk-linux-arm64-musl': 0.3.142 - '@anthropic-ai/claude-agent-sdk-linux-x64': 0.3.142 - '@anthropic-ai/claude-agent-sdk-linux-x64-musl': 0.3.142 - '@anthropic-ai/claude-agent-sdk-win32-arm64': 0.3.142 - '@anthropic-ai/claude-agent-sdk-win32-x64': 0.3.142 - transitivePeerDependencies: - - '@cfworker/json-schema' - - supports-color + '@anthropic-ai/claude-agent-sdk-darwin-arm64': 0.3.146 + '@anthropic-ai/claude-agent-sdk-darwin-x64': 0.3.146 + '@anthropic-ai/claude-agent-sdk-linux-arm64': 0.3.146 + '@anthropic-ai/claude-agent-sdk-linux-arm64-musl': 0.3.146 + '@anthropic-ai/claude-agent-sdk-linux-x64': 0.3.146 + '@anthropic-ai/claude-agent-sdk-linux-x64-musl': 0.3.146 + '@anthropic-ai/claude-agent-sdk-win32-arm64': 0.3.146 + '@anthropic-ai/claude-agent-sdk-win32-x64': 0.3.146 - '@anthropic-ai/sdk@0.93.0(zod@4.4.3)': + '@anthropic-ai/sdk@0.97.1(zod@4.4.3)': dependencies: json-schema-to-ts: 3.1.1 + standardwebhooks: 1.0.0 optionalDependencies: zod: 4.4.3 @@ -6121,452 +6083,346 @@ snapshots: '@aws-crypto/sha1-browser': 5.2.0 '@aws-crypto/sha256-browser': 5.2.0 '@aws-crypto/sha256-js': 5.2.0 - '@aws-sdk/core': 3.974.8 - '@aws-sdk/credential-provider-node': 3.972.39 - '@aws-sdk/middleware-bucket-endpoint': 3.972.10 - '@aws-sdk/middleware-expect-continue': 3.972.10 - '@aws-sdk/middleware-flexible-checksums': 3.974.16 - '@aws-sdk/middleware-host-header': 3.972.10 + '@aws-sdk/core': 3.974.12 + '@aws-sdk/credential-provider-node': 3.972.43 + '@aws-sdk/middleware-bucket-endpoint': 3.972.14 + '@aws-sdk/middleware-expect-continue': 3.972.12 + '@aws-sdk/middleware-flexible-checksums': 3.974.20 + '@aws-sdk/middleware-host-header': 3.972.13 '@aws-sdk/middleware-location-constraint': 3.972.10 - '@aws-sdk/middleware-logger': 3.972.10 - '@aws-sdk/middleware-recursion-detection': 3.972.11 - '@aws-sdk/middleware-sdk-s3': 3.972.37 + '@aws-sdk/middleware-logger': 3.972.12 + '@aws-sdk/middleware-recursion-detection': 3.972.14 + '@aws-sdk/middleware-sdk-s3': 3.972.41 '@aws-sdk/middleware-ssec': 3.972.10 - '@aws-sdk/middleware-user-agent': 3.972.38 - '@aws-sdk/region-config-resolver': 3.972.13 - '@aws-sdk/signature-v4-multi-region': 3.996.25 + '@aws-sdk/middleware-user-agent': 3.972.42 + '@aws-sdk/region-config-resolver': 3.972.16 + '@aws-sdk/signature-v4-multi-region': 3.996.27 '@aws-sdk/types': 3.973.8 - '@aws-sdk/util-endpoints': 3.996.8 - '@aws-sdk/util-user-agent-browser': 3.972.10 - '@aws-sdk/util-user-agent-node': 3.973.24 - '@smithy/config-resolver': 4.4.17 - '@smithy/core': 3.23.17 - '@smithy/eventstream-serde-browser': 4.2.14 - '@smithy/eventstream-serde-config-resolver': 4.3.14 - '@smithy/eventstream-serde-node': 4.2.14 - '@smithy/fetch-http-handler': 5.3.17 - '@smithy/hash-blob-browser': 4.2.15 - '@smithy/hash-node': 4.2.14 - '@smithy/hash-stream-node': 4.2.14 - '@smithy/invalid-dependency': 4.2.14 - '@smithy/md5-js': 4.2.14 - '@smithy/middleware-content-length': 4.2.14 - '@smithy/middleware-endpoint': 4.4.32 - '@smithy/middleware-retry': 4.5.7 - '@smithy/middleware-serde': 4.2.20 - '@smithy/middleware-stack': 4.2.14 - '@smithy/node-config-provider': 4.3.14 - '@smithy/node-http-handler': 4.6.1 - '@smithy/protocol-http': 5.3.14 - '@smithy/smithy-client': 4.12.13 - '@smithy/types': 4.14.1 - '@smithy/url-parser': 4.2.14 - '@smithy/util-base64': 4.3.2 - '@smithy/util-body-length-browser': 4.2.2 - '@smithy/util-body-length-node': 4.2.3 - '@smithy/util-defaults-mode-browser': 4.3.49 - '@smithy/util-defaults-mode-node': 4.2.54 - '@smithy/util-endpoints': 3.4.2 - '@smithy/util-middleware': 4.2.14 - '@smithy/util-retry': 4.3.6 - '@smithy/util-stream': 4.5.25 - '@smithy/util-utf8': 4.2.2 - '@smithy/util-waiter': 4.3.0 + '@aws-sdk/util-endpoints': 3.996.11 + '@aws-sdk/util-user-agent-browser': 3.972.13 + '@aws-sdk/util-user-agent-node': 3.973.28 + '@smithy/config-resolver': 4.5.3 + '@smithy/core': 3.24.3 + '@smithy/eventstream-serde-browser': 4.3.3 + '@smithy/eventstream-serde-config-resolver': 4.4.3 + '@smithy/eventstream-serde-node': 4.3.3 + '@smithy/fetch-http-handler': 5.4.3 + '@smithy/hash-blob-browser': 4.3.3 + '@smithy/hash-node': 4.3.3 + '@smithy/hash-stream-node': 4.3.3 + '@smithy/invalid-dependency': 4.3.3 + '@smithy/md5-js': 4.3.3 + '@smithy/middleware-content-length': 4.3.3 + '@smithy/middleware-endpoint': 4.5.3 + '@smithy/middleware-retry': 4.6.3 + '@smithy/middleware-serde': 4.3.3 + '@smithy/middleware-stack': 4.3.3 + '@smithy/node-config-provider': 4.4.3 + '@smithy/node-http-handler': 4.7.3 + '@smithy/protocol-http': 5.4.3 + '@smithy/smithy-client': 4.13.3 + '@smithy/types': 4.14.2 + '@smithy/url-parser': 4.3.3 + '@smithy/util-base64': 4.4.3 + '@smithy/util-body-length-browser': 4.3.3 + '@smithy/util-body-length-node': 4.3.3 + '@smithy/util-defaults-mode-browser': 4.4.3 + '@smithy/util-defaults-mode-node': 4.3.3 + '@smithy/util-endpoints': 3.5.3 + '@smithy/util-middleware': 4.3.3 + '@smithy/util-retry': 4.4.3 + '@smithy/util-stream': 4.6.3 + '@smithy/util-utf8': 4.3.3 + '@smithy/util-waiter': 4.4.3 tslib: 2.8.1 - transitivePeerDependencies: - - aws-crt '@aws-sdk/client-sts@3.1045.0': dependencies: '@aws-crypto/sha256-browser': 5.2.0 '@aws-crypto/sha256-js': 5.2.0 - '@aws-sdk/core': 3.974.8 - '@aws-sdk/credential-provider-node': 3.972.39 - '@aws-sdk/middleware-host-header': 3.972.10 - '@aws-sdk/middleware-logger': 3.972.10 - '@aws-sdk/middleware-recursion-detection': 3.972.11 - '@aws-sdk/middleware-user-agent': 3.972.38 - '@aws-sdk/region-config-resolver': 3.972.13 - '@aws-sdk/signature-v4-multi-region': 3.996.25 + '@aws-sdk/core': 3.974.12 + '@aws-sdk/credential-provider-node': 3.972.43 + '@aws-sdk/middleware-host-header': 3.972.13 + '@aws-sdk/middleware-logger': 3.972.12 + '@aws-sdk/middleware-recursion-detection': 3.972.14 + '@aws-sdk/middleware-user-agent': 3.972.42 + '@aws-sdk/region-config-resolver': 3.972.16 + '@aws-sdk/signature-v4-multi-region': 3.996.27 '@aws-sdk/types': 3.973.8 - '@aws-sdk/util-endpoints': 3.996.8 - '@aws-sdk/util-user-agent-browser': 3.972.10 - '@aws-sdk/util-user-agent-node': 3.973.24 - '@smithy/config-resolver': 4.4.17 - '@smithy/core': 3.23.17 - '@smithy/fetch-http-handler': 5.3.17 - '@smithy/hash-node': 4.2.14 - '@smithy/invalid-dependency': 4.2.14 - '@smithy/middleware-content-length': 4.2.14 - '@smithy/middleware-endpoint': 4.4.32 - '@smithy/middleware-retry': 4.5.7 - '@smithy/middleware-serde': 4.2.20 - '@smithy/middleware-stack': 4.2.14 - '@smithy/node-config-provider': 4.3.14 - '@smithy/node-http-handler': 4.6.1 - '@smithy/protocol-http': 5.3.14 - '@smithy/smithy-client': 4.12.13 - '@smithy/types': 4.14.1 - '@smithy/url-parser': 4.2.14 - '@smithy/util-base64': 4.3.2 - '@smithy/util-body-length-browser': 4.2.2 - '@smithy/util-body-length-node': 4.2.3 - '@smithy/util-defaults-mode-browser': 4.3.49 - '@smithy/util-defaults-mode-node': 4.2.54 - '@smithy/util-endpoints': 3.4.2 - '@smithy/util-middleware': 4.2.14 - '@smithy/util-retry': 4.3.6 - '@smithy/util-utf8': 4.2.2 - tslib: 2.8.1 - transitivePeerDependencies: - - aws-crt - - '@aws-sdk/core@3.974.8': - dependencies: - '@aws-sdk/types': 3.973.8 - '@aws-sdk/xml-builder': 3.972.22 - '@smithy/core': 3.23.17 - '@smithy/node-config-provider': 4.3.14 - '@smithy/property-provider': 4.2.14 - '@smithy/protocol-http': 5.3.14 - '@smithy/signature-v4': 5.3.14 - '@smithy/smithy-client': 4.12.13 - '@smithy/types': 4.14.1 - '@smithy/util-base64': 4.3.2 - '@smithy/util-middleware': 4.2.14 - '@smithy/util-retry': 4.3.6 - '@smithy/util-utf8': 4.2.2 + '@aws-sdk/util-endpoints': 3.996.11 + '@aws-sdk/util-user-agent-browser': 3.972.13 + '@aws-sdk/util-user-agent-node': 3.973.28 + '@smithy/config-resolver': 4.5.3 + '@smithy/core': 3.24.3 + '@smithy/fetch-http-handler': 5.4.3 + '@smithy/hash-node': 4.3.3 + '@smithy/invalid-dependency': 4.3.3 + '@smithy/middleware-content-length': 4.3.3 + '@smithy/middleware-endpoint': 4.5.3 + '@smithy/middleware-retry': 4.6.3 + '@smithy/middleware-serde': 4.3.3 + '@smithy/middleware-stack': 4.3.3 + '@smithy/node-config-provider': 4.4.3 + '@smithy/node-http-handler': 4.7.3 + '@smithy/protocol-http': 5.4.3 + '@smithy/smithy-client': 4.13.3 + '@smithy/types': 4.14.2 + '@smithy/url-parser': 4.3.3 + '@smithy/util-base64': 4.4.3 + '@smithy/util-body-length-browser': 4.3.3 + '@smithy/util-body-length-node': 4.3.3 + '@smithy/util-defaults-mode-browser': 4.4.3 + '@smithy/util-defaults-mode-node': 4.3.3 + '@smithy/util-endpoints': 3.5.3 + '@smithy/util-middleware': 4.3.3 + '@smithy/util-retry': 4.4.3 + '@smithy/util-utf8': 4.3.3 tslib: 2.8.1 - '@aws-sdk/crc64-nvme@3.972.7': + '@aws-sdk/core@3.974.12': dependencies: - '@smithy/types': 4.14.1 + '@aws-sdk/types': 3.973.8 + '@aws-sdk/xml-builder': 3.972.24 + '@aws/lambda-invoke-store': 0.2.4 + '@smithy/core': 3.24.3 + '@smithy/signature-v4': 5.4.3 + '@smithy/types': 4.14.2 + bowser: 2.14.1 tslib: 2.8.1 - '@aws-sdk/credential-provider-env@3.972.34': + '@aws-sdk/crc64-nvme@3.972.8': dependencies: - '@aws-sdk/core': 3.974.8 - '@aws-sdk/types': 3.973.8 - '@smithy/property-provider': 4.2.14 - '@smithy/types': 4.14.1 + '@smithy/types': 4.14.2 tslib: 2.8.1 - '@aws-sdk/credential-provider-http@3.972.36': + '@aws-sdk/credential-provider-env@3.972.38': dependencies: - '@aws-sdk/core': 3.974.8 + '@aws-sdk/core': 3.974.12 '@aws-sdk/types': 3.973.8 - '@smithy/fetch-http-handler': 5.3.17 - '@smithy/node-http-handler': 4.6.1 - '@smithy/property-provider': 4.2.14 - '@smithy/protocol-http': 5.3.14 - '@smithy/smithy-client': 4.12.13 - '@smithy/types': 4.14.1 - '@smithy/util-stream': 4.5.25 + '@smithy/core': 3.24.3 + '@smithy/types': 4.14.2 tslib: 2.8.1 - '@aws-sdk/credential-provider-ini@3.972.38': + '@aws-sdk/credential-provider-http@3.972.40': dependencies: - '@aws-sdk/core': 3.974.8 - '@aws-sdk/credential-provider-env': 3.972.34 - '@aws-sdk/credential-provider-http': 3.972.36 - '@aws-sdk/credential-provider-login': 3.972.38 - '@aws-sdk/credential-provider-process': 3.972.34 - '@aws-sdk/credential-provider-sso': 3.972.38 - '@aws-sdk/credential-provider-web-identity': 3.972.38 - '@aws-sdk/nested-clients': 3.997.6 + '@aws-sdk/core': 3.974.12 '@aws-sdk/types': 3.973.8 - '@smithy/credential-provider-imds': 4.2.14 - '@smithy/property-provider': 4.2.14 - '@smithy/shared-ini-file-loader': 4.4.9 - '@smithy/types': 4.14.1 - tslib: 2.8.1 - transitivePeerDependencies: - - aws-crt - - '@aws-sdk/credential-provider-login@3.972.38': - dependencies: - '@aws-sdk/core': 3.974.8 - '@aws-sdk/nested-clients': 3.997.6 - '@aws-sdk/types': 3.973.8 - '@smithy/property-provider': 4.2.14 - '@smithy/protocol-http': 5.3.14 - '@smithy/shared-ini-file-loader': 4.4.9 - '@smithy/types': 4.14.1 - tslib: 2.8.1 - transitivePeerDependencies: - - aws-crt - - '@aws-sdk/credential-provider-node@3.972.39': - dependencies: - '@aws-sdk/credential-provider-env': 3.972.34 - '@aws-sdk/credential-provider-http': 3.972.36 - '@aws-sdk/credential-provider-ini': 3.972.38 - '@aws-sdk/credential-provider-process': 3.972.34 - '@aws-sdk/credential-provider-sso': 3.972.38 - '@aws-sdk/credential-provider-web-identity': 3.972.38 - '@aws-sdk/types': 3.973.8 - '@smithy/credential-provider-imds': 4.2.14 - '@smithy/property-provider': 4.2.14 - '@smithy/shared-ini-file-loader': 4.4.9 - '@smithy/types': 4.14.1 - tslib: 2.8.1 - transitivePeerDependencies: - - aws-crt - - '@aws-sdk/credential-provider-process@3.972.34': - dependencies: - '@aws-sdk/core': 3.974.8 - '@aws-sdk/types': 3.973.8 - '@smithy/property-provider': 4.2.14 - '@smithy/shared-ini-file-loader': 4.4.9 - '@smithy/types': 4.14.1 + '@smithy/core': 3.24.3 + '@smithy/fetch-http-handler': 5.4.3 + '@smithy/node-http-handler': 4.7.3 + '@smithy/types': 4.14.2 tslib: 2.8.1 - '@aws-sdk/credential-provider-sso@3.972.38': + '@aws-sdk/credential-provider-ini@3.972.42': dependencies: - '@aws-sdk/core': 3.974.8 - '@aws-sdk/nested-clients': 3.997.6 - '@aws-sdk/token-providers': 3.1041.0 + '@aws-sdk/core': 3.974.12 + '@aws-sdk/credential-provider-env': 3.972.38 + '@aws-sdk/credential-provider-http': 3.972.40 + '@aws-sdk/credential-provider-login': 3.972.42 + '@aws-sdk/credential-provider-process': 3.972.38 + '@aws-sdk/credential-provider-sso': 3.972.42 + '@aws-sdk/credential-provider-web-identity': 3.972.42 + '@aws-sdk/nested-clients': 3.997.10 '@aws-sdk/types': 3.973.8 - '@smithy/property-provider': 4.2.14 - '@smithy/shared-ini-file-loader': 4.4.9 - '@smithy/types': 4.14.1 + '@smithy/core': 3.24.3 + '@smithy/credential-provider-imds': 4.3.3 + '@smithy/types': 4.14.2 tslib: 2.8.1 - transitivePeerDependencies: - - aws-crt - '@aws-sdk/credential-provider-web-identity@3.972.38': + '@aws-sdk/credential-provider-login@3.972.42': dependencies: - '@aws-sdk/core': 3.974.8 - '@aws-sdk/nested-clients': 3.997.6 + '@aws-sdk/core': 3.974.12 + '@aws-sdk/nested-clients': 3.997.10 '@aws-sdk/types': 3.973.8 - '@smithy/property-provider': 4.2.14 - '@smithy/shared-ini-file-loader': 4.4.9 - '@smithy/types': 4.14.1 + '@smithy/core': 3.24.3 + '@smithy/types': 4.14.2 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-node@3.972.43': + dependencies: + '@aws-sdk/credential-provider-env': 3.972.38 + '@aws-sdk/credential-provider-http': 3.972.40 + '@aws-sdk/credential-provider-ini': 3.972.42 + '@aws-sdk/credential-provider-process': 3.972.38 + '@aws-sdk/credential-provider-sso': 3.972.42 + '@aws-sdk/credential-provider-web-identity': 3.972.42 + '@aws-sdk/types': 3.973.8 + '@smithy/core': 3.24.3 + '@smithy/credential-provider-imds': 4.3.3 + '@smithy/types': 4.14.2 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-process@3.972.38': + dependencies: + '@aws-sdk/core': 3.974.12 + '@aws-sdk/types': 3.973.8 + '@smithy/core': 3.24.3 + '@smithy/types': 4.14.2 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-sso@3.972.42': + dependencies: + '@aws-sdk/core': 3.974.12 + '@aws-sdk/nested-clients': 3.997.10 + '@aws-sdk/token-providers': 3.1049.0 + '@aws-sdk/types': 3.973.8 + '@smithy/core': 3.24.3 + '@smithy/types': 4.14.2 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-web-identity@3.972.42': + dependencies: + '@aws-sdk/core': 3.974.12 + '@aws-sdk/nested-clients': 3.997.10 + '@aws-sdk/types': 3.973.8 + '@smithy/core': 3.24.3 + '@smithy/types': 4.14.2 tslib: 2.8.1 - transitivePeerDependencies: - - aws-crt '@aws-sdk/ec2-metadata-service@3.1045.0': dependencies: '@aws-sdk/types': 3.973.8 - '@smithy/node-config-provider': 4.3.14 - '@smithy/node-http-handler': 4.6.1 - '@smithy/protocol-http': 5.3.14 - '@smithy/types': 4.14.1 - '@smithy/util-stream': 4.5.25 + '@smithy/node-config-provider': 4.4.3 + '@smithy/node-http-handler': 4.7.3 + '@smithy/protocol-http': 5.4.3 + '@smithy/types': 4.14.2 + '@smithy/util-stream': 4.6.3 tslib: 2.8.1 - '@aws-sdk/middleware-bucket-endpoint@3.972.10': + '@aws-sdk/middleware-bucket-endpoint@3.972.14': + dependencies: + '@aws-sdk/core': 3.974.12 + '@aws-sdk/types': 3.973.8 + '@smithy/core': 3.24.3 + '@smithy/types': 4.14.2 + tslib: 2.8.1 + + '@aws-sdk/middleware-expect-continue@3.972.12': dependencies: '@aws-sdk/types': 3.973.8 - '@aws-sdk/util-arn-parser': 3.972.3 - '@smithy/node-config-provider': 4.3.14 - '@smithy/protocol-http': 5.3.14 - '@smithy/types': 4.14.1 - '@smithy/util-config-provider': 4.2.2 + '@smithy/core': 3.24.3 + '@smithy/types': 4.14.2 tslib: 2.8.1 - '@aws-sdk/middleware-expect-continue@3.972.10': - dependencies: - '@aws-sdk/types': 3.973.8 - '@smithy/protocol-http': 5.3.14 - '@smithy/types': 4.14.1 - tslib: 2.8.1 - - '@aws-sdk/middleware-flexible-checksums@3.974.16': + '@aws-sdk/middleware-flexible-checksums@3.974.20': dependencies: '@aws-crypto/crc32': 5.2.0 '@aws-crypto/crc32c': 5.2.0 '@aws-crypto/util': 5.2.0 - '@aws-sdk/core': 3.974.8 - '@aws-sdk/crc64-nvme': 3.972.7 + '@aws-sdk/core': 3.974.12 + '@aws-sdk/crc64-nvme': 3.972.8 '@aws-sdk/types': 3.973.8 - '@smithy/is-array-buffer': 4.2.2 - '@smithy/node-config-provider': 4.3.14 - '@smithy/protocol-http': 5.3.14 - '@smithy/types': 4.14.1 - '@smithy/util-middleware': 4.2.14 - '@smithy/util-stream': 4.5.25 - '@smithy/util-utf8': 4.2.2 + '@smithy/core': 3.24.3 + '@smithy/types': 4.14.2 tslib: 2.8.1 - '@aws-sdk/middleware-host-header@3.972.10': + '@aws-sdk/middleware-host-header@3.972.13': dependencies: - '@aws-sdk/types': 3.973.8 - '@smithy/protocol-http': 5.3.14 - '@smithy/types': 4.14.1 + '@aws-sdk/core': 3.974.12 tslib: 2.8.1 '@aws-sdk/middleware-location-constraint@3.972.10': dependencies: '@aws-sdk/types': 3.973.8 - '@smithy/types': 4.14.1 + '@smithy/types': 4.14.2 tslib: 2.8.1 - '@aws-sdk/middleware-logger@3.972.10': + '@aws-sdk/middleware-logger@3.972.12': dependencies: - '@aws-sdk/types': 3.973.8 - '@smithy/types': 4.14.1 + '@aws-sdk/core': 3.974.12 tslib: 2.8.1 - '@aws-sdk/middleware-recursion-detection@3.972.11': + '@aws-sdk/middleware-recursion-detection@3.972.14': dependencies: - '@aws-sdk/types': 3.973.8 - '@aws/lambda-invoke-store': 0.2.4 - '@smithy/protocol-http': 5.3.14 - '@smithy/types': 4.14.1 + '@aws-sdk/core': 3.974.12 tslib: 2.8.1 - '@aws-sdk/middleware-sdk-s3@3.972.37': + '@aws-sdk/middleware-sdk-s3@3.972.41': dependencies: - '@aws-sdk/core': 3.974.8 + '@aws-sdk/core': 3.974.12 + '@aws-sdk/signature-v4-multi-region': 3.996.27 '@aws-sdk/types': 3.973.8 - '@aws-sdk/util-arn-parser': 3.972.3 - '@smithy/core': 3.23.17 - '@smithy/node-config-provider': 4.3.14 - '@smithy/protocol-http': 5.3.14 - '@smithy/signature-v4': 5.3.14 - '@smithy/smithy-client': 4.12.13 - '@smithy/types': 4.14.1 - '@smithy/util-config-provider': 4.2.2 - '@smithy/util-middleware': 4.2.14 - '@smithy/util-stream': 4.5.25 - '@smithy/util-utf8': 4.2.2 + '@smithy/core': 3.24.3 + '@smithy/signature-v4': 5.4.3 + '@smithy/types': 4.14.2 tslib: 2.8.1 '@aws-sdk/middleware-ssec@3.972.10': dependencies: '@aws-sdk/types': 3.973.8 - '@smithy/types': 4.14.1 + '@smithy/types': 4.14.2 tslib: 2.8.1 - '@aws-sdk/middleware-user-agent@3.972.38': + '@aws-sdk/middleware-user-agent@3.972.42': dependencies: - '@aws-sdk/core': 3.974.8 - '@aws-sdk/types': 3.973.8 - '@aws-sdk/util-endpoints': 3.996.8 - '@smithy/core': 3.23.17 - '@smithy/protocol-http': 5.3.14 - '@smithy/types': 4.14.1 - '@smithy/util-retry': 4.3.6 + '@aws-sdk/core': 3.974.12 tslib: 2.8.1 - '@aws-sdk/nested-clients@3.997.6': + '@aws-sdk/nested-clients@3.997.10': dependencies: '@aws-crypto/sha256-browser': 5.2.0 '@aws-crypto/sha256-js': 5.2.0 - '@aws-sdk/core': 3.974.8 - '@aws-sdk/middleware-host-header': 3.972.10 - '@aws-sdk/middleware-logger': 3.972.10 - '@aws-sdk/middleware-recursion-detection': 3.972.11 - '@aws-sdk/middleware-user-agent': 3.972.38 - '@aws-sdk/region-config-resolver': 3.972.13 - '@aws-sdk/signature-v4-multi-region': 3.996.25 + '@aws-sdk/core': 3.974.12 + '@aws-sdk/signature-v4-multi-region': 3.996.27 '@aws-sdk/types': 3.973.8 - '@aws-sdk/util-endpoints': 3.996.8 - '@aws-sdk/util-user-agent-browser': 3.972.10 - '@aws-sdk/util-user-agent-node': 3.973.24 - '@smithy/config-resolver': 4.4.17 - '@smithy/core': 3.23.17 - '@smithy/fetch-http-handler': 5.3.17 - '@smithy/hash-node': 4.2.14 - '@smithy/invalid-dependency': 4.2.14 - '@smithy/middleware-content-length': 4.2.14 - '@smithy/middleware-endpoint': 4.4.32 - '@smithy/middleware-retry': 4.5.7 - '@smithy/middleware-serde': 4.2.20 - '@smithy/middleware-stack': 4.2.14 - '@smithy/node-config-provider': 4.3.14 - '@smithy/node-http-handler': 4.6.1 - '@smithy/protocol-http': 5.3.14 - '@smithy/smithy-client': 4.12.13 - '@smithy/types': 4.14.1 - '@smithy/url-parser': 4.2.14 - '@smithy/util-base64': 4.3.2 - '@smithy/util-body-length-browser': 4.2.2 - '@smithy/util-body-length-node': 4.2.3 - '@smithy/util-defaults-mode-browser': 4.3.49 - '@smithy/util-defaults-mode-node': 4.2.54 - '@smithy/util-endpoints': 3.4.2 - '@smithy/util-middleware': 4.2.14 - '@smithy/util-retry': 4.3.6 - '@smithy/util-utf8': 4.2.2 - tslib: 2.8.1 - transitivePeerDependencies: - - aws-crt - - '@aws-sdk/region-config-resolver@3.972.13': - dependencies: - '@aws-sdk/types': 3.973.8 - '@smithy/config-resolver': 4.4.17 - '@smithy/node-config-provider': 4.3.14 - '@smithy/types': 4.14.1 + '@smithy/core': 3.24.3 + '@smithy/fetch-http-handler': 5.4.3 + '@smithy/node-http-handler': 4.7.3 + '@smithy/types': 4.14.2 tslib: 2.8.1 - '@aws-sdk/signature-v4-multi-region@3.996.25': + '@aws-sdk/region-config-resolver@3.972.16': dependencies: - '@aws-sdk/middleware-sdk-s3': 3.972.37 - '@aws-sdk/types': 3.973.8 - '@smithy/protocol-http': 5.3.14 - '@smithy/signature-v4': 5.3.14 - '@smithy/types': 4.14.1 + '@aws-sdk/core': 3.974.12 tslib: 2.8.1 - '@aws-sdk/token-providers@3.1041.0': + '@aws-sdk/signature-v4-multi-region@3.996.27': dependencies: - '@aws-sdk/core': 3.974.8 - '@aws-sdk/nested-clients': 3.997.6 '@aws-sdk/types': 3.973.8 - '@smithy/property-provider': 4.2.14 - '@smithy/shared-ini-file-loader': 4.4.9 - '@smithy/types': 4.14.1 + '@smithy/core': 3.24.3 + '@smithy/signature-v4': 5.4.3 + '@smithy/types': 4.14.2 + tslib: 2.8.1 + + '@aws-sdk/token-providers@3.1049.0': + dependencies: + '@aws-sdk/core': 3.974.12 + '@aws-sdk/nested-clients': 3.997.10 + '@aws-sdk/types': 3.973.8 + '@smithy/core': 3.24.3 + '@smithy/types': 4.14.2 tslib: 2.8.1 - transitivePeerDependencies: - - aws-crt '@aws-sdk/types@3.973.8': dependencies: - '@smithy/types': 4.14.1 + '@smithy/types': 4.14.2 tslib: 2.8.1 - '@aws-sdk/util-arn-parser@3.972.3': + '@aws-sdk/util-endpoints@3.996.11': dependencies: - tslib: 2.8.1 - - '@aws-sdk/util-endpoints@3.996.8': - dependencies: - '@aws-sdk/types': 3.973.8 - '@smithy/types': 4.14.1 - '@smithy/url-parser': 4.2.14 - '@smithy/util-endpoints': 3.4.2 + '@aws-sdk/core': 3.974.12 + '@smithy/core': 3.24.3 tslib: 2.8.1 '@aws-sdk/util-locate-window@3.965.5': dependencies: tslib: 2.8.1 - '@aws-sdk/util-user-agent-browser@3.972.10': + '@aws-sdk/util-user-agent-browser@3.972.13': dependencies: - '@aws-sdk/types': 3.973.8 - '@smithy/types': 4.14.1 - bowser: 2.14.1 + '@aws-sdk/core': 3.974.12 tslib: 2.8.1 - '@aws-sdk/util-user-agent-node@3.973.24': + '@aws-sdk/util-user-agent-node@3.973.28': dependencies: - '@aws-sdk/middleware-user-agent': 3.972.38 - '@aws-sdk/types': 3.973.8 - '@smithy/node-config-provider': 4.3.14 - '@smithy/types': 4.14.1 - '@smithy/util-config-provider': 4.2.2 + '@aws-sdk/core': 3.974.12 tslib: 2.8.1 - '@aws-sdk/xml-builder@3.972.22': + '@aws-sdk/xml-builder@3.972.24': dependencies: '@nodable/entities': 2.1.0 - '@smithy/types': 4.14.1 - fast-xml-parser: 5.7.2 + '@smithy/types': 4.14.2 + fast-xml-parser: 5.7.3 tslib: 2.8.1 '@aws/lambda-invoke-store@0.2.4': {} @@ -6651,7 +6507,7 @@ snapshots: '@azure/core-xml@1.5.1': dependencies: - fast-xml-parser: 5.7.2 + fast-xml-parser: 5.8.0 tslib: 2.8.1 '@azure/identity@4.13.1': @@ -6663,8 +6519,8 @@ snapshots: '@azure/core-tracing': 1.3.1 '@azure/core-util': 1.13.1 '@azure/logger': 1.3.0 - '@azure/msal-browser': 5.9.0 - '@azure/msal-node': 5.1.5 + '@azure/msal-browser': 5.11.0 + '@azure/msal-node': 5.2.2 open: 10.2.0 tslib: 2.8.1 transitivePeerDependencies: @@ -6708,15 +6564,15 @@ snapshots: transitivePeerDependencies: - supports-color - '@azure/msal-browser@5.9.0': + '@azure/msal-browser@5.11.0': dependencies: - '@azure/msal-common': 16.5.2 + '@azure/msal-common': 16.6.2 - '@azure/msal-common@16.5.2': {} + '@azure/msal-common@16.6.2': {} - '@azure/msal-node@5.1.5': + '@azure/msal-node@5.2.2': dependencies: - '@azure/msal-common': 16.5.2 + '@azure/msal-common': 16.6.2 jsonwebtoken: 9.0.3 '@azure/storage-blob@12.26.0': @@ -6797,21 +6653,21 @@ snapshots: '@clack/core@1.3.1': dependencies: - fast-wrap-ansi: 0.2.0 + fast-wrap-ansi: 0.2.2 sisteransi: 1.0.5 '@clack/prompts@1.4.0': dependencies: '@clack/core': 1.3.1 fast-string-width: 3.0.2 - fast-wrap-ansi: 0.2.0 + fast-wrap-ansi: 0.2.2 sisteransi: 1.0.5 - '@clickhouse/client-common@1.18.4': {} + '@clickhouse/client-common@1.18.5': {} - '@clickhouse/client@1.18.4': + '@clickhouse/client@1.18.5': dependencies: - '@clickhouse/client-common': 1.18.4 + '@clickhouse/client-common': 1.18.5 '@colors/colors@1.5.0': optional: true @@ -7135,7 +6991,7 @@ snapshots: '@mdx-js/mdx@3.1.1': dependencies: - '@types/estree': 1.0.8 + '@types/estree': 1.0.9 '@types/estree-jsx': 1.0.5 '@types/hast': 3.0.4 '@types/mdx': 2.0.13 @@ -7174,9 +7030,9 @@ snapshots: eventsource: 3.0.7 eventsource-parser: 3.0.8 express: 5.2.1 - express-rate-limit: 8.4.1(express@5.2.1) + express-rate-limit: 8.5.2(express@5.2.1) hono: 4.12.18 - jose: 6.2.2 + jose: 6.2.3 json-schema-typed: 8.0.2 pkce-challenge: 5.0.1 raw-body: 3.0.2 @@ -7189,7 +7045,7 @@ snapshots: dependencies: '@emnapi/core': 1.10.0 '@emnapi/runtime': 1.10.0 - '@tybys/wasm-util': 0.10.1 + '@tybys/wasm-util': 0.10.2 optional: true '@next/env@16.2.6': {} @@ -7220,7 +7076,7 @@ snapshots: '@nodable/entities@2.1.0': {} - '@notionhq/client@5.21.0': {} + '@notionhq/client@5.22.0': {} '@octokit/auth-token@6.0.0': {} @@ -7283,77 +7139,77 @@ snapshots: dependencies: '@octokit/openapi-types': 27.0.0 - '@opentelemetry/api@1.9.0': {} + '@opentelemetry/api@1.9.1': {} '@orama/orama@3.1.18': {} - '@oxc-parser/binding-android-arm-eabi@0.128.0': + '@oxc-parser/binding-android-arm-eabi@0.130.0': optional: true - '@oxc-parser/binding-android-arm64@0.128.0': + '@oxc-parser/binding-android-arm64@0.130.0': optional: true - '@oxc-parser/binding-darwin-arm64@0.128.0': + '@oxc-parser/binding-darwin-arm64@0.130.0': optional: true - '@oxc-parser/binding-darwin-x64@0.128.0': + '@oxc-parser/binding-darwin-x64@0.130.0': optional: true - '@oxc-parser/binding-freebsd-x64@0.128.0': + '@oxc-parser/binding-freebsd-x64@0.130.0': optional: true - '@oxc-parser/binding-linux-arm-gnueabihf@0.128.0': + '@oxc-parser/binding-linux-arm-gnueabihf@0.130.0': optional: true - '@oxc-parser/binding-linux-arm-musleabihf@0.128.0': + '@oxc-parser/binding-linux-arm-musleabihf@0.130.0': optional: true - '@oxc-parser/binding-linux-arm64-gnu@0.128.0': + '@oxc-parser/binding-linux-arm64-gnu@0.130.0': optional: true - '@oxc-parser/binding-linux-arm64-musl@0.128.0': + '@oxc-parser/binding-linux-arm64-musl@0.130.0': optional: true - '@oxc-parser/binding-linux-ppc64-gnu@0.128.0': + '@oxc-parser/binding-linux-ppc64-gnu@0.130.0': optional: true - '@oxc-parser/binding-linux-riscv64-gnu@0.128.0': + '@oxc-parser/binding-linux-riscv64-gnu@0.130.0': optional: true - '@oxc-parser/binding-linux-riscv64-musl@0.128.0': + '@oxc-parser/binding-linux-riscv64-musl@0.130.0': optional: true - '@oxc-parser/binding-linux-s390x-gnu@0.128.0': + '@oxc-parser/binding-linux-s390x-gnu@0.130.0': optional: true - '@oxc-parser/binding-linux-x64-gnu@0.128.0': + '@oxc-parser/binding-linux-x64-gnu@0.130.0': optional: true - '@oxc-parser/binding-linux-x64-musl@0.128.0': + '@oxc-parser/binding-linux-x64-musl@0.130.0': optional: true - '@oxc-parser/binding-openharmony-arm64@0.128.0': + '@oxc-parser/binding-openharmony-arm64@0.130.0': optional: true - '@oxc-parser/binding-wasm32-wasi@0.128.0': + '@oxc-parser/binding-wasm32-wasi@0.130.0': dependencies: '@emnapi/core': 1.10.0 '@emnapi/runtime': 1.10.0 '@napi-rs/wasm-runtime': 1.1.4(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0) optional: true - '@oxc-parser/binding-win32-arm64-msvc@0.128.0': + '@oxc-parser/binding-win32-arm64-msvc@0.130.0': optional: true - '@oxc-parser/binding-win32-ia32-msvc@0.128.0': + '@oxc-parser/binding-win32-ia32-msvc@0.130.0': optional: true - '@oxc-parser/binding-win32-x64-msvc@0.128.0': + '@oxc-parser/binding-win32-x64-msvc@0.130.0': optional: true - '@oxc-project/types@0.127.0': {} + '@oxc-project/types@0.130.0': {} - '@oxc-project/types@0.128.0': {} + '@oxc-project/types@0.132.0': {} '@oxc-resolver/binding-android-arm-eabi@11.19.1': optional: true @@ -7432,412 +7288,418 @@ snapshots: '@pnpm/network.ca-file': 1.0.2 config-chain: 1.1.13 + '@posthog/core@1.29.7': + dependencies: + '@posthog/types': 1.374.4 + + '@posthog/types@1.374.4': {} + '@radix-ui/number@1.1.1': {} '@radix-ui/primitive@1.1.3': {} - '@radix-ui/react-accordion@1.2.12(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': + '@radix-ui/react-accordion@1.2.12(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': dependencies: '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-collapsible': 1.1.12(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.6) - '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.6) - '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.6) - '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.6) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.6) + '@radix-ui/react-collapsible': 1.1.12(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.15)(react@19.2.6) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.15)(react@19.2.6) + '@radix-ui/react-direction': 1.1.1(@types/react@19.2.15)(react@19.2.6) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.15)(react@19.2.6) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.15)(react@19.2.6) react: 19.2.6 react-dom: 19.2.6(react@19.2.6) optionalDependencies: - '@types/react': 19.2.14 - '@types/react-dom': 19.2.3(@types/react@19.2.14) + '@types/react': 19.2.15 + '@types/react-dom': 19.2.3(@types/react@19.2.15) - '@radix-ui/react-arrow@1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': + '@radix-ui/react-arrow@1.1.7(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': dependencies: - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) react: 19.2.6 react-dom: 19.2.6(react@19.2.6) optionalDependencies: - '@types/react': 19.2.14 - '@types/react-dom': 19.2.3(@types/react@19.2.14) + '@types/react': 19.2.15 + '@types/react-dom': 19.2.3(@types/react@19.2.15) - '@radix-ui/react-collapsible@1.1.12(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': + '@radix-ui/react-collapsible@1.1.12(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': dependencies: '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.6) - '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.6) - '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.6) - '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.6) - '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.6) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.15)(react@19.2.6) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.15)(react@19.2.6) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.15)(react@19.2.6) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.15)(react@19.2.6) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.15)(react@19.2.6) react: 19.2.6 react-dom: 19.2.6(react@19.2.6) optionalDependencies: - '@types/react': 19.2.14 - '@types/react-dom': 19.2.3(@types/react@19.2.14) + '@types/react': 19.2.15 + '@types/react-dom': 19.2.3(@types/react@19.2.15) - '@radix-ui/react-collection@1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': + '@radix-ui/react-collection@1.1.7(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': dependencies: - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.6) - '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.6) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@radix-ui/react-slot': 1.2.3(@types/react@19.2.14)(react@19.2.6) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.15)(react@19.2.6) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.15)(react@19.2.6) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-slot': 1.2.3(@types/react@19.2.15)(react@19.2.6) react: 19.2.6 react-dom: 19.2.6(react@19.2.6) optionalDependencies: - '@types/react': 19.2.14 - '@types/react-dom': 19.2.3(@types/react@19.2.14) + '@types/react': 19.2.15 + '@types/react-dom': 19.2.3(@types/react@19.2.15) - '@radix-ui/react-compose-refs@1.1.2(@types/react@19.2.14)(react@19.2.6)': + '@radix-ui/react-compose-refs@1.1.2(@types/react@19.2.15)(react@19.2.6)': dependencies: react: 19.2.6 optionalDependencies: - '@types/react': 19.2.14 + '@types/react': 19.2.15 - '@radix-ui/react-context@1.1.2(@types/react@19.2.14)(react@19.2.6)': + '@radix-ui/react-context@1.1.2(@types/react@19.2.15)(react@19.2.6)': dependencies: react: 19.2.6 optionalDependencies: - '@types/react': 19.2.14 + '@types/react': 19.2.15 - '@radix-ui/react-dialog@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': + '@radix-ui/react-dialog@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': dependencies: '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.6) - '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.6) - '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@radix-ui/react-focus-guards': 1.1.3(@types/react@19.2.14)(react@19.2.6) - '@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.6) - '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@radix-ui/react-slot': 1.2.3(@types/react@19.2.14)(react@19.2.6) - '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.6) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.15)(react@19.2.6) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.15)(react@19.2.6) + '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-focus-guards': 1.1.3(@types/react@19.2.15)(react@19.2.6) + '@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.15)(react@19.2.6) + '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-slot': 1.2.3(@types/react@19.2.15)(react@19.2.6) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.15)(react@19.2.6) aria-hidden: 1.2.6 react: 19.2.6 react-dom: 19.2.6(react@19.2.6) - react-remove-scroll: 2.7.2(@types/react@19.2.14)(react@19.2.6) + react-remove-scroll: 2.7.2(@types/react@19.2.15)(react@19.2.6) optionalDependencies: - '@types/react': 19.2.14 - '@types/react-dom': 19.2.3(@types/react@19.2.14) + '@types/react': 19.2.15 + '@types/react-dom': 19.2.3(@types/react@19.2.15) - '@radix-ui/react-direction@1.1.1(@types/react@19.2.14)(react@19.2.6)': + '@radix-ui/react-direction@1.1.1(@types/react@19.2.15)(react@19.2.6)': dependencies: react: 19.2.6 optionalDependencies: - '@types/react': 19.2.14 + '@types/react': 19.2.15 - '@radix-ui/react-dismissable-layer@1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': + '@radix-ui/react-dismissable-layer@1.1.11(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': dependencies: '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.6) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.6) - '@radix-ui/react-use-escape-keydown': 1.1.1(@types/react@19.2.14)(react@19.2.6) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.15)(react@19.2.6) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.15)(react@19.2.6) + '@radix-ui/react-use-escape-keydown': 1.1.1(@types/react@19.2.15)(react@19.2.6) react: 19.2.6 react-dom: 19.2.6(react@19.2.6) optionalDependencies: - '@types/react': 19.2.14 - '@types/react-dom': 19.2.3(@types/react@19.2.14) + '@types/react': 19.2.15 + '@types/react-dom': 19.2.3(@types/react@19.2.15) - '@radix-ui/react-focus-guards@1.1.3(@types/react@19.2.14)(react@19.2.6)': + '@radix-ui/react-focus-guards@1.1.3(@types/react@19.2.15)(react@19.2.6)': dependencies: react: 19.2.6 optionalDependencies: - '@types/react': 19.2.14 + '@types/react': 19.2.15 - '@radix-ui/react-focus-scope@1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': + '@radix-ui/react-focus-scope@1.1.7(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': dependencies: - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.6) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.6) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.15)(react@19.2.6) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.15)(react@19.2.6) react: 19.2.6 react-dom: 19.2.6(react@19.2.6) optionalDependencies: - '@types/react': 19.2.14 - '@types/react-dom': 19.2.3(@types/react@19.2.14) + '@types/react': 19.2.15 + '@types/react-dom': 19.2.3(@types/react@19.2.15) - '@radix-ui/react-id@1.1.1(@types/react@19.2.14)(react@19.2.6)': + '@radix-ui/react-id@1.1.1(@types/react@19.2.15)(react@19.2.6)': dependencies: - '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.6) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.15)(react@19.2.6) react: 19.2.6 optionalDependencies: - '@types/react': 19.2.14 + '@types/react': 19.2.15 - '@radix-ui/react-navigation-menu@1.2.14(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': + '@radix-ui/react-navigation-menu@1.2.14(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': dependencies: '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.6) - '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.6) - '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.6) - '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.6) - '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.6) - '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.6) - '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.6) - '@radix-ui/react-use-previous': 1.1.1(@types/react@19.2.14)(react@19.2.6) - '@radix-ui/react-visually-hidden': 1.2.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.15)(react@19.2.6) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.15)(react@19.2.6) + '@radix-ui/react-direction': 1.1.1(@types/react@19.2.15)(react@19.2.6) + '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.15)(react@19.2.6) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.15)(react@19.2.6) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.15)(react@19.2.6) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.15)(react@19.2.6) + '@radix-ui/react-use-previous': 1.1.1(@types/react@19.2.15)(react@19.2.6) + '@radix-ui/react-visually-hidden': 1.2.3(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) react: 19.2.6 react-dom: 19.2.6(react@19.2.6) optionalDependencies: - '@types/react': 19.2.14 - '@types/react-dom': 19.2.3(@types/react@19.2.14) + '@types/react': 19.2.15 + '@types/react-dom': 19.2.3(@types/react@19.2.15) - '@radix-ui/react-popover@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': + '@radix-ui/react-popover@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': dependencies: '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.6) - '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.6) - '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@radix-ui/react-focus-guards': 1.1.3(@types/react@19.2.14)(react@19.2.6) - '@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.6) - '@radix-ui/react-popper': 1.2.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@radix-ui/react-slot': 1.2.3(@types/react@19.2.14)(react@19.2.6) - '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.6) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.15)(react@19.2.6) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.15)(react@19.2.6) + '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-focus-guards': 1.1.3(@types/react@19.2.15)(react@19.2.6) + '@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.15)(react@19.2.6) + '@radix-ui/react-popper': 1.2.8(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-slot': 1.2.3(@types/react@19.2.15)(react@19.2.6) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.15)(react@19.2.6) aria-hidden: 1.2.6 react: 19.2.6 react-dom: 19.2.6(react@19.2.6) - react-remove-scroll: 2.7.2(@types/react@19.2.14)(react@19.2.6) + react-remove-scroll: 2.7.2(@types/react@19.2.15)(react@19.2.6) optionalDependencies: - '@types/react': 19.2.14 - '@types/react-dom': 19.2.3(@types/react@19.2.14) + '@types/react': 19.2.15 + '@types/react-dom': 19.2.3(@types/react@19.2.15) - '@radix-ui/react-popper@1.2.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': + '@radix-ui/react-popper@1.2.8(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': dependencies: '@floating-ui/react-dom': 2.1.8(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@radix-ui/react-arrow': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.6) - '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.6) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.6) - '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.6) - '@radix-ui/react-use-rect': 1.1.1(@types/react@19.2.14)(react@19.2.6) - '@radix-ui/react-use-size': 1.1.1(@types/react@19.2.14)(react@19.2.6) + '@radix-ui/react-arrow': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.15)(react@19.2.6) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.15)(react@19.2.6) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.15)(react@19.2.6) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.15)(react@19.2.6) + '@radix-ui/react-use-rect': 1.1.1(@types/react@19.2.15)(react@19.2.6) + '@radix-ui/react-use-size': 1.1.1(@types/react@19.2.15)(react@19.2.6) '@radix-ui/rect': 1.1.1 react: 19.2.6 react-dom: 19.2.6(react@19.2.6) optionalDependencies: - '@types/react': 19.2.14 - '@types/react-dom': 19.2.3(@types/react@19.2.14) + '@types/react': 19.2.15 + '@types/react-dom': 19.2.3(@types/react@19.2.15) - '@radix-ui/react-portal@1.1.9(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': + '@radix-ui/react-portal@1.1.9(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': dependencies: - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.6) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.15)(react@19.2.6) react: 19.2.6 react-dom: 19.2.6(react@19.2.6) optionalDependencies: - '@types/react': 19.2.14 - '@types/react-dom': 19.2.3(@types/react@19.2.14) + '@types/react': 19.2.15 + '@types/react-dom': 19.2.3(@types/react@19.2.15) - '@radix-ui/react-presence@1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': + '@radix-ui/react-presence@1.1.5(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': dependencies: - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.6) - '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.6) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.15)(react@19.2.6) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.15)(react@19.2.6) react: 19.2.6 react-dom: 19.2.6(react@19.2.6) optionalDependencies: - '@types/react': 19.2.14 - '@types/react-dom': 19.2.3(@types/react@19.2.14) + '@types/react': 19.2.15 + '@types/react-dom': 19.2.3(@types/react@19.2.15) - '@radix-ui/react-primitive@2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': + '@radix-ui/react-primitive@2.1.3(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': dependencies: - '@radix-ui/react-slot': 1.2.3(@types/react@19.2.14)(react@19.2.6) + '@radix-ui/react-slot': 1.2.3(@types/react@19.2.15)(react@19.2.6) react: 19.2.6 react-dom: 19.2.6(react@19.2.6) optionalDependencies: - '@types/react': 19.2.14 - '@types/react-dom': 19.2.3(@types/react@19.2.14) + '@types/react': 19.2.15 + '@types/react-dom': 19.2.3(@types/react@19.2.15) - '@radix-ui/react-roving-focus@1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': + '@radix-ui/react-roving-focus@1.1.11(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': dependencies: '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.6) - '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.6) - '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.6) - '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.6) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.6) - '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.6) + '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.15)(react@19.2.6) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.15)(react@19.2.6) + '@radix-ui/react-direction': 1.1.1(@types/react@19.2.15)(react@19.2.6) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.15)(react@19.2.6) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.15)(react@19.2.6) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.15)(react@19.2.6) react: 19.2.6 react-dom: 19.2.6(react@19.2.6) optionalDependencies: - '@types/react': 19.2.14 - '@types/react-dom': 19.2.3(@types/react@19.2.14) + '@types/react': 19.2.15 + '@types/react-dom': 19.2.3(@types/react@19.2.15) - '@radix-ui/react-scroll-area@1.2.10(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': + '@radix-ui/react-scroll-area@1.2.10(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': dependencies: '@radix-ui/number': 1.1.1 '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.6) - '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.6) - '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.6) - '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.6) - '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.6) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.15)(react@19.2.6) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.15)(react@19.2.6) + '@radix-ui/react-direction': 1.1.1(@types/react@19.2.15)(react@19.2.6) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.15)(react@19.2.6) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.15)(react@19.2.6) react: 19.2.6 react-dom: 19.2.6(react@19.2.6) optionalDependencies: - '@types/react': 19.2.14 - '@types/react-dom': 19.2.3(@types/react@19.2.14) + '@types/react': 19.2.15 + '@types/react-dom': 19.2.3(@types/react@19.2.15) - '@radix-ui/react-slot@1.2.3(@types/react@19.2.14)(react@19.2.6)': + '@radix-ui/react-slot@1.2.3(@types/react@19.2.15)(react@19.2.6)': dependencies: - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.6) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.15)(react@19.2.6) react: 19.2.6 optionalDependencies: - '@types/react': 19.2.14 + '@types/react': 19.2.15 - '@radix-ui/react-slot@1.2.4(@types/react@19.2.14)(react@19.2.6)': + '@radix-ui/react-slot@1.2.4(@types/react@19.2.15)(react@19.2.6)': dependencies: - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.6) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.15)(react@19.2.6) react: 19.2.6 optionalDependencies: - '@types/react': 19.2.14 + '@types/react': 19.2.15 - '@radix-ui/react-tabs@1.1.13(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': + '@radix-ui/react-tabs@1.1.13(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': dependencies: '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.6) - '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.6) - '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.6) - '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.6) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.15)(react@19.2.6) + '@radix-ui/react-direction': 1.1.1(@types/react@19.2.15)(react@19.2.6) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.15)(react@19.2.6) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.15)(react@19.2.6) react: 19.2.6 react-dom: 19.2.6(react@19.2.6) optionalDependencies: - '@types/react': 19.2.14 - '@types/react-dom': 19.2.3(@types/react@19.2.14) + '@types/react': 19.2.15 + '@types/react-dom': 19.2.3(@types/react@19.2.15) - '@radix-ui/react-use-callback-ref@1.1.1(@types/react@19.2.14)(react@19.2.6)': + '@radix-ui/react-use-callback-ref@1.1.1(@types/react@19.2.15)(react@19.2.6)': dependencies: react: 19.2.6 optionalDependencies: - '@types/react': 19.2.14 + '@types/react': 19.2.15 - '@radix-ui/react-use-controllable-state@1.2.2(@types/react@19.2.14)(react@19.2.6)': + '@radix-ui/react-use-controllable-state@1.2.2(@types/react@19.2.15)(react@19.2.6)': dependencies: - '@radix-ui/react-use-effect-event': 0.0.2(@types/react@19.2.14)(react@19.2.6) - '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.6) + '@radix-ui/react-use-effect-event': 0.0.2(@types/react@19.2.15)(react@19.2.6) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.15)(react@19.2.6) react: 19.2.6 optionalDependencies: - '@types/react': 19.2.14 + '@types/react': 19.2.15 - '@radix-ui/react-use-effect-event@0.0.2(@types/react@19.2.14)(react@19.2.6)': + '@radix-ui/react-use-effect-event@0.0.2(@types/react@19.2.15)(react@19.2.6)': dependencies: - '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.6) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.15)(react@19.2.6) react: 19.2.6 optionalDependencies: - '@types/react': 19.2.14 + '@types/react': 19.2.15 - '@radix-ui/react-use-escape-keydown@1.1.1(@types/react@19.2.14)(react@19.2.6)': + '@radix-ui/react-use-escape-keydown@1.1.1(@types/react@19.2.15)(react@19.2.6)': dependencies: - '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.6) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.15)(react@19.2.6) react: 19.2.6 optionalDependencies: - '@types/react': 19.2.14 + '@types/react': 19.2.15 - '@radix-ui/react-use-layout-effect@1.1.1(@types/react@19.2.14)(react@19.2.6)': + '@radix-ui/react-use-layout-effect@1.1.1(@types/react@19.2.15)(react@19.2.6)': dependencies: react: 19.2.6 optionalDependencies: - '@types/react': 19.2.14 + '@types/react': 19.2.15 - '@radix-ui/react-use-previous@1.1.1(@types/react@19.2.14)(react@19.2.6)': + '@radix-ui/react-use-previous@1.1.1(@types/react@19.2.15)(react@19.2.6)': dependencies: react: 19.2.6 optionalDependencies: - '@types/react': 19.2.14 + '@types/react': 19.2.15 - '@radix-ui/react-use-rect@1.1.1(@types/react@19.2.14)(react@19.2.6)': + '@radix-ui/react-use-rect@1.1.1(@types/react@19.2.15)(react@19.2.6)': dependencies: '@radix-ui/rect': 1.1.1 react: 19.2.6 optionalDependencies: - '@types/react': 19.2.14 + '@types/react': 19.2.15 - '@radix-ui/react-use-size@1.1.1(@types/react@19.2.14)(react@19.2.6)': + '@radix-ui/react-use-size@1.1.1(@types/react@19.2.15)(react@19.2.6)': dependencies: - '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.6) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.15)(react@19.2.6) react: 19.2.6 optionalDependencies: - '@types/react': 19.2.14 + '@types/react': 19.2.15 - '@radix-ui/react-visually-hidden@1.2.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': + '@radix-ui/react-visually-hidden@1.2.3(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': dependencies: - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) react: 19.2.6 react-dom: 19.2.6(react@19.2.6) optionalDependencies: - '@types/react': 19.2.14 - '@types/react-dom': 19.2.3(@types/react@19.2.14) + '@types/react': 19.2.15 + '@types/react-dom': 19.2.3(@types/react@19.2.15) '@radix-ui/rect@1.1.1': {} - '@rolldown/binding-android-arm64@1.0.0-rc.17': + '@rolldown/binding-android-arm64@1.0.2': optional: true - '@rolldown/binding-darwin-arm64@1.0.0-rc.17': + '@rolldown/binding-darwin-arm64@1.0.2': optional: true - '@rolldown/binding-darwin-x64@1.0.0-rc.17': + '@rolldown/binding-darwin-x64@1.0.2': optional: true - '@rolldown/binding-freebsd-x64@1.0.0-rc.17': + '@rolldown/binding-freebsd-x64@1.0.2': optional: true - '@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.17': + '@rolldown/binding-linux-arm-gnueabihf@1.0.2': optional: true - '@rolldown/binding-linux-arm64-gnu@1.0.0-rc.17': + '@rolldown/binding-linux-arm64-gnu@1.0.2': optional: true - '@rolldown/binding-linux-arm64-musl@1.0.0-rc.17': + '@rolldown/binding-linux-arm64-musl@1.0.2': optional: true - '@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.17': + '@rolldown/binding-linux-ppc64-gnu@1.0.2': optional: true - '@rolldown/binding-linux-s390x-gnu@1.0.0-rc.17': + '@rolldown/binding-linux-s390x-gnu@1.0.2': optional: true - '@rolldown/binding-linux-x64-gnu@1.0.0-rc.17': + '@rolldown/binding-linux-x64-gnu@1.0.2': optional: true - '@rolldown/binding-linux-x64-musl@1.0.0-rc.17': + '@rolldown/binding-linux-x64-musl@1.0.2': optional: true - '@rolldown/binding-openharmony-arm64@1.0.0-rc.17': + '@rolldown/binding-openharmony-arm64@1.0.2': optional: true - '@rolldown/binding-wasm32-wasi@1.0.0-rc.17': + '@rolldown/binding-wasm32-wasi@1.0.2': dependencies: '@emnapi/core': 1.10.0 '@emnapi/runtime': 1.10.0 '@napi-rs/wasm-runtime': 1.1.4(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0) optional: true - '@rolldown/binding-win32-arm64-msvc@1.0.0-rc.17': + '@rolldown/binding-win32-arm64-msvc@1.0.2': optional: true - '@rolldown/binding-win32-x64-msvc@1.0.0-rc.17': + '@rolldown/binding-win32-x64-msvc@1.0.2': optional: true - '@rolldown/pluginutils@1.0.0-rc.17': {} + '@rolldown/pluginutils@1.0.1': {} '@sec-ant/readable-stream@0.4.1': {} @@ -7918,13 +7780,13 @@ snapshots: fs-extra: 11.3.5 lodash-es: 4.18.1 nerf-dart: 1.0.0 - normalize-url: 9.0.0 - npm: 11.14.1 + normalize-url: 9.0.1 + npm: 11.15.0 rc: 1.2.8 read-pkg: 10.1.0 registry-auth-token: 5.1.1 semantic-release: 25.0.3(typescript@6.0.3) - semver: 7.7.4 + semver: 7.8.0 tempy: 3.2.0 '@semantic-release/release-notes-generator@14.1.1(semantic-release@25.0.3(typescript@6.0.3))': @@ -7941,40 +7803,40 @@ snapshots: transitivePeerDependencies: - supports-color - '@shikijs/core@4.0.2': + '@shikijs/core@4.1.0': dependencies: - '@shikijs/primitive': 4.0.2 - '@shikijs/types': 4.0.2 + '@shikijs/primitive': 4.1.0 + '@shikijs/types': 4.1.0 '@shikijs/vscode-textmate': 10.0.2 '@types/hast': 3.0.4 hast-util-to-html: 9.0.5 - '@shikijs/engine-javascript@4.0.2': + '@shikijs/engine-javascript@4.1.0': dependencies: - '@shikijs/types': 4.0.2 + '@shikijs/types': 4.1.0 '@shikijs/vscode-textmate': 10.0.2 oniguruma-to-es: 4.3.6 - '@shikijs/engine-oniguruma@4.0.2': + '@shikijs/engine-oniguruma@4.1.0': dependencies: - '@shikijs/types': 4.0.2 + '@shikijs/types': 4.1.0 '@shikijs/vscode-textmate': 10.0.2 - '@shikijs/langs@4.0.2': + '@shikijs/langs@4.1.0': dependencies: - '@shikijs/types': 4.0.2 + '@shikijs/types': 4.1.0 - '@shikijs/primitive@4.0.2': + '@shikijs/primitive@4.1.0': dependencies: - '@shikijs/types': 4.0.2 + '@shikijs/types': 4.1.0 '@shikijs/vscode-textmate': 10.0.2 '@types/hast': 3.0.4 - '@shikijs/themes@4.0.2': + '@shikijs/themes@4.1.0': dependencies: - '@shikijs/types': 4.0.2 + '@shikijs/types': 4.1.0 - '@shikijs/types@4.0.2': + '@shikijs/types@4.1.0': dependencies: '@shikijs/vscode-textmate': 10.0.2 '@types/hast': 3.0.4 @@ -7993,251 +7855,148 @@ snapshots: '@sindresorhus/merge-streams@4.0.0': {} - '@smithy/chunked-blob-reader-native@4.2.3': + '@smithy/config-resolver@4.5.3': dependencies: - '@smithy/util-base64': 4.3.2 + '@smithy/core': 3.24.3 tslib: 2.8.1 - '@smithy/chunked-blob-reader@5.2.2': - dependencies: - tslib: 2.8.1 - - '@smithy/config-resolver@4.4.17': - dependencies: - '@smithy/node-config-provider': 4.3.14 - '@smithy/types': 4.14.1 - '@smithy/util-config-provider': 4.2.2 - '@smithy/util-endpoints': 3.4.2 - '@smithy/util-middleware': 4.2.14 - tslib: 2.8.1 - - '@smithy/core@3.23.17': - dependencies: - '@smithy/protocol-http': 5.3.14 - '@smithy/types': 4.14.1 - '@smithy/url-parser': 4.2.14 - '@smithy/util-base64': 4.3.2 - '@smithy/util-body-length-browser': 4.2.2 - '@smithy/util-middleware': 4.2.14 - '@smithy/util-stream': 4.5.25 - '@smithy/util-utf8': 4.2.2 - '@smithy/uuid': 1.1.2 - tslib: 2.8.1 - - '@smithy/credential-provider-imds@4.2.14': - dependencies: - '@smithy/node-config-provider': 4.3.14 - '@smithy/property-provider': 4.2.14 - '@smithy/types': 4.14.1 - '@smithy/url-parser': 4.2.14 - tslib: 2.8.1 - - '@smithy/eventstream-codec@4.2.14': + '@smithy/core@3.24.3': dependencies: '@aws-crypto/crc32': 5.2.0 - '@smithy/types': 4.14.1 - '@smithy/util-hex-encoding': 4.2.2 + '@smithy/types': 4.14.2 tslib: 2.8.1 - '@smithy/eventstream-serde-browser@4.2.14': + '@smithy/credential-provider-imds@4.3.3': dependencies: - '@smithy/eventstream-serde-universal': 4.2.14 - '@smithy/types': 4.14.1 + '@smithy/core': 3.24.3 + '@smithy/types': 4.14.2 tslib: 2.8.1 - '@smithy/eventstream-serde-config-resolver@4.3.14': + '@smithy/eventstream-serde-browser@4.3.3': dependencies: - '@smithy/types': 4.14.1 + '@smithy/core': 3.24.3 tslib: 2.8.1 - '@smithy/eventstream-serde-node@4.2.14': + '@smithy/eventstream-serde-config-resolver@4.4.3': dependencies: - '@smithy/eventstream-serde-universal': 4.2.14 - '@smithy/types': 4.14.1 + '@smithy/core': 3.24.3 tslib: 2.8.1 - '@smithy/eventstream-serde-universal@4.2.14': + '@smithy/eventstream-serde-node@4.3.3': dependencies: - '@smithy/eventstream-codec': 4.2.14 - '@smithy/types': 4.14.1 + '@smithy/core': 3.24.3 tslib: 2.8.1 - '@smithy/fetch-http-handler@5.3.17': + '@smithy/fetch-http-handler@5.4.3': dependencies: - '@smithy/protocol-http': 5.3.14 - '@smithy/querystring-builder': 4.2.14 - '@smithy/types': 4.14.1 - '@smithy/util-base64': 4.3.2 + '@smithy/core': 3.24.3 + '@smithy/types': 4.14.2 tslib: 2.8.1 - '@smithy/hash-blob-browser@4.2.15': + '@smithy/hash-blob-browser@4.3.3': dependencies: - '@smithy/chunked-blob-reader': 5.2.2 - '@smithy/chunked-blob-reader-native': 4.2.3 - '@smithy/types': 4.14.1 + '@smithy/core': 3.24.3 tslib: 2.8.1 - '@smithy/hash-node@4.2.14': + '@smithy/hash-node@4.3.3': dependencies: - '@smithy/types': 4.14.1 - '@smithy/util-buffer-from': 4.2.2 - '@smithy/util-utf8': 4.2.2 + '@smithy/core': 3.24.3 tslib: 2.8.1 - '@smithy/hash-stream-node@4.2.14': + '@smithy/hash-stream-node@4.3.3': dependencies: - '@smithy/types': 4.14.1 - '@smithy/util-utf8': 4.2.2 + '@smithy/core': 3.24.3 tslib: 2.8.1 - '@smithy/invalid-dependency@4.2.14': + '@smithy/invalid-dependency@4.3.3': dependencies: - '@smithy/types': 4.14.1 + '@smithy/core': 3.24.3 tslib: 2.8.1 '@smithy/is-array-buffer@2.2.0': dependencies: tslib: 2.8.1 - '@smithy/is-array-buffer@4.2.2': + '@smithy/md5-js@4.3.3': + dependencies: + '@smithy/core': 3.24.3 + tslib: 2.8.1 + + '@smithy/middleware-content-length@4.3.3': + dependencies: + '@smithy/core': 3.24.3 + tslib: 2.8.1 + + '@smithy/middleware-endpoint@4.5.3': + dependencies: + '@smithy/core': 3.24.3 + tslib: 2.8.1 + + '@smithy/middleware-retry@4.6.3': + dependencies: + '@smithy/core': 3.24.3 + tslib: 2.8.1 + + '@smithy/middleware-serde@4.3.3': + dependencies: + '@smithy/core': 3.24.3 + tslib: 2.8.1 + + '@smithy/middleware-stack@4.3.3': + dependencies: + '@smithy/core': 3.24.3 + tslib: 2.8.1 + + '@smithy/node-config-provider@4.4.3': + dependencies: + '@smithy/core': 3.24.3 + tslib: 2.8.1 + + '@smithy/node-http-handler@4.7.3': + dependencies: + '@smithy/core': 3.24.3 + '@smithy/types': 4.14.2 + tslib: 2.8.1 + + '@smithy/protocol-http@5.4.3': + dependencies: + '@smithy/core': 3.24.3 + tslib: 2.8.1 + + '@smithy/signature-v4@5.4.3': + dependencies: + '@smithy/core': 3.24.3 + '@smithy/types': 4.14.2 + tslib: 2.8.1 + + '@smithy/smithy-client@4.13.3': + dependencies: + '@smithy/core': 3.24.3 + '@smithy/types': 4.14.2 + tslib: 2.8.1 + + '@smithy/types@4.14.2': dependencies: tslib: 2.8.1 - '@smithy/md5-js@4.2.14': + '@smithy/url-parser@4.3.3': dependencies: - '@smithy/types': 4.14.1 - '@smithy/util-utf8': 4.2.2 + '@smithy/core': 3.24.3 tslib: 2.8.1 - '@smithy/middleware-content-length@4.2.14': + '@smithy/util-base64@4.4.3': dependencies: - '@smithy/protocol-http': 5.3.14 - '@smithy/types': 4.14.1 + '@smithy/core': 3.24.3 tslib: 2.8.1 - '@smithy/middleware-endpoint@4.4.32': + '@smithy/util-body-length-browser@4.3.3': dependencies: - '@smithy/core': 3.23.17 - '@smithy/middleware-serde': 4.2.20 - '@smithy/node-config-provider': 4.3.14 - '@smithy/shared-ini-file-loader': 4.4.9 - '@smithy/types': 4.14.1 - '@smithy/url-parser': 4.2.14 - '@smithy/util-middleware': 4.2.14 + '@smithy/core': 3.24.3 tslib: 2.8.1 - '@smithy/middleware-retry@4.5.7': - dependencies: - '@smithy/core': 3.23.17 - '@smithy/node-config-provider': 4.3.14 - '@smithy/protocol-http': 5.3.14 - '@smithy/service-error-classification': 4.3.1 - '@smithy/smithy-client': 4.12.13 - '@smithy/types': 4.14.1 - '@smithy/util-middleware': 4.2.14 - '@smithy/util-retry': 4.3.6 - '@smithy/uuid': 1.1.2 - tslib: 2.8.1 - - '@smithy/middleware-serde@4.2.20': - dependencies: - '@smithy/core': 3.23.17 - '@smithy/protocol-http': 5.3.14 - '@smithy/types': 4.14.1 - tslib: 2.8.1 - - '@smithy/middleware-stack@4.2.14': - dependencies: - '@smithy/types': 4.14.1 - tslib: 2.8.1 - - '@smithy/node-config-provider@4.3.14': - dependencies: - '@smithy/property-provider': 4.2.14 - '@smithy/shared-ini-file-loader': 4.4.9 - '@smithy/types': 4.14.1 - tslib: 2.8.1 - - '@smithy/node-http-handler@4.6.1': - dependencies: - '@smithy/protocol-http': 5.3.14 - '@smithy/querystring-builder': 4.2.14 - '@smithy/types': 4.14.1 - tslib: 2.8.1 - - '@smithy/property-provider@4.2.14': - dependencies: - '@smithy/types': 4.14.1 - tslib: 2.8.1 - - '@smithy/protocol-http@5.3.14': - dependencies: - '@smithy/types': 4.14.1 - tslib: 2.8.1 - - '@smithy/querystring-builder@4.2.14': - dependencies: - '@smithy/types': 4.14.1 - '@smithy/util-uri-escape': 4.2.2 - tslib: 2.8.1 - - '@smithy/querystring-parser@4.2.14': - dependencies: - '@smithy/types': 4.14.1 - tslib: 2.8.1 - - '@smithy/service-error-classification@4.3.1': - dependencies: - '@smithy/types': 4.14.1 - - '@smithy/shared-ini-file-loader@4.4.9': - dependencies: - '@smithy/types': 4.14.1 - tslib: 2.8.1 - - '@smithy/signature-v4@5.3.14': - dependencies: - '@smithy/is-array-buffer': 4.2.2 - '@smithy/protocol-http': 5.3.14 - '@smithy/types': 4.14.1 - '@smithy/util-hex-encoding': 4.2.2 - '@smithy/util-middleware': 4.2.14 - '@smithy/util-uri-escape': 4.2.2 - '@smithy/util-utf8': 4.2.2 - tslib: 2.8.1 - - '@smithy/smithy-client@4.12.13': - dependencies: - '@smithy/core': 3.23.17 - '@smithy/middleware-endpoint': 4.4.32 - '@smithy/middleware-stack': 4.2.14 - '@smithy/protocol-http': 5.3.14 - '@smithy/types': 4.14.1 - '@smithy/util-stream': 4.5.25 - tslib: 2.8.1 - - '@smithy/types@4.14.1': - dependencies: - tslib: 2.8.1 - - '@smithy/url-parser@4.2.14': - dependencies: - '@smithy/querystring-parser': 4.2.14 - '@smithy/types': 4.14.1 - tslib: 2.8.1 - - '@smithy/util-base64@4.3.2': - dependencies: - '@smithy/util-buffer-from': 4.2.2 - '@smithy/util-utf8': 4.2.2 - tslib: 2.8.1 - - '@smithy/util-body-length-browser@4.2.2': - dependencies: - tslib: 2.8.1 - - '@smithy/util-body-length-node@4.2.3': + '@smithy/util-body-length-node@4.3.3': dependencies: + '@smithy/core': 3.24.3 tslib: 2.8.1 '@smithy/util-buffer-from@2.2.0': @@ -8245,66 +8004,34 @@ snapshots: '@smithy/is-array-buffer': 2.2.0 tslib: 2.8.1 - '@smithy/util-buffer-from@4.2.2': + '@smithy/util-defaults-mode-browser@4.4.3': dependencies: - '@smithy/is-array-buffer': 4.2.2 + '@smithy/core': 3.24.3 tslib: 2.8.1 - '@smithy/util-config-provider@4.2.2': + '@smithy/util-defaults-mode-node@4.3.3': dependencies: + '@smithy/core': 3.24.3 tslib: 2.8.1 - '@smithy/util-defaults-mode-browser@4.3.49': + '@smithy/util-endpoints@3.5.3': dependencies: - '@smithy/property-provider': 4.2.14 - '@smithy/smithy-client': 4.12.13 - '@smithy/types': 4.14.1 + '@smithy/core': 3.24.3 tslib: 2.8.1 - '@smithy/util-defaults-mode-node@4.2.54': + '@smithy/util-middleware@4.3.3': dependencies: - '@smithy/config-resolver': 4.4.17 - '@smithy/credential-provider-imds': 4.2.14 - '@smithy/node-config-provider': 4.3.14 - '@smithy/property-provider': 4.2.14 - '@smithy/smithy-client': 4.12.13 - '@smithy/types': 4.14.1 + '@smithy/core': 3.24.3 tslib: 2.8.1 - '@smithy/util-endpoints@3.4.2': + '@smithy/util-retry@4.4.3': dependencies: - '@smithy/node-config-provider': 4.3.14 - '@smithy/types': 4.14.1 + '@smithy/core': 3.24.3 tslib: 2.8.1 - '@smithy/util-hex-encoding@4.2.2': - dependencies: - tslib: 2.8.1 - - '@smithy/util-middleware@4.2.14': - dependencies: - '@smithy/types': 4.14.1 - tslib: 2.8.1 - - '@smithy/util-retry@4.3.6': - dependencies: - '@smithy/service-error-classification': 4.3.1 - '@smithy/types': 4.14.1 - tslib: 2.8.1 - - '@smithy/util-stream@4.5.25': - dependencies: - '@smithy/fetch-http-handler': 5.3.17 - '@smithy/node-http-handler': 4.6.1 - '@smithy/types': 4.14.1 - '@smithy/util-base64': 4.3.2 - '@smithy/util-buffer-from': 4.2.2 - '@smithy/util-hex-encoding': 4.2.2 - '@smithy/util-utf8': 4.2.2 - tslib: 2.8.1 - - '@smithy/util-uri-escape@4.2.2': + '@smithy/util-stream@4.6.3': dependencies: + '@smithy/core': 3.24.3 tslib: 2.8.1 '@smithy/util-utf8@2.3.0': @@ -8312,18 +8039,14 @@ snapshots: '@smithy/util-buffer-from': 2.2.0 tslib: 2.8.1 - '@smithy/util-utf8@4.2.2': + '@smithy/util-utf8@4.3.3': dependencies: - '@smithy/util-buffer-from': 4.2.2 + '@smithy/core': 3.24.3 tslib: 2.8.1 - '@smithy/util-waiter@4.3.0': - dependencies: - '@smithy/types': 4.14.1 - tslib: 2.8.1 - - '@smithy/uuid@1.1.2': + '@smithy/util-waiter@4.4.3': dependencies: + '@smithy/core': 3.24.3 tslib: 2.8.1 '@so-ric/colorspace@1.1.6': @@ -8331,6 +8054,8 @@ snapshots: color: 5.0.3 text-hex: 1.0.0 + '@stablelib/base64@1.0.1': {} + '@standard-schema/spec@1.1.0': {} '@swc/helpers@0.5.15': @@ -8340,7 +8065,7 @@ snapshots: '@tailwindcss/node@4.3.0': dependencies: '@jridgewell/remapping': 2.3.5 - enhanced-resolve: 5.21.2 + enhanced-resolve: 5.21.6 jiti: 2.7.0 lightningcss: 1.32.0 magic-string: 0.30.21 @@ -8416,14 +8141,14 @@ snapshots: '@tediousjs/connection-string@1.1.0': {} - '@tybys/wasm-util@0.10.1': + '@tybys/wasm-util@0.10.2': dependencies: tslib: 2.8.1 optional: true '@types/better-sqlite3@7.6.13': dependencies: - '@types/node': 24.12.2 + '@types/node': 24.12.4 '@types/chai@5.2.3': dependencies: @@ -8459,9 +8184,9 @@ snapshots: '@types/estree-jsx@1.0.5': dependencies: - '@types/estree': 1.0.8 + '@types/estree': 1.0.9 - '@types/estree@1.0.8': {} + '@types/estree@1.0.9': {} '@types/hast@3.0.4': dependencies: @@ -8477,14 +8202,14 @@ snapshots: '@types/mssql@12.3.0(@azure/core-client@1.10.1)': dependencies: - '@types/node': 24.12.2 + '@types/node': 24.12.4 tarn: 3.0.2 tedious: 19.2.1(@azure/core-client@1.10.1) transitivePeerDependencies: - '@azure/core-client' - supports-color - '@types/node@24.12.2': + '@types/node@24.12.4': dependencies: undici-types: 7.16.0 @@ -8492,21 +8217,21 @@ snapshots: '@types/pg@8.20.0': dependencies: - '@types/node': 24.12.2 - pg-protocol: 1.13.0 + '@types/node': 24.12.4 + pg-protocol: 1.14.0 pg-types: 2.2.0 - '@types/react-dom@19.2.3(@types/react@19.2.14)': + '@types/react-dom@19.2.3(@types/react@19.2.15)': dependencies: - '@types/react': 19.2.14 + '@types/react': 19.2.15 - '@types/react@19.2.14': + '@types/react@19.2.15': dependencies: csstype: 3.2.3 '@types/readable-stream@4.0.23': dependencies: - '@types/node': 24.12.2 + '@types/node': 24.12.4 '@types/triple-beam@1.3.5': {} @@ -8526,68 +8251,68 @@ snapshots: '@vercel/oidc@3.2.0': {} - '@vitest/coverage-v8@4.1.6(vitest@4.1.6)': + '@vitest/coverage-v8@4.1.7(vitest@4.1.7)': dependencies: '@bcoe/v8-coverage': 1.0.2 - '@vitest/utils': 4.1.6 + '@vitest/utils': 4.1.7 ast-v8-to-istanbul: 1.0.0 istanbul-lib-coverage: 3.2.2 istanbul-lib-report: 3.0.1 istanbul-reports: 3.2.0 - magicast: 0.5.2 + magicast: 0.5.3 obug: 2.1.1 std-env: 4.1.0 tinyrainbow: 3.1.0 - vitest: 4.1.6(@opentelemetry/api@1.9.0)(@types/node@24.12.2)(@vitest/coverage-v8@4.1.6)(vite@8.0.10(@types/node@24.12.2)(esbuild@0.28.0)(jiti@2.7.0)(yaml@2.9.0)) + vitest: 4.1.7(@opentelemetry/api@1.9.1)(@types/node@24.12.4)(@vitest/coverage-v8@4.1.7)(vite@8.0.14(@types/node@24.12.4)(esbuild@0.28.0)(jiti@2.7.0)(yaml@2.9.0)) - '@vitest/expect@4.1.6': + '@vitest/expect@4.1.7': dependencies: '@standard-schema/spec': 1.1.0 '@types/chai': 5.2.3 - '@vitest/spy': 4.1.6 - '@vitest/utils': 4.1.6 + '@vitest/spy': 4.1.7 + '@vitest/utils': 4.1.7 chai: 6.2.2 tinyrainbow: 3.1.0 - '@vitest/mocker@4.1.6(vite@8.0.10(@types/node@24.12.2)(esbuild@0.28.0)(jiti@2.7.0)(yaml@2.9.0))': + '@vitest/mocker@4.1.7(vite@8.0.14(@types/node@24.12.4)(esbuild@0.28.0)(jiti@2.7.0)(yaml@2.9.0))': dependencies: - '@vitest/spy': 4.1.6 + '@vitest/spy': 4.1.7 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: 8.0.10(@types/node@24.12.2)(esbuild@0.28.0)(jiti@2.7.0)(yaml@2.9.0) + vite: 8.0.14(@types/node@24.12.4)(esbuild@0.28.0)(jiti@2.7.0)(yaml@2.9.0) - '@vitest/pretty-format@4.1.6': + '@vitest/pretty-format@4.1.7': dependencies: tinyrainbow: 3.1.0 - '@vitest/runner@4.1.6': + '@vitest/runner@4.1.7': dependencies: - '@vitest/utils': 4.1.6 + '@vitest/utils': 4.1.7 pathe: 2.0.3 - '@vitest/snapshot@4.1.6': + '@vitest/snapshot@4.1.7': dependencies: - '@vitest/pretty-format': 4.1.6 - '@vitest/utils': 4.1.6 + '@vitest/pretty-format': 4.1.7 + '@vitest/utils': 4.1.7 magic-string: 0.30.21 pathe: 2.0.3 - '@vitest/spy@4.1.6': {} + '@vitest/spy@4.1.7': {} - '@vitest/utils@4.1.6': + '@vitest/utils@4.1.7': dependencies: - '@vitest/pretty-format': 4.1.6 + '@vitest/pretty-format': 4.1.7 convert-source-map: 2.0.0 tinyrainbow: 3.1.0 - '@xyflow/react@12.10.2(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': + '@xyflow/react@12.10.2(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': dependencies: '@xyflow/system': 0.0.76 classcat: 5.0.5 react: 19.2.6 react-dom: 19.2.6(react@19.2.6) - zustand: 4.5.7(@types/react@19.2.14)(react@19.2.6) + zustand: 4.5.7(@types/react@19.2.15)(react@19.2.6) transitivePeerDependencies: - '@types/react' - immer @@ -8619,6 +8344,12 @@ snapshots: acorn@8.16.0: {} + agent-base@6.0.2: + dependencies: + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + agent-base@7.1.4: {} agent-base@9.0.0: {} @@ -8633,12 +8364,12 @@ snapshots: clean-stack: 5.3.0 indent-string: 5.0.0 - ai@6.0.180(zod@4.4.3): + ai@6.0.188(zod@4.4.3): dependencies: - '@ai-sdk/gateway': 3.0.114(zod@4.4.3) + '@ai-sdk/gateway': 3.0.118(zod@4.4.3) '@ai-sdk/provider': 3.0.10 '@ai-sdk/provider-utils': 4.0.27(zod@4.4.3) - '@opentelemetry/api': 1.9.0 + '@opentelemetry/api': 1.9.1 zod: 4.4.3 ajv-formats@3.0.1(ajv@8.20.0): @@ -8720,13 +8451,15 @@ snapshots: aws-ssl-profiles@1.1.2: {} - axios@1.15.2: + axios@1.16.1: dependencies: follow-redirects: 1.16.0 form-data: 4.0.5 + https-proxy-agent: 5.0.1 proxy-from-env: 2.1.0 transitivePeerDependencies: - debug + - supports-color bail@2.0.2: {} @@ -8736,7 +8469,7 @@ snapshots: base64-js@1.5.1: {} - baseline-browser-mapping@2.10.29: {} + baseline-browser-mapping@2.10.31: {} before-after-hook@4.0.0: {} @@ -8780,9 +8513,9 @@ snapshots: http-errors: 2.0.1 iconv-lite: 0.7.2 on-finished: 2.4.1 - qs: 6.15.1 + qs: 6.15.2 raw-body: 3.0.2 - type-is: 2.0.1 + type-is: 2.1.0 transitivePeerDependencies: - supports-color @@ -8833,7 +8566,7 @@ snapshots: callsites@3.1.0: {} - caniuse-lite@1.0.30001792: {} + caniuse-lite@1.0.30001793: {} ccount@2.0.1: {} @@ -8917,7 +8650,7 @@ snapshots: cliui@9.0.1: dependencies: string-width: 7.2.0 - strip-ansi: 7.1.2 + strip-ansi: 7.2.0 wrap-ansi: 9.0.2 clsx@2.1.1: {} @@ -8999,7 +8732,7 @@ snapshots: conventional-commits-filter: 5.0.0 handlebars: 4.7.9 meow: 13.2.0 - semver: 7.7.4 + semver: 7.8.0 conventional-commits-filter@5.0.0: {} @@ -9168,7 +8901,7 @@ snapshots: dependencies: once: 1.4.0 - enhanced-resolve@5.21.2: + enhanced-resolve@5.21.6: dependencies: graceful-fs: 4.2.11 tapable: 2.3.3 @@ -9262,7 +8995,7 @@ snapshots: estree-util-attach-comments@3.0.0: dependencies: - '@types/estree': 1.0.8 + '@types/estree': 1.0.9 estree-util-build-jsx@3.0.1: dependencies: @@ -9275,7 +9008,7 @@ snapshots: estree-util-scope@1.0.0: dependencies: - '@types/estree': 1.0.8 + '@types/estree': 1.0.9 devlop: 1.1.0 estree-util-to-js@2.0.0: @@ -9286,7 +9019,7 @@ snapshots: estree-util-value-to-estree@3.5.0: dependencies: - '@types/estree': 1.0.8 + '@types/estree': 1.0.9 estree-util-visit@2.0.0: dependencies: @@ -9295,7 +9028,7 @@ snapshots: estree-walker@3.0.3: dependencies: - '@types/estree': 1.0.8 + '@types/estree': 1.0.9 etag@1.8.1: {} @@ -9356,7 +9089,7 @@ snapshots: expect-type@1.3.0: {} - express-rate-limit@8.4.1(express@5.2.1): + express-rate-limit@8.5.2(express@5.2.1): dependencies: express: 5.2.1 ip-address: 10.1.1 @@ -9383,13 +9116,13 @@ snapshots: once: 1.4.0 parseurl: 1.3.3 proxy-addr: 2.0.7 - qs: 6.15.1 + qs: 6.15.2 range-parser: 1.2.1 router: 2.2.0 send: 1.2.1 serve-static: 2.2.1 statuses: 2.0.2 - type-is: 2.0.1 + type-is: 2.1.0 vary: 1.1.2 transitivePeerDependencies: - supports-color @@ -9400,6 +9133,8 @@ snapshots: fast-deep-equal@3.1.3: {} + fast-sha256@1.3.0: {} + fast-string-truncated-width@3.0.3: {} fast-string-width@3.0.2: @@ -9408,7 +9143,7 @@ snapshots: fast-uri@3.1.2: {} - fast-wrap-ansi@0.2.0: + fast-wrap-ansi@0.2.2: dependencies: fast-string-width: 3.0.2 @@ -9416,12 +9151,20 @@ snapshots: dependencies: path-expression-matcher: 1.5.0 - fast-xml-parser@5.7.2: + fast-xml-parser@5.7.3: dependencies: '@nodable/entities': 2.1.0 fast-xml-builder: 1.1.7 path-expression-matcher: 1.5.0 - strnum: 2.2.3 + strnum: 2.3.0 + + fast-xml-parser@5.8.0: + dependencies: + '@nodable/entities': 2.1.0 + fast-xml-builder: 1.1.7 + path-expression-matcher: 1.5.0 + strnum: 2.3.0 + xml-naming: 0.1.0 fastest-levenshtein@1.0.16: {} @@ -9440,7 +9183,7 @@ snapshots: node-domexception: 1.0.0 web-streams-polyfill: 3.3.3 - fflate@0.8.2: {} + fflate@0.8.3: {} figures@2.0.0: dependencies: @@ -9500,10 +9243,10 @@ snapshots: forwarded@0.2.0: {} - framer-motion@12.38.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6): + framer-motion@12.40.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6): dependencies: - motion-dom: 12.38.0 - motion-utils: 12.36.0 + motion-dom: 12.40.0 + motion-utils: 12.39.0 tslib: 2.8.1 optionalDependencies: react: 19.2.6 @@ -9524,7 +9267,7 @@ snapshots: fsevents@2.3.3: optional: true - fumadocs-core@16.8.10(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.4)(@types/mdast@4.0.4)(@types/react@19.2.14)(lucide-react@1.14.0(react@19.2.6))(next@16.2.6(@opentelemetry/api@1.9.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(zod@4.4.3): + fumadocs-core@16.8.10(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.4)(@types/mdast@4.0.4)(@types/react@19.2.15)(lucide-react@1.16.0(react@19.2.6))(next@16.2.6(@opentelemetry/api@1.9.1)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(zod@4.4.3): dependencies: '@orama/orama': 3.1.18 estree-util-value-to-estree: 3.5.0 @@ -9538,7 +9281,7 @@ snapshots: remark-gfm: 4.0.1 remark-rehype: 11.1.2 scroll-into-view-if-needed: 3.1.0 - shiki: 4.0.2 + shiki: 4.1.0 tinyglobby: 0.2.16 unified: 11.0.5 unist-util-visit: 5.1.0 @@ -9548,23 +9291,23 @@ snapshots: '@types/estree-jsx': 1.0.5 '@types/hast': 3.0.4 '@types/mdast': 4.0.4 - '@types/react': 19.2.14 - lucide-react: 1.14.0(react@19.2.6) - next: 16.2.6(@opentelemetry/api@1.9.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@types/react': 19.2.15 + lucide-react: 1.16.0(react@19.2.6) + next: 16.2.6(@opentelemetry/api@1.9.1)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) react: 19.2.6 react-dom: 19.2.6(react@19.2.6) zod: 4.4.3 transitivePeerDependencies: - supports-color - fumadocs-mdx@15.0.4(@types/mdast@4.0.4)(@types/mdx@2.0.13)(@types/react@19.2.14)(fumadocs-core@16.8.10(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.4)(@types/mdast@4.0.4)(@types/react@19.2.14)(lucide-react@1.14.0(react@19.2.6))(next@16.2.6(@opentelemetry/api@1.9.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(zod@4.4.3))(next@16.2.6(@opentelemetry/api@1.9.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react@19.2.6)(vite@8.0.10(@types/node@24.12.2)(esbuild@0.28.0)(jiti@2.7.0)(yaml@2.9.0)): + fumadocs-mdx@15.0.7(@types/mdast@4.0.4)(@types/mdx@2.0.13)(@types/react@19.2.15)(fumadocs-core@16.8.10(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.4)(@types/mdast@4.0.4)(@types/react@19.2.15)(lucide-react@1.16.0(react@19.2.6))(next@16.2.6(@opentelemetry/api@1.9.1)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(zod@4.4.3))(next@16.2.6(@opentelemetry/api@1.9.1)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react@19.2.6)(rolldown@1.0.2)(vite@8.0.14(@types/node@24.12.4)(esbuild@0.28.0)(jiti@2.7.0)(yaml@2.9.0)): dependencies: '@mdx-js/mdx': 3.1.1 '@standard-schema/spec': 1.1.0 chokidar: 5.0.0 esbuild: 0.28.0 estree-util-value-to-estree: 3.5.0 - fumadocs-core: 16.8.10(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.4)(@types/mdast@4.0.4)(@types/react@19.2.14)(lucide-react@1.14.0(react@19.2.6))(next@16.2.6(@opentelemetry/api@1.9.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(zod@4.4.3) + fumadocs-core: 16.8.10(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.4)(@types/mdast@4.0.4)(@types/react@19.2.15)(lucide-react@1.16.0(react@19.2.6))(next@16.2.6(@opentelemetry/api@1.9.1)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(zod@4.4.3) js-yaml: 4.1.1 mdast-util-mdx: 3.0.0 picocolors: 1.1.1 @@ -9579,43 +9322,44 @@ snapshots: optionalDependencies: '@types/mdast': 4.0.4 '@types/mdx': 2.0.13 - '@types/react': 19.2.14 - next: 16.2.6(@opentelemetry/api@1.9.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@types/react': 19.2.15 + next: 16.2.6(@opentelemetry/api@1.9.1)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) react: 19.2.6 - vite: 8.0.10(@types/node@24.12.2)(esbuild@0.28.0)(jiti@2.7.0)(yaml@2.9.0) + rolldown: 1.0.2 + vite: 8.0.14(@types/node@24.12.4)(esbuild@0.28.0)(jiti@2.7.0)(yaml@2.9.0) transitivePeerDependencies: - supports-color - fumadocs-ui@16.8.10(@tailwindcss/oxide@4.3.0)(@types/mdx@2.0.13)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(fumadocs-core@16.8.10(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.4)(@types/mdast@4.0.4)(@types/react@19.2.14)(lucide-react@1.14.0(react@19.2.6))(next@16.2.6(@opentelemetry/api@1.9.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(zod@4.4.3))(next@16.2.6(@opentelemetry/api@1.9.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(tailwindcss@4.3.0): + fumadocs-ui@16.8.10(@tailwindcss/oxide@4.3.0)(@types/mdx@2.0.13)(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(fumadocs-core@16.8.10(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.4)(@types/mdast@4.0.4)(@types/react@19.2.15)(lucide-react@1.16.0(react@19.2.6))(next@16.2.6(@opentelemetry/api@1.9.1)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(zod@4.4.3))(next@16.2.6(@opentelemetry/api@1.9.1)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(tailwindcss@4.3.0): dependencies: '@fumadocs/tailwind': 0.0.5(@tailwindcss/oxide@4.3.0)(tailwindcss@4.3.0) - '@radix-ui/react-accordion': 1.2.12(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@radix-ui/react-collapsible': 1.1.12(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@radix-ui/react-dialog': 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.6) - '@radix-ui/react-navigation-menu': 1.2.14(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@radix-ui/react-popover': 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@radix-ui/react-scroll-area': 1.2.10(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@radix-ui/react-slot': 1.2.4(@types/react@19.2.14)(react@19.2.6) - '@radix-ui/react-tabs': 1.1.13(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-accordion': 1.2.12(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-collapsible': 1.1.12(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-dialog': 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-direction': 1.1.1(@types/react@19.2.15)(react@19.2.6) + '@radix-ui/react-navigation-menu': 1.2.14(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-popover': 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-scroll-area': 1.2.10(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-slot': 1.2.4(@types/react@19.2.15)(react@19.2.6) + '@radix-ui/react-tabs': 1.1.13(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) class-variance-authority: 0.7.1 - fumadocs-core: 16.8.10(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.4)(@types/mdast@4.0.4)(@types/react@19.2.14)(lucide-react@1.14.0(react@19.2.6))(next@16.2.6(@opentelemetry/api@1.9.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(zod@4.4.3) - lucide-react: 1.14.0(react@19.2.6) - motion: 12.38.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + fumadocs-core: 16.8.10(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.4)(@types/mdast@4.0.4)(@types/react@19.2.15)(lucide-react@1.16.0(react@19.2.6))(next@16.2.6(@opentelemetry/api@1.9.1)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(zod@4.4.3) + lucide-react: 1.16.0(react@19.2.6) + motion: 12.40.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6) next-themes: 0.4.6(react-dom@19.2.6(react@19.2.6))(react@19.2.6) react: 19.2.6 react-dom: 19.2.6(react@19.2.6) - react-remove-scroll: 2.7.2(@types/react@19.2.14)(react@19.2.6) + react-remove-scroll: 2.7.2(@types/react@19.2.15)(react@19.2.6) rehype-raw: 7.0.0 scroll-into-view-if-needed: 3.1.0 - shiki: 4.0.2 + shiki: 4.1.0 tailwind-merge: 3.6.0 unist-util-visit: 5.1.0 optionalDependencies: '@types/mdx': 2.0.13 - '@types/react': 19.2.14 - next: 16.2.6(@opentelemetry/api@1.9.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@types/react': 19.2.15 + next: 16.2.6(@opentelemetry/api@1.9.1)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) transitivePeerDependencies: - '@emotion/is-prop-valid' - '@tailwindcss/oxide' @@ -9650,7 +9394,7 @@ snapshots: get-caller-file@2.0.5: {} - get-east-asian-width@1.5.0: {} + get-east-asian-width@1.6.0: {} get-intrinsic@1.3.0: dependencies: @@ -9782,7 +9526,7 @@ snapshots: hast-util-to-estree@3.1.3: dependencies: - '@types/estree': 1.0.8 + '@types/estree': 1.0.9 '@types/estree-jsx': 1.0.5 '@types/hast': 3.0.4 comma-separated-tokens: 2.0.3 @@ -9817,7 +9561,7 @@ snapshots: hast-util-to-jsx-runtime@2.3.6: dependencies: - '@types/estree': 1.0.8 + '@types/estree': 1.0.9 '@types/hast': 3.0.4 '@types/unist': 3.0.3 comma-separated-tokens: 2.0.3 @@ -9873,7 +9617,7 @@ snapshots: hosted-git-info@9.0.3: dependencies: - lru-cache: 11.3.6 + lru-cache: 11.5.0 html-entities@2.6.0: {} @@ -9903,6 +9647,13 @@ snapshots: transitivePeerDependencies: - supports-color + https-proxy-agent@5.0.1: + dependencies: + agent-base: 6.0.2 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + https-proxy-agent@7.0.6: dependencies: agent-base: 7.1.4 @@ -9960,11 +9711,11 @@ snapshots: ini@5.0.0: {} - ink-testing-library@4.0.0(@types/react@19.2.14): + ink-testing-library@4.0.0(@types/react@19.2.15): optionalDependencies: - '@types/react': 19.2.14 + '@types/react': 19.2.15 - ink@7.0.2(@types/react@19.2.14)(react@19.2.6): + ink@7.0.3(@types/react@19.2.15)(react@19.2.6): dependencies: '@alcalzone/ansi-tokenize': 0.3.0 ansi-escapes: 7.3.0 @@ -9993,7 +9744,7 @@ snapshots: ws: 8.20.1 yoga-layout: 3.2.1 optionalDependencies: - '@types/react': 19.2.14 + '@types/react': 19.2.15 transitivePeerDependencies: - bufferutil - utf-8-validate @@ -10023,7 +9774,7 @@ snapshots: is-fullwidth-code-point@5.1.0: dependencies: - get-east-asian-width: 1.5.0 + get-east-asian-width: 1.6.0 is-hexadecimal@2.0.1: {} @@ -10088,7 +9839,7 @@ snapshots: jiti@2.7.0: {} - jose@6.2.2: {} + jose@6.2.3: {} js-md4@0.3.2: {} @@ -10138,7 +9889,7 @@ snapshots: lodash.isstring: 4.0.1 lodash.once: 4.1.1 ms: 2.1.3 - semver: 7.7.4 + semver: 7.8.0 jwa@2.0.1: dependencies: @@ -10151,14 +9902,14 @@ snapshots: jwa: 2.0.1 safe-buffer: 5.2.1 - knip@6.12.2(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0): + knip@6.14.1(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0): dependencies: fdir: 6.5.0(picomatch@4.0.4) formatly: 0.3.0 get-tsconfig: 4.14.0 jiti: 2.7.0 minimist: 1.2.8 - oxc-parser: 0.128.0 + oxc-parser: 0.130.0 oxc-resolver: 11.19.1(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0) picomatch: 4.0.4 smol-toml: 1.6.1 @@ -10284,11 +10035,11 @@ snapshots: lru-cache@10.4.3: {} - lru-cache@11.3.6: {} + lru-cache@11.5.0: {} lru.min@1.1.4: {} - lucide-react@1.14.0(react@19.2.6): + lucide-react@1.16.0(react@19.2.6): dependencies: react: 19.2.6 @@ -10296,7 +10047,7 @@ snapshots: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 - magicast@0.5.2: + magicast@0.5.3: dependencies: '@babel/parser': 7.29.3 '@babel/types': 7.29.0 @@ -10310,7 +10061,7 @@ snapshots: make-dir@4.0.0: dependencies: - semver: 7.7.4 + semver: 7.8.0 markdown-extensions@2.0.0: {} @@ -10581,7 +10332,7 @@ snapshots: micromark-extension-mdx-expression@3.0.1: dependencies: - '@types/estree': 1.0.8 + '@types/estree': 1.0.9 devlop: 1.1.0 micromark-factory-mdx-expression: 2.0.3 micromark-factory-space: 2.0.1 @@ -10592,7 +10343,7 @@ snapshots: micromark-extension-mdx-jsx@3.0.2: dependencies: - '@types/estree': 1.0.8 + '@types/estree': 1.0.9 devlop: 1.1.0 estree-util-is-identifier-name: 3.0.0 micromark-factory-mdx-expression: 2.0.3 @@ -10609,7 +10360,7 @@ snapshots: micromark-extension-mdxjs-esm@3.0.0: dependencies: - '@types/estree': 1.0.8 + '@types/estree': 1.0.9 devlop: 1.1.0 micromark-core-commonmark: 2.0.3 micromark-util-character: 2.1.1 @@ -10645,7 +10396,7 @@ snapshots: micromark-factory-mdx-expression@2.0.3: dependencies: - '@types/estree': 1.0.8 + '@types/estree': 1.0.9 devlop: 1.1.0 micromark-factory-space: 2.0.1 micromark-util-character: 2.1.1 @@ -10709,7 +10460,7 @@ snapshots: micromark-util-events-to-acorn@2.0.3: dependencies: - '@types/estree': 1.0.8 + '@types/estree': 1.0.9 '@types/unist': 3.0.3 devlop: 1.1.0 estree-util-visit: 2.0.0 @@ -10811,15 +10562,15 @@ snapshots: moment@2.30.1: {} - motion-dom@12.38.0: + motion-dom@12.40.0: dependencies: - motion-utils: 12.36.0 + motion-utils: 12.39.0 - motion-utils@12.36.0: {} + motion-utils@12.39.0: {} - motion@12.38.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6): + motion@12.40.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6): dependencies: - framer-motion: 12.38.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + framer-motion: 12.40.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6) tslib: 2.8.1 optionalDependencies: react: 19.2.6 @@ -10827,7 +10578,7 @@ snapshots: ms@2.1.3: {} - mssql@12.5.2(@azure/core-client@1.10.1): + mssql@12.5.4(@azure/core-client@1.10.1): dependencies: '@tediousjs/connection-string': 1.1.0 commander: 11.1.0 @@ -10838,9 +10589,9 @@ snapshots: - '@azure/core-client' - supports-color - mysql2@3.22.3(@types/node@24.12.2): + mysql2@3.22.3(@types/node@24.12.4): dependencies: - '@types/node': 24.12.2 + '@types/node': 24.12.4 aws-ssl-profiles: 1.1.2 denque: 2.1.0 generate-function: 2.3.1 @@ -10860,7 +10611,7 @@ snapshots: dependencies: lru.min: 1.1.4 - nanoid@3.3.11: {} + nanoid@3.3.12: {} napi-build-utils@2.0.0: {} @@ -10877,12 +10628,12 @@ snapshots: react: 19.2.6 react-dom: 19.2.6(react@19.2.6) - next@16.2.6(@opentelemetry/api@1.9.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6): + next@16.2.6(@opentelemetry/api@1.9.1)(react-dom@19.2.6(react@19.2.6))(react@19.2.6): dependencies: '@next/env': 16.2.6 '@swc/helpers': 0.5.15 - baseline-browser-mapping: 2.10.29 - caniuse-lite: 1.0.30001792 + baseline-browser-mapping: 2.10.31 + caniuse-lite: 1.0.30001793 postcss: 8.5.10 react: 19.2.6 react-dom: 19.2.6(react@19.2.6) @@ -10896,15 +10647,15 @@ snapshots: '@next/swc-linux-x64-musl': 16.2.6 '@next/swc-win32-arm64-msvc': 16.2.6 '@next/swc-win32-x64-msvc': 16.2.6 - '@opentelemetry/api': 1.9.0 + '@opentelemetry/api': 1.9.1 sharp: 0.34.5 transitivePeerDependencies: - '@babel/core' - babel-plugin-macros - node-abi@3.89.0: + node-abi@3.92.0: dependencies: - semver: 7.7.4 + semver: 7.8.0 node-domexception@1.0.0: {} @@ -10924,16 +10675,16 @@ snapshots: normalize-package-data@6.0.2: dependencies: hosted-git-info: 7.0.2 - semver: 7.7.4 + semver: 7.8.0 validate-npm-package-license: 3.0.4 normalize-package-data@8.0.0: dependencies: hosted-git-info: 9.0.3 - semver: 7.7.4 + semver: 7.8.0 validate-npm-package-license: 3.0.4 - normalize-url@9.0.0: {} + normalize-url@9.0.1: {} npm-run-path@4.0.1: dependencies: @@ -10948,7 +10699,7 @@ snapshots: path-key: 4.0.0 unicorn-magic: 0.3.0 - npm@11.14.1: {} + npm@11.15.0: {} oauth4webapi@3.8.6: {} @@ -10998,35 +10749,35 @@ snapshots: is-docker: 2.2.1 is-wsl: 2.2.0 - openai@6.37.0(ws@8.20.1)(zod@4.4.3): + openai@6.38.0(ws@8.20.1)(zod@4.4.3): optionalDependencies: ws: 8.20.1 zod: 4.4.3 - oxc-parser@0.128.0: + oxc-parser@0.130.0: dependencies: - '@oxc-project/types': 0.128.0 + '@oxc-project/types': 0.130.0 optionalDependencies: - '@oxc-parser/binding-android-arm-eabi': 0.128.0 - '@oxc-parser/binding-android-arm64': 0.128.0 - '@oxc-parser/binding-darwin-arm64': 0.128.0 - '@oxc-parser/binding-darwin-x64': 0.128.0 - '@oxc-parser/binding-freebsd-x64': 0.128.0 - '@oxc-parser/binding-linux-arm-gnueabihf': 0.128.0 - '@oxc-parser/binding-linux-arm-musleabihf': 0.128.0 - '@oxc-parser/binding-linux-arm64-gnu': 0.128.0 - '@oxc-parser/binding-linux-arm64-musl': 0.128.0 - '@oxc-parser/binding-linux-ppc64-gnu': 0.128.0 - '@oxc-parser/binding-linux-riscv64-gnu': 0.128.0 - '@oxc-parser/binding-linux-riscv64-musl': 0.128.0 - '@oxc-parser/binding-linux-s390x-gnu': 0.128.0 - '@oxc-parser/binding-linux-x64-gnu': 0.128.0 - '@oxc-parser/binding-linux-x64-musl': 0.128.0 - '@oxc-parser/binding-openharmony-arm64': 0.128.0 - '@oxc-parser/binding-wasm32-wasi': 0.128.0 - '@oxc-parser/binding-win32-arm64-msvc': 0.128.0 - '@oxc-parser/binding-win32-ia32-msvc': 0.128.0 - '@oxc-parser/binding-win32-x64-msvc': 0.128.0 + '@oxc-parser/binding-android-arm-eabi': 0.130.0 + '@oxc-parser/binding-android-arm64': 0.130.0 + '@oxc-parser/binding-darwin-arm64': 0.130.0 + '@oxc-parser/binding-darwin-x64': 0.130.0 + '@oxc-parser/binding-freebsd-x64': 0.130.0 + '@oxc-parser/binding-linux-arm-gnueabihf': 0.130.0 + '@oxc-parser/binding-linux-arm-musleabihf': 0.130.0 + '@oxc-parser/binding-linux-arm64-gnu': 0.130.0 + '@oxc-parser/binding-linux-arm64-musl': 0.130.0 + '@oxc-parser/binding-linux-ppc64-gnu': 0.130.0 + '@oxc-parser/binding-linux-riscv64-gnu': 0.130.0 + '@oxc-parser/binding-linux-riscv64-musl': 0.130.0 + '@oxc-parser/binding-linux-s390x-gnu': 0.130.0 + '@oxc-parser/binding-linux-x64-gnu': 0.130.0 + '@oxc-parser/binding-linux-x64-musl': 0.130.0 + '@oxc-parser/binding-openharmony-arm64': 0.130.0 + '@oxc-parser/binding-wasm32-wasi': 0.130.0 + '@oxc-parser/binding-win32-arm64-msvc': 0.130.0 + '@oxc-parser/binding-win32-ia32-msvc': 0.130.0 + '@oxc-parser/binding-win32-x64-msvc': 0.130.0 oxc-resolver@11.19.1(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0): optionalDependencies: @@ -11156,18 +10907,18 @@ snapshots: pegjs@0.10.0: {} - pg-cloudflare@1.3.0: + pg-cloudflare@1.4.0: optional: true - pg-connection-string@2.12.0: {} + pg-connection-string@2.13.0: {} pg-int8@1.0.1: {} - pg-pool@3.13.0(pg@8.20.0): + pg-pool@3.14.0(pg@8.21.0): dependencies: - pg: 8.20.0 + pg: 8.21.0 - pg-protocol@1.13.0: {} + pg-protocol@1.14.0: {} pg-types@2.2.0: dependencies: @@ -11177,15 +10928,15 @@ snapshots: postgres-date: 1.0.7 postgres-interval: 1.2.0 - pg@8.20.0: + pg@8.21.0: dependencies: - pg-connection-string: 2.12.0 - pg-pool: 3.13.0(pg@8.20.0) - pg-protocol: 1.13.0 + pg-connection-string: 2.13.0 + pg-pool: 3.14.0(pg@8.21.0) + pg-protocol: 1.14.0 pg-types: 2.2.0 pgpass: 1.0.5 optionalDependencies: - pg-cloudflare: 1.3.0 + pg-cloudflare: 1.4.0 pgpass@1.0.5: dependencies: @@ -11208,7 +10959,7 @@ snapshots: postcss@8.5.10: dependencies: - nanoid: 3.3.11 + nanoid: 3.3.12 picocolors: 1.1.1 source-map-js: 1.2.1 @@ -11222,7 +10973,9 @@ snapshots: dependencies: xtend: 4.0.2 - posthog-node@5.0.0: {} + posthog-node@5.34.9: + dependencies: + '@posthog/core': 1.29.7 prebuild-install@7.1.3: dependencies: @@ -11232,7 +10985,7 @@ snapshots: minimist: 1.2.8 mkdirp-classic: 0.5.3 napi-build-utils: 2.0.0 - node-abi: 3.89.0 + node-abi: 3.92.0 pump: 3.0.4 rc: 1.2.8 simple-get: 4.0.1 @@ -11263,7 +11016,7 @@ snapshots: end-of-stream: 1.4.5 once: 1.4.0 - qs@6.15.1: + qs@6.15.2: dependencies: side-channel: 1.1.0 @@ -11293,32 +11046,32 @@ snapshots: react: 19.2.6 scheduler: 0.27.0 - react-remove-scroll-bar@2.3.8(@types/react@19.2.14)(react@19.2.6): + react-remove-scroll-bar@2.3.8(@types/react@19.2.15)(react@19.2.6): dependencies: react: 19.2.6 - react-style-singleton: 2.2.3(@types/react@19.2.14)(react@19.2.6) + react-style-singleton: 2.2.3(@types/react@19.2.15)(react@19.2.6) tslib: 2.8.1 optionalDependencies: - '@types/react': 19.2.14 + '@types/react': 19.2.15 - react-remove-scroll@2.7.2(@types/react@19.2.14)(react@19.2.6): + react-remove-scroll@2.7.2(@types/react@19.2.15)(react@19.2.6): dependencies: react: 19.2.6 - react-remove-scroll-bar: 2.3.8(@types/react@19.2.14)(react@19.2.6) - react-style-singleton: 2.2.3(@types/react@19.2.14)(react@19.2.6) + react-remove-scroll-bar: 2.3.8(@types/react@19.2.15)(react@19.2.6) + react-style-singleton: 2.2.3(@types/react@19.2.15)(react@19.2.6) tslib: 2.8.1 - use-callback-ref: 1.3.3(@types/react@19.2.14)(react@19.2.6) - use-sidecar: 1.1.3(@types/react@19.2.14)(react@19.2.6) + use-callback-ref: 1.3.3(@types/react@19.2.15)(react@19.2.6) + use-sidecar: 1.1.3(@types/react@19.2.15)(react@19.2.6) optionalDependencies: - '@types/react': 19.2.14 + '@types/react': 19.2.15 - react-style-singleton@2.2.3(@types/react@19.2.14)(react@19.2.6): + react-style-singleton@2.2.3(@types/react@19.2.15)(react@19.2.6): dependencies: get-nonce: 1.0.1 react: 19.2.6 tslib: 2.8.1 optionalDependencies: - '@types/react': 19.2.14 + '@types/react': 19.2.15 react@19.2.6: {} @@ -11378,7 +11131,7 @@ snapshots: recma-build-jsx@1.0.0: dependencies: - '@types/estree': 1.0.8 + '@types/estree': 1.0.9 estree-util-build-jsx: 3.0.1 vfile: 6.0.3 @@ -11393,14 +11146,14 @@ snapshots: recma-parse@1.0.0: dependencies: - '@types/estree': 1.0.8 + '@types/estree': 1.0.9 esast-util-from-js: 2.0.1 unified: 11.0.5 vfile: 6.0.3 recma-stringify@1.0.0: dependencies: - '@types/estree': 1.0.8 + '@types/estree': 1.0.9 estree-util-to-js: 2.0.0 unified: 11.0.5 vfile: 6.0.3 @@ -11427,7 +11180,7 @@ snapshots: rehype-recma@1.0.0: dependencies: - '@types/estree': 1.0.8 + '@types/estree': 1.0.9 '@types/hast': 3.0.4 hast-util-to-estree: 3.1.3 transitivePeerDependencies: @@ -11505,26 +11258,26 @@ snapshots: transitivePeerDependencies: - supports-color - rolldown@1.0.0-rc.17: + rolldown@1.0.2: dependencies: - '@oxc-project/types': 0.127.0 - '@rolldown/pluginutils': 1.0.0-rc.17 + '@oxc-project/types': 0.132.0 + '@rolldown/pluginutils': 1.0.1 optionalDependencies: - '@rolldown/binding-android-arm64': 1.0.0-rc.17 - '@rolldown/binding-darwin-arm64': 1.0.0-rc.17 - '@rolldown/binding-darwin-x64': 1.0.0-rc.17 - '@rolldown/binding-freebsd-x64': 1.0.0-rc.17 - '@rolldown/binding-linux-arm-gnueabihf': 1.0.0-rc.17 - '@rolldown/binding-linux-arm64-gnu': 1.0.0-rc.17 - '@rolldown/binding-linux-arm64-musl': 1.0.0-rc.17 - '@rolldown/binding-linux-ppc64-gnu': 1.0.0-rc.17 - '@rolldown/binding-linux-s390x-gnu': 1.0.0-rc.17 - '@rolldown/binding-linux-x64-gnu': 1.0.0-rc.17 - '@rolldown/binding-linux-x64-musl': 1.0.0-rc.17 - '@rolldown/binding-openharmony-arm64': 1.0.0-rc.17 - '@rolldown/binding-wasm32-wasi': 1.0.0-rc.17 - '@rolldown/binding-win32-arm64-msvc': 1.0.0-rc.17 - '@rolldown/binding-win32-x64-msvc': 1.0.0-rc.17 + '@rolldown/binding-android-arm64': 1.0.2 + '@rolldown/binding-darwin-arm64': 1.0.2 + '@rolldown/binding-darwin-x64': 1.0.2 + '@rolldown/binding-freebsd-x64': 1.0.2 + '@rolldown/binding-linux-arm-gnueabihf': 1.0.2 + '@rolldown/binding-linux-arm64-gnu': 1.0.2 + '@rolldown/binding-linux-arm64-musl': 1.0.2 + '@rolldown/binding-linux-ppc64-gnu': 1.0.2 + '@rolldown/binding-linux-s390x-gnu': 1.0.2 + '@rolldown/binding-linux-x64-gnu': 1.0.2 + '@rolldown/binding-linux-x64-musl': 1.0.2 + '@rolldown/binding-openharmony-arm64': 1.0.2 + '@rolldown/binding-wasm32-wasi': 1.0.2 + '@rolldown/binding-win32-arm64-msvc': 1.0.2 + '@rolldown/binding-win32-x64-msvc': 1.0.2 router@2.2.0: dependencies: @@ -11579,7 +11332,7 @@ snapshots: p-reduce: 3.0.0 read-package-up: 12.0.0 resolve-from: 5.0.0 - semver: 7.7.4 + semver: 7.8.0 signale: 1.4.0 yargs: 18.0.0 transitivePeerDependencies: @@ -11588,7 +11341,7 @@ snapshots: semver-regex@4.0.5: {} - semver@7.7.4: {} + semver@7.8.0: {} send@1.2.1: dependencies: @@ -11621,7 +11374,7 @@ snapshots: dependencies: '@img/colour': 1.1.0 detect-libc: 2.1.2 - semver: 7.7.4 + semver: 7.8.0 optionalDependencies: '@img/sharp-darwin-arm64': 0.34.5 '@img/sharp-darwin-x64': 0.34.5 @@ -11655,14 +11408,14 @@ snapshots: shebang-regex@3.0.0: {} - shiki@4.0.2: + shiki@4.1.0: dependencies: - '@shikijs/core': 4.0.2 - '@shikijs/engine-javascript': 4.0.2 - '@shikijs/engine-oniguruma': 4.0.2 - '@shikijs/langs': 4.0.2 - '@shikijs/themes': 4.0.2 - '@shikijs/types': 4.0.2 + '@shikijs/core': 4.1.0 + '@shikijs/engine-javascript': 4.1.0 + '@shikijs/engine-oniguruma': 4.1.0 + '@shikijs/langs': 4.1.0 + '@shikijs/themes': 4.1.0 + '@shikijs/types': 4.1.0 '@shikijs/vscode-textmate': 10.0.2 '@types/hast': 3.0.4 @@ -11739,27 +11492,27 @@ snapshots: smol-toml@1.6.1: {} - snowflake-sdk@2.4.1(asn1.js@5.4.1): + snowflake-sdk@2.4.2(asn1.js@5.4.1): dependencies: '@aws-crypto/sha256-js': 5.2.0 '@aws-sdk/client-s3': 3.1045.0 '@aws-sdk/client-sts': 3.1045.0 - '@aws-sdk/credential-provider-node': 3.972.39 + '@aws-sdk/credential-provider-node': 3.972.43 '@aws-sdk/ec2-metadata-service': 3.1045.0 '@azure/identity': 4.13.1 '@azure/storage-blob': 12.26.0 - '@smithy/node-http-handler': 4.6.1 - '@smithy/protocol-http': 5.3.14 - '@smithy/signature-v4': 5.3.14 + '@smithy/node-http-handler': 4.7.3 + '@smithy/protocol-http': 5.4.3 + '@smithy/signature-v4': 5.4.3 '@techteamer/ocsp': 1.0.1 asn1.js: 5.4.1 asn1.js-rfc2560: 5.0.1(asn1.js@5.4.1) asn1.js-rfc5280: 3.0.0 - axios: 1.15.2 + axios: 1.16.1 big-integer: 1.6.52 bignumber.js: 9.3.1 expand-tilde: 2.0.2 - fast-xml-parser: 5.7.2 + fast-xml-parser: 5.8.0 fastest-levenshtein: 1.0.16 generic-pool: 3.9.0 google-auth-library: 10.6.2 @@ -11774,7 +11527,6 @@ snapshots: toml: 3.0.0 winston: 3.19.0 transitivePeerDependencies: - - aws-crt - debug - supports-color @@ -11820,6 +11572,11 @@ snapshots: stackback@0.0.2: {} + standardwebhooks@1.0.0: + dependencies: + '@stablelib/base64': 1.0.1 + fast-sha256: 1.3.0 + statuses@2.0.2: {} std-env@4.1.0: {} @@ -11844,13 +11601,13 @@ snapshots: string-width@7.2.0: dependencies: emoji-regex: 10.6.0 - get-east-asian-width: 1.5.0 - strip-ansi: 7.1.2 + get-east-asian-width: 1.6.0 + strip-ansi: 7.2.0 string-width@8.2.1: dependencies: - get-east-asian-width: 1.5.0 - strip-ansi: 7.1.2 + get-east-asian-width: 1.6.0 + strip-ansi: 7.2.0 string_decoder@1.1.1: dependencies: @@ -11869,7 +11626,7 @@ snapshots: dependencies: ansi-regex: 5.0.1 - strip-ansi@7.1.2: + strip-ansi@7.2.0: dependencies: ansi-regex: 6.2.2 @@ -11885,7 +11642,7 @@ snapshots: strip-json-comments@5.0.3: {} - strnum@2.2.3: {} + strnum@2.3.0: {} stubs@3.0.0: {} @@ -11952,7 +11709,7 @@ snapshots: '@azure/identity': 4.13.1 '@azure/keyvault-keys': 4.10.0(@azure/core-client@1.10.1) '@js-joda/core': 5.7.0 - '@types/node': 24.12.2 + '@types/node': 24.12.4 bl: 6.1.6 iconv-lite: 0.7.2 js-md4: 0.3.2 @@ -12003,8 +11760,6 @@ snapshots: tinybench@2.9.0: {} - tinyexec@1.1.1: {} - tinyexec@1.1.2: {} tinyglobby@0.2.16: @@ -12050,9 +11805,9 @@ snapshots: dependencies: tagged-tag: 1.0.0 - type-is@2.0.1: + type-is@2.1.0: dependencies: - content-type: 1.0.5 + content-type: 2.0.0 media-typer: 1.1.0 mime-types: 3.0.2 @@ -12131,20 +11886,20 @@ snapshots: url-join@5.0.0: {} - use-callback-ref@1.3.3(@types/react@19.2.14)(react@19.2.6): + use-callback-ref@1.3.3(@types/react@19.2.15)(react@19.2.6): dependencies: react: 19.2.6 tslib: 2.8.1 optionalDependencies: - '@types/react': 19.2.14 + '@types/react': 19.2.15 - use-sidecar@1.1.3(@types/react@19.2.14)(react@19.2.6): + use-sidecar@1.1.3(@types/react@19.2.15)(react@19.2.6): dependencies: detect-node-es: 1.1.0 react: 19.2.6 tslib: 2.8.1 optionalDependencies: - '@types/react': 19.2.14 + '@types/react': 19.2.15 use-sync-external-store@1.6.0(react@19.2.6): dependencies: @@ -12174,29 +11929,29 @@ snapshots: '@types/unist': 3.0.3 vfile-message: 4.0.3 - vite@8.0.10(@types/node@24.12.2)(esbuild@0.28.0)(jiti@2.7.0)(yaml@2.9.0): + vite@8.0.14(@types/node@24.12.4)(esbuild@0.28.0)(jiti@2.7.0)(yaml@2.9.0): dependencies: lightningcss: 1.32.0 picomatch: 4.0.4 postcss: 8.5.10 - rolldown: 1.0.0-rc.17 + rolldown: 1.0.2 tinyglobby: 0.2.16 optionalDependencies: - '@types/node': 24.12.2 + '@types/node': 24.12.4 esbuild: 0.28.0 fsevents: 2.3.3 jiti: 2.7.0 yaml: 2.9.0 - vitest@4.1.6(@opentelemetry/api@1.9.0)(@types/node@24.12.2)(@vitest/coverage-v8@4.1.6)(vite@8.0.10(@types/node@24.12.2)(esbuild@0.28.0)(jiti@2.7.0)(yaml@2.9.0)): + vitest@4.1.7(@opentelemetry/api@1.9.1)(@types/node@24.12.4)(@vitest/coverage-v8@4.1.7)(vite@8.0.14(@types/node@24.12.4)(esbuild@0.28.0)(jiti@2.7.0)(yaml@2.9.0)): dependencies: - '@vitest/expect': 4.1.6 - '@vitest/mocker': 4.1.6(vite@8.0.10(@types/node@24.12.2)(esbuild@0.28.0)(jiti@2.7.0)(yaml@2.9.0)) - '@vitest/pretty-format': 4.1.6 - '@vitest/runner': 4.1.6 - '@vitest/snapshot': 4.1.6 - '@vitest/spy': 4.1.6 - '@vitest/utils': 4.1.6 + '@vitest/expect': 4.1.7 + '@vitest/mocker': 4.1.7(vite@8.0.14(@types/node@24.12.4)(esbuild@0.28.0)(jiti@2.7.0)(yaml@2.9.0)) + '@vitest/pretty-format': 4.1.7 + '@vitest/runner': 4.1.7 + '@vitest/snapshot': 4.1.7 + '@vitest/spy': 4.1.7 + '@vitest/utils': 4.1.7 es-module-lexer: 2.1.0 expect-type: 1.3.0 magic-string: 0.30.21 @@ -12205,15 +11960,15 @@ snapshots: picomatch: 4.0.4 std-env: 4.1.0 tinybench: 2.9.0 - tinyexec: 1.1.1 + tinyexec: 1.1.2 tinyglobby: 0.2.16 tinyrainbow: 3.1.0 - vite: 8.0.10(@types/node@24.12.2)(esbuild@0.28.0)(jiti@2.7.0)(yaml@2.9.0) + vite: 8.0.14(@types/node@24.12.4)(esbuild@0.28.0)(jiti@2.7.0)(yaml@2.9.0) why-is-node-running: 2.3.0 optionalDependencies: - '@opentelemetry/api': 1.9.0 - '@types/node': 24.12.2 - '@vitest/coverage-v8': 4.1.6(vitest@4.1.6) + '@opentelemetry/api': 1.9.1 + '@types/node': 24.12.4 + '@vitest/coverage-v8': 4.1.7(vitest@4.1.7) transitivePeerDependencies: - msw @@ -12264,7 +12019,7 @@ snapshots: dependencies: ansi-styles: 6.2.3 string-width: 8.2.1 - strip-ansi: 7.1.2 + strip-ansi: 7.2.0 wrap-ansi@7.0.0: dependencies: @@ -12276,7 +12031,7 @@ snapshots: dependencies: ansi-styles: 6.2.3 string-width: 7.2.0 - strip-ansi: 7.1.2 + strip-ansi: 7.2.0 wrappy@1.0.2: {} @@ -12286,6 +12041,8 @@ snapshots: dependencies: is-wsl: 3.1.1 + xml-naming@0.1.0: {} + xtend@4.0.2: {} y18n@5.0.8: {} @@ -12327,11 +12084,11 @@ snapshots: zod@4.4.3: {} - zustand@4.5.7(@types/react@19.2.14)(react@19.2.6): + zustand@4.5.7(@types/react@19.2.15)(react@19.2.6): dependencies: use-sync-external-store: 1.6.0(react@19.2.6) optionalDependencies: - '@types/react': 19.2.14 + '@types/react': 19.2.15 react: 19.2.6 zwitch@2.0.4: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 04eacb8f..ae7b5b37 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -23,3 +23,4 @@ allowBuilds: better-sqlite3: true esbuild: true sharp: true +minimumReleaseAge: 10080 diff --git a/pyproject.toml b/pyproject.toml index 67d2acbb..0395de17 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -13,10 +13,10 @@ Issues = "https://github.com/kaelio/ktx/issues" [dependency-groups] dev = [ - "pre-commit>=4.6.0", - "pytest>=9.0.2", - "pytest-cov>=7.1.0", - "ruff>=0.8.4", + "pre-commit>=4.6.0", + "pytest>=9.0.2", + "pytest-cov>=7.1.0", + "ruff>=0.8.4", ] [tool.uv] @@ -32,14 +32,14 @@ torch = { index = "pytorch-cpu" } [tool.uv.workspace] members = [ - "python/ktx-sl", - "python/ktx-daemon", + "python/ktx-sl", + "python/ktx-daemon", ] [tool.pytest.ini_options] addopts = ["--import-mode=importlib"] pythonpath = ["python/ktx-sl/tests"] testpaths = [ - "python/ktx-sl/tests", - "python/ktx-daemon/tests", + "python/ktx-sl/tests", + "python/ktx-daemon/tests", ] diff --git a/python/ktx-daemon/pyproject.toml b/python/ktx-daemon/pyproject.toml index 0c6c95e4..3cc4d2d2 100644 --- a/python/ktx-daemon/pyproject.toml +++ b/python/ktx-daemon/pyproject.toml @@ -6,18 +6,18 @@ readme = "README.md" requires-python = ">=3.13" license = "Apache-2.0" dependencies = [ - "fastapi>=0.115.0", - "ktx-sl", - "lkml>=1.3.7", - "numpy>=2.2.6", - "orjson>=3.11.4", - "pandas>=2.2.3", - "posthog>=7.0.0", - "psycopg[binary]>=3.2.0", - "pydantic>=2.9.0", - "requests>=2.32.0", - "sqlglot>=26", - "uvicorn[standard]>=0.32.0", + "fastapi>=0.136.3", + "ktx-sl", + "lkml>=1.3.7", + "numpy>=2.4.6", + "orjson>=3.11.9", + "pandas>=3.0.3", + "posthog>=7.16.1", + "psycopg[binary]>=3.3.4", + "pydantic>=2.13.4", + "requests>=2.34.2", + "sqlglot>=30", + "uvicorn[standard]>=0.48.0", ] [project.scripts] @@ -25,8 +25,8 @@ ktx-daemon = "ktx_daemon.__main__:main" [project.optional-dependencies] local-embeddings = [ - "sentence-transformers>=5.1.1", - "torch>=2.2.0", + "sentence-transformers>=5.1.1", + "torch>=2.2.0", ] [project.urls] @@ -43,8 +43,8 @@ packages = ["src/ktx_daemon"] [dependency-groups] dev = [ - "httpx>=0.28.1", - "pytest>=9.0.2", + "httpx>=0.28.1", + "pytest>=9.0.2", ] [tool.uv.sources] diff --git a/python/ktx-sl/pyproject.toml b/python/ktx-sl/pyproject.toml index 02cfd06a..6207390f 100644 --- a/python/ktx-sl/pyproject.toml +++ b/python/ktx-sl/pyproject.toml @@ -6,9 +6,9 @@ readme = "README.md" requires-python = ">=3.13" license = "Apache-2.0" dependencies = [ - "sqlglot>=26", - "pydantic>=2", - "pyyaml>=6", + "sqlglot>=30", + "pydantic>=2", + "pyyaml>=6", ] [project.urls] @@ -18,13 +18,13 @@ Issues = "https://github.com/kaelio/ktx/issues" [project.optional-dependencies] dev = [ - "pytest>=8", - "pytest-cov", - "ruff", - "pre-commit", + "pytest>=8", + "pytest-cov", + "ruff", + "pre-commit", ] tpch = [ - "duckdb>=1.0", + "duckdb>=1.0", ] [tool.pytest.ini_options] @@ -40,9 +40,9 @@ branch = true show_missing = true skip_empty = true exclude_lines = [ - "pragma: no cover", - "if __name__ == .__main__.", - "if TYPE_CHECKING:", + "pragma: no cover", + "if __name__ == .__main__.", + "if TYPE_CHECKING:", ] [build-system] @@ -54,6 +54,6 @@ packages = ["semantic_layer"] [dependency-groups] dev = [ - "pytest>=9.0.2", - "pytest-cov>=7.1.0", + "pytest>=9.0.2", + "pytest-cov>=7.1.0", ] diff --git a/scripts/upgrade-dependencies.mjs b/scripts/upgrade-dependencies.mjs new file mode 100644 index 00000000..5ec4996e --- /dev/null +++ b/scripts/upgrade-dependencies.mjs @@ -0,0 +1,111 @@ +#!/usr/bin/env node + +import { execFile as execFileCallback } from 'node:child_process'; +import { readFile as fsReadFile } from 'node:fs/promises'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; +import { promisify } from 'node:util'; + +const execFileAsync = promisify(execFileCallback); +const npmCheckUpdatesRejectArgs = ['--reject', 'fumadocs-core,fumadocs-ui']; + +function ktxRootDir() { + return resolve(dirname(fileURLToPath(import.meta.url)), '..'); +} + +function failureText(error) { + const stdout = typeof error?.stdout === 'string' ? error.stdout.trim() : ''; + const stderr = typeof error?.stderr === 'string' ? error.stderr.trim() : ''; + const message = error instanceof Error ? error.message.trim() : String(error); + return [stderr, stdout, message].filter((line) => line.length > 0).join('\n') || 'Command failed'; +} + +function commandText(command, args) { + return [command, ...args].join(' '); +} + +function pythonDependencyUpdatePhases() { + const manifests = ['pyproject.toml', 'python/ktx-sl/pyproject.toml', 'python/ktx-daemon/pyproject.toml']; + return manifests.map((manifest) => ({ + name: `Python dependency constraints: ${manifest}`, + command: 'uvx', + args: ['dependency-check-updates', '--manifest', manifest, '-u'], + retry: commandText('uvx', ['dependency-check-updates', '--manifest', manifest, '-u']), + })); +} + +async function pnpmMinimumReleaseAgeCooldown(rootDir, readFile) { + let workspaceConfig; + try { + workspaceConfig = await readFile(resolve(rootDir, 'pnpm-workspace.yaml'), 'utf8'); + } catch (error) { + if (error?.code === 'ENOENT') { + return []; + } + throw error; + } + + const match = workspaceConfig.match(/^\s*minimumReleaseAge:\s*(\d+)\s*$/m); + if (!match) { + return []; + } + return ['--cooldown', `${match[1]}m`]; +} + +export async function runDependencyUpgrade(options = {}) { + const rootDir = options.rootDir ?? ktxRootDir(); + const execFile = options.execFile ?? execFileAsync; + const readFile = options.readFile ?? fsReadFile; + const log = options.log ?? ((line) => process.stdout.write(`${line}\n`)); + const npmCheckUpdatesCooldownArgs = await pnpmMinimumReleaseAgeCooldown(rootDir, readFile); + const phases = [ + { + name: 'TypeScript dependency constraints', + command: 'pnpm', + args: ['dlx', 'npm-check-updates', '-u', '--deep', ...npmCheckUpdatesRejectArgs, ...npmCheckUpdatesCooldownArgs], + retry: commandText('pnpm', [ + 'dlx', + 'npm-check-updates', + '-u', + '--deep', + ...npmCheckUpdatesRejectArgs, + ...npmCheckUpdatesCooldownArgs, + ]), + }, + ...pythonDependencyUpdatePhases(), + { + name: 'TypeScript lockfile', + command: 'pnpm', + args: ['install'], + retry: 'pnpm install', + }, + { + name: 'Python lockfile', + command: 'uv', + args: ['lock', '--upgrade'], + retry: 'uv lock --upgrade', + }, + ]; + + for (const phase of phases) { + log(`RUN ${phase.name}: ${commandText(phase.command, phase.args)}`); + try { + await execFile(phase.command, phase.args, { cwd: rootDir, maxBuffer: 1024 * 1024 * 64 }); + log(`PASS ${phase.name}`); + } catch (error) { + log(`FAIL ${phase.name}: ${failureText(error)}`); + log(`Retry: ${phase.retry}`); + return { ok: false, failedPhase: phase }; + } + } + + log('Dependency manifests and lockfiles were updated. Run `pnpm run check` before committing.'); + return { ok: true }; +} + +if (import.meta.url === pathToFileURL(process.argv[1]).href) { + const result = await runDependencyUpgrade(); + if (!result.ok) { + process.exitCode = 1; + } +} diff --git a/scripts/upgrade-dependencies.test.mjs b/scripts/upgrade-dependencies.test.mjs new file mode 100644 index 00000000..6f42bf7d --- /dev/null +++ b/scripts/upgrade-dependencies.test.mjs @@ -0,0 +1,123 @@ +import assert from 'node:assert/strict'; +import { readFile } from 'node:fs/promises'; +import { test } from 'node:test'; +import { runDependencyUpgrade } from './upgrade-dependencies.mjs'; + +test('runDependencyUpgrade updates TypeScript and Python manifests before regenerating lockfiles', async () => { + const calls = []; + const logs = []; + + const result = await runDependencyUpgrade({ + rootDir: '/workspace/ktx', + readFile: async (path) => { + assert.equal(path, '/workspace/ktx/pnpm-workspace.yaml'); + return 'packages: []\nminimumReleaseAge: 10080\n'; + }, + execFile: async (command, args, options) => { + calls.push({ command, args, cwd: options.cwd }); + return { stdout: '', stderr: '' }; + }, + log: (line) => logs.push(line), + }); + + assert.equal(result.ok, true); + assert.deepEqual( + calls.map((call) => [call.command, call.args]), + [ + [ + 'pnpm', + [ + 'dlx', + 'npm-check-updates', + '-u', + '--deep', + '--reject', + 'fumadocs-core,fumadocs-ui', + '--cooldown', + '10080m', + ], + ], + ['uvx', ['dependency-check-updates', '--manifest', 'pyproject.toml', '-u']], + ['uvx', ['dependency-check-updates', '--manifest', 'python/ktx-sl/pyproject.toml', '-u']], + ['uvx', ['dependency-check-updates', '--manifest', 'python/ktx-daemon/pyproject.toml', '-u']], + ['pnpm', ['install']], + ['uv', ['lock', '--upgrade']], + ], + ); + assert.equal(calls.every((call) => call.cwd === '/workspace/ktx'), true); + assert.equal(logs.some((line) => line.includes('PASS Python dependency constraints')), true); +}); + +test('runDependencyUpgrade stops at the failed phase and prints a retry command', async () => { + const calls = []; + const logs = []; + + const result = await runDependencyUpgrade({ + rootDir: '/workspace/ktx', + readFile: async () => 'packages: []\n', + execFile: async (command, args) => { + calls.push({ command, args }); + if (command === 'uvx' && args.includes('python/ktx-sl/pyproject.toml')) { + const error = new Error('dependency-check-updates failed'); + error.stdout = 'checking Python dependencies'; + error.stderr = 'could not read pyproject.toml'; + throw error; + } + return { stdout: '', stderr: '' }; + }, + log: (line) => logs.push(line), + }); + + assert.equal(result.ok, false); + assert.equal(result.failedPhase.name, 'Python dependency constraints: python/ktx-sl/pyproject.toml'); + assert.equal(result.failedPhase.retry, 'uvx dependency-check-updates --manifest python/ktx-sl/pyproject.toml -u'); + assert.deepEqual( + calls.map((call) => [call.command, call.args]), + [ + ['pnpm', ['dlx', 'npm-check-updates', '-u', '--deep', '--reject', 'fumadocs-core,fumadocs-ui']], + ['uvx', ['dependency-check-updates', '--manifest', 'pyproject.toml', '-u']], + ['uvx', ['dependency-check-updates', '--manifest', 'python/ktx-sl/pyproject.toml', '-u']], + ], + ); + assert.equal(logs.some((line) => line.includes('FAIL Python dependency constraints')), true); + assert.equal(logs.some((line) => line.includes('could not read pyproject.toml')), true); + assert.equal(logs.some((line) => line.includes('checking Python dependencies')), true); + assert.equal( + logs.some((line) => line.includes('Retry: uvx dependency-check-updates --manifest python/ktx-sl/pyproject.toml -u')), + true, + ); +}); + +test('runDependencyUpgrade ignores missing pnpm minimum release age config', async () => { + const calls = []; + + const result = await runDependencyUpgrade({ + rootDir: '/workspace/ktx', + readFile: async () => { + throw Object.assign(new Error('missing'), { code: 'ENOENT' }); + }, + execFile: async (command, args) => { + calls.push({ command, args }); + return { stdout: '', stderr: '' }; + }, + log: () => undefined, + }); + + assert.equal(result.ok, true); + assert.deepEqual(calls[0], { + command: 'pnpm', + args: ['dlx', 'npm-check-updates', '-u', '--deep', '--reject', 'fumadocs-core,fumadocs-ui'], + }); + assert.equal( + calls + .filter((call) => call.command === 'uvx') + .every((call) => call.args.includes('--manifest') && !call.args.includes('-d')), + true, + ); +}); + +test('package scripts expose the full dependency upgrade command', async () => { + const packageJson = JSON.parse(await readFile(new URL('../package.json', import.meta.url), 'utf8')); + + assert.equal(packageJson.scripts['deps:upgrade'], 'node scripts/upgrade-dependencies.mjs'); +}); diff --git a/tombi.toml b/tombi.toml new file mode 100644 index 00000000..bf3b3519 --- /dev/null +++ b/tombi.toml @@ -0,0 +1,5 @@ +[[schemas]] +path = "tombi://www.schemastore.org/pyproject.json" +include = ["**/pyproject.toml"] +format.rules.array-values-order.enabled = false +format.rules.table-keys-order.enabled = false diff --git a/uv.lock b/uv.lock index 25a8fab6..e72fc85a 100644 --- a/uv.lock +++ b/uv.lock @@ -2,21 +2,21 @@ version = 1 revision = 3 requires-python = ">=3.13" resolution-markers = [ - "python_full_version >= '3.14' and sys_platform == 'win32'", - "python_full_version >= '3.14' and sys_platform == 'emscripten'", - "python_full_version >= '3.14' and sys_platform != 'darwin' and sys_platform != 'emscripten' and sys_platform != 'win32'", - "python_full_version < '3.14' and sys_platform == 'win32'", - "python_full_version < '3.14' and sys_platform == 'emscripten'", - "python_full_version < '3.14' and sys_platform != 'darwin' and sys_platform != 'emscripten' and sys_platform != 'win32'", - "python_full_version >= '3.14' and sys_platform == 'darwin'", - "python_full_version < '3.14' and sys_platform == 'darwin'", + "python_full_version >= '3.14' and sys_platform == 'win32'", + "python_full_version >= '3.14' and sys_platform == 'emscripten'", + "python_full_version >= '3.14' and sys_platform != 'darwin' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version >= '3.14' and sys_platform == 'darwin'", + "python_full_version < '3.14' and sys_platform == 'win32'", + "python_full_version < '3.14' and sys_platform == 'emscripten'", + "python_full_version < '3.14' and sys_platform != 'darwin' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version < '3.14' and sys_platform == 'darwin'", ] [manifest] members = [ - "ktx-daemon", - "ktx-sl", - "ktx-workspace", + "ktx-daemon", + "ktx-sl", + "ktx-workspace", ] [[package]] @@ -25,7 +25,7 @@ version = "0.0.4" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/57/ba/046ceea27344560984e26a590f90bc7f4a75b06701f653222458922b558c/annotated_doc-0.0.4.tar.gz", hash = "sha256:fbcda96e87e9c92ad167c2e53839e57503ecfda18804ea28102353485033faa4", size = 7288, upload-time = "2025-11-10T22:07:42.062Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/1e/d3/26bf1008eb3d2daa8ef4cacc7f3bfdc11818d111f7e2d0201bc6e3b49d45/annotated_doc-0.0.4-py3-none-any.whl", hash = "sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320", size = 5303, upload-time = "2025-11-10T22:07:40.673Z" }, + { url = "https://files.pythonhosted.org/packages/1e/d3/26bf1008eb3d2daa8ef4cacc7f3bfdc11818d111f7e2d0201bc6e3b49d45/annotated_doc-0.0.4-py3-none-any.whl", hash = "sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320", size = 5303, upload-time = "2025-11-10T22:07:40.673Z" }, ] [[package]] @@ -34,7 +34,7 @@ version = "0.7.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, + { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, ] [[package]] @@ -42,11 +42,11 @@ name = "anyio" version = "4.13.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "idna" }, + { name = "idna" }, ] sdist = { url = "https://files.pythonhosted.org/packages/19/14/2c5dd9f512b66549ae92767a9c7b330ae88e1932ca57876909410251fe13/anyio-4.13.0.tar.gz", hash = "sha256:334b70e641fd2221c1505b3890c69882fe4a2df910cba14d97019b90b24439dc", size = 231622, upload-time = "2026-03-24T12:59:09.671Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/da/42/e921fccf5015463e32a3cf6ee7f980a6ed0f395ceeaa45060b61d86486c2/anyio-4.13.0-py3-none-any.whl", hash = "sha256:08b310f9e24a9594186fd75b4f73f4a4152069e3853f1ed8bfbf58369f4ad708", size = 114353, upload-time = "2026-03-24T12:59:08.246Z" }, + { url = "https://files.pythonhosted.org/packages/da/42/e921fccf5015463e32a3cf6ee7f980a6ed0f395ceeaa45060b61d86486c2/anyio-4.13.0-py3-none-any.whl", hash = "sha256:08b310f9e24a9594186fd75b4f73f4a4152069e3853f1ed8bfbf58369f4ad708", size = 114353, upload-time = "2026-03-24T12:59:08.246Z" }, ] [[package]] @@ -55,16 +55,16 @@ version = "2.2.1" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/47/d7/5bbeb12c44d7c4f2fb5b56abce497eb5ed9f34d85701de869acedd602619/backoff-2.2.1.tar.gz", hash = "sha256:03f829f5bb1923180821643f8753b0502c3b682293992485b0eef2807afa5cba", size = 17001, upload-time = "2022-10-05T19:19:32.061Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/df/73/b6e24bd22e6720ca8ee9a85a0c4a2971af8497d8f3193fa05390cbd46e09/backoff-2.2.1-py3-none-any.whl", hash = "sha256:63579f9a0628e06278f7e47b7d7d5b6ce20dc65c5e96a6f3ca99a6adca0396e8", size = 15148, upload-time = "2022-10-05T19:19:30.546Z" }, + { url = "https://files.pythonhosted.org/packages/df/73/b6e24bd22e6720ca8ee9a85a0c4a2971af8497d8f3193fa05390cbd46e09/backoff-2.2.1-py3-none-any.whl", hash = "sha256:63579f9a0628e06278f7e47b7d7d5b6ce20dc65c5e96a6f3ca99a6adca0396e8", size = 15148, upload-time = "2022-10-05T19:19:30.546Z" }, ] [[package]] name = "certifi" -version = "2026.4.22" +version = "2026.5.20" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/25/ee/6caf7a40c36a1220410afe15a1cc64993a1f864871f698c0f93acb72842a/certifi-2026.4.22.tar.gz", hash = "sha256:8d455352a37b71bf76a79caa83a3d6c25afee4a385d632127b6afb3963f1c580", size = 137077, upload-time = "2026-04-22T11:26:11.191Z" } +sdist = { url = "https://files.pythonhosted.org/packages/f3/ce/ee2ecad540810a79593028e88299baeae54d346cc7a0d94b6199988b89b1/certifi-2026.5.20.tar.gz", hash = "sha256:69dea482ab64caa7b9f6aba1c6bf48bb6a5448d1c0f1b17ab42ad8c763a5344d", size = 135422, upload-time = "2026-05-20T11:46:50.073Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/22/30/7cd8fdcdfbc5b869528b079bfb76dcdf6056b1a2097a662e5e8c04f42965/certifi-2026.4.22-py3-none-any.whl", hash = "sha256:3cb2210c8f88ba2318d29b0388d1023c8492ff72ecdde4ebdaddbb13a31b1c4a", size = 135707, upload-time = "2026-04-22T11:26:09.372Z" }, + { url = "https://files.pythonhosted.org/packages/59/8c/57e832b7af6d7c5abe66eb3fbe3a3a32f4d11ea23a1aa7131371035be991/certifi-2026.5.20-py3-none-any.whl", hash = "sha256:3c52e209ba0a4ad7aebe60436a4ab349c39e1e602e8c134221e546902ad25897", size = 134134, upload-time = "2026-05-20T11:46:48.578Z" }, ] [[package]] @@ -73,7 +73,7 @@ version = "3.5.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/4e/b5/721b8799b04bf9afe054a3899c6cf4e880fcf8563cc71c15610242490a0c/cfgv-3.5.0.tar.gz", hash = "sha256:d5b1034354820651caa73ede66a6294d6e95c1b00acc5e9b098e917404669132", size = 7334, upload-time = "2025-11-19T20:55:51.612Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/db/3c/33bac158f8ab7f89b2e59426d5fe2e4f63f7ed25df84c036890172b412b5/cfgv-3.5.0-py2.py3-none-any.whl", hash = "sha256:a8dc6b26ad22ff227d2634a65cb388215ce6cc96bbcc5cfde7641ae87e8dacc0", size = 7445, upload-time = "2025-11-19T20:55:50.744Z" }, + { url = "https://files.pythonhosted.org/packages/db/3c/33bac158f8ab7f89b2e59426d5fe2e4f63f7ed25df84c036890172b412b5/cfgv-3.5.0-py2.py3-none-any.whl", hash = "sha256:a8dc6b26ad22ff227d2634a65cb388215ce6cc96bbcc5cfde7641ae87e8dacc0", size = 7445, upload-time = "2025-11-19T20:55:50.744Z" }, ] [[package]] @@ -82,67 +82,67 @@ version = "3.4.7" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/e7/a1/67fe25fac3c7642725500a3f6cfe5821ad557c3abb11c9d20d12c7008d3e/charset_normalizer-3.4.7.tar.gz", hash = "sha256:ae89db9e5f98a11a4bf50407d4363e7b09b31e55bc117b4f7d80aab97ba009e5", size = 144271, upload-time = "2026-04-02T09:28:39.342Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c1/3b/66777e39d3ae1ddc77ee606be4ec6d8cbd4c801f65e5a1b6f2b11b8346dd/charset_normalizer-3.4.7-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:f496c9c3cc02230093d8330875c4c3cdfc3b73612a5fd921c65d39cbcef08063", size = 309627, upload-time = "2026-04-02T09:26:45.198Z" }, - { url = "https://files.pythonhosted.org/packages/2e/4e/b7f84e617b4854ade48a1b7915c8ccfadeba444d2a18c291f696e37f0d3b/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ea948db76d31190bf08bd371623927ee1339d5f2a0b4b1b4a4439a65298703c", size = 207008, upload-time = "2026-04-02T09:26:46.824Z" }, - { url = "https://files.pythonhosted.org/packages/c4/bb/ec73c0257c9e11b268f018f068f5d00aa0ef8c8b09f7753ebd5f2880e248/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a277ab8928b9f299723bc1a2dabb1265911b1a76341f90a510368ca44ad9ab66", size = 228303, upload-time = "2026-04-02T09:26:48.397Z" }, - { url = "https://files.pythonhosted.org/packages/85/fb/32d1f5033484494619f701e719429c69b766bfc4dbc61aa9e9c8c166528b/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3bec022aec2c514d9cf199522a802bd007cd588ab17ab2525f20f9c34d067c18", size = 224282, upload-time = "2026-04-02T09:26:49.684Z" }, - { url = "https://files.pythonhosted.org/packages/fa/07/330e3a0dda4c404d6da83b327270906e9654a24f6c546dc886a0eb0ffb23/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e044c39e41b92c845bc815e5ae4230804e8e7bc29e399b0437d64222d92809dd", size = 215595, upload-time = "2026-04-02T09:26:50.915Z" }, - { url = "https://files.pythonhosted.org/packages/e3/7c/fc890655786e423f02556e0216d4b8c6bcb6bdfa890160dc66bf52dee468/charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:f495a1652cf3fbab2eb0639776dad966c2fb874d79d87ca07f9d5f059b8bd215", size = 201986, upload-time = "2026-04-02T09:26:52.197Z" }, - { url = "https://files.pythonhosted.org/packages/d8/97/bfb18b3db2aed3b90cf54dc292ad79fdd5ad65c4eae454099475cbeadd0d/charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e712b419df8ba5e42b226c510472b37bd57b38e897d3eca5e8cfd410a29fa859", size = 211711, upload-time = "2026-04-02T09:26:53.49Z" }, - { url = "https://files.pythonhosted.org/packages/6f/a5/a581c13798546a7fd557c82614a5c65a13df2157e9ad6373166d2a3e645d/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7804338df6fcc08105c7745f1502ba68d900f45fd770d5bdd5288ddccb8a42d8", size = 210036, upload-time = "2026-04-02T09:26:54.975Z" }, - { url = "https://files.pythonhosted.org/packages/8c/bf/b3ab5bcb478e4193d517644b0fb2bf5497fbceeaa7a1bc0f4d5b50953861/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:481551899c856c704d58119b5025793fa6730adda3571971af568f66d2424bb5", size = 202998, upload-time = "2026-04-02T09:26:56.303Z" }, - { url = "https://files.pythonhosted.org/packages/e7/4e/23efd79b65d314fa320ec6017b4b5834d5c12a58ba4610aa353af2e2f577/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:f59099f9b66f0d7145115e6f80dd8b1d847176df89b234a5a6b3f00437aa0832", size = 230056, upload-time = "2026-04-02T09:26:57.554Z" }, - { url = "https://files.pythonhosted.org/packages/b9/9f/1e1941bc3f0e01df116e68dc37a55c4d249df5e6fa77f008841aef68264f/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:f59ad4c0e8f6bba240a9bb85504faa1ab438237199d4cce5f622761507b8f6a6", size = 211537, upload-time = "2026-04-02T09:26:58.843Z" }, - { url = "https://files.pythonhosted.org/packages/80/0f/088cbb3020d44428964a6c97fe1edfb1b9550396bf6d278330281e8b709c/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:3dedcc22d73ec993f42055eff4fcfed9318d1eeb9a6606c55892a26964964e48", size = 226176, upload-time = "2026-04-02T09:27:00.437Z" }, - { url = "https://files.pythonhosted.org/packages/6a/9f/130394f9bbe06f4f63e22641d32fc9b202b7e251c9aef4db044324dac493/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:64f02c6841d7d83f832cd97ccf8eb8a906d06eb95d5276069175c696b024b60a", size = 217723, upload-time = "2026-04-02T09:27:02.021Z" }, - { url = "https://files.pythonhosted.org/packages/73/55/c469897448a06e49f8fa03f6caae97074fde823f432a98f979cc42b90e69/charset_normalizer-3.4.7-cp313-cp313-win32.whl", hash = "sha256:4042d5c8f957e15221d423ba781e85d553722fc4113f523f2feb7b188cc34c5e", size = 148085, upload-time = "2026-04-02T09:27:03.192Z" }, - { url = "https://files.pythonhosted.org/packages/5d/78/1b74c5bbb3f99b77a1715c91b3e0b5bdb6fe302d95ace4f5b1bec37b0167/charset_normalizer-3.4.7-cp313-cp313-win_amd64.whl", hash = "sha256:3946fa46a0cf3e4c8cb1cc52f56bb536310d34f25f01ca9b6c16afa767dab110", size = 158819, upload-time = "2026-04-02T09:27:04.454Z" }, - { url = "https://files.pythonhosted.org/packages/68/86/46bd42279d323deb8687c4a5a811fd548cb7d1de10cf6535d099877a9a9f/charset_normalizer-3.4.7-cp313-cp313-win_arm64.whl", hash = "sha256:80d04837f55fc81da168b98de4f4b797ef007fc8a79ab71c6ec9bc4dd662b15b", size = 147915, upload-time = "2026-04-02T09:27:05.971Z" }, - { url = "https://files.pythonhosted.org/packages/97/c8/c67cb8c70e19ef1960b97b22ed2a1567711de46c4ddf19799923adc836c2/charset_normalizer-3.4.7-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:c36c333c39be2dbca264d7803333c896ab8fa7d4d6f0ab7edb7dfd7aea6e98c0", size = 309234, upload-time = "2026-04-02T09:27:07.194Z" }, - { url = "https://files.pythonhosted.org/packages/99/85/c091fdee33f20de70d6c8b522743b6f831a2f1cd3ff86de4c6a827c48a76/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1c2aed2e5e41f24ea8ef1590b8e848a79b56f3a5564a65ceec43c9d692dc7d8a", size = 208042, upload-time = "2026-04-02T09:27:08.749Z" }, - { url = "https://files.pythonhosted.org/packages/87/1c/ab2ce611b984d2fd5d86a5a8a19c1ae26acac6bad967da4967562c75114d/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:54523e136b8948060c0fa0bc7b1b50c32c186f2fceee897a495406bb6e311d2b", size = 228706, upload-time = "2026-04-02T09:27:09.951Z" }, - { url = "https://files.pythonhosted.org/packages/a8/29/2b1d2cb00bf085f59d29eb773ce58ec2d325430f8c216804a0a5cd83cbca/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:715479b9a2802ecac752a3b0efa2b0b60285cf962ee38414211abdfccc233b41", size = 224727, upload-time = "2026-04-02T09:27:11.175Z" }, - { url = "https://files.pythonhosted.org/packages/47/5c/032c2d5a07fe4d4855fea851209cca2b6f03ebeb6d4e3afdb3358386a684/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bd6c2a1c7573c64738d716488d2cdd3c00e340e4835707d8fdb8dc1a66ef164e", size = 215882, upload-time = "2026-04-02T09:27:12.446Z" }, - { url = "https://files.pythonhosted.org/packages/2c/c2/356065d5a8b78ed04499cae5f339f091946a6a74f91e03476c33f0ab7100/charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:c45e9440fb78f8ddabcf714b68f936737a121355bf59f3907f4e17721b9d1aae", size = 200860, upload-time = "2026-04-02T09:27:13.721Z" }, - { url = "https://files.pythonhosted.org/packages/0c/cd/a32a84217ced5039f53b29f460962abb2d4420def55afabe45b1c3c7483d/charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3534e7dcbdcf757da6b85a0bbf5b6868786d5982dd959b065e65481644817a18", size = 211564, upload-time = "2026-04-02T09:27:15.272Z" }, - { url = "https://files.pythonhosted.org/packages/44/86/58e6f13ce26cc3b8f4a36b94a0f22ae2f00a72534520f4ae6857c4b81f89/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e8ac484bf18ce6975760921bb6148041faa8fef0547200386ea0b52b5d27bf7b", size = 211276, upload-time = "2026-04-02T09:27:16.834Z" }, - { url = "https://files.pythonhosted.org/packages/8f/fe/d17c32dc72e17e155e06883efa84514ca375f8a528ba2546bee73fc4df81/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:a5fe03b42827c13cdccd08e6c0247b6a6d4b5e3cdc53fd1749f5896adcdc2356", size = 201238, upload-time = "2026-04-02T09:27:18.229Z" }, - { url = "https://files.pythonhosted.org/packages/6a/29/f33daa50b06525a237451cdb6c69da366c381a3dadcd833fa5676bc468b3/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:2d6eb928e13016cea4f1f21d1e10c1cebd5a421bc57ddf5b1142ae3f86824fab", size = 230189, upload-time = "2026-04-02T09:27:19.445Z" }, - { url = "https://files.pythonhosted.org/packages/b6/6e/52c84015394a6a0bdcd435210a7e944c5f94ea1055f5cc5d56c5fe368e7b/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:e74327fb75de8986940def6e8dee4f127cc9752bee7355bb323cc5b2659b6d46", size = 211352, upload-time = "2026-04-02T09:27:20.79Z" }, - { url = "https://files.pythonhosted.org/packages/8c/d7/4353be581b373033fb9198bf1da3cf8f09c1082561e8e922aa7b39bf9fe8/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:d6038d37043bced98a66e68d3aa2b6a35505dc01328cd65217cefe82f25def44", size = 227024, upload-time = "2026-04-02T09:27:22.063Z" }, - { url = "https://files.pythonhosted.org/packages/30/45/99d18aa925bd1740098ccd3060e238e21115fffbfdcb8f3ece837d0ace6c/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7579e913a5339fb8fa133f6bbcfd8e6749696206cf05acdbdca71a1b436d8e72", size = 217869, upload-time = "2026-04-02T09:27:23.486Z" }, - { url = "https://files.pythonhosted.org/packages/5c/05/5ee478aa53f4bb7996482153d4bfe1b89e0f087f0ab6b294fcf92d595873/charset_normalizer-3.4.7-cp314-cp314-win32.whl", hash = "sha256:5b77459df20e08151cd6f8b9ef8ef1f961ef73d85c21a555c7eed5b79410ec10", size = 148541, upload-time = "2026-04-02T09:27:25.146Z" }, - { url = "https://files.pythonhosted.org/packages/48/77/72dcb0921b2ce86420b2d79d454c7022bf5be40202a2a07906b9f2a35c97/charset_normalizer-3.4.7-cp314-cp314-win_amd64.whl", hash = "sha256:92a0a01ead5e668468e952e4238cccd7c537364eb7d851ab144ab6627dbbe12f", size = 159634, upload-time = "2026-04-02T09:27:26.642Z" }, - { url = "https://files.pythonhosted.org/packages/c6/a3/c2369911cd72f02386e4e340770f6e158c7980267da16af8f668217abaa0/charset_normalizer-3.4.7-cp314-cp314-win_arm64.whl", hash = "sha256:67f6279d125ca0046a7fd386d01b311c6363844deac3e5b069b514ba3e63c246", size = 148384, upload-time = "2026-04-02T09:27:28.271Z" }, - { url = "https://files.pythonhosted.org/packages/94/09/7e8a7f73d24dba1f0035fbbf014d2c36828fc1bf9c88f84093e57d315935/charset_normalizer-3.4.7-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:effc3f449787117233702311a1b7d8f59cba9ced946ba727bdc329ec69028e24", size = 330133, upload-time = "2026-04-02T09:27:29.474Z" }, - { url = "https://files.pythonhosted.org/packages/8d/da/96975ddb11f8e977f706f45cddd8540fd8242f71ecdb5d18a80723dcf62c/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fbccdc05410c9ee21bbf16a35f4c1d16123dcdeb8a1d38f33654fa21d0234f79", size = 216257, upload-time = "2026-04-02T09:27:30.793Z" }, - { url = "https://files.pythonhosted.org/packages/e5/e8/1d63bf8ef2d388e95c64b2098f45f84758f6d102a087552da1485912637b/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:733784b6d6def852c814bce5f318d25da2ee65dd4839a0718641c696e09a2960", size = 234851, upload-time = "2026-04-02T09:27:32.44Z" }, - { url = "https://files.pythonhosted.org/packages/9b/40/e5ff04233e70da2681fa43969ad6f66ca5611d7e669be0246c4c7aaf6dc8/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a89c23ef8d2c6b27fd200a42aa4ac72786e7c60d40efdc76e6011260b6e949c4", size = 233393, upload-time = "2026-04-02T09:27:34.03Z" }, - { url = "https://files.pythonhosted.org/packages/be/c1/06c6c49d5a5450f76899992f1ee40b41d076aee9279b49cf9974d2f313d5/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6c114670c45346afedc0d947faf3c7f701051d2518b943679c8ff88befe14f8e", size = 223251, upload-time = "2026-04-02T09:27:35.369Z" }, - { url = "https://files.pythonhosted.org/packages/2b/9f/f2ff16fb050946169e3e1f82134d107e5d4ae72647ec8a1b1446c148480f/charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:a180c5e59792af262bf263b21a3c49353f25945d8d9f70628e73de370d55e1e1", size = 206609, upload-time = "2026-04-02T09:27:36.661Z" }, - { url = "https://files.pythonhosted.org/packages/69/d5/a527c0cd8d64d2eab7459784fb4169a0ac76e5a6fc5237337982fd61347e/charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3c9a494bc5ec77d43cea229c4f6db1e4d8fe7e1bbffa8b6f0f0032430ff8ab44", size = 220014, upload-time = "2026-04-02T09:27:38.019Z" }, - { url = "https://files.pythonhosted.org/packages/7e/80/8a7b8104a3e203074dc9aa2c613d4b726c0e136bad1cc734594b02867972/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8d828b6667a32a728a1ad1d93957cdf37489c57b97ae6c4de2860fa749b8fc1e", size = 218979, upload-time = "2026-04-02T09:27:39.37Z" }, - { url = "https://files.pythonhosted.org/packages/02/9a/b759b503d507f375b2b5c153e4d2ee0a75aa215b7f2489cf314f4541f2c0/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:cf1493cd8607bec4d8a7b9b004e699fcf8f9103a9284cc94962cb73d20f9d4a3", size = 209238, upload-time = "2026-04-02T09:27:40.722Z" }, - { url = "https://files.pythonhosted.org/packages/c2/4e/0f3f5d47b86bdb79256e7290b26ac847a2832d9a4033f7eb2cd4bcf4bb5b/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:0c96c3b819b5c3e9e165495db84d41914d6894d55181d2d108cc1a69bfc9cce0", size = 236110, upload-time = "2026-04-02T09:27:42.33Z" }, - { url = "https://files.pythonhosted.org/packages/96/23/bce28734eb3ed2c91dcf93abeb8a5cf393a7b2749725030bb630e554fdd8/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:752a45dc4a6934060b3b0dab47e04edc3326575f82be64bc4fc293914566503e", size = 219824, upload-time = "2026-04-02T09:27:43.924Z" }, - { url = "https://files.pythonhosted.org/packages/2c/6f/6e897c6984cc4d41af319b077f2f600fc8214eb2fe2d6bcb79141b882400/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:8778f0c7a52e56f75d12dae53ae320fae900a8b9b4164b981b9c5ce059cd1fcb", size = 233103, upload-time = "2026-04-02T09:27:45.348Z" }, - { url = "https://files.pythonhosted.org/packages/76/22/ef7bd0fe480a0ae9b656189ec00744b60933f68b4f42a7bb06589f6f576a/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ce3412fbe1e31eb81ea42f4169ed94861c56e643189e1e75f0041f3fe7020abe", size = 225194, upload-time = "2026-04-02T09:27:46.706Z" }, - { url = "https://files.pythonhosted.org/packages/c5/a7/0e0ab3e0b5bc1219bd80a6a0d4d72ca74d9250cb2382b7c699c147e06017/charset_normalizer-3.4.7-cp314-cp314t-win32.whl", hash = "sha256:c03a41a8784091e67a39648f70c5f97b5b6a37f216896d44d2cdcb82615339a0", size = 159827, upload-time = "2026-04-02T09:27:48.053Z" }, - { url = "https://files.pythonhosted.org/packages/7a/1d/29d32e0fb40864b1f878c7f5a0b343ae676c6e2b271a2d55cc3a152391da/charset_normalizer-3.4.7-cp314-cp314t-win_amd64.whl", hash = "sha256:03853ed82eeebbce3c2abfdbc98c96dc205f32a79627688ac9a27370ea61a49c", size = 174168, upload-time = "2026-04-02T09:27:49.795Z" }, - { url = "https://files.pythonhosted.org/packages/de/32/d92444ad05c7a6e41fb2036749777c163baf7a0301a040cb672d6b2b1ae9/charset_normalizer-3.4.7-cp314-cp314t-win_arm64.whl", hash = "sha256:c35abb8bfff0185efac5878da64c45dafd2b37fb0383add1be155a763c1f083d", size = 153018, upload-time = "2026-04-02T09:27:51.116Z" }, - { url = "https://files.pythonhosted.org/packages/db/8f/61959034484a4a7c527811f4721e75d02d653a35afb0b6054474d8185d4c/charset_normalizer-3.4.7-py3-none-any.whl", hash = "sha256:3dce51d0f5e7951f8bb4900c257dad282f49190fdbebecd4ba99bcc41fef404d", size = 61958, upload-time = "2026-04-02T09:28:37.794Z" }, + { url = "https://files.pythonhosted.org/packages/c1/3b/66777e39d3ae1ddc77ee606be4ec6d8cbd4c801f65e5a1b6f2b11b8346dd/charset_normalizer-3.4.7-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:f496c9c3cc02230093d8330875c4c3cdfc3b73612a5fd921c65d39cbcef08063", size = 309627, upload-time = "2026-04-02T09:26:45.198Z" }, + { url = "https://files.pythonhosted.org/packages/2e/4e/b7f84e617b4854ade48a1b7915c8ccfadeba444d2a18c291f696e37f0d3b/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ea948db76d31190bf08bd371623927ee1339d5f2a0b4b1b4a4439a65298703c", size = 207008, upload-time = "2026-04-02T09:26:46.824Z" }, + { url = "https://files.pythonhosted.org/packages/c4/bb/ec73c0257c9e11b268f018f068f5d00aa0ef8c8b09f7753ebd5f2880e248/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a277ab8928b9f299723bc1a2dabb1265911b1a76341f90a510368ca44ad9ab66", size = 228303, upload-time = "2026-04-02T09:26:48.397Z" }, + { url = "https://files.pythonhosted.org/packages/85/fb/32d1f5033484494619f701e719429c69b766bfc4dbc61aa9e9c8c166528b/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3bec022aec2c514d9cf199522a802bd007cd588ab17ab2525f20f9c34d067c18", size = 224282, upload-time = "2026-04-02T09:26:49.684Z" }, + { url = "https://files.pythonhosted.org/packages/fa/07/330e3a0dda4c404d6da83b327270906e9654a24f6c546dc886a0eb0ffb23/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e044c39e41b92c845bc815e5ae4230804e8e7bc29e399b0437d64222d92809dd", size = 215595, upload-time = "2026-04-02T09:26:50.915Z" }, + { url = "https://files.pythonhosted.org/packages/e3/7c/fc890655786e423f02556e0216d4b8c6bcb6bdfa890160dc66bf52dee468/charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:f495a1652cf3fbab2eb0639776dad966c2fb874d79d87ca07f9d5f059b8bd215", size = 201986, upload-time = "2026-04-02T09:26:52.197Z" }, + { url = "https://files.pythonhosted.org/packages/d8/97/bfb18b3db2aed3b90cf54dc292ad79fdd5ad65c4eae454099475cbeadd0d/charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e712b419df8ba5e42b226c510472b37bd57b38e897d3eca5e8cfd410a29fa859", size = 211711, upload-time = "2026-04-02T09:26:53.49Z" }, + { url = "https://files.pythonhosted.org/packages/6f/a5/a581c13798546a7fd557c82614a5c65a13df2157e9ad6373166d2a3e645d/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7804338df6fcc08105c7745f1502ba68d900f45fd770d5bdd5288ddccb8a42d8", size = 210036, upload-time = "2026-04-02T09:26:54.975Z" }, + { url = "https://files.pythonhosted.org/packages/8c/bf/b3ab5bcb478e4193d517644b0fb2bf5497fbceeaa7a1bc0f4d5b50953861/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:481551899c856c704d58119b5025793fa6730adda3571971af568f66d2424bb5", size = 202998, upload-time = "2026-04-02T09:26:56.303Z" }, + { url = "https://files.pythonhosted.org/packages/e7/4e/23efd79b65d314fa320ec6017b4b5834d5c12a58ba4610aa353af2e2f577/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:f59099f9b66f0d7145115e6f80dd8b1d847176df89b234a5a6b3f00437aa0832", size = 230056, upload-time = "2026-04-02T09:26:57.554Z" }, + { url = "https://files.pythonhosted.org/packages/b9/9f/1e1941bc3f0e01df116e68dc37a55c4d249df5e6fa77f008841aef68264f/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:f59ad4c0e8f6bba240a9bb85504faa1ab438237199d4cce5f622761507b8f6a6", size = 211537, upload-time = "2026-04-02T09:26:58.843Z" }, + { url = "https://files.pythonhosted.org/packages/80/0f/088cbb3020d44428964a6c97fe1edfb1b9550396bf6d278330281e8b709c/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:3dedcc22d73ec993f42055eff4fcfed9318d1eeb9a6606c55892a26964964e48", size = 226176, upload-time = "2026-04-02T09:27:00.437Z" }, + { url = "https://files.pythonhosted.org/packages/6a/9f/130394f9bbe06f4f63e22641d32fc9b202b7e251c9aef4db044324dac493/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:64f02c6841d7d83f832cd97ccf8eb8a906d06eb95d5276069175c696b024b60a", size = 217723, upload-time = "2026-04-02T09:27:02.021Z" }, + { url = "https://files.pythonhosted.org/packages/73/55/c469897448a06e49f8fa03f6caae97074fde823f432a98f979cc42b90e69/charset_normalizer-3.4.7-cp313-cp313-win32.whl", hash = "sha256:4042d5c8f957e15221d423ba781e85d553722fc4113f523f2feb7b188cc34c5e", size = 148085, upload-time = "2026-04-02T09:27:03.192Z" }, + { url = "https://files.pythonhosted.org/packages/5d/78/1b74c5bbb3f99b77a1715c91b3e0b5bdb6fe302d95ace4f5b1bec37b0167/charset_normalizer-3.4.7-cp313-cp313-win_amd64.whl", hash = "sha256:3946fa46a0cf3e4c8cb1cc52f56bb536310d34f25f01ca9b6c16afa767dab110", size = 158819, upload-time = "2026-04-02T09:27:04.454Z" }, + { url = "https://files.pythonhosted.org/packages/68/86/46bd42279d323deb8687c4a5a811fd548cb7d1de10cf6535d099877a9a9f/charset_normalizer-3.4.7-cp313-cp313-win_arm64.whl", hash = "sha256:80d04837f55fc81da168b98de4f4b797ef007fc8a79ab71c6ec9bc4dd662b15b", size = 147915, upload-time = "2026-04-02T09:27:05.971Z" }, + { url = "https://files.pythonhosted.org/packages/97/c8/c67cb8c70e19ef1960b97b22ed2a1567711de46c4ddf19799923adc836c2/charset_normalizer-3.4.7-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:c36c333c39be2dbca264d7803333c896ab8fa7d4d6f0ab7edb7dfd7aea6e98c0", size = 309234, upload-time = "2026-04-02T09:27:07.194Z" }, + { url = "https://files.pythonhosted.org/packages/99/85/c091fdee33f20de70d6c8b522743b6f831a2f1cd3ff86de4c6a827c48a76/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1c2aed2e5e41f24ea8ef1590b8e848a79b56f3a5564a65ceec43c9d692dc7d8a", size = 208042, upload-time = "2026-04-02T09:27:08.749Z" }, + { url = "https://files.pythonhosted.org/packages/87/1c/ab2ce611b984d2fd5d86a5a8a19c1ae26acac6bad967da4967562c75114d/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:54523e136b8948060c0fa0bc7b1b50c32c186f2fceee897a495406bb6e311d2b", size = 228706, upload-time = "2026-04-02T09:27:09.951Z" }, + { url = "https://files.pythonhosted.org/packages/a8/29/2b1d2cb00bf085f59d29eb773ce58ec2d325430f8c216804a0a5cd83cbca/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:715479b9a2802ecac752a3b0efa2b0b60285cf962ee38414211abdfccc233b41", size = 224727, upload-time = "2026-04-02T09:27:11.175Z" }, + { url = "https://files.pythonhosted.org/packages/47/5c/032c2d5a07fe4d4855fea851209cca2b6f03ebeb6d4e3afdb3358386a684/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bd6c2a1c7573c64738d716488d2cdd3c00e340e4835707d8fdb8dc1a66ef164e", size = 215882, upload-time = "2026-04-02T09:27:12.446Z" }, + { url = "https://files.pythonhosted.org/packages/2c/c2/356065d5a8b78ed04499cae5f339f091946a6a74f91e03476c33f0ab7100/charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:c45e9440fb78f8ddabcf714b68f936737a121355bf59f3907f4e17721b9d1aae", size = 200860, upload-time = "2026-04-02T09:27:13.721Z" }, + { url = "https://files.pythonhosted.org/packages/0c/cd/a32a84217ced5039f53b29f460962abb2d4420def55afabe45b1c3c7483d/charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3534e7dcbdcf757da6b85a0bbf5b6868786d5982dd959b065e65481644817a18", size = 211564, upload-time = "2026-04-02T09:27:15.272Z" }, + { url = "https://files.pythonhosted.org/packages/44/86/58e6f13ce26cc3b8f4a36b94a0f22ae2f00a72534520f4ae6857c4b81f89/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e8ac484bf18ce6975760921bb6148041faa8fef0547200386ea0b52b5d27bf7b", size = 211276, upload-time = "2026-04-02T09:27:16.834Z" }, + { url = "https://files.pythonhosted.org/packages/8f/fe/d17c32dc72e17e155e06883efa84514ca375f8a528ba2546bee73fc4df81/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:a5fe03b42827c13cdccd08e6c0247b6a6d4b5e3cdc53fd1749f5896adcdc2356", size = 201238, upload-time = "2026-04-02T09:27:18.229Z" }, + { url = "https://files.pythonhosted.org/packages/6a/29/f33daa50b06525a237451cdb6c69da366c381a3dadcd833fa5676bc468b3/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:2d6eb928e13016cea4f1f21d1e10c1cebd5a421bc57ddf5b1142ae3f86824fab", size = 230189, upload-time = "2026-04-02T09:27:19.445Z" }, + { url = "https://files.pythonhosted.org/packages/b6/6e/52c84015394a6a0bdcd435210a7e944c5f94ea1055f5cc5d56c5fe368e7b/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:e74327fb75de8986940def6e8dee4f127cc9752bee7355bb323cc5b2659b6d46", size = 211352, upload-time = "2026-04-02T09:27:20.79Z" }, + { url = "https://files.pythonhosted.org/packages/8c/d7/4353be581b373033fb9198bf1da3cf8f09c1082561e8e922aa7b39bf9fe8/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:d6038d37043bced98a66e68d3aa2b6a35505dc01328cd65217cefe82f25def44", size = 227024, upload-time = "2026-04-02T09:27:22.063Z" }, + { url = "https://files.pythonhosted.org/packages/30/45/99d18aa925bd1740098ccd3060e238e21115fffbfdcb8f3ece837d0ace6c/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7579e913a5339fb8fa133f6bbcfd8e6749696206cf05acdbdca71a1b436d8e72", size = 217869, upload-time = "2026-04-02T09:27:23.486Z" }, + { url = "https://files.pythonhosted.org/packages/5c/05/5ee478aa53f4bb7996482153d4bfe1b89e0f087f0ab6b294fcf92d595873/charset_normalizer-3.4.7-cp314-cp314-win32.whl", hash = "sha256:5b77459df20e08151cd6f8b9ef8ef1f961ef73d85c21a555c7eed5b79410ec10", size = 148541, upload-time = "2026-04-02T09:27:25.146Z" }, + { url = "https://files.pythonhosted.org/packages/48/77/72dcb0921b2ce86420b2d79d454c7022bf5be40202a2a07906b9f2a35c97/charset_normalizer-3.4.7-cp314-cp314-win_amd64.whl", hash = "sha256:92a0a01ead5e668468e952e4238cccd7c537364eb7d851ab144ab6627dbbe12f", size = 159634, upload-time = "2026-04-02T09:27:26.642Z" }, + { url = "https://files.pythonhosted.org/packages/c6/a3/c2369911cd72f02386e4e340770f6e158c7980267da16af8f668217abaa0/charset_normalizer-3.4.7-cp314-cp314-win_arm64.whl", hash = "sha256:67f6279d125ca0046a7fd386d01b311c6363844deac3e5b069b514ba3e63c246", size = 148384, upload-time = "2026-04-02T09:27:28.271Z" }, + { url = "https://files.pythonhosted.org/packages/94/09/7e8a7f73d24dba1f0035fbbf014d2c36828fc1bf9c88f84093e57d315935/charset_normalizer-3.4.7-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:effc3f449787117233702311a1b7d8f59cba9ced946ba727bdc329ec69028e24", size = 330133, upload-time = "2026-04-02T09:27:29.474Z" }, + { url = "https://files.pythonhosted.org/packages/8d/da/96975ddb11f8e977f706f45cddd8540fd8242f71ecdb5d18a80723dcf62c/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fbccdc05410c9ee21bbf16a35f4c1d16123dcdeb8a1d38f33654fa21d0234f79", size = 216257, upload-time = "2026-04-02T09:27:30.793Z" }, + { url = "https://files.pythonhosted.org/packages/e5/e8/1d63bf8ef2d388e95c64b2098f45f84758f6d102a087552da1485912637b/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:733784b6d6def852c814bce5f318d25da2ee65dd4839a0718641c696e09a2960", size = 234851, upload-time = "2026-04-02T09:27:32.44Z" }, + { url = "https://files.pythonhosted.org/packages/9b/40/e5ff04233e70da2681fa43969ad6f66ca5611d7e669be0246c4c7aaf6dc8/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a89c23ef8d2c6b27fd200a42aa4ac72786e7c60d40efdc76e6011260b6e949c4", size = 233393, upload-time = "2026-04-02T09:27:34.03Z" }, + { url = "https://files.pythonhosted.org/packages/be/c1/06c6c49d5a5450f76899992f1ee40b41d076aee9279b49cf9974d2f313d5/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6c114670c45346afedc0d947faf3c7f701051d2518b943679c8ff88befe14f8e", size = 223251, upload-time = "2026-04-02T09:27:35.369Z" }, + { url = "https://files.pythonhosted.org/packages/2b/9f/f2ff16fb050946169e3e1f82134d107e5d4ae72647ec8a1b1446c148480f/charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:a180c5e59792af262bf263b21a3c49353f25945d8d9f70628e73de370d55e1e1", size = 206609, upload-time = "2026-04-02T09:27:36.661Z" }, + { url = "https://files.pythonhosted.org/packages/69/d5/a527c0cd8d64d2eab7459784fb4169a0ac76e5a6fc5237337982fd61347e/charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3c9a494bc5ec77d43cea229c4f6db1e4d8fe7e1bbffa8b6f0f0032430ff8ab44", size = 220014, upload-time = "2026-04-02T09:27:38.019Z" }, + { url = "https://files.pythonhosted.org/packages/7e/80/8a7b8104a3e203074dc9aa2c613d4b726c0e136bad1cc734594b02867972/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8d828b6667a32a728a1ad1d93957cdf37489c57b97ae6c4de2860fa749b8fc1e", size = 218979, upload-time = "2026-04-02T09:27:39.37Z" }, + { url = "https://files.pythonhosted.org/packages/02/9a/b759b503d507f375b2b5c153e4d2ee0a75aa215b7f2489cf314f4541f2c0/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:cf1493cd8607bec4d8a7b9b004e699fcf8f9103a9284cc94962cb73d20f9d4a3", size = 209238, upload-time = "2026-04-02T09:27:40.722Z" }, + { url = "https://files.pythonhosted.org/packages/c2/4e/0f3f5d47b86bdb79256e7290b26ac847a2832d9a4033f7eb2cd4bcf4bb5b/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:0c96c3b819b5c3e9e165495db84d41914d6894d55181d2d108cc1a69bfc9cce0", size = 236110, upload-time = "2026-04-02T09:27:42.33Z" }, + { url = "https://files.pythonhosted.org/packages/96/23/bce28734eb3ed2c91dcf93abeb8a5cf393a7b2749725030bb630e554fdd8/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:752a45dc4a6934060b3b0dab47e04edc3326575f82be64bc4fc293914566503e", size = 219824, upload-time = "2026-04-02T09:27:43.924Z" }, + { url = "https://files.pythonhosted.org/packages/2c/6f/6e897c6984cc4d41af319b077f2f600fc8214eb2fe2d6bcb79141b882400/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:8778f0c7a52e56f75d12dae53ae320fae900a8b9b4164b981b9c5ce059cd1fcb", size = 233103, upload-time = "2026-04-02T09:27:45.348Z" }, + { url = "https://files.pythonhosted.org/packages/76/22/ef7bd0fe480a0ae9b656189ec00744b60933f68b4f42a7bb06589f6f576a/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ce3412fbe1e31eb81ea42f4169ed94861c56e643189e1e75f0041f3fe7020abe", size = 225194, upload-time = "2026-04-02T09:27:46.706Z" }, + { url = "https://files.pythonhosted.org/packages/c5/a7/0e0ab3e0b5bc1219bd80a6a0d4d72ca74d9250cb2382b7c699c147e06017/charset_normalizer-3.4.7-cp314-cp314t-win32.whl", hash = "sha256:c03a41a8784091e67a39648f70c5f97b5b6a37f216896d44d2cdcb82615339a0", size = 159827, upload-time = "2026-04-02T09:27:48.053Z" }, + { url = "https://files.pythonhosted.org/packages/7a/1d/29d32e0fb40864b1f878c7f5a0b343ae676c6e2b271a2d55cc3a152391da/charset_normalizer-3.4.7-cp314-cp314t-win_amd64.whl", hash = "sha256:03853ed82eeebbce3c2abfdbc98c96dc205f32a79627688ac9a27370ea61a49c", size = 174168, upload-time = "2026-04-02T09:27:49.795Z" }, + { url = "https://files.pythonhosted.org/packages/de/32/d92444ad05c7a6e41fb2036749777c163baf7a0301a040cb672d6b2b1ae9/charset_normalizer-3.4.7-cp314-cp314t-win_arm64.whl", hash = "sha256:c35abb8bfff0185efac5878da64c45dafd2b37fb0383add1be155a763c1f083d", size = 153018, upload-time = "2026-04-02T09:27:51.116Z" }, + { url = "https://files.pythonhosted.org/packages/db/8f/61959034484a4a7c527811f4721e75d02d653a35afb0b6054474d8185d4c/charset_normalizer-3.4.7-py3-none-any.whl", hash = "sha256:3dce51d0f5e7951f8bb4900c257dad282f49190fdbebecd4ba99bcc41fef404d", size = 61958, upload-time = "2026-04-02T09:28:37.794Z" }, ] [[package]] name = "click" -version = "8.3.3" +version = "8.4.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "colorama", marker = "sys_platform == 'win32'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/bb/63/f9e1ea081ce35720d8b92acde70daaedace594dc93b693c869e0d5910718/click-8.3.3.tar.gz", hash = "sha256:398329ad4837b2ff7cbe1dd166a4c0f8900c3ca3a218de04466f38f6497f18a2", size = 328061, upload-time = "2026-04-22T15:11:27.506Z" } +sdist = { url = "https://files.pythonhosted.org/packages/9b/98/518d8e5081007684232226f475082b30087d0f585e8457db087298259f49/click-8.4.1.tar.gz", hash = "sha256:918b5633eddf6b41c32d4f454bf0de810065c74e3f7dbf8ee5452f8be88d3e96", size = 353007, upload-time = "2026-05-22T04:08:37.769Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ae/44/c1221527f6a71a01ec6fbad7fa78f1d50dfa02217385cf0fa3eec7087d59/click-8.3.3-py3-none-any.whl", hash = "sha256:a2bf429bb3033c89fa4936ffb35d5cb471e3719e1f3c8a7c3fff0b8314305613", size = 110502, upload-time = "2026-04-22T15:11:25.044Z" }, + { url = "https://files.pythonhosted.org/packages/c7/0d/67e5b4109ea4a837e80daa87c2c696711955e40449a97e8926672534def2/click-8.4.1-py3-none-any.whl", hash = "sha256:482be17c6991b8c19c5429a1e995d9b0efdbb63172824c41f99965dc0ade8ec2", size = 116639, upload-time = "2026-05-22T04:08:35.26Z" }, ] [[package]] @@ -151,76 +151,76 @@ version = "0.4.6" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, ] [[package]] name = "coverage" -version = "7.13.5" +version = "7.14.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/9d/e0/70553e3000e345daff267cec284ce4cbf3fc141b6da229ac52775b5428f1/coverage-7.13.5.tar.gz", hash = "sha256:c81f6515c4c40141f83f502b07bbfa5c240ba25bbe73da7b33f1e5b6120ff179", size = 915967, upload-time = "2026-03-17T10:33:18.341Z" } +sdist = { url = "https://files.pythonhosted.org/packages/54/fd/0ab2772530e946e1be1abd0bc09e647ec9b02e88f0867857601fefca8953/coverage-7.14.1.tar.gz", hash = "sha256:30c08f7d90415aa98b3c990385dea2939b0da55f38515e5b369b83655f8523be", size = 920132, upload-time = "2026-05-26T20:41:36.783Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/74/8c/74fedc9663dcf168b0a059d4ea756ecae4da77a489048f94b5f512a8d0b3/coverage-7.13.5-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5ec4af212df513e399cf11610cc27063f1586419e814755ab362e50a85ea69c1", size = 219576, upload-time = "2026-03-17T10:31:09.045Z" }, - { url = "https://files.pythonhosted.org/packages/0c/c9/44fb661c55062f0818a6ffd2685c67aa30816200d5f2817543717d4b92eb/coverage-7.13.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:941617e518602e2d64942c88ec8499f7fbd49d3f6c4327d3a71d43a1973032f3", size = 219942, upload-time = "2026-03-17T10:31:10.708Z" }, - { url = "https://files.pythonhosted.org/packages/5f/13/93419671cee82b780bab7ea96b67c8ef448f5f295f36bf5031154ec9a790/coverage-7.13.5-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:da305e9937617ee95c2e39d8ff9f040e0487cbf1ac174f777ed5eddd7a7c1f26", size = 250935, upload-time = "2026-03-17T10:31:12.392Z" }, - { url = "https://files.pythonhosted.org/packages/ac/68/1666e3a4462f8202d836920114fa7a5ee9275d1fa45366d336c551a162dd/coverage-7.13.5-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:78e696e1cc714e57e8b25760b33a8b1026b7048d270140d25dafe1b0a1ee05a3", size = 253541, upload-time = "2026-03-17T10:31:14.247Z" }, - { url = "https://files.pythonhosted.org/packages/4e/5e/3ee3b835647be646dcf3c65a7c6c18f87c27326a858f72ab22c12730773d/coverage-7.13.5-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:02ca0eed225b2ff301c474aeeeae27d26e2537942aa0f87491d3e147e784a82b", size = 254780, upload-time = "2026-03-17T10:31:16.193Z" }, - { url = "https://files.pythonhosted.org/packages/44/b3/cb5bd1a04cfcc49ede6cd8409d80bee17661167686741e041abc7ee1b9a9/coverage-7.13.5-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:04690832cbea4e4663d9149e05dba142546ca05cb1848816760e7f58285c970a", size = 256912, upload-time = "2026-03-17T10:31:17.89Z" }, - { url = "https://files.pythonhosted.org/packages/1b/66/c1dceb7b9714473800b075f5c8a84f4588f887a90eb8645282031676e242/coverage-7.13.5-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0590e44dd2745c696a778f7bab6aa95256de2cbc8b8cff4f7db8ff09813d6969", size = 251165, upload-time = "2026-03-17T10:31:19.605Z" }, - { url = "https://files.pythonhosted.org/packages/b7/62/5502b73b97aa2e53ea22a39cf8649ff44827bef76d90bf638777daa27a9d/coverage-7.13.5-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d7cfad2d6d81dd298ab6b89fe72c3b7b05ec7544bdda3b707ddaecff8d25c161", size = 252908, upload-time = "2026-03-17T10:31:21.312Z" }, - { url = "https://files.pythonhosted.org/packages/7d/37/7792c2d69854397ca77a55c4646e5897c467928b0e27f2d235d83b5d08c6/coverage-7.13.5-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:e092b9499de38ae0fbfbc603a74660eb6ff3e869e507b50d85a13b6db9863e15", size = 250873, upload-time = "2026-03-17T10:31:23.565Z" }, - { url = "https://files.pythonhosted.org/packages/a3/23/bc866fb6163be52a8a9e5d708ba0d3b1283c12158cefca0a8bbb6e247a43/coverage-7.13.5-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:48c39bc4a04d983a54a705a6389512883d4a3b9862991b3617d547940e9f52b1", size = 255030, upload-time = "2026-03-17T10:31:25.58Z" }, - { url = "https://files.pythonhosted.org/packages/7d/8b/ef67e1c222ef49860701d346b8bbb70881bef283bd5f6cbba68a39a086c7/coverage-7.13.5-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:2d3807015f138ffea1ed9afeeb8624fd781703f2858b62a8dd8da5a0994c57b6", size = 250694, upload-time = "2026-03-17T10:31:27.316Z" }, - { url = "https://files.pythonhosted.org/packages/46/0d/866d1f74f0acddbb906db212e096dee77a8e2158ca5e6bb44729f9d93298/coverage-7.13.5-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ee2aa19e03161671ec964004fb74b2257805d9710bf14a5c704558b9d8dbaf17", size = 252469, upload-time = "2026-03-17T10:31:29.472Z" }, - { url = "https://files.pythonhosted.org/packages/7a/f5/be742fec31118f02ce42b21c6af187ad6a344fed546b56ca60caacc6a9a0/coverage-7.13.5-cp313-cp313-win32.whl", hash = "sha256:ce1998c0483007608c8382f4ff50164bfc5bd07a2246dd272aa4043b75e61e85", size = 222112, upload-time = "2026-03-17T10:31:31.526Z" }, - { url = "https://files.pythonhosted.org/packages/66/40/7732d648ab9d069a46e686043241f01206348e2bbf128daea85be4d6414b/coverage-7.13.5-cp313-cp313-win_amd64.whl", hash = "sha256:631efb83f01569670a5e866ceb80fe483e7c159fac6f167e6571522636104a0b", size = 222923, upload-time = "2026-03-17T10:31:33.633Z" }, - { url = "https://files.pythonhosted.org/packages/48/af/fea819c12a095781f6ccd504890aaddaf88b8fab263c4940e82c7b770124/coverage-7.13.5-cp313-cp313-win_arm64.whl", hash = "sha256:f4cd16206ad171cbc2470dbea9103cf9a7607d5fe8c242fdf1edf36174020664", size = 221540, upload-time = "2026-03-17T10:31:35.445Z" }, - { url = "https://files.pythonhosted.org/packages/23/d2/17879af479df7fbbd44bd528a31692a48f6b25055d16482fdf5cdb633805/coverage-7.13.5-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0428cbef5783ad91fe240f673cc1f76b25e74bbfe1a13115e4aa30d3f538162d", size = 220262, upload-time = "2026-03-17T10:31:37.184Z" }, - { url = "https://files.pythonhosted.org/packages/5b/4c/d20e554f988c8f91d6a02c5118f9abbbf73a8768a3048cb4962230d5743f/coverage-7.13.5-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e0b216a19534b2427cc201a26c25da4a48633f29a487c61258643e89d28200c0", size = 220617, upload-time = "2026-03-17T10:31:39.245Z" }, - { url = "https://files.pythonhosted.org/packages/29/9c/f9f5277b95184f764b24e7231e166dfdb5780a46d408a2ac665969416d61/coverage-7.13.5-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:972a9cd27894afe4bc2b1480107054e062df08e671df7c2f18c205e805ccd806", size = 261912, upload-time = "2026-03-17T10:31:41.324Z" }, - { url = "https://files.pythonhosted.org/packages/d5/f6/7f1ab39393eeb50cfe4747ae8ef0e4fc564b989225aa1152e13a180d74f8/coverage-7.13.5-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:4b59148601efcd2bac8c4dbf1f0ad6391693ccf7a74b8205781751637076aee3", size = 263987, upload-time = "2026-03-17T10:31:43.724Z" }, - { url = "https://files.pythonhosted.org/packages/a0/d7/62c084fb489ed9c6fbdf57e006752e7c516ea46fd690e5ed8b8617c7d52e/coverage-7.13.5-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:505d7083c8b0c87a8fa8c07370c285847c1f77739b22e299ad75a6af6c32c5c9", size = 266416, upload-time = "2026-03-17T10:31:45.769Z" }, - { url = "https://files.pythonhosted.org/packages/a9/f6/df63d8660e1a0bff6125947afda112a0502736f470d62ca68b288ea762d8/coverage-7.13.5-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:60365289c3741e4db327e7baff2a4aaacf22f788e80fa4683393891b70a89fbd", size = 267558, upload-time = "2026-03-17T10:31:48.293Z" }, - { url = "https://files.pythonhosted.org/packages/5b/02/353ca81d36779bd108f6d384425f7139ac3c58c750dcfaafe5d0bee6436b/coverage-7.13.5-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1b88c69c8ef5d4b6fe7dea66d6636056a0f6a7527c440e890cf9259011f5e606", size = 261163, upload-time = "2026-03-17T10:31:50.125Z" }, - { url = "https://files.pythonhosted.org/packages/2c/16/2e79106d5749bcaf3aee6d309123548e3276517cd7851faa8da213bc61bf/coverage-7.13.5-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:5b13955d31d1633cf9376908089b7cebe7d15ddad7aeaabcbe969a595a97e95e", size = 263981, upload-time = "2026-03-17T10:31:51.961Z" }, - { url = "https://files.pythonhosted.org/packages/29/c7/c29e0c59ffa6942030ae6f50b88ae49988e7e8da06de7ecdbf49c6d4feae/coverage-7.13.5-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:f70c9ab2595c56f81a89620e22899eea8b212a4041bd728ac6f4a28bf5d3ddd0", size = 261604, upload-time = "2026-03-17T10:31:53.872Z" }, - { url = "https://files.pythonhosted.org/packages/40/48/097cdc3db342f34006a308ab41c3a7c11c3f0d84750d340f45d88a782e00/coverage-7.13.5-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:084b84a8c63e8d6fc7e3931b316a9bcafca1458d753c539db82d31ed20091a87", size = 265321, upload-time = "2026-03-17T10:31:55.997Z" }, - { url = "https://files.pythonhosted.org/packages/bb/1f/4994af354689e14fd03a75f8ec85a9a68d94e0188bbdab3fc1516b55e512/coverage-7.13.5-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:ad14385487393e386e2ea988b09d62dd42c397662ac2dabc3832d71253eee479", size = 260502, upload-time = "2026-03-17T10:31:58.308Z" }, - { url = "https://files.pythonhosted.org/packages/22/c6/9bb9ef55903e628033560885f5c31aa227e46878118b63ab15dc7ba87797/coverage-7.13.5-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:7f2c47b36fe7709a6e83bfadf4eefb90bd25fbe4014d715224c4316f808e59a2", size = 262688, upload-time = "2026-03-17T10:32:00.141Z" }, - { url = "https://files.pythonhosted.org/packages/14/4f/f5df9007e50b15e53e01edea486814783a7f019893733d9e4d6caad75557/coverage-7.13.5-cp313-cp313t-win32.whl", hash = "sha256:67e9bc5449801fad0e5dff329499fb090ba4c5800b86805c80617b4e29809b2a", size = 222788, upload-time = "2026-03-17T10:32:02.246Z" }, - { url = "https://files.pythonhosted.org/packages/e1/98/aa7fccaa97d0f3192bec013c4e6fd6d294a6ed44b640e6bb61f479e00ed5/coverage-7.13.5-cp313-cp313t-win_amd64.whl", hash = "sha256:da86cdcf10d2519e10cabb8ac2de03da1bcb6e4853790b7fbd48523332e3a819", size = 223851, upload-time = "2026-03-17T10:32:04.416Z" }, - { url = "https://files.pythonhosted.org/packages/3d/8b/e5c469f7352651e5f013198e9e21f97510b23de957dd06a84071683b4b60/coverage-7.13.5-cp313-cp313t-win_arm64.whl", hash = "sha256:0ecf12ecb326fe2c339d93fc131816f3a7367d223db37817208905c89bded911", size = 222104, upload-time = "2026-03-17T10:32:06.65Z" }, - { url = "https://files.pythonhosted.org/packages/8e/77/39703f0d1d4b478bfd30191d3c14f53caf596fac00efb3f8f6ee23646439/coverage-7.13.5-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fbabfaceaeb587e16f7008f7795cd80d20ec548dc7f94fbb0d4ec2e038ce563f", size = 219621, upload-time = "2026-03-17T10:32:08.589Z" }, - { url = "https://files.pythonhosted.org/packages/e2/3e/51dff36d99ae14639a133d9b164d63e628532e2974d8b1edb99dd1ebc733/coverage-7.13.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:9bb2a28101a443669a423b665939381084412b81c3f8c0fcfbac57f4e30b5b8e", size = 219953, upload-time = "2026-03-17T10:32:10.507Z" }, - { url = "https://files.pythonhosted.org/packages/6a/6c/1f1917b01eb647c2f2adc9962bd66c79eb978951cab61bdc1acab3290c07/coverage-7.13.5-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:bd3a2fbc1c6cccb3c5106140d87cc6a8715110373ef42b63cf5aea29df8c217a", size = 250992, upload-time = "2026-03-17T10:32:12.41Z" }, - { url = "https://files.pythonhosted.org/packages/22/e5/06b1f88f42a5a99df42ce61208bdec3bddb3d261412874280a19796fc09c/coverage-7.13.5-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:6c36ddb64ed9d7e496028d1d00dfec3e428e0aabf4006583bb1839958d280510", size = 253503, upload-time = "2026-03-17T10:32:14.449Z" }, - { url = "https://files.pythonhosted.org/packages/80/28/2a148a51e5907e504fa7b85490277734e6771d8844ebcc48764a15e28155/coverage-7.13.5-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:380e8e9084d8eb38db3a9176a1a4f3c0082c3806fa0dc882d1d87abc3c789247", size = 254852, upload-time = "2026-03-17T10:32:16.56Z" }, - { url = "https://files.pythonhosted.org/packages/61/77/50e8d3d85cc0b7ebe09f30f151d670e302c7ff4a1bf6243f71dd8b0981fa/coverage-7.13.5-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e808af52a0513762df4d945ea164a24b37f2f518cbe97e03deaa0ee66139b4d6", size = 257161, upload-time = "2026-03-17T10:32:19.004Z" }, - { url = "https://files.pythonhosted.org/packages/3b/c4/b5fd1d4b7bf8d0e75d997afd3925c59ba629fc8616f1b3aae7605132e256/coverage-7.13.5-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e301d30dd7e95ae068671d746ba8c34e945a82682e62918e41b2679acd2051a0", size = 251021, upload-time = "2026-03-17T10:32:21.344Z" }, - { url = "https://files.pythonhosted.org/packages/f8/66/6ea21f910e92d69ef0b1c3346ea5922a51bad4446c9126db2ae96ee24c4c/coverage-7.13.5-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:800bc829053c80d240a687ceeb927a94fd108bbdc68dfbe505d0d75ab578a882", size = 252858, upload-time = "2026-03-17T10:32:23.506Z" }, - { url = "https://files.pythonhosted.org/packages/9e/ea/879c83cb5d61aa2a35fb80e72715e92672daef8191b84911a643f533840c/coverage-7.13.5-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:0b67af5492adb31940ee418a5a655c28e48165da5afab8c7fa6fd72a142f8740", size = 250823, upload-time = "2026-03-17T10:32:25.516Z" }, - { url = "https://files.pythonhosted.org/packages/8a/fb/616d95d3adb88b9803b275580bdeee8bd1b69a886d057652521f83d7322f/coverage-7.13.5-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:c9136ff29c3a91e25b1d1552b5308e53a1e0653a23e53b6366d7c2dcbbaf8a16", size = 255099, upload-time = "2026-03-17T10:32:27.944Z" }, - { url = "https://files.pythonhosted.org/packages/1c/93/25e6917c90ec1c9a56b0b26f6cad6408e5f13bb6b35d484a0d75c9cf000d/coverage-7.13.5-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:cff784eef7f0b8f6cb28804fbddcfa99f89efe4cc35fb5627e3ac58f91ed3ac0", size = 250638, upload-time = "2026-03-17T10:32:29.914Z" }, - { url = "https://files.pythonhosted.org/packages/fc/7b/dc1776b0464145a929deed214aef9fb1493f159b59ff3c7eeeedf91eddd0/coverage-7.13.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:68a4953be99b17ac3c23b6efbc8a38330d99680c9458927491d18700ef23ded0", size = 252295, upload-time = "2026-03-17T10:32:31.981Z" }, - { url = "https://files.pythonhosted.org/packages/ea/fb/99cbbc56a26e07762a2740713f3c8f9f3f3106e3a3dd8cc4474954bccd34/coverage-7.13.5-cp314-cp314-win32.whl", hash = "sha256:35a31f2b1578185fbe6aa2e74cea1b1d0bbf4c552774247d9160d29b80ed56cc", size = 222360, upload-time = "2026-03-17T10:32:34.233Z" }, - { url = "https://files.pythonhosted.org/packages/8d/b7/4758d4f73fb536347cc5e4ad63662f9d60ba9118cb6785e9616b2ce5d7fa/coverage-7.13.5-cp314-cp314-win_amd64.whl", hash = "sha256:2aa055ae1857258f9e0045be26a6d62bdb47a72448b62d7b55f4820f361a2633", size = 223174, upload-time = "2026-03-17T10:32:36.369Z" }, - { url = "https://files.pythonhosted.org/packages/2c/f2/24d84e1dfe70f8ac9fdf30d338239860d0d1d5da0bda528959d0ebc9da28/coverage-7.13.5-cp314-cp314-win_arm64.whl", hash = "sha256:1b11eef33edeae9d142f9b4358edb76273b3bfd30bc3df9a4f95d0e49caf94e8", size = 221739, upload-time = "2026-03-17T10:32:38.736Z" }, - { url = "https://files.pythonhosted.org/packages/60/5b/4a168591057b3668c2428bff25dd3ebc21b629d666d90bcdfa0217940e84/coverage-7.13.5-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:10a0c37f0b646eaff7cce1874c31d1f1ccb297688d4c747291f4f4c70741cc8b", size = 220351, upload-time = "2026-03-17T10:32:41.196Z" }, - { url = "https://files.pythonhosted.org/packages/f5/21/1fd5c4dbfe4a58b6b99649125635df46decdfd4a784c3cd6d410d303e370/coverage-7.13.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b5db73ba3c41c7008037fa731ad5459fc3944cb7452fc0aa9f822ad3533c583c", size = 220612, upload-time = "2026-03-17T10:32:43.204Z" }, - { url = "https://files.pythonhosted.org/packages/d6/fe/2a924b3055a5e7e4512655a9d4609781b0d62334fa0140c3e742926834e2/coverage-7.13.5-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:750db93a81e3e5a9831b534be7b1229df848b2e125a604fe6651e48aa070e5f9", size = 261985, upload-time = "2026-03-17T10:32:45.514Z" }, - { url = "https://files.pythonhosted.org/packages/d7/0d/c8928f2bd518c45990fe1a2ab8db42e914ef9b726c975facc4282578c3eb/coverage-7.13.5-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9ddb4f4a5479f2539644be484da179b653273bca1a323947d48ab107b3ed1f29", size = 264107, upload-time = "2026-03-17T10:32:47.971Z" }, - { url = "https://files.pythonhosted.org/packages/ef/ae/4ae35bbd9a0af9d820362751f0766582833c211224b38665c0f8de3d487f/coverage-7.13.5-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d8a7a2049c14f413163e2bdabd37e41179b1d1ccb10ffc6ccc4b7a718429c607", size = 266513, upload-time = "2026-03-17T10:32:50.1Z" }, - { url = "https://files.pythonhosted.org/packages/9c/20/d326174c55af36f74eac6ae781612d9492f060ce8244b570bb9d50d9d609/coverage-7.13.5-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e1c85e0b6c05c592ea6d8768a66a254bfb3874b53774b12d4c89c481eb78cb90", size = 267650, upload-time = "2026-03-17T10:32:52.391Z" }, - { url = "https://files.pythonhosted.org/packages/7a/5e/31484d62cbd0eabd3412e30d74386ece4a0837d4f6c3040a653878bfc019/coverage-7.13.5-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:777c4d1eff1b67876139d24288aaf1817f6c03d6bae9c5cc8d27b83bcfe38fe3", size = 261089, upload-time = "2026-03-17T10:32:54.544Z" }, - { url = "https://files.pythonhosted.org/packages/e9/d8/49a72d6de146eebb0b7e48cc0f4bc2c0dd858e3d4790ab2b39a2872b62bd/coverage-7.13.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:6697e29b93707167687543480a40f0db8f356e86d9f67ddf2e37e2dfd91a9dab", size = 263982, upload-time = "2026-03-17T10:32:56.803Z" }, - { url = "https://files.pythonhosted.org/packages/06/3b/0351f1bd566e6e4dd39e978efe7958bde1d32f879e85589de147654f57bb/coverage-7.13.5-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:8fdf453a942c3e4d99bd80088141c4c6960bb232c409d9c3558e2dbaa3998562", size = 261579, upload-time = "2026-03-17T10:32:59.466Z" }, - { url = "https://files.pythonhosted.org/packages/5d/ce/796a2a2f4017f554d7810f5c573449b35b1e46788424a548d4d19201b222/coverage-7.13.5-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:32ca0c0114c9834a43f045a87dcebd69d108d8ffb666957ea65aa132f50332e2", size = 265316, upload-time = "2026-03-17T10:33:01.847Z" }, - { url = "https://files.pythonhosted.org/packages/3d/16/d5ae91455541d1a78bc90abf495be600588aff8f6db5c8b0dae739fa39c9/coverage-7.13.5-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:8769751c10f339021e2638cd354e13adeac54004d1941119b2c96fe5276d45ea", size = 260427, upload-time = "2026-03-17T10:33:03.945Z" }, - { url = "https://files.pythonhosted.org/packages/48/11/07f413dba62db21fb3fad5d0de013a50e073cc4e2dc4306e770360f6dfc8/coverage-7.13.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:cec2d83125531bd153175354055cdb7a09987af08a9430bd173c937c6d0fba2a", size = 262745, upload-time = "2026-03-17T10:33:06.285Z" }, - { url = "https://files.pythonhosted.org/packages/91/15/d792371332eb4663115becf4bad47e047d16234b1aff687b1b18c58d60ae/coverage-7.13.5-cp314-cp314t-win32.whl", hash = "sha256:0cd9ed7a8b181775459296e402ca4fb27db1279740a24e93b3b41942ebe4b215", size = 223146, upload-time = "2026-03-17T10:33:08.756Z" }, - { url = "https://files.pythonhosted.org/packages/db/51/37221f59a111dca5e85be7dbf09696323b5b9f13ff65e0641d535ed06ea8/coverage-7.13.5-cp314-cp314t-win_amd64.whl", hash = "sha256:301e3b7dfefecaca37c9f1aa6f0049b7d4ab8dd933742b607765d757aca77d43", size = 224254, upload-time = "2026-03-17T10:33:11.174Z" }, - { url = "https://files.pythonhosted.org/packages/54/83/6acacc889de8987441aa7d5adfbdbf33d288dad28704a67e574f1df9bcbb/coverage-7.13.5-cp314-cp314t-win_arm64.whl", hash = "sha256:9dacc2ad679b292709e0f5fc1ac74a6d4d5562e424058962c7bb0c658ad25e45", size = 222276, upload-time = "2026-03-17T10:33:13.466Z" }, - { url = "https://files.pythonhosted.org/packages/9e/ee/a4cf96b8ce1e566ed238f0659ac2d3f007ed1d14b181bcb684e19561a69a/coverage-7.13.5-py3-none-any.whl", hash = "sha256:34b02417cf070e173989b3db962f7ed56d2f644307b2cf9d5a0f258e13084a61", size = 211346, upload-time = "2026-03-17T10:33:15.691Z" }, + { url = "https://files.pythonhosted.org/packages/8a/9e/5f6d56327c62b185225d145191c607e07515294a0aa6338e58805cd4a5ac/coverage-7.14.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:be9f2c802dcfce3f71298303aa5dad0dce440a76c52f2f60dacd8656dab78793", size = 220044, upload-time = "2026-05-26T20:39:29.902Z" }, + { url = "https://files.pythonhosted.org/packages/75/92/e82aca356744cbbc0f77a0b623e38918c1872361963413a3bab5d0340393/coverage-7.14.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:6223a72fd0e4c7156353ec0f08a5f93623e1d3034d0e2683b9bb8ea674131b1d", size = 220412, upload-time = "2026-05-26T20:39:31.561Z" }, + { url = "https://files.pythonhosted.org/packages/27/c9/385bde0bf7ed0f4bf3a7ee5367060a86b5d218718cfd6fb943c0f836b34f/coverage-7.14.1-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:7279d2110a28cebc738b6459ecda2771735a4c18465fbbd36b3288fe5ed92247", size = 251412, upload-time = "2026-05-26T20:39:33.337Z" }, + { url = "https://files.pythonhosted.org/packages/51/8c/23faf6a2343a0d17f960a4bd56c43bc7eb4cf312f774dd6ceebd82c7d8fc/coverage-7.14.1-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9eeb3fcbc13ba40dfbdb22d01d196a28e9cef9ed4c29b60061a1e0e823a9929d", size = 254008, upload-time = "2026-05-26T20:39:35.009Z" }, + { url = "https://files.pythonhosted.org/packages/42/06/36f4aa9ca8a815e6036156e80706a67828bb97bd826948244f6996dda957/coverage-7.14.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5f0cfc27c539f07cf5c0a4cfe211d0b6cae039f8f40526dbaa71944e64b50a7b", size = 255241, upload-time = "2026-05-26T20:39:36.71Z" }, + { url = "https://files.pythonhosted.org/packages/ca/79/95266316352f90f6b1c6736bb413302edfde2453fb32422d3911642691b3/coverage-7.14.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:221c70f316241a78e77e607c227cefc8808d4e08f28d99c04f35694690e940be", size = 257373, upload-time = "2026-05-26T20:39:38.412Z" }, + { url = "https://files.pythonhosted.org/packages/e3/9c/58316d1f66c488b5fca8a0eb3e98348807813efa8a0d0833b9021be27488/coverage-7.14.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:da028256b04ec30e5e0114b6f76172938c313991f0a2d3d894271315cf5d5e43", size = 251635, upload-time = "2026-05-26T20:39:40.268Z" }, + { url = "https://files.pythonhosted.org/packages/ef/5a/ca2398a568e16fed7bb713e84ba3603a7164fb65779abe645c565ec890d5/coverage-7.14.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:76a085d7005236a767e3426148b2c407e53ad61695c562f8a81da2d373324901", size = 253373, upload-time = "2026-05-26T20:39:42.145Z" }, + { url = "https://files.pythonhosted.org/packages/6e/2c/0396562c32deaebe7be51d865b3a41e9a87d7561acafe1a28f53b07e019a/coverage-7.14.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:b553d04b5e778a8e56d57eb134aff42a92718ecba45e79c4764ecfa40efd92ff", size = 251341, upload-time = "2026-05-26T20:39:43.907Z" }, + { url = "https://files.pythonhosted.org/packages/fd/8f/a94f9221184c9cae1ee115820e3798e48b6b17777a9f19e46fb9a0c8dc74/coverage-7.14.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:46f714d2fb8ae2f4f29f23ada7f1e79b759fff5a70f94a1dac23af204c3ec9e4", size = 255497, upload-time = "2026-05-26T20:39:46.166Z" }, + { url = "https://files.pythonhosted.org/packages/71/69/505d70e47db1eaebcd002c39759707621ef184cd6b1ae084d9f41293f323/coverage-7.14.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:1896f5e19ff3f0431c7ce2172adc54890fd97f86b59ced8ca1649145d9ffe35d", size = 251159, upload-time = "2026-05-26T20:39:48.03Z" }, + { url = "https://files.pythonhosted.org/packages/e0/aa/58681c383aa33a9d2ed40a02d7a22fbf780d1fa4d575396365777828198c/coverage-7.14.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:62fd185ef9df3c33d1c8178c5af105f762afbad96038de9a4ae100aa6297ca33", size = 252934, upload-time = "2026-05-26T20:39:49.872Z" }, + { url = "https://files.pythonhosted.org/packages/eb/fd/11c928cd6bdffc7074bb5965c173d9ebf517fb00205e1da524b98d29ef92/coverage-7.14.1-cp313-cp313-win32.whl", hash = "sha256:ab4af6352741a604c431c6072fce5bee33bf0f20dc7a56618d6bf6bb89e9810c", size = 222584, upload-time = "2026-05-26T20:39:51.68Z" }, + { url = "https://files.pythonhosted.org/packages/6f/92/fb416fc26d340dcba19518c418d6048e913186e17243982c5e435e41fa7a/coverage-7.14.1-cp313-cp313-win_amd64.whl", hash = "sha256:7af486dabe8954d03b087f0021540897afe084f04e16ff5579e08cc46f871416", size = 223394, upload-time = "2026-05-26T20:39:53.472Z" }, + { url = "https://files.pythonhosted.org/packages/73/c6/02d56e3867972f77d5036de924643f26c056e848f00452cafb4dbc3c29b4/coverage-7.14.1-cp313-cp313-win_arm64.whl", hash = "sha256:2224f89ffd0c5605ccce1ed7a584da162bc7c55f601ab1c946bc9de31a486b42", size = 222015, upload-time = "2026-05-26T20:39:55.374Z" }, + { url = "https://files.pythonhosted.org/packages/4d/9e/fcc77914050df73f7662fa1f00902774c79c075a8388ab334074574bf77e/coverage-7.14.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:de286598cc65d2b489411174b1faec2f5a7775fb3201fd925db2a76b4030f37d", size = 220733, upload-time = "2026-05-26T20:39:57.189Z" }, + { url = "https://files.pythonhosted.org/packages/f7/67/2963cbdaf5cbadec44efa3a1e39eaa1f02df4079585f05387607a221e126/coverage-7.14.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:042c46ded7c288aeb07cf14a28b6c1e10b78fcba40171c3fa1e939377eeef0b5", size = 221086, upload-time = "2026-05-26T20:39:59.019Z" }, + { url = "https://files.pythonhosted.org/packages/c8/c5/8701645574e11881f2f47d8930f98bc48b5d43b25eb5b4430dfc4a2f9f48/coverage-7.14.1-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:f4ddbe407477f04c45115d1a4e5bc480f753553b534d338d4c3358b1cdd0ea52", size = 262381, upload-time = "2026-05-26T20:40:00.822Z" }, + { url = "https://files.pythonhosted.org/packages/7c/28/7a64d73598263e0c5abd5084211a8474488d31b3c552ff531c719dfcff62/coverage-7.14.1-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:d13e6725992e2d2fd7d81d4f5241952d13740121dfd501da09201be39b2c003a", size = 264458, upload-time = "2026-05-26T20:40:02.506Z" }, + { url = "https://files.pythonhosted.org/packages/fa/d8/4969179db9f7eb4df218e69540adf829d1c835f59452513d065d15446802/coverage-7.14.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f747dc8edcfe740130f28f32f3995e955494285717e86ee25af51db2219df08a", size = 266884, upload-time = "2026-05-26T20:40:04.421Z" }, + { url = "https://files.pythonhosted.org/packages/a6/78/a45d5794dbc9bafd97afc96a4377c86c7820d78b6cf51b89bc1d4e919275/coverage-7.14.1-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ced2f09ef276fd58611a1ef502164ad266d2b75174e5a40cabbdb4033f9f6cf2", size = 268022, upload-time = "2026-05-26T20:40:06.298Z" }, + { url = "https://files.pythonhosted.org/packages/21/cb/4f5e354e9e3e67af96bd4e57113e6db6b22298c7168b13eec408a549903d/coverage-7.14.1-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b84800013769a78ccb9ef4659402e26d06867e337b61ec365f77ad008adea80e", size = 261631, upload-time = "2026-05-26T20:40:08.226Z" }, + { url = "https://files.pythonhosted.org/packages/ec/49/eced49af4cb996d5d8b7e94e736175c513e4facd3398507b89892b4326d8/coverage-7.14.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:ea8cd6ca0ee9f616aaef3afc6882e32c2cbf18b00d96313ffd76af650574034d", size = 264443, upload-time = "2026-05-26T20:40:10.137Z" }, + { url = "https://files.pythonhosted.org/packages/f1/d8/5603a88a7c5913a6b54f6cb1a8c46f7b39cbb30f27cd3f492908da09b2d7/coverage-7.14.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:aa5e304a873fabddc11e484e9b6b738bd38bd7bed17b09aa84eecf5332e8b8bb", size = 262069, upload-time = "2026-05-26T20:40:11.999Z" }, + { url = "https://files.pythonhosted.org/packages/f0/59/2ae3cb79da554a06c8619d6c88ea19dd1e4aed4b834b6a83bb1fa243bdc5/coverage-7.14.1-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:5a1c5215be81035e629d5bc756650634d0bf31991038db7a0eccb90f025ce16d", size = 265780, upload-time = "2026-05-26T20:40:13.858Z" }, + { url = "https://files.pythonhosted.org/packages/af/5f/b130c1dc999031f2648bd25317fbce505ad8d5562079b4ed81e736a84967/coverage-7.14.1-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:79058c47dae6788504b5effb319961bcd72d7240551464b91d474bc0ed186d69", size = 260970, upload-time = "2026-05-26T20:40:16.142Z" }, + { url = "https://files.pythonhosted.org/packages/87/d1/ec13ccddeb48ec963bdfa72a11224bac2584bd045ba13beca82f8113e9c7/coverage-7.14.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:370c5afae3fa0658e11694a32b24c2778f6bc2d17718121f94ee185e69f26b54", size = 263157, upload-time = "2026-05-26T20:40:18.382Z" }, + { url = "https://files.pythonhosted.org/packages/cf/c2/cd91ead503045161092d3845f7bb95ea2f25131ce96d3e314dd835d91b9c/coverage-7.14.1-cp313-cp313t-win32.whl", hash = "sha256:3758dd0a7f1fa57365ef2e781df0f0731d38b6e3772259d13dae4bd8a958d4b1", size = 223259, upload-time = "2026-05-26T20:40:20.381Z" }, + { url = "https://files.pythonhosted.org/packages/71/9f/1e28d97e6bd2c76b07f38b7c02870f1371255ff6717f54eca578fcbbdd0e/coverage-7.14.1-cp313-cp313t-win_amd64.whl", hash = "sha256:6ff665fb023a77386fe11685190cee1f60a7d635994a30d9b0a061533d470fce", size = 224320, upload-time = "2026-05-26T20:40:22.316Z" }, + { url = "https://files.pythonhosted.org/packages/a9/e0/d936e908f0e1efa55e52b91e01b52f1055cef5e1ab2718493390ed8e2fb8/coverage-7.14.1-cp313-cp313t-win_arm64.whl", hash = "sha256:17a5a241e5997621a956a7f402a7433ef4221e5152809b785bec79e2323799f1", size = 222577, upload-time = "2026-05-26T20:40:24.894Z" }, + { url = "https://files.pythonhosted.org/packages/d6/34/fc2f101b151af3799a101f0550b0454aa008afdc0add677394ec4aa8ea10/coverage-7.14.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:d5ed429d0b8edaac649e889b4ffcedb6c80b06629a3f93050e3dddfb99235bee", size = 220091, upload-time = "2026-05-26T20:40:27.249Z" }, + { url = "https://files.pythonhosted.org/packages/3d/a7/1ebae2ab5b961b5c79bb09fe7b3ac99edb190d8be4a8c510b2cf66f46468/coverage-7.14.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:8011224a62280e50dab346960c03cf47aca1a1e09e608c0fb33fd6e0cc8e9500", size = 220421, upload-time = "2026-05-26T20:40:30.084Z" }, + { url = "https://files.pythonhosted.org/packages/5e/90/92aca9cf0acc95123c96cd1eb1f08917897a7f5dee01e15738922971ec31/coverage-7.14.1-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:12c42ec1e14f553c4f817e989365982e646e27211f10a0f717855b94a79c8906", size = 251466, upload-time = "2026-05-26T20:40:32.542Z" }, + { url = "https://files.pythonhosted.org/packages/26/2b/78048cbe3b999f6cbf9cc0d90abba6a88a3e0863a8c1c6cbc762f3f8802f/coverage-7.14.1-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:06144cd511cf2624873a035c5069cf297144f6e77a73ee3d7a55b605ec5efb42", size = 253973, upload-time = "2026-05-26T20:40:34.473Z" }, + { url = "https://files.pythonhosted.org/packages/8e/21/c2e33b29d1cfde484a19d437afc343c6cd30b08d78cbbf9f5aff14e57b2b/coverage-7.14.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a311d8e1da24be5c1ccf85cbfb06315dbaa1703d5a1eab3f6432c72b837917c8", size = 255318, upload-time = "2026-05-26T20:40:38.154Z" }, + { url = "https://files.pythonhosted.org/packages/8e/ee/aad2f108d63b769121005302f16bf66db8625c88ceaba466942e09a2607e/coverage-7.14.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c79cead5b5bc584d9c71451cb984d0e3a84e0c0937379c8efcbf27c8d661b851", size = 257633, upload-time = "2026-05-26T20:40:40.164Z" }, + { url = "https://files.pythonhosted.org/packages/c2/f8/11a2c29b4fd76d9849f81d0bb812ec0017a9396df3217214e38934a8c837/coverage-7.14.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:dcbf65f1f66a26cdd88c35cf68fb4729c5d1cd2e88added72420541dfb212034", size = 251488, upload-time = "2026-05-26T20:40:42.631Z" }, + { url = "https://files.pythonhosted.org/packages/c9/b8/9a5820de4b8ac2b71d85e3b5fb49108d7469c665f0e2ad0dd7569023e305/coverage-7.14.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fd86572566fb40189a8260446158235159bc7a82dfbc87a3b39cf4fb57fcec1c", size = 253329, upload-time = "2026-05-26T20:40:45.208Z" }, + { url = "https://files.pythonhosted.org/packages/6b/ff/f33e4823667e27548e8fd8df44217515303f9808d0ff29817db56f87d990/coverage-7.14.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:7771b601718fdde84832c3a434ca9bbf4ae9adbc49d84198b4110700c3c77c36", size = 251291, upload-time = "2026-05-26T20:40:47.502Z" }, + { url = "https://files.pythonhosted.org/packages/68/9b/489db0ebb209054766b90a9014a45f6d26eb724c02ec21311c3733b5a644/coverage-7.14.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:39b21e212c55af06fa375e3dbf90a8a8e38792f3a910c580066d23563830ddd5", size = 255564, upload-time = "2026-05-26T20:40:49.372Z" }, + { url = "https://files.pythonhosted.org/packages/27/b5/16bc2d4c2409b23c7737edb68c83bc89e345f378050549fe1d75ac7d34d5/coverage-7.14.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:f2302660e32562a532b442480121aef8aa61a5bdb20b30bf0adab29f10a5a4b4", size = 251107, upload-time = "2026-05-26T20:40:51.677Z" }, + { url = "https://files.pythonhosted.org/packages/7d/0c/2629997469a00cd069d588a41c9dc887610f2775ae89d250c4791e65272a/coverage-7.14.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:03a6f93c1ec3b7f2e77b5dbcc5573a2c21f12529a5c6bbe0f16f72303cc2fa4d", size = 252764, upload-time = "2026-05-26T20:40:54.267Z" }, + { url = "https://files.pythonhosted.org/packages/d2/ee/f78d63c8f079e0d7211c7e2401fa17e311514534ba61bae03e4b287ce4ab/coverage-7.14.1-cp314-cp314-win32.whl", hash = "sha256:8a3ce026d73290f42f08dafecbd82c193a74df280461fbf97300fec51fd133ee", size = 222837, upload-time = "2026-05-26T20:40:56.496Z" }, + { url = "https://files.pythonhosted.org/packages/dc/b9/be539854f93a70dfbeec69117f33ec70dc42ff0b65b5b07ab8d40d04228e/coverage-7.14.1-cp314-cp314-win_amd64.whl", hash = "sha256:114c95ef29302423b87d159075805f4ab973254a2638a5d7d046c94887cc87d7", size = 223650, upload-time = "2026-05-26T20:40:58.351Z" }, + { url = "https://files.pythonhosted.org/packages/fe/9e/24e2842fef40f35ac82ba3a7719c8023d011bf3bf652d0675316a9d088a1/coverage-7.14.1-cp314-cp314-win_arm64.whl", hash = "sha256:a07891c3f4805442b31b71e84ba3cf29ed1aa9a428284e06deeb4b23e5b46343", size = 222218, upload-time = "2026-05-26T20:41:00.321Z" }, + { url = "https://files.pythonhosted.org/packages/0a/1d/ac0a9df5fe31c1e8bdd658074905fc12844a05c1a7e3fdb8417e97c31e23/coverage-7.14.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:1101a5ebb083aecb625ebb6209d4105b58f647b093cb2dc8122d7b33f743cfe1", size = 220822, upload-time = "2026-05-26T20:41:02.281Z" }, + { url = "https://files.pythonhosted.org/packages/32/cf/f964fd9aff20323f9f1a726c97135f8a76bcd87b92dad141a456a43f3c64/coverage-7.14.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:851b9e1e4e8a4608e77c79714b2e77c0970d2ed7202a05e92ae407817481887b", size = 221084, upload-time = "2026-05-26T20:41:04.593Z" }, + { url = "https://files.pythonhosted.org/packages/d8/5e/7e5ef2aba844de2b80d678619fcf0841b42e3f37f16411226f3fe4c1016f/coverage-7.14.1-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:d5b89cdfb2ee051b71e8c3c70bd81a9eff81100f736a269136fe1a68efe00474", size = 262454, upload-time = "2026-05-26T20:41:06.641Z" }, + { url = "https://files.pythonhosted.org/packages/64/62/75809bded87015cc4935524218a2a8ed8dd1a8498bfed30a2f4f7a4b4d34/coverage-7.14.1-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0177614a0370f227888b4e436a7c55686d6a9f90eb1ade2b624ba685a1686e86", size = 264578, upload-time = "2026-05-26T20:41:08.556Z" }, + { url = "https://files.pythonhosted.org/packages/f3/42/d33392dc14633525012d2d504fa1a33b05538bf535f5c1d64675e5754b78/coverage-7.14.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2d69af5dea2de76fc485a83032a630523f985198b7e25be901ec60181587b01e", size = 266981, upload-time = "2026-05-26T20:41:10.824Z" }, + { url = "https://files.pythonhosted.org/packages/2a/49/0157c4428c2aca7f1e09d5565930586fd5ae36f1655f08b0daa7cf1fcae1/coverage-7.14.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:35ab22d91de736e8966b980dc355cbcdd2c6dbbcfe275f9a2991bc8a91b3df65", size = 268112, upload-time = "2026-05-26T20:41:12.966Z" }, + { url = "https://files.pythonhosted.org/packages/96/26/86b9ce71f4092b1ed325ce1421698081df1286b833400b6836912834d6e0/coverage-7.14.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:357d4e32935c36588aaba057d734fa32428c360c9fc2e4442afbf1b646beee6e", size = 261558, upload-time = "2026-05-26T20:41:15Z" }, + { url = "https://files.pythonhosted.org/packages/20/4c/c311210c5472cf5401d8422b0d7812cdd520f24417673afabda6c323faca/coverage-7.14.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:51bd64741cc6fa065abd300ede1afe5a5291ece9c31da8b24884deda48bcc3f8", size = 264447, upload-time = "2026-05-26T20:41:17.369Z" }, + { url = "https://files.pythonhosted.org/packages/fb/71/59513f8710ed3e6b0ac0a050a5b7e977bb9c9e880354863b5d00d8809256/coverage-7.14.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:9132cd363a68a4c3daa7c8704a654b1e39d3360f6f5b8ddd470608a945236c07", size = 262048, upload-time = "2026-05-26T20:41:19.309Z" }, + { url = "https://files.pythonhosted.org/packages/84/8d/bceed32dc494f5bbf50f775cd2e78ca814953942b5ea28d3c1c3ac316f14/coverage-7.14.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:07c6290b1697b862c0478eab545eec949a0d0e4d6d03497f446d706da3b4f2de", size = 265781, upload-time = "2026-05-26T20:41:21.559Z" }, + { url = "https://files.pythonhosted.org/packages/e7/c5/9348fe40dbfd4991aaf78df2c6c3098bfb2cc834d1fd362a64b4efef855a/coverage-7.14.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:5ea0c297e27133853b4d8a3eb799bff5a2dbd9f2f41537a240d337ac9b4df890", size = 260896, upload-time = "2026-05-26T20:41:23.428Z" }, + { url = "https://files.pythonhosted.org/packages/ca/92/1ea0f03929da7cf87206b1fa24f4c8e9c158be0455481af29ec0a1f3503f/coverage-7.14.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:01b7733daad0237daa01ef80fe2dfceffc911e6a17fa7b55d14aa8214eaaaecd", size = 263214, upload-time = "2026-05-26T20:41:25.419Z" }, + { url = "https://files.pythonhosted.org/packages/f6/a9/b2493c054c0e01a643266742ab45e15744e60743f9260cd930c7142b1124/coverage-7.14.1-cp314-cp314t-win32.whl", hash = "sha256:6adc5a36984624a70bf11d7184e20fa0a49aa7c47ffab43804106a1a695ea22e", size = 223624, upload-time = "2026-05-26T20:41:27.795Z" }, + { url = "https://files.pythonhosted.org/packages/fc/bd/3e1e6a57fccd2d7c83fcdf338e93ba98eb85c6e877dd34731ac585375490/coverage-7.14.1-cp314-cp314t-win_amd64.whl", hash = "sha256:ddf799247318f34dbcd2efa8c95a8d0642674e926bb1774cf9b63dfd2a389d1c", size = 224728, upload-time = "2026-05-26T20:41:30.098Z" }, + { url = "https://files.pythonhosted.org/packages/bb/d7/31066cf1d2f0c6c797fce911bcfa01dd35642dc6da992a950256097c5860/coverage-7.14.1-cp314-cp314t-win_arm64.whl", hash = "sha256:145986fe66647eb489f18d9a997567a3fd358584c4b5a808769113abc07466af", size = 222752, upload-time = "2026-05-26T20:41:32.123Z" }, + { url = "https://files.pythonhosted.org/packages/8a/3c/1a983b9a745d7f83d53f057bcc5bf79ba6a2bbc08266b3f0c7d6fe630c9b/coverage-7.14.1-py3-none-any.whl", hash = "sha256:a252f21c27e38347e60111a3266b03827422a7d5525951aceee313aa68bab1d2", size = 211815, upload-time = "2026-05-26T20:41:34.078Z" }, ] [[package]] @@ -229,7 +229,7 @@ version = "0.4.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/96/8e/709914eb2b5749865801041647dc7f4e6d00b549cfe88b65ca192995f07c/distlib-0.4.0.tar.gz", hash = "sha256:feec40075be03a04501a973d81f633735b4b69f98b05450592310c0f401a4e0d", size = 614605, upload-time = "2025-07-17T16:52:00.465Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/33/6b/e0547afaf41bf2c42e52430072fa5658766e3d65bd4b03a563d1b6336f57/distlib-0.4.0-py2.py3-none-any.whl", hash = "sha256:9659f7d87e46584a30b5780e43ac7a2143098441670ff0a49d5f9034c54a6c16", size = 469047, upload-time = "2025-07-17T16:51:58.613Z" }, + { url = "https://files.pythonhosted.org/packages/33/6b/e0547afaf41bf2c42e52430072fa5658766e3d65bd4b03a563d1b6336f57/distlib-0.4.0-py2.py3-none-any.whl", hash = "sha256:9659f7d87e46584a30b5780e43ac7a2143098441670ff0a49d5f9034c54a6c16", size = 469047, upload-time = "2025-07-17T16:51:58.613Z" }, ] [[package]] @@ -238,45 +238,45 @@ version = "1.9.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/fc/f8/98eea607f65de6527f8a2e8885fc8015d3e6f5775df186e443e0964a11c3/distro-1.9.0.tar.gz", hash = "sha256:2fa77c6fd8940f116ee1d6b94a2f90b13b5ea8d019b98bc8bafdcabcdd9bdbed", size = 60722, upload-time = "2023-12-24T09:54:32.31Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/12/b3/231ffd4ab1fc9d679809f356cebee130ac7daa00d6d6f3206dd4fd137e9e/distro-1.9.0-py3-none-any.whl", hash = "sha256:7bffd925d65168f85027d8da9af6bddab658135b840670a223589bc0c8ef02b2", size = 20277, upload-time = "2023-12-24T09:54:30.421Z" }, + { url = "https://files.pythonhosted.org/packages/12/b3/231ffd4ab1fc9d679809f356cebee130ac7daa00d6d6f3206dd4fd137e9e/distro-1.9.0-py3-none-any.whl", hash = "sha256:7bffd925d65168f85027d8da9af6bddab658135b840670a223589bc0c8ef02b2", size = 20277, upload-time = "2023-12-24T09:54:30.421Z" }, ] [[package]] name = "duckdb" -version = "1.5.2" +version = "1.5.3" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/0c/66/744b4931b799a42f8cb9bc7a6f169e7b8e51195b62b246db407fd90bf15f/duckdb-1.5.2.tar.gz", hash = "sha256:638da0d5102b6cb6f7d47f83d0600708ac1d3cb46c5e9aaabc845f9ba4d69246", size = 18017166, upload-time = "2026-04-13T11:30:09.065Z" } +sdist = { url = "https://files.pythonhosted.org/packages/69/00/d579dcb2a536b6ea3a2563cdad6844f77d81a9b2d4b22a858097f2468acf/duckdb-1.5.3.tar.gz", hash = "sha256:df39428eb130faa35ae96fd35245bdeae6ecf43936250b116b5fead568eb9f16", size = 18026640, upload-time = "2026-05-20T11:55:31.901Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/98/f2/e3d742808f138d374be4bb516fade3d1f33749b813650810ab7885cdc363/duckdb-1.5.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:4420b3f47027a7849d0e1815532007f377fa95ee5810b47ea717d35525c12f79", size = 30064879, upload-time = "2026-04-13T11:29:30.763Z" }, - { url = "https://files.pythonhosted.org/packages/72/0d/f3dc1cf97e1267ca15e4307d456f96ce583961f0703fd75e62b2ad8d64fa/duckdb-1.5.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:bb42e6ed543902e14eae647850da24103a89f0bc2587dec5601b1c1f213bd2ed", size = 15969327, upload-time = "2026-04-13T11:29:33.481Z" }, - { url = "https://files.pythonhosted.org/packages/b1/e0/d5418def53ae4e05a63075705ff44ed5af5a1a5932627eb2b600c5df1c93/duckdb-1.5.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:98c0535cd6d901f61a5ea3c2e26a1fd28482953d794deb183daf568e3aa5dda6", size = 14225107, upload-time = "2026-04-13T11:29:35.882Z" }, - { url = "https://files.pythonhosted.org/packages/16/a7/15aaa59dbecc35e9711980fcdbf525b32a52470b32d18ef678193a146213/duckdb-1.5.2-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:486c862bf7f163c0110b6d85b3e5c031d224a671cca468f12ebb1d3a348f6b39", size = 19313433, upload-time = "2026-04-13T11:29:38.367Z" }, - { url = "https://files.pythonhosted.org/packages/bd/21/d903cc63a5140c822b7b62b373a87dc557e60c29b321dfb435061c5e67cf/duckdb-1.5.2-cp313-cp313-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:70631c847ca918ee710ec874241b00cf9d2e5be90762cbb2a0389f17823c08f7", size = 21429837, upload-time = "2026-04-13T11:29:41.135Z" }, - { url = "https://files.pythonhosted.org/packages/e3/0a/b770d1f60c70597302130d6247f418549b7094251a02348fbaf1c7e147ae/duckdb-1.5.2-cp313-cp313-win_amd64.whl", hash = "sha256:52a21823f3fbb52f0f0e5425e20b07391ad882464b955879499b5ff0b45a376b", size = 13107699, upload-time = "2026-04-13T11:29:43.905Z" }, - { url = "https://files.pythonhosted.org/packages/d9/cf/e200fe431d700962d1a908d2ce89f53ccee1cc8db260174ae663ba09686b/duckdb-1.5.2-cp313-cp313-win_arm64.whl", hash = "sha256:411ad438bd4140f189a10e7f515781335962c5d18bd07837dc6d202e3985253d", size = 13927646, upload-time = "2026-04-13T11:29:46.598Z" }, - { url = "https://files.pythonhosted.org/packages/83/a1/f6286c67726cc1ea60a6e3c0d9fbc66527dde24ae089a51bbe298b13ca78/duckdb-1.5.2-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:6b0fe75c148000f060aa1a27b293cacc0ea08cc1cad724fbf2143d56070a3785", size = 30078598, upload-time = "2026-04-13T11:29:49.828Z" }, - { url = "https://files.pythonhosted.org/packages/de/6a/59febb02f21a4a5c6b0b0099ef7c965fdd5e61e4904cf813809bb792e35f/duckdb-1.5.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:35579b8e3a064b5eaf15b0eafc558056a13f79a0a62e34cc4baf57119daecfec", size = 15975120, upload-time = "2026-04-13T11:29:52.631Z" }, - { url = "https://files.pythonhosted.org/packages/09/70/ce750854d37bb5a45cccbb2c3cb04df4af56aea8fc30a2499bb643b4a9c0/duckdb-1.5.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:ea58ff5b0880593a280cf5511734b17711b32ee1f58b47d726e8600848358160", size = 14227762, upload-time = "2026-04-13T11:29:55.564Z" }, - { url = "https://files.pythonhosted.org/packages/28/dc/ad45ac3c0b6c4687dc649e8f6cf01af1c8b0443932a39b2abb4ebcb3babd/duckdb-1.5.2-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ef461bca07313412dc09961c4a4757a851f56b95ac01c58fac6007632b7b94f2", size = 19315668, upload-time = "2026-04-13T11:29:58.427Z" }, - { url = "https://files.pythonhosted.org/packages/cc/b1/1464f468d2e5813f5808de95df9d3113a645a5bfa2ffcaecbc542ddae272/duckdb-1.5.2-cp314-cp314-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:be37680ddb380015cb37318e378c53511c45c4f0d8fac5599d22b7d092b9217a", size = 21434056, upload-time = "2026-04-13T11:30:01.238Z" }, - { url = "https://files.pythonhosted.org/packages/ce/32/6673607e024722473fa7aafdd29c0e3dd231dd528f6cd8b5797fbeeb229d/duckdb-1.5.2-cp314-cp314-win_amd64.whl", hash = "sha256:0b291786014df1133f8f18b9df4d004484613146e858d71a21791e0fcca16cf4", size = 13633667, upload-time = "2026-04-13T11:30:04.05Z" }, - { url = "https://files.pythonhosted.org/packages/7a/e3/9d34173ec068631faea3ea6e73050700729363e7e33306a9a3218e5cdc61/duckdb-1.5.2-cp314-cp314-win_arm64.whl", hash = "sha256:c9f3e0b71b8a50fccfb42794899285d9d318ce2503782b9dd54868e5ecd0ad31", size = 14402513, upload-time = "2026-04-13T11:30:06.609Z" }, + { url = "https://files.pythonhosted.org/packages/cc/9c/a528eb09d8be51954c485864bd06753e616939a080cbc3dd4417e8c94a57/duckdb-1.5.3-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:e75a6122c12579a99848517f6f00a4e342aebda3590c30fe9b5cc5f39d5e6afc", size = 32626254, upload-time = "2026-05-20T11:54:53.65Z" }, + { url = "https://files.pythonhosted.org/packages/ec/3c/1534c0a6db347c05eb7d0f6ecfb7aefbe74cbff398e4892a8fd1903a20e8/duckdb-1.5.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:fd3963c1cb9d9567777f4a898a9dbe388a2fe9724681801b1e7d6d93eecf1b76", size = 17300917, upload-time = "2026-05-20T11:54:56.628Z" }, + { url = "https://files.pythonhosted.org/packages/23/fa/beafb91e6e152d2161c4a9cbc472334c87607eb61ad7104b5a7fa8d8d7b1/duckdb-1.5.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:3d5db8c0b55e072cf437948ebb5d7e23d7b9d03d905fa5f9145583e65aa447f7", size = 15449411, upload-time = "2026-05-20T11:54:59.089Z" }, + { url = "https://files.pythonhosted.org/packages/50/0a/49b6fe04e2fcd63729eb607dadd44818dde77342a4f5ce086c6c92f1dd4d/duckdb-1.5.3-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ce80aed7a538422129a57eaca9141e3afb51f8bf562b1908b1576c9725b5b22", size = 19333120, upload-time = "2026-05-20T11:55:01.727Z" }, + { url = "https://files.pythonhosted.org/packages/63/4c/0907c3f76adb9dd90e67610b31e0304a35814e65c4c41a354a262c09b885/duckdb-1.5.3-cp313-cp313-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:787df63824f07bf18022dbc3b8ca4b2bfab0ebe616464f55c6e8cd0f59ea762e", size = 21453266, upload-time = "2026-05-20T11:55:04.5Z" }, + { url = "https://files.pythonhosted.org/packages/6d/9c/d2f23a7803ddbbd9413f7572ecf66a15120ed5ced7ce5c73e698c1406b76/duckdb-1.5.3-cp313-cp313-win_amd64.whl", hash = "sha256:bb5bb5dcdd09d62ee60f0ddbbef918e71cce304ffe28428b1131949d39ffaabf", size = 13118640, upload-time = "2026-05-20T11:55:07.389Z" }, + { url = "https://files.pythonhosted.org/packages/27/d5/7ba2316415bcdab6edd765bbbe35c2ca8a3800f2fe695cd70e3cdb997f09/duckdb-1.5.3-cp313-cp313-win_arm64.whl", hash = "sha256:2fa17ecdd5d3db122836cb71bb93601c2106a3be883c17dffddc02fbf3fa7888", size = 13926409, upload-time = "2026-05-20T11:55:10.166Z" }, + { url = "https://files.pythonhosted.org/packages/a5/c2/d4b6f8a5e4d3bc25773be6da76a99d9661ebbf3552c007c460d2dd59dbf8/duckdb-1.5.3-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:4bfa9a4dadf71e83e2c4eaca2f9421c82a54defecc1b0b4c0be95e2389dec4fe", size = 32636685, upload-time = "2026-05-20T11:55:13.158Z" }, + { url = "https://files.pythonhosted.org/packages/42/58/e835c8298979d29db7a62cb5acc29e9b57aeaca7cdde2fcd3ac980f5cb18/duckdb-1.5.3-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:aea7baf67ad7e1829ac76f67d7dcbd7fb1f57c3eb179d55ac30952df4709ae30", size = 17308134, upload-time = "2026-05-20T11:55:16.194Z" }, + { url = "https://files.pythonhosted.org/packages/c9/46/617b51363f5613418c8b224b3cce16b58e6dde80904566bec232579c1d4e/duckdb-1.5.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0b0b4f088a65d77e1217ce5d7eff889e63fedc44281200d899ff47c84d8ff836", size = 15449891, upload-time = "2026-05-20T11:55:18.687Z" }, + { url = "https://files.pythonhosted.org/packages/b3/72/354146656e8d9ba3853d3a5ee80a481b8c5f70edfc3d5ae80a8c4479c967/duckdb-1.5.3-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fe8d0c1f6a120aa03fa6e0d03897c71a1842e6cf7afd31d181348391f7108fe1", size = 19338499, upload-time = "2026-05-20T11:55:21.34Z" }, + { url = "https://files.pythonhosted.org/packages/56/8f/65fc623b51448f2bfba1a9ec6ab3debb4664c0876c0113a5e782600b53ac/duckdb-1.5.3-cp314-cp314-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d0405eae18ec6e8210a471c97dbfe87a7e4d605274b7fe572a1f276e92158f13", size = 21455828, upload-time = "2026-05-20T11:55:23.847Z" }, + { url = "https://files.pythonhosted.org/packages/2b/db/d0274cbe9f5fe219f77c0bdf900ac77103569e83c102a4225ce04cbc607d/duckdb-1.5.3-cp314-cp314-win_amd64.whl", hash = "sha256:33ae08b3e818d7613d8936744b67718c2062c2f530376895bfd89efb51b81538", size = 13640011, upload-time = "2026-05-20T11:55:26.276Z" }, + { url = "https://files.pythonhosted.org/packages/07/5d/8f1899b8bef291caf953992fcd6c24df9f29387a35645e58c2504a5ca473/duckdb-1.5.3-cp314-cp314-win_arm64.whl", hash = "sha256:746433e49bbc667b4df283153415fbe37e9083e0eff6c3cd6e54de7536869cd4", size = 14411554, upload-time = "2026-05-20T11:55:29.037Z" }, ] [[package]] name = "fastapi" -version = "0.136.1" +version = "0.136.3" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "annotated-doc" }, - { name = "pydantic" }, - { name = "starlette" }, - { name = "typing-extensions" }, - { name = "typing-inspection" }, + { name = "annotated-doc" }, + { name = "pydantic" }, + { name = "starlette" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/5d/45/c130091c2dfa061bbfe3150f2a5091ef1adf149f2a8d2ae769ecaf6e99a2/fastapi-0.136.1.tar.gz", hash = "sha256:7af665ad7acfa0a3baf8983d393b6b471b9da10ede59c60045f49fbc89a0fa7f", size = 397448, upload-time = "2026-04-23T16:49:44.046Z" } +sdist = { url = "https://files.pythonhosted.org/packages/81/2d/ff8d91d7b564d464629a0fd50a4489c97fcb836ac230bf3a7269232a9b1f/fastapi-0.136.3.tar.gz", hash = "sha256:e487fae93ad408e6f47641ee4dfe389864fd7bec92e547ea8498fc13f43e83ab", size = 396410, upload-time = "2026-05-23T18:53:15.192Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/5a/ff/2e4eca3ade2c22fe1dea7043b8ee9dabe47753349eb1b56a202de8af6349/fastapi-0.136.1-py3-none-any.whl", hash = "sha256:a6e9d7eeada96c93a4d69cb03836b44fa34e2854accb7244a1ece36cd4781c3f", size = 117683, upload-time = "2026-04-23T16:49:42.437Z" }, + { url = "https://files.pythonhosted.org/packages/e0/82/45359b62a067409bd929ae8a56b8ed13e5a8c8a61194b3c236920999ab83/fastapi-0.136.3-py3-none-any.whl", hash = "sha256:3d2a69bdf04b7e9f3afa292c3bc7a98816bbfafa10bc9b45f3f3700d2f761620", size = 117481, upload-time = "2026-05-23T18:53:16.924Z" }, ] [[package]] @@ -285,16 +285,16 @@ version = "3.29.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/b5/fe/997687a931ab51049acce6fa1f23e8f01216374ea81374ddee763c493db5/filelock-3.29.0.tar.gz", hash = "sha256:69974355e960702e789734cb4871f884ea6fe50bd8404051a3530bc07809cf90", size = 57571, upload-time = "2026-04-19T15:39:10.068Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/81/47/dd9a212ef6e343a6857485ffe25bba537304f1913bdbed446a23f7f592e1/filelock-3.29.0-py3-none-any.whl", hash = "sha256:96f5f6344709aa1572bbf631c640e4ebeeb519e08da902c39a001882f30ac258", size = 39812, upload-time = "2026-04-19T15:39:08.752Z" }, + { url = "https://files.pythonhosted.org/packages/81/47/dd9a212ef6e343a6857485ffe25bba537304f1913bdbed446a23f7f592e1/filelock-3.29.0-py3-none-any.whl", hash = "sha256:96f5f6344709aa1572bbf631c640e4ebeeb519e08da902c39a001882f30ac258", size = 39812, upload-time = "2026-04-19T15:39:08.752Z" }, ] [[package]] name = "fsspec" -version = "2026.3.0" +version = "2026.4.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/e1/cf/b50ddf667c15276a9ab15a70ef5f257564de271957933ffea49d2cdbcdfb/fsspec-2026.3.0.tar.gz", hash = "sha256:1ee6a0e28677557f8c2f994e3eea77db6392b4de9cd1f5d7a9e87a0ae9d01b41", size = 313547, upload-time = "2026-03-27T19:11:14.892Z" } +sdist = { url = "https://files.pythonhosted.org/packages/d5/8d/1c51c094345df128ca4a990d633fe1a0ff28726c9e6b3c41ba65087bba1d/fsspec-2026.4.0.tar.gz", hash = "sha256:301d8ac70ae90ef3ad05dcf94d6c3754a097f9b5fe4667d2787aa359ec7df7e4", size = 312760, upload-time = "2026-04-29T20:42:38.635Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d5/1f/5f4a3cd9e4440e9d9bc78ad0a91a1c8d46b4d429d5239ebe6793c9fe5c41/fsspec-2026.3.0-py3-none-any.whl", hash = "sha256:d2ceafaad1b3457968ed14efa28798162f1638dbb5d2a6868a2db002a5ee39a4", size = 202595, upload-time = "2026-03-27T19:11:13.595Z" }, + { url = "https://files.pythonhosted.org/packages/d5/0c/043d5e551459da400957a1395e0febbf771446ff34291afcbe3d8be2a279/fsspec-2026.4.0-py3-none-any.whl", hash = "sha256:11ef7bb35dab8a394fde6e608221d5cf3e8499401c249bebaeaad760a1a8dec2", size = 203402, upload-time = "2026-04-29T20:42:36.842Z" }, ] [[package]] @@ -303,39 +303,39 @@ version = "0.16.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, ] [[package]] name = "hf-xet" -version = "1.4.3" +version = "1.5.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/53/92/ec9ad04d0b5728dca387a45af7bc98fbb0d73b2118759f5f6038b61a57e8/hf_xet-1.4.3.tar.gz", hash = "sha256:8ddedb73c8c08928c793df2f3401ec26f95be7f7e516a7bee2fbb546f6676113", size = 670477, upload-time = "2026-03-31T22:40:07.874Z" } +sdist = { url = "https://files.pythonhosted.org/packages/74/d8/5c06fc76461418326a7decf8367480c35be11a41fd938633929c60a9ec6b/hf_xet-1.5.0.tar.gz", hash = "sha256:e0fb0a34d9f406eed88233e829a67ec016bec5af19e480eac65a233ea289a948", size = 837196, upload-time = "2026-05-06T06:18:15.583Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/72/43/724d307b34e353da0abd476e02f72f735cdd2bc86082dee1b32ea0bfee1d/hf_xet-1.4.3-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:7551659ba4f1e1074e9623996f28c3873682530aee0a846b7f2f066239228144", size = 3800935, upload-time = "2026-03-31T22:39:49.618Z" }, - { url = "https://files.pythonhosted.org/packages/2b/d2/8bee5996b699262edb87dbb54118d287c0e1b2fc78af7cdc41857ba5e3c4/hf_xet-1.4.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:bee693ada985e7045997f05f081d0e12c4c08bd7626dc397f8a7c487e6c04f7f", size = 3558942, upload-time = "2026-03-31T22:39:47.938Z" }, - { url = "https://files.pythonhosted.org/packages/c3/a1/e993d09cbe251196fb60812b09a58901c468127b7259d2bf0f68bf6088eb/hf_xet-1.4.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:21644b404bb0100fe3857892f752c4d09642586fd988e61501c95bbf44b393a3", size = 4207657, upload-time = "2026-03-31T22:39:39.69Z" }, - { url = "https://files.pythonhosted.org/packages/64/44/9eb6d21e5c34c63e5e399803a6932fa983cabdf47c0ecbcfe7ea97684b8c/hf_xet-1.4.3-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:987f09cfe418237812896a6736b81b1af02a3a6dcb4b4944425c4c4fca7a7cf8", size = 3986765, upload-time = "2026-03-31T22:39:37.936Z" }, - { url = "https://files.pythonhosted.org/packages/ea/7b/8ad6f16fdb82f5f7284a34b5ec48645bd575bdcd2f6f0d1644775909c486/hf_xet-1.4.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:60cf7fc43a99da0a853345cf86d23738c03983ee5249613a6305d3e57a5dca74", size = 4188162, upload-time = "2026-03-31T22:39:58.382Z" }, - { url = "https://files.pythonhosted.org/packages/1b/c4/39d6e136cbeea9ca5a23aad4b33024319222adbdc059ebcda5fc7d9d5ff4/hf_xet-1.4.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:2815a49a7a59f3e2edf0cf113ae88e8cb2ca2a221bf353fb60c609584f4884d4", size = 4424525, upload-time = "2026-03-31T22:40:00.225Z" }, - { url = "https://files.pythonhosted.org/packages/46/f2/adc32dae6bdbc367853118b9878139ac869419a4ae7ba07185dc31251b76/hf_xet-1.4.3-cp313-cp313t-win_amd64.whl", hash = "sha256:42ee323265f1e6a81b0e11094564fb7f7e0ec75b5105ffd91ae63f403a11931b", size = 3671610, upload-time = "2026-03-31T22:40:10.42Z" }, - { url = "https://files.pythonhosted.org/packages/e2/19/25d897dcc3f81953e0c2cde9ec186c7a0fee413eb0c9a7a9130d87d94d3a/hf_xet-1.4.3-cp313-cp313t-win_arm64.whl", hash = "sha256:27c976ba60079fb8217f485b9c5c7fcd21c90b0367753805f87cb9f3cdc4418a", size = 3528529, upload-time = "2026-03-31T22:40:09.106Z" }, - { url = "https://files.pythonhosted.org/packages/ec/36/3e8f85ca9fe09b8de2b2e10c63b3b3353d7dda88a0b3d426dffbe7b8313b/hf_xet-1.4.3-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:5251d5ece3a81815bae9abab41cf7ddb7bcb8f56411bce0827f4a3071c92fdc6", size = 3801019, upload-time = "2026-03-31T22:39:56.651Z" }, - { url = "https://files.pythonhosted.org/packages/b5/9c/defb6cb1de28bccb7bd8d95f6e60f72a3d3fa4cb3d0329c26fb9a488bfe7/hf_xet-1.4.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1feb0f3abeacee143367c326a128a2e2b60868ec12a36c225afb1d6c5a05e6d2", size = 3558746, upload-time = "2026-03-31T22:39:54.766Z" }, - { url = "https://files.pythonhosted.org/packages/c1/bd/8d001191893178ff8e826e46ad5299446e62b93cd164e17b0ffea08832ec/hf_xet-1.4.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8b301fc150290ca90b4fccd079829b84bb4786747584ae08b94b4577d82fb791", size = 4207692, upload-time = "2026-03-31T22:39:46.246Z" }, - { url = "https://files.pythonhosted.org/packages/ce/48/6790b402803250e9936435613d3a78b9aaeee7973439f0918848dde58309/hf_xet-1.4.3-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:d972fbe95ddc0d3c0fc49b31a8a69f47db35c1e3699bf316421705741aab6653", size = 3986281, upload-time = "2026-03-31T22:39:44.648Z" }, - { url = "https://files.pythonhosted.org/packages/51/56/ea62552fe53db652a9099eda600b032d75554d0e86c12a73824bfedef88b/hf_xet-1.4.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c5b48db1ee344a805a1b9bd2cda9b6b65fe77ed3787bd6e87ad5521141d317cd", size = 4187414, upload-time = "2026-03-31T22:40:04.951Z" }, - { url = "https://files.pythonhosted.org/packages/7d/f5/bc1456d4638061bea997e6d2db60a1a613d7b200e0755965ec312dc1ef79/hf_xet-1.4.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:22bdc1f5fb8b15bf2831440b91d1c9bbceeb7e10c81a12e8d75889996a5c9da8", size = 4424368, upload-time = "2026-03-31T22:40:06.347Z" }, - { url = "https://files.pythonhosted.org/packages/e4/76/ab597bae87e1f06d18d3ecb8ed7f0d3c9a37037fc32ce76233d369273c64/hf_xet-1.4.3-cp314-cp314t-win_amd64.whl", hash = "sha256:0392c79b7cf48418cd61478c1a925246cf10639f4cd9d94368d8ca1e8df9ea07", size = 3672280, upload-time = "2026-03-31T22:40:16.401Z" }, - { url = "https://files.pythonhosted.org/packages/62/05/2e462d34e23a09a74d73785dbed71cc5dbad82a72eee2ad60a72a554155d/hf_xet-1.4.3-cp314-cp314t-win_arm64.whl", hash = "sha256:681c92a07796325778a79d76c67011764ecc9042a8c3579332b61b63ae512075", size = 3528945, upload-time = "2026-03-31T22:40:14.995Z" }, - { url = "https://files.pythonhosted.org/packages/ac/9f/9c23e4a447b8f83120798f9279d0297a4d1360bdbf59ef49ebec78fe2545/hf_xet-1.4.3-cp37-abi3-macosx_10_12_x86_64.whl", hash = "sha256:d0da85329eaf196e03e90b84c2d0aca53bd4573d097a75f99609e80775f98025", size = 3805048, upload-time = "2026-03-31T22:39:53.105Z" }, - { url = "https://files.pythonhosted.org/packages/0b/f8/7aacb8e5f4a7899d39c787b5984e912e6c18b11be136ef13947d7a66d265/hf_xet-1.4.3-cp37-abi3-macosx_11_0_arm64.whl", hash = "sha256:e23717ce4186b265f69afa66e6f0069fe7efbf331546f5c313d00e123dc84583", size = 3562178, upload-time = "2026-03-31T22:39:51.295Z" }, - { url = "https://files.pythonhosted.org/packages/df/9a/a24b26dc8a65f0ecc0fe5be981a19e61e7ca963b85e062c083f3a9100529/hf_xet-1.4.3-cp37-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fc360b70c815bf340ed56c7b8c63aacf11762a4b099b2fe2c9bd6d6068668c08", size = 4212320, upload-time = "2026-03-31T22:39:42.922Z" }, - { url = "https://files.pythonhosted.org/packages/53/60/46d493db155d2ee2801b71fb1b0fd67696359047fdd8caee2c914cc50c79/hf_xet-1.4.3-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:39f2d2e9654cd9b4319885733993807aab6de9dfbd34c42f0b78338d6617421f", size = 3991546, upload-time = "2026-03-31T22:39:41.335Z" }, - { url = "https://files.pythonhosted.org/packages/bc/f5/067363e1c96c6b17256910830d1b54099d06287e10f4ec6ec4e7e08371fc/hf_xet-1.4.3-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:49ad8a8cead2b56051aa84d7fce3e1335efe68df3cf6c058f22a65513885baac", size = 4193200, upload-time = "2026-03-31T22:40:01.936Z" }, - { url = "https://files.pythonhosted.org/packages/42/4b/53951592882d9c23080c7644542fda34a3813104e9e11fa1a7d82d419cb8/hf_xet-1.4.3-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:7716d62015477a70ea272d2d68cd7cad140f61c52ee452e133e139abfe2c17ba", size = 4429392, upload-time = "2026-03-31T22:40:03.492Z" }, - { url = "https://files.pythonhosted.org/packages/8a/21/75a6c175b4e79662ad8e62f46a40ce341d8d6b206b06b4320d07d55b188c/hf_xet-1.4.3-cp37-abi3-win_amd64.whl", hash = "sha256:6b591fcad34e272a5b02607485e4f2a1334aebf1bc6d16ce8eb1eb8978ac2021", size = 3677359, upload-time = "2026-03-31T22:40:13.619Z" }, - { url = "https://files.pythonhosted.org/packages/8a/7c/44314ecd0e89f8b2b51c9d9e5e7a60a9c1c82024ac471d415860557d3cd8/hf_xet-1.4.3-cp37-abi3-win_arm64.whl", hash = "sha256:7c2c7e20bcfcc946dc67187c203463f5e932e395845d098cc2a93f5b67ca0b47", size = 3533664, upload-time = "2026-03-31T22:40:12.152Z" }, + { url = "https://files.pythonhosted.org/packages/68/9b/6912c99070915a4f28119e3c5b52a9abd1eec0ad5cb293b8c967a0c6f5a2/hf_xet-1.5.0-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:7d70fe2ce97b9db73b9c9b9c81fe3693640aec83416a966c446afea54acfae3c", size = 4023383, upload-time = "2026-05-06T06:17:53.947Z" }, + { url = "https://files.pythonhosted.org/packages/0f/6d/9563cfde59b5d8128a9c7ec972a087f4c782e4f7bac5a85234edfd5d5e49/hf_xet-1.5.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:73a0dae8c71de3b0633a45c73f4a4a5ed09e94b43441d82981a781d4f12baa42", size = 3792751, upload-time = "2026-05-06T06:17:51.791Z" }, + { url = "https://files.pythonhosted.org/packages/07/a5/ed5a0cf35b49a0571af5a8f53416dad1877a718c021c9937c3a53cb45781/hf_xet-1.5.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a60290ec57e9b71767fba7c3645ddafdd0759974b540441510c629c6db6db24a", size = 4456058, upload-time = "2026-05-06T06:17:40.735Z" }, + { url = "https://files.pythonhosted.org/packages/60/fb/3ae8bf2a7a37a4197d0195d7247fd25b3952e15cb8a599e285dfaa6f52b3/hf_xet-1.5.0-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:e5de0f6deada0dada870bb376a11bcd1f08abf3a968a6d118f33e72d1b1eb480", size = 4250783, upload-time = "2026-05-06T06:17:38.412Z" }, + { url = "https://files.pythonhosted.org/packages/a2/9b/8bae40d4d91525085137196e84eb0ed49cf65b5e96e5c3ecdadd8bd0fac2/hf_xet-1.5.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:c799d49f1a5544a0ef7591c0ee75e0d6b93d6f56dc7a4979f59f7518d2872216", size = 4445594, upload-time = "2026-05-06T06:18:04.219Z" }, + { url = "https://files.pythonhosted.org/packages/13/59/c74efbbd4e8728172b2cc72a2bc014d2947a4b7bdced932fbd3f5da1a4e5/hf_xet-1.5.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:2baea1b0b989e5c152fe81425f7745ddc8901280ba3d97c98d8cdece7b706c60", size = 4663995, upload-time = "2026-05-06T06:18:06.1Z" }, + { url = "https://files.pythonhosted.org/packages/73/32/8e1e0410af64cda9b139d1dcebdc993a8ff9c8c7c0e2696ae356d75ccc0d/hf_xet-1.5.0-cp313-cp313t-win_amd64.whl", hash = "sha256:526345b3ed45f374f6317349df489167606736c876241ba984105afe7fd4839d", size = 3966608, upload-time = "2026-05-06T06:18:19.74Z" }, + { url = "https://files.pythonhosted.org/packages/fc/34/a8febc8f4edbea8b3e21b02ebc8b628679b84ba7e45cde624a7736b51500/hf_xet-1.5.0-cp313-cp313t-win_arm64.whl", hash = "sha256:786d28e2eb8315d5035544b9d137b4a842d600c434bb91bf7d0d953cce906ad4", size = 3796946, upload-time = "2026-05-06T06:18:17.568Z" }, + { url = "https://files.pythonhosted.org/packages/2a/20/8fc8996afe5815fa1a6be8e9e5c02f24500f409d599e905800d498a4e14d/hf_xet-1.5.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:872d5601e6deea30d15865ede55d29eac6daf5a534ab417b99b6ef6b076dd96c", size = 4023495, upload-time = "2026-05-06T06:18:01.94Z" }, + { url = "https://files.pythonhosted.org/packages/32/6a/93d84463c00cecb561a7508aa6303e35ee2894294eac14245526924415fe/hf_xet-1.5.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:9929561f5abf4581c8ea79587881dfef6b8abb2a0d8a51915936fc2a614f4e73", size = 3792731, upload-time = "2026-05-06T06:18:00.021Z" }, + { url = "https://files.pythonhosted.org/packages/9d/5a/8ec8e0c863b382d00b3c2e2af6ded6b06371be617144a625903a6d562f4b/hf_xet-1.5.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f7b7bbae318e583a86fb21e5a4a175d6721d628a2874f4bd022d0e660c32a682", size = 4456738, upload-time = "2026-05-06T06:17:49.574Z" }, + { url = "https://files.pythonhosted.org/packages/c5/ca/f7effa1a67717da2bcc6b6c28f71c6ca648c77acaec4e2c32f40cbe16d85/hf_xet-1.5.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:cf7b2dc6f31a4ea754bb50f74cde482dcf5d366d184076d8530b9872787f3761", size = 4251622, upload-time = "2026-05-06T06:17:47.096Z" }, + { url = "https://files.pythonhosted.org/packages/65/f2/19247dba3e231cf77dec59ddfb878f00057635ff773d099c9b59d37812c3/hf_xet-1.5.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8dbcbab554c9ef158ef2c991545c3e970ddd8cc7acdcd0a78c5a41095dab4ded", size = 4445667, upload-time = "2026-05-06T06:18:11.983Z" }, + { url = "https://files.pythonhosted.org/packages/7f/64/6f116801a3bcfb6f59f5c251f48cadc47ea54026441c4a385079286a94fa/hf_xet-1.5.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5906bf7718d3636dc13402914736abe723492cb730f744834f5f5b67d3a12702", size = 4664619, upload-time = "2026-05-06T06:18:13.771Z" }, + { url = "https://files.pythonhosted.org/packages/5c/e8/069542d37946ed08669b127e1496fa99e78196d71de8d41eda5e9f1b7a58/hf_xet-1.5.0-cp314-cp314t-win_amd64.whl", hash = "sha256:5f3dc2248fc01cc0a00cd392ab497f1ca373fcbc7e3f2da1f452480b384e839e", size = 3966802, upload-time = "2026-05-06T06:18:28.162Z" }, + { url = "https://files.pythonhosted.org/packages/f9/91/fc6fdec27b14d04e88c386ac0a0129732b53fa23f7c4a78f4b83a039c567/hf_xet-1.5.0-cp314-cp314t-win_arm64.whl", hash = "sha256:b285cea1b5bab46b758772716ba8d6854a1a0310fed1c249d678a8b38601e5a0", size = 3797168, upload-time = "2026-05-06T06:18:26.287Z" }, + { url = "https://files.pythonhosted.org/packages/3d/fb/69ff198a82cae7eb1a69fb84d93b3a3e4816564d76817fe541ddc96874eb/hf_xet-1.5.0-cp37-abi3-macosx_10_12_x86_64.whl", hash = "sha256:dad0dc84e941b8ba3c860659fe1fdc35c049d47cce293f003287757e971a8f56", size = 4030814, upload-time = "2026-05-06T06:17:57.933Z" }, + { url = "https://files.pythonhosted.org/packages/9b/ff/edcc2b40162bef3ff78e14ab637e5f3b89243d6aee72f5949d3bb6a5af83/hf_xet-1.5.0-cp37-abi3-macosx_11_0_arm64.whl", hash = "sha256:fd6e5a9b0fdac4ed03ed45ef79254a655b1aaab514a02202617fbf643f5fdf7a", size = 3798444, upload-time = "2026-05-06T06:17:55.79Z" }, + { url = "https://files.pythonhosted.org/packages/49/4d/103f76b04310e5e57656696cc184690d20c466af0bca3ca88f8c8ea5d4f3/hf_xet-1.5.0-cp37-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3531b1823a0e6d77d80f9ed15ca0e00f0d115094f8ac033d5cae88f4564cc949", size = 4465986, upload-time = "2026-05-06T06:17:44.886Z" }, + { url = "https://files.pythonhosted.org/packages/c4/a2/546f47f464737b3edbab6f8ddb57f2599b93d2cbb66f06abb475ccb48651/hf_xet-1.5.0-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:9a0ee58cd18d5ea799f7ed11290bbccbe56bdd8b1d97ca74b9cc49a3945d7a3b", size = 4259865, upload-time = "2026-05-06T06:17:42.639Z" }, + { url = "https://files.pythonhosted.org/packages/95/7f/1be593c1f28613be2e196473481cd81bfc5910795e30a34e8f744f6cac4f/hf_xet-1.5.0-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:1e60df5a42e9bed8628b6416af2cba4cba57ae9f02de226a06b020d98e1aab18", size = 4459835, upload-time = "2026-05-06T06:18:08.026Z" }, + { url = "https://files.pythonhosted.org/packages/aa/b2/703569fc881f3284487e68cda7b42179978480da3c438042a6bbbb4a671c/hf_xet-1.5.0-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:4b35549ce62601b84da4ff9b24d970032ace3d4430f52d91bcbb26c901d6c690", size = 4672414, upload-time = "2026-05-06T06:18:09.864Z" }, + { url = "https://files.pythonhosted.org/packages/af/37/1b6def445c567286b50aa3b33828158e135b1be44938dde59f11382a500c/hf_xet-1.5.0-cp37-abi3-win_amd64.whl", hash = "sha256:2806c7c17b4d23f8d88f7c4814f838c3b6150773fe339c20af23e1cfaf2797e4", size = 3977238, upload-time = "2026-05-06T06:18:23.621Z" }, + { url = "https://files.pythonhosted.org/packages/62/94/3b66b148778ee100dcfd69c2ca22b57b41b44d3063ceec934f209e9184ce/hf_xet-1.5.0-cp37-abi3-win_arm64.whl", hash = "sha256:b6c9df403040248c76d808d3e047d64db2d923bae593eb244c41e425cf6cd7be", size = 3806916, upload-time = "2026-05-06T06:18:21.7Z" }, ] [[package]] @@ -343,34 +343,41 @@ name = "httpcore" version = "1.0.9" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "certifi" }, - { name = "h11" }, + { name = "certifi" }, + { name = "h11" }, ] sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, + { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, ] [[package]] name = "httptools" -version = "0.7.1" +version = "0.8.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/b5/46/120a669232c7bdedb9d52d4aeae7e6c7dfe151e99dc70802e2fc7a5e1993/httptools-0.7.1.tar.gz", hash = "sha256:abd72556974f8e7c74a259655924a717a2365b236c882c3f6f8a45fe94703ac9", size = 258961, upload-time = "2025-10-10T03:55:08.559Z" } +sdist = { url = "https://files.pythonhosted.org/packages/43/e5/d471fcb0e14523fe1c3f4ba58ca52480e7bd70ad7109a3846bc75892f7fb/httptools-0.8.0.tar.gz", hash = "sha256:6b2a32f18d97e16e90827d7a819ffa8dbd8cc245fc4e1fa9d1095b54ef4bd999", size = 271342, upload-time = "2026-05-25T22:17:48.841Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/09/8f/c77b1fcbfd262d422f12da02feb0d218fa228d52485b77b953832105bb90/httptools-0.7.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:6babce6cfa2a99545c60bfef8bee0cc0545413cb0018f617c8059a30ad985de3", size = 202889, upload-time = "2025-10-10T03:54:47.089Z" }, - { url = "https://files.pythonhosted.org/packages/0a/1a/22887f53602feaa066354867bc49a68fc295c2293433177ee90870a7d517/httptools-0.7.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:601b7628de7504077dd3dcb3791c6b8694bbd967148a6d1f01806509254fb1ca", size = 108180, upload-time = "2025-10-10T03:54:48.052Z" }, - { url = "https://files.pythonhosted.org/packages/32/6a/6aaa91937f0010d288d3d124ca2946d48d60c3a5ee7ca62afe870e3ea011/httptools-0.7.1-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:04c6c0e6c5fb0739c5b8a9eb046d298650a0ff38cf42537fc372b28dc7e4472c", size = 478596, upload-time = "2025-10-10T03:54:48.919Z" }, - { url = "https://files.pythonhosted.org/packages/6d/70/023d7ce117993107be88d2cbca566a7c1323ccbaf0af7eabf2064fe356f6/httptools-0.7.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:69d4f9705c405ae3ee83d6a12283dc9feba8cc6aaec671b412917e644ab4fa66", size = 473268, upload-time = "2025-10-10T03:54:49.993Z" }, - { url = "https://files.pythonhosted.org/packages/32/4d/9dd616c38da088e3f436e9a616e1d0cc66544b8cdac405cc4e81c8679fc7/httptools-0.7.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:44c8f4347d4b31269c8a9205d8a5ee2df5322b09bbbd30f8f862185bb6b05346", size = 455517, upload-time = "2025-10-10T03:54:51.066Z" }, - { url = "https://files.pythonhosted.org/packages/1d/3a/a6c595c310b7df958e739aae88724e24f9246a514d909547778d776799be/httptools-0.7.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:465275d76db4d554918aba40bf1cbebe324670f3dfc979eaffaa5d108e2ed650", size = 458337, upload-time = "2025-10-10T03:54:52.196Z" }, - { url = "https://files.pythonhosted.org/packages/fd/82/88e8d6d2c51edc1cc391b6e044c6c435b6aebe97b1abc33db1b0b24cd582/httptools-0.7.1-cp313-cp313-win_amd64.whl", hash = "sha256:322d00c2068d125bd570f7bf78b2d367dad02b919d8581d7476d8b75b294e3e6", size = 85743, upload-time = "2025-10-10T03:54:53.448Z" }, - { url = "https://files.pythonhosted.org/packages/34/50/9d095fcbb6de2d523e027a2f304d4551855c2f46e0b82befd718b8b20056/httptools-0.7.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:c08fe65728b8d70b6923ce31e3956f859d5e1e8548e6f22ec520a962c6757270", size = 203619, upload-time = "2025-10-10T03:54:54.321Z" }, - { url = "https://files.pythonhosted.org/packages/07/f0/89720dc5139ae54b03f861b5e2c55a37dba9a5da7d51e1e824a1f343627f/httptools-0.7.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:7aea2e3c3953521c3c51106ee11487a910d45586e351202474d45472db7d72d3", size = 108714, upload-time = "2025-10-10T03:54:55.163Z" }, - { url = "https://files.pythonhosted.org/packages/b3/cb/eea88506f191fb552c11787c23f9a405f4c7b0c5799bf73f2249cd4f5228/httptools-0.7.1-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0e68b8582f4ea9166be62926077a3334064d422cf08ab87d8b74664f8e9058e1", size = 472909, upload-time = "2025-10-10T03:54:56.056Z" }, - { url = "https://files.pythonhosted.org/packages/e0/4a/a548bdfae6369c0d078bab5769f7b66f17f1bfaa6fa28f81d6be6959066b/httptools-0.7.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:df091cf961a3be783d6aebae963cc9b71e00d57fa6f149025075217bc6a55a7b", size = 470831, upload-time = "2025-10-10T03:54:57.219Z" }, - { url = "https://files.pythonhosted.org/packages/4d/31/14df99e1c43bd132eec921c2e7e11cda7852f65619bc0fc5bdc2d0cb126c/httptools-0.7.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f084813239e1eb403ddacd06a30de3d3e09a9b76e7894dcda2b22f8a726e9c60", size = 452631, upload-time = "2025-10-10T03:54:58.219Z" }, - { url = "https://files.pythonhosted.org/packages/22/d2/b7e131f7be8d854d48cb6d048113c30f9a46dca0c9a8b08fcb3fcd588cdc/httptools-0.7.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7347714368fb2b335e9063bc2b96f2f87a9ceffcd9758ac295f8bbcd3ffbc0ca", size = 452910, upload-time = "2025-10-10T03:54:59.366Z" }, - { url = "https://files.pythonhosted.org/packages/53/cf/878f3b91e4e6e011eff6d1fa9ca39f7eb17d19c9d7971b04873734112f30/httptools-0.7.1-cp314-cp314-win_amd64.whl", hash = "sha256:cfabda2a5bb85aa2a904ce06d974a3f30fb36cc63d7feaddec05d2050acede96", size = 88205, upload-time = "2025-10-10T03:55:00.389Z" }, + { url = "https://files.pythonhosted.org/packages/5e/e5/8cfcabc5546e8022f168be28bcdaa128a240a0befdd03b59d558b4f18bd6/httptools-0.8.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:614ceea8ea606848bece2338ac03b3ce5324bcb4be8dc7d377ed708012fa4db8", size = 205148, upload-time = "2026-05-25T22:17:16.333Z" }, + { url = "https://files.pythonhosted.org/packages/2a/0e/0fb14848c19a686c8062ff9067c1a48793e3224b47bc5b201535b6036fce/httptools-0.8.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2d689918c15a013c65ef52d9fd495d766893ab831a2c8d89f2ac5940a5df847c", size = 111368, upload-time = "2026-05-25T22:17:17.586Z" }, + { url = "https://files.pythonhosted.org/packages/2e/1b/46f1cecf06b9bbde8e4b8c88034ac7908989e5ff7a3a388ef38392949c1f/httptools-0.8.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:eb3028cca2fc0a6d720e52ef61d8ebb62fcbfeb1de56874546d858d3f25a26b7", size = 486447, upload-time = "2026-05-25T22:17:18.564Z" }, + { url = "https://files.pythonhosted.org/packages/77/00/258bfc0837221f81d9725c45f9b948a6a6b2994a147a4fb66e85100c668f/httptools-0.8.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:88bdd940f2b5d487b4d032c6afa5489a7dc4694410d43de3c38c4fb3af0dc45d", size = 482448, upload-time = "2026-05-25T22:17:19.912Z" }, + { url = "https://files.pythonhosted.org/packages/04/ab/d1cef3b5523f4d272a70f42a776c3169a2dddfe3a54de4b2ce4a36341528/httptools-0.8.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6a43c9dd399758ccc0531acb0a3c4a6c299ee893ee9400e9c893b7bdcfae0681", size = 464460, upload-time = "2026-05-25T22:17:20.882Z" }, + { url = "https://files.pythonhosted.org/packages/ce/48/5d1d072442277bb2b3434e0e60690b8e8c23840ef7de8b6ea54040a536d3/httptools-0.8.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:0770728beb05094c809b98e814edff5fef69d26ad7d21185f2f6d5884a0ba683", size = 471312, upload-time = "2026-05-25T22:17:22.085Z" }, + { url = "https://files.pythonhosted.org/packages/0d/66/b96623b27e51a68199ef4efdda0613cced9233fe3062ac74e50749c5ad37/httptools-0.8.0-cp313-cp313-win_amd64.whl", hash = "sha256:7685df791fad561384bfb139e77fde27a1ffd93134e016f95a0db424ffbf77b1", size = 90117, upload-time = "2026-05-25T22:17:23.074Z" }, + { url = "https://files.pythonhosted.org/packages/1a/12/fa3fbf5f9517b273edea2dc982aa82a8c634091e67c590792b729017bc6f/httptools-0.8.0-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:de242a49b5d18e0a8776e654e9f6bf6d89f3875a5c35b425a0e7ce940feb3fd6", size = 206183, upload-time = "2026-05-25T22:17:24.004Z" }, + { url = "https://files.pythonhosted.org/packages/30/fc/5e7c4cb443370f2090a3aba0453a07384d29ff66b7435bb90e77e1037599/httptools-0.8.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:159e9ab5f701ccd42e555a12f1ad8ff69702910fc1c996cf2bb66e5fcb7a231b", size = 112079, upload-time = "2026-05-25T22:17:25.216Z" }, + { url = "https://files.pythonhosted.org/packages/ba/53/771bd891eb0f236f32145d6a1775777ec85745f3cc983a1f23d1a3b8ddfe/httptools-0.8.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:c4a9f1707e4823d54dfec6c33fa3697d302aed536ed352a7ebb5a061ddb869d0", size = 481596, upload-time = "2026-05-25T22:17:26.186Z" }, + { url = "https://files.pythonhosted.org/packages/62/42/94e15bc68ce3d423243c45d7f1b0c7561f13844f97dc52ae23182fb65628/httptools-0.8.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d76ad7b951387e3632c8716a9bb03ac5b45c5f16119aa409db0459520887944e", size = 480865, upload-time = "2026-05-25T22:17:27.542Z" }, + { url = "https://files.pythonhosted.org/packages/1c/7c/fe2980fc03723272e30f135b62360b075f513dfe7cc73aef36c7f04012bd/httptools-0.8.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a3b7387147361c3fd47a0bde763c5c91b5b4cd4dc9989b8ece84ff436c99843b", size = 463189, upload-time = "2026-05-25T22:17:28.546Z" }, + { url = "https://files.pythonhosted.org/packages/15/1b/47fc5fff68acd1bfa20b4734059c9a06cadb88119dcd5258b5b0d21d91c8/httptools-0.8.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f256d6ce930c52ca1cb2a960b7da03548c454e7d28b06059ad41bfe789036ce0", size = 466610, upload-time = "2026-05-25T22:17:29.816Z" }, + { url = "https://files.pythonhosted.org/packages/60/bd/07b13c93ffd9bec9546e0d43f8e19378dd696dbd278511406bc07371ef1f/httptools-0.8.0-cp314-cp314-win_amd64.whl", hash = "sha256:19d1ee275bb59ba2643ba9a3a1e51cc0c788caf2b8df506368e03f56fdd08527", size = 92705, upload-time = "2026-05-25T22:17:31.133Z" }, + { url = "https://files.pythonhosted.org/packages/fd/c4/121648f68ce066d7bd762d6b6d97e620847642d38d54f3d90ff11d947629/httptools-0.8.0-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:de1ed58a974e75d56560acc7e7fed01a454994429456f65209789992e41f2568", size = 215023, upload-time = "2026-05-25T22:17:32.401Z" }, + { url = "https://files.pythonhosted.org/packages/b9/b0/312a062ae741ae3e8baa8c8bf20be81b2e67337b259ab4349bebc7b6142e/httptools-0.8.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:e93c227b595c6926c1acee96891dd9da4be338cfbe82e5cd3bb9d8dd7dc4ac0b", size = 117405, upload-time = "2026-05-25T22:17:33.742Z" }, + { url = "https://files.pythonhosted.org/packages/fc/37/fccd705f795386bb05bf413012fecff2a33e5aa8c2f069096de3e9fd8702/httptools-0.8.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2a021c3a8e65cc125390d72f59b968afca3bdcaff25bd67965e0a055a14946ca", size = 558497, upload-time = "2026-05-25T22:17:34.732Z" }, + { url = "https://files.pythonhosted.org/packages/bd/39/f172e8003576de35f5ba77ff417cf0e34429d35dc014deef15afa337a72c/httptools-0.8.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:48774d39cbb70e2b1f71f88852a3087ae1d3a1eb80482bb48c13067ab080c14f", size = 571585, upload-time = "2026-05-25T22:17:35.813Z" }, + { url = "https://files.pythonhosted.org/packages/3e/b9/f5564760af99f3dbbf3f9104dc00e5da27e96cf433c6bdcf77617f70bf3f/httptools-0.8.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:88eead8ec8680a9f146c655bc88445a325bd7921cfd8194c7337e9467282427d", size = 543297, upload-time = "2026-05-25T22:17:37.08Z" }, + { url = "https://files.pythonhosted.org/packages/99/67/8d9f2c313618e161b82f3873188e7196126da1d6e29688df40eb3997c77a/httptools-0.8.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:2c032fa028f46871ec7e1fc59fc15e8023eab3e6bbe6ece786a1611719a5d081", size = 539535, upload-time = "2026-05-25T22:17:38.032Z" }, + { url = "https://files.pythonhosted.org/packages/48/63/b906c01e53f50d432c0defe43ce52764a111dc1bdd028bafbeb54dcfd008/httptools-0.8.0-cp314-cp314t-win_amd64.whl", hash = "sha256:384c17174464c8e873398b7af24f0b1f44d992c820328413951a625323155d77", size = 108209, upload-time = "2026-05-25T22:17:39.473Z" }, ] [[package]] @@ -378,34 +385,35 @@ name = "httpx" version = "0.28.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "anyio" }, - { name = "certifi" }, - { name = "httpcore" }, - { name = "idna" }, + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, ] sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, ] [[package]] name = "huggingface-hub" -version = "1.12.0" +version = "1.16.4" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "filelock" }, - { name = "fsspec" }, - { name = "hf-xet", marker = "platform_machine == 'AMD64' or platform_machine == 'aarch64' or platform_machine == 'amd64' or platform_machine == 'arm64' or platform_machine == 'x86_64'" }, - { name = "httpx" }, - { name = "packaging" }, - { name = "pyyaml" }, - { name = "tqdm" }, - { name = "typer" }, - { name = "typing-extensions" }, + { name = "click" }, + { name = "filelock" }, + { name = "fsspec" }, + { name = "hf-xet", marker = "platform_machine == 'AMD64' or platform_machine == 'aarch64' or platform_machine == 'amd64' or platform_machine == 'arm64' or platform_machine == 'x86_64'" }, + { name = "httpx" }, + { name = "packaging" }, + { name = "pyyaml" }, + { name = "tqdm" }, + { name = "typer" }, + { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/56/52/1b54cb569509c725a32c1315261ac9fd0e6b91bbbf74d86fca10d3376164/huggingface_hub-1.12.0.tar.gz", hash = "sha256:7c3fe85e24b652334e5d456d7a812cd9a071e75630fac4365d9165ab5e4a34b6", size = 763091, upload-time = "2026-04-24T13:32:08.674Z" } +sdist = { url = "https://files.pythonhosted.org/packages/ab/11/9b6e439cb2417c479c3da108b38363232a1554721de9f8ef4836cb07422b/huggingface_hub-1.16.4.tar.gz", hash = "sha256:023bacd155f837d3fa56379ac8e23dababe6d6d87b04f8dacc258a44a38abe01", size = 792585, upload-time = "2026-05-26T17:19:09.971Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/7e/2b/ef03ddb96bd1123503c2bd6932001020292deea649e9bf4caa2cb65a85bf/huggingface_hub-1.12.0-py3-none-any.whl", hash = "sha256:d74939969585ee35748bd66de09baf84099d461bda7287cd9043bfb99b0e424d", size = 646806, upload-time = "2026-04-24T13:32:06.717Z" }, + { url = "https://files.pythonhosted.org/packages/da/86/e05d58ea272089151ba9f6fcc7b44a97aa2533d5a5bce46611220c23c6d6/huggingface_hub-1.16.4-py3-none-any.whl", hash = "sha256:994ec184c3330952d7b5f131ea0b1a6ba1047bd05461f5dec191f8fc1099fbd7", size = 668190, upload-time = "2026-05-26T17:19:08.228Z" }, ] [[package]] @@ -414,16 +422,16 @@ version = "2.6.19" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/52/63/51723b5f116cc04b061cb6f5a561790abf249d25931d515cd375e063e0f4/identify-2.6.19.tar.gz", hash = "sha256:6be5020c38fcb07da56c53733538a3081ea5aa70d36a156f83044bfbf9173842", size = 99567, upload-time = "2026-04-17T18:39:50.265Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/94/84/d9273cd09688070a6523c4aee4663a8538721b2b755c4962aafae0011e72/identify-2.6.19-py2.py3-none-any.whl", hash = "sha256:20e6a87f786f768c092a721ad107fc9df0eb89347be9396cadf3f4abbd1fb78a", size = 99397, upload-time = "2026-04-17T18:39:49.221Z" }, + { url = "https://files.pythonhosted.org/packages/94/84/d9273cd09688070a6523c4aee4663a8538721b2b755c4962aafae0011e72/identify-2.6.19-py2.py3-none-any.whl", hash = "sha256:20e6a87f786f768c092a721ad107fc9df0eb89347be9396cadf3f4abbd1fb78a", size = 99397, upload-time = "2026-04-17T18:39:49.221Z" }, ] [[package]] name = "idna" -version = "3.15" +version = "3.16" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/82/77/7b3966d0b9d1d31a36ddf1746926a11dface89a83409bf1483f0237aa758/idna-3.15.tar.gz", hash = "sha256:ca962446ea538f7092a95e057da437618e886f4d349216d2b1e294abfdb65fdc", size = 199245, upload-time = "2026-05-12T22:45:57.011Z" } +sdist = { url = "https://files.pythonhosted.org/packages/1a/88/bcf9709822fe69d02c2a6a77956c98ce6ea8ca8767a9aadcedc7eb6a2390/idna-3.16.tar.gz", hash = "sha256:d7a6da03db833450fca25d2358ac9ff06cd624577a4aea3a596d5c0f77b8e03d", size = 203770, upload-time = "2026-05-22T00:16:18.781Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d2/23/408243171aa9aaba178d3e2559159c24c1171a641aa83b67bdd3394ead8e/idna-3.15-py3-none-any.whl", hash = "sha256:048adeaf8c2d788c40fee287673ccaa74c24ffd8dcf09ffa555a2fbb59f10ac8", size = 72340, upload-time = "2026-05-12T22:45:55.733Z" }, + { url = "https://files.pythonhosted.org/packages/94/16/70255075a9859a0e3adb789b68ceb0e210dec03934245fd98d248226572f/idna-3.16-py3-none-any.whl", hash = "sha256:cc246e3a3f89580c3a951b5ad298ca4638078b2cdd4f115654332b5c26daded5", size = 74165, upload-time = "2026-05-22T00:16:16.698Z" }, ] [[package]] @@ -432,7 +440,7 @@ version = "2.3.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, ] [[package]] @@ -440,11 +448,11 @@ name = "jinja2" version = "3.1.6" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "markupsafe" }, + { name = "markupsafe" }, ] sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" }, + { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" }, ] [[package]] @@ -453,7 +461,7 @@ version = "1.5.3" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/41/f2/d34e8b3a08a9cc79a50b2208a93dce981fe615b64d5a4d4abee421d898df/joblib-1.5.3.tar.gz", hash = "sha256:8561a3269e6801106863fd0d6d84bb737be9e7631e33aaed3fb9ce5953688da3", size = 331603, upload-time = "2025-12-15T08:41:46.427Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/7b/91/984aca2ec129e2757d1e4e3c81c3fcda9d0f85b74670a094cc443d9ee949/joblib-1.5.3-py3-none-any.whl", hash = "sha256:5fc3c5039fc5ca8c0276333a188bbd59d6b7ab37fe6632daa76bc7f9ec18e713", size = 309071, upload-time = "2025-12-15T08:41:44.973Z" }, + { url = "https://files.pythonhosted.org/packages/7b/91/984aca2ec129e2757d1e4e3c81c3fcda9d0f85b74670a094cc443d9ee949/joblib-1.5.3-py3-none-any.whl", hash = "sha256:5fc3c5039fc5ca8c0276333a188bbd59d6b7ab37fe6632daa76bc7f9ec18e713", size = 309071, upload-time = "2025-12-15T08:41:44.973Z" }, ] [[package]] @@ -461,56 +469,56 @@ name = "ktx-daemon" version = "0.6.0" source = { editable = "python/ktx-daemon" } dependencies = [ - { name = "fastapi" }, - { name = "ktx-sl" }, - { name = "lkml" }, - { name = "numpy" }, - { name = "orjson" }, - { name = "pandas" }, - { name = "posthog" }, - { name = "psycopg", extra = ["binary"] }, - { name = "pydantic" }, - { name = "requests" }, - { name = "sqlglot" }, - { name = "uvicorn", extra = ["standard"] }, + { name = "fastapi" }, + { name = "ktx-sl" }, + { name = "lkml" }, + { name = "numpy" }, + { name = "orjson" }, + { name = "pandas" }, + { name = "posthog" }, + { name = "psycopg", extra = ["binary"] }, + { name = "pydantic" }, + { name = "requests" }, + { name = "sqlglot" }, + { name = "uvicorn", extra = ["standard"] }, ] [package.optional-dependencies] local-embeddings = [ - { name = "sentence-transformers" }, - { name = "torch", version = "2.11.0", source = { registry = "https://download.pytorch.org/whl/cpu" }, marker = "sys_platform == 'darwin'" }, - { name = "torch", version = "2.11.0+cpu", source = { registry = "https://download.pytorch.org/whl/cpu" }, marker = "sys_platform != 'darwin'" }, + { name = "sentence-transformers" }, + { name = "torch", version = "2.12.0", source = { registry = "https://download.pytorch.org/whl/cpu" }, marker = "sys_platform == 'darwin'" }, + { name = "torch", version = "2.12.0+cpu", source = { registry = "https://download.pytorch.org/whl/cpu" }, marker = "sys_platform != 'darwin'" }, ] [package.dev-dependencies] dev = [ - { name = "httpx" }, - { name = "pytest" }, + { name = "httpx" }, + { name = "pytest" }, ] [package.metadata] requires-dist = [ - { name = "fastapi", specifier = ">=0.115.0" }, - { name = "ktx-sl", editable = "python/ktx-sl" }, - { name = "lkml", specifier = ">=1.3.7" }, - { name = "numpy", specifier = ">=2.2.6" }, - { name = "orjson", specifier = ">=3.11.4" }, - { name = "pandas", specifier = ">=2.2.3" }, - { name = "posthog", specifier = ">=7.0.0" }, - { name = "psycopg", extras = ["binary"], specifier = ">=3.2.0" }, - { name = "pydantic", specifier = ">=2.9.0" }, - { name = "requests", specifier = ">=2.32.0" }, - { name = "sentence-transformers", marker = "extra == 'local-embeddings'", specifier = ">=5.1.1" }, - { name = "sqlglot", specifier = ">=26" }, - { name = "torch", marker = "extra == 'local-embeddings'", specifier = ">=2.2.0", index = "https://download.pytorch.org/whl/cpu" }, - { name = "uvicorn", extras = ["standard"], specifier = ">=0.32.0" }, + { name = "fastapi", specifier = ">=0.136.3" }, + { name = "ktx-sl", editable = "python/ktx-sl" }, + { name = "lkml", specifier = ">=1.3.7" }, + { name = "numpy", specifier = ">=2.4.6" }, + { name = "orjson", specifier = ">=3.11.9" }, + { name = "pandas", specifier = ">=3.0.3" }, + { name = "posthog", specifier = ">=7.16.1" }, + { name = "psycopg", extras = ["binary"], specifier = ">=3.3.4" }, + { name = "pydantic", specifier = ">=2.13.4" }, + { name = "requests", specifier = ">=2.34.2" }, + { name = "sentence-transformers", marker = "extra == 'local-embeddings'", specifier = ">=5.1.1" }, + { name = "sqlglot", specifier = ">=30" }, + { name = "torch", marker = "extra == 'local-embeddings'", specifier = ">=2.2.0", index = "https://download.pytorch.org/whl/cpu" }, + { name = "uvicorn", extras = ["standard"], specifier = ">=0.48.0" }, ] provides-extras = ["local-embeddings"] [package.metadata.requires-dev] dev = [ - { name = "httpx", specifier = ">=0.28.1" }, - { name = "pytest", specifier = ">=9.0.2" }, + { name = "httpx", specifier = ">=0.28.1" }, + { name = "pytest", specifier = ">=9.0.2" }, ] [[package]] @@ -518,45 +526,45 @@ name = "ktx-sl" version = "0.6.0" source = { editable = "python/ktx-sl" } dependencies = [ - { name = "pydantic" }, - { name = "pyyaml" }, - { name = "sqlglot" }, + { name = "pydantic" }, + { name = "pyyaml" }, + { name = "sqlglot" }, ] [package.optional-dependencies] dev = [ - { name = "pre-commit" }, - { name = "pytest" }, - { name = "pytest-cov" }, - { name = "ruff" }, + { name = "pre-commit" }, + { name = "pytest" }, + { name = "pytest-cov" }, + { name = "ruff" }, ] tpch = [ - { name = "duckdb" }, + { name = "duckdb" }, ] [package.dev-dependencies] dev = [ - { name = "pytest" }, - { name = "pytest-cov" }, + { name = "pytest" }, + { name = "pytest-cov" }, ] [package.metadata] requires-dist = [ - { name = "duckdb", marker = "extra == 'tpch'", specifier = ">=1.0" }, - { name = "pre-commit", marker = "extra == 'dev'" }, - { name = "pydantic", specifier = ">=2" }, - { name = "pytest", marker = "extra == 'dev'", specifier = ">=8" }, - { name = "pytest-cov", marker = "extra == 'dev'" }, - { name = "pyyaml", specifier = ">=6" }, - { name = "ruff", marker = "extra == 'dev'" }, - { name = "sqlglot", specifier = ">=26" }, + { name = "duckdb", marker = "extra == 'tpch'", specifier = ">=1.0" }, + { name = "pre-commit", marker = "extra == 'dev'" }, + { name = "pydantic", specifier = ">=2" }, + { name = "pytest", marker = "extra == 'dev'", specifier = ">=8" }, + { name = "pytest-cov", marker = "extra == 'dev'" }, + { name = "pyyaml", specifier = ">=6" }, + { name = "ruff", marker = "extra == 'dev'" }, + { name = "sqlglot", specifier = ">=30" }, ] provides-extras = ["dev", "tpch"] [package.metadata.requires-dev] dev = [ - { name = "pytest", specifier = ">=9.0.2" }, - { name = "pytest-cov", specifier = ">=7.1.0" }, + { name = "pytest", specifier = ">=9.0.2" }, + { name = "pytest-cov", specifier = ">=7.1.0" }, ] [[package]] @@ -566,20 +574,19 @@ source = { virtual = "." } [package.dev-dependencies] dev = [ - { name = "pre-commit" }, - { name = "pytest" }, - { name = "pytest-cov" }, - { name = "ruff" }, + { name = "pre-commit" }, + { name = "pytest" }, + { name = "pytest-cov" }, + { name = "ruff" }, ] [package.metadata] - [package.metadata.requires-dev] dev = [ - { name = "pre-commit", specifier = ">=4.6.0" }, - { name = "pytest", specifier = ">=9.0.2" }, - { name = "pytest-cov", specifier = ">=7.1.0" }, - { name = "ruff", specifier = ">=0.8.4" }, + { name = "pre-commit", specifier = ">=4.6.0" }, + { name = "pytest", specifier = ">=9.0.2" }, + { name = "pytest-cov", specifier = ">=7.1.0" }, + { name = "ruff", specifier = ">=0.8.4" }, ] [[package]] @@ -588,19 +595,19 @@ version = "1.3.7" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/bf/18/18a3d0281c5e209156b877796096d4ac7259f03465409673056386c99221/lkml-1.3.7.tar.gz", hash = "sha256:51dc9f1b7e74cd7a00e0dbbf06fb573952015328f1f4a3a0730d444444a8ae7a", size = 28763, upload-time = "2025-01-31T02:30:35.472Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c7/15/e7124d4ec54fdcafa801b55d6b67d6196ed6c8a0de554e1a8b67b66fec65/lkml-1.3.7-py2.py3-none-any.whl", hash = "sha256:ce54c517f81fbd21d452038be9e2504fa02951a5bc30f7d7f1eb552c1f3f2b39", size = 23062, upload-time = "2025-01-31T02:30:34.377Z" }, + { url = "https://files.pythonhosted.org/packages/c7/15/e7124d4ec54fdcafa801b55d6b67d6196ed6c8a0de554e1a8b67b66fec65/lkml-1.3.7-py2.py3-none-any.whl", hash = "sha256:ce54c517f81fbd21d452038be9e2504fa02951a5bc30f7d7f1eb552c1f3f2b39", size = 23062, upload-time = "2025-01-31T02:30:34.377Z" }, ] [[package]] name = "markdown-it-py" -version = "4.0.0" +version = "4.2.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "mdurl" }, + { name = "mdurl" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/5b/f5/4ec618ed16cc4f8fb3b701563655a69816155e79e24a17b651541804721d/markdown_it_py-4.0.0.tar.gz", hash = "sha256:cb0a2b4aa34f932c007117b194e945bd74e0ec24133ceb5bac59009cda1cb9f3", size = 73070, upload-time = "2025-08-11T12:57:52.854Z" } +sdist = { url = "https://files.pythonhosted.org/packages/06/ff/7841249c247aa650a76b9ee4bbaeae59370dc8bfd2f6c01f3630c35eb134/markdown_it_py-4.2.0.tar.gz", hash = "sha256:04a21681d6fbb623de53f6f364d352309d4094dd4194040a10fd51833e418d49", size = 82454, upload-time = "2026-05-07T12:08:28.36Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/94/54/e7d793b573f298e1c9013b8c4dade17d481164aa517d1d7148619c2cedbf/markdown_it_py-4.0.0-py3-none-any.whl", hash = "sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147", size = 87321, upload-time = "2025-08-11T12:57:51.923Z" }, + { url = "https://files.pythonhosted.org/packages/b3/81/4da04ced5a082363ecfa159c010d200ecbd959ae410c10c0264a38cac0f5/markdown_it_py-4.2.0-py3-none-any.whl", hash = "sha256:9f7ebbcd14fe59494226453aed97c1070d83f8d24b6fc3a3bcf9a38092641c4a", size = 91687, upload-time = "2026-05-07T12:08:27.182Z" }, ] [[package]] @@ -609,50 +616,50 @@ version = "3.0.3" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/38/2f/907b9c7bbba283e68f20259574b13d005c121a0fa4c175f9bed27c4597ff/markupsafe-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795", size = 11622, upload-time = "2025-09-27T18:36:41.777Z" }, - { url = "https://files.pythonhosted.org/packages/9c/d9/5f7756922cdd676869eca1c4e3c0cd0df60ed30199ffd775e319089cb3ed/markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219", size = 12029, upload-time = "2025-09-27T18:36:43.257Z" }, - { url = "https://files.pythonhosted.org/packages/00/07/575a68c754943058c78f30db02ee03a64b3c638586fba6a6dd56830b30a3/markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6", size = 24374, upload-time = "2025-09-27T18:36:44.508Z" }, - { url = "https://files.pythonhosted.org/packages/a9/21/9b05698b46f218fc0e118e1f8168395c65c8a2c750ae2bab54fc4bd4e0e8/markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676", size = 22980, upload-time = "2025-09-27T18:36:45.385Z" }, - { url = "https://files.pythonhosted.org/packages/7f/71/544260864f893f18b6827315b988c146b559391e6e7e8f7252839b1b846a/markupsafe-3.0.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9", size = 21990, upload-time = "2025-09-27T18:36:46.916Z" }, - { url = "https://files.pythonhosted.org/packages/c2/28/b50fc2f74d1ad761af2f5dcce7492648b983d00a65b8c0e0cb457c82ebbe/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1", size = 23784, upload-time = "2025-09-27T18:36:47.884Z" }, - { url = "https://files.pythonhosted.org/packages/ed/76/104b2aa106a208da8b17a2fb72e033a5a9d7073c68f7e508b94916ed47a9/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc", size = 21588, upload-time = "2025-09-27T18:36:48.82Z" }, - { url = "https://files.pythonhosted.org/packages/b5/99/16a5eb2d140087ebd97180d95249b00a03aa87e29cc224056274f2e45fd6/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12", size = 23041, upload-time = "2025-09-27T18:36:49.797Z" }, - { url = "https://files.pythonhosted.org/packages/19/bc/e7140ed90c5d61d77cea142eed9f9c303f4c4806f60a1044c13e3f1471d0/markupsafe-3.0.3-cp313-cp313-win32.whl", hash = "sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed", size = 14543, upload-time = "2025-09-27T18:36:51.584Z" }, - { url = "https://files.pythonhosted.org/packages/05/73/c4abe620b841b6b791f2edc248f556900667a5a1cf023a6646967ae98335/markupsafe-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5", size = 15113, upload-time = "2025-09-27T18:36:52.537Z" }, - { url = "https://files.pythonhosted.org/packages/f0/3a/fa34a0f7cfef23cf9500d68cb7c32dd64ffd58a12b09225fb03dd37d5b80/markupsafe-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485", size = 13911, upload-time = "2025-09-27T18:36:53.513Z" }, - { url = "https://files.pythonhosted.org/packages/e4/d7/e05cd7efe43a88a17a37b3ae96e79a19e846f3f456fe79c57ca61356ef01/markupsafe-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73", size = 11658, upload-time = "2025-09-27T18:36:54.819Z" }, - { url = "https://files.pythonhosted.org/packages/99/9e/e412117548182ce2148bdeacdda3bb494260c0b0184360fe0d56389b523b/markupsafe-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37", size = 12066, upload-time = "2025-09-27T18:36:55.714Z" }, - { url = "https://files.pythonhosted.org/packages/bc/e6/fa0ffcda717ef64a5108eaa7b4f5ed28d56122c9a6d70ab8b72f9f715c80/markupsafe-3.0.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19", size = 25639, upload-time = "2025-09-27T18:36:56.908Z" }, - { url = "https://files.pythonhosted.org/packages/96/ec/2102e881fe9d25fc16cb4b25d5f5cde50970967ffa5dddafdb771237062d/markupsafe-3.0.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025", size = 23569, upload-time = "2025-09-27T18:36:57.913Z" }, - { url = "https://files.pythonhosted.org/packages/4b/30/6f2fce1f1f205fc9323255b216ca8a235b15860c34b6798f810f05828e32/markupsafe-3.0.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6", size = 23284, upload-time = "2025-09-27T18:36:58.833Z" }, - { url = "https://files.pythonhosted.org/packages/58/47/4a0ccea4ab9f5dcb6f79c0236d954acb382202721e704223a8aafa38b5c8/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f", size = 24801, upload-time = "2025-09-27T18:36:59.739Z" }, - { url = "https://files.pythonhosted.org/packages/6a/70/3780e9b72180b6fecb83a4814d84c3bf4b4ae4bf0b19c27196104149734c/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb", size = 22769, upload-time = "2025-09-27T18:37:00.719Z" }, - { url = "https://files.pythonhosted.org/packages/98/c5/c03c7f4125180fc215220c035beac6b9cb684bc7a067c84fc69414d315f5/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009", size = 23642, upload-time = "2025-09-27T18:37:01.673Z" }, - { url = "https://files.pythonhosted.org/packages/80/d6/2d1b89f6ca4bff1036499b1e29a1d02d282259f3681540e16563f27ebc23/markupsafe-3.0.3-cp313-cp313t-win32.whl", hash = "sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354", size = 14612, upload-time = "2025-09-27T18:37:02.639Z" }, - { url = "https://files.pythonhosted.org/packages/2b/98/e48a4bfba0a0ffcf9925fe2d69240bfaa19c6f7507b8cd09c70684a53c1e/markupsafe-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218", size = 15200, upload-time = "2025-09-27T18:37:03.582Z" }, - { url = "https://files.pythonhosted.org/packages/0e/72/e3cc540f351f316e9ed0f092757459afbc595824ca724cbc5a5d4263713f/markupsafe-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287", size = 13973, upload-time = "2025-09-27T18:37:04.929Z" }, - { url = "https://files.pythonhosted.org/packages/33/8a/8e42d4838cd89b7dde187011e97fe6c3af66d8c044997d2183fbd6d31352/markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe", size = 11619, upload-time = "2025-09-27T18:37:06.342Z" }, - { url = "https://files.pythonhosted.org/packages/b5/64/7660f8a4a8e53c924d0fa05dc3a55c9cee10bbd82b11c5afb27d44b096ce/markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026", size = 12029, upload-time = "2025-09-27T18:37:07.213Z" }, - { url = "https://files.pythonhosted.org/packages/da/ef/e648bfd021127bef5fa12e1720ffed0c6cbb8310c8d9bea7266337ff06de/markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737", size = 24408, upload-time = "2025-09-27T18:37:09.572Z" }, - { url = "https://files.pythonhosted.org/packages/41/3c/a36c2450754618e62008bf7435ccb0f88053e07592e6028a34776213d877/markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97", size = 23005, upload-time = "2025-09-27T18:37:10.58Z" }, - { url = "https://files.pythonhosted.org/packages/bc/20/b7fdf89a8456b099837cd1dc21974632a02a999ec9bf7ca3e490aacd98e7/markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d", size = 22048, upload-time = "2025-09-27T18:37:11.547Z" }, - { url = "https://files.pythonhosted.org/packages/9a/a7/591f592afdc734f47db08a75793a55d7fbcc6902a723ae4cfbab61010cc5/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda", size = 23821, upload-time = "2025-09-27T18:37:12.48Z" }, - { url = "https://files.pythonhosted.org/packages/7d/33/45b24e4f44195b26521bc6f1a82197118f74df348556594bd2262bda1038/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf", size = 21606, upload-time = "2025-09-27T18:37:13.485Z" }, - { url = "https://files.pythonhosted.org/packages/ff/0e/53dfaca23a69fbfbbf17a4b64072090e70717344c52eaaaa9c5ddff1e5f0/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe", size = 23043, upload-time = "2025-09-27T18:37:14.408Z" }, - { url = "https://files.pythonhosted.org/packages/46/11/f333a06fc16236d5238bfe74daccbca41459dcd8d1fa952e8fbd5dccfb70/markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9", size = 14747, upload-time = "2025-09-27T18:37:15.36Z" }, - { url = "https://files.pythonhosted.org/packages/28/52/182836104b33b444e400b14f797212f720cbc9ed6ba34c800639d154e821/markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581", size = 15341, upload-time = "2025-09-27T18:37:16.496Z" }, - { url = "https://files.pythonhosted.org/packages/6f/18/acf23e91bd94fd7b3031558b1f013adfa21a8e407a3fdb32745538730382/markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4", size = 14073, upload-time = "2025-09-27T18:37:17.476Z" }, - { url = "https://files.pythonhosted.org/packages/3c/f0/57689aa4076e1b43b15fdfa646b04653969d50cf30c32a102762be2485da/markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab", size = 11661, upload-time = "2025-09-27T18:37:18.453Z" }, - { url = "https://files.pythonhosted.org/packages/89/c3/2e67a7ca217c6912985ec766c6393b636fb0c2344443ff9d91404dc4c79f/markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175", size = 12069, upload-time = "2025-09-27T18:37:19.332Z" }, - { url = "https://files.pythonhosted.org/packages/f0/00/be561dce4e6ca66b15276e184ce4b8aec61fe83662cce2f7d72bd3249d28/markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634", size = 25670, upload-time = "2025-09-27T18:37:20.245Z" }, - { url = "https://files.pythonhosted.org/packages/50/09/c419f6f5a92e5fadde27efd190eca90f05e1261b10dbd8cbcb39cd8ea1dc/markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50", size = 23598, upload-time = "2025-09-27T18:37:21.177Z" }, - { url = "https://files.pythonhosted.org/packages/22/44/a0681611106e0b2921b3033fc19bc53323e0b50bc70cffdd19f7d679bb66/markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e", size = 23261, upload-time = "2025-09-27T18:37:22.167Z" }, - { url = "https://files.pythonhosted.org/packages/5f/57/1b0b3f100259dc9fffe780cfb60d4be71375510e435efec3d116b6436d43/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5", size = 24835, upload-time = "2025-09-27T18:37:23.296Z" }, - { url = "https://files.pythonhosted.org/packages/26/6a/4bf6d0c97c4920f1597cc14dd720705eca0bf7c787aebc6bb4d1bead5388/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523", size = 22733, upload-time = "2025-09-27T18:37:24.237Z" }, - { url = "https://files.pythonhosted.org/packages/14/c7/ca723101509b518797fedc2fdf79ba57f886b4aca8a7d31857ba3ee8281f/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc", size = 23672, upload-time = "2025-09-27T18:37:25.271Z" }, - { url = "https://files.pythonhosted.org/packages/fb/df/5bd7a48c256faecd1d36edc13133e51397e41b73bb77e1a69deab746ebac/markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d", size = 14819, upload-time = "2025-09-27T18:37:26.285Z" }, - { url = "https://files.pythonhosted.org/packages/1a/8a/0402ba61a2f16038b48b39bccca271134be00c5c9f0f623208399333c448/markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9", size = 15426, upload-time = "2025-09-27T18:37:27.316Z" }, - { url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" }, + { url = "https://files.pythonhosted.org/packages/38/2f/907b9c7bbba283e68f20259574b13d005c121a0fa4c175f9bed27c4597ff/markupsafe-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795", size = 11622, upload-time = "2025-09-27T18:36:41.777Z" }, + { url = "https://files.pythonhosted.org/packages/9c/d9/5f7756922cdd676869eca1c4e3c0cd0df60ed30199ffd775e319089cb3ed/markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219", size = 12029, upload-time = "2025-09-27T18:36:43.257Z" }, + { url = "https://files.pythonhosted.org/packages/00/07/575a68c754943058c78f30db02ee03a64b3c638586fba6a6dd56830b30a3/markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6", size = 24374, upload-time = "2025-09-27T18:36:44.508Z" }, + { url = "https://files.pythonhosted.org/packages/a9/21/9b05698b46f218fc0e118e1f8168395c65c8a2c750ae2bab54fc4bd4e0e8/markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676", size = 22980, upload-time = "2025-09-27T18:36:45.385Z" }, + { url = "https://files.pythonhosted.org/packages/7f/71/544260864f893f18b6827315b988c146b559391e6e7e8f7252839b1b846a/markupsafe-3.0.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9", size = 21990, upload-time = "2025-09-27T18:36:46.916Z" }, + { url = "https://files.pythonhosted.org/packages/c2/28/b50fc2f74d1ad761af2f5dcce7492648b983d00a65b8c0e0cb457c82ebbe/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1", size = 23784, upload-time = "2025-09-27T18:36:47.884Z" }, + { url = "https://files.pythonhosted.org/packages/ed/76/104b2aa106a208da8b17a2fb72e033a5a9d7073c68f7e508b94916ed47a9/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc", size = 21588, upload-time = "2025-09-27T18:36:48.82Z" }, + { url = "https://files.pythonhosted.org/packages/b5/99/16a5eb2d140087ebd97180d95249b00a03aa87e29cc224056274f2e45fd6/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12", size = 23041, upload-time = "2025-09-27T18:36:49.797Z" }, + { url = "https://files.pythonhosted.org/packages/19/bc/e7140ed90c5d61d77cea142eed9f9c303f4c4806f60a1044c13e3f1471d0/markupsafe-3.0.3-cp313-cp313-win32.whl", hash = "sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed", size = 14543, upload-time = "2025-09-27T18:36:51.584Z" }, + { url = "https://files.pythonhosted.org/packages/05/73/c4abe620b841b6b791f2edc248f556900667a5a1cf023a6646967ae98335/markupsafe-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5", size = 15113, upload-time = "2025-09-27T18:36:52.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/3a/fa34a0f7cfef23cf9500d68cb7c32dd64ffd58a12b09225fb03dd37d5b80/markupsafe-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485", size = 13911, upload-time = "2025-09-27T18:36:53.513Z" }, + { url = "https://files.pythonhosted.org/packages/e4/d7/e05cd7efe43a88a17a37b3ae96e79a19e846f3f456fe79c57ca61356ef01/markupsafe-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73", size = 11658, upload-time = "2025-09-27T18:36:54.819Z" }, + { url = "https://files.pythonhosted.org/packages/99/9e/e412117548182ce2148bdeacdda3bb494260c0b0184360fe0d56389b523b/markupsafe-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37", size = 12066, upload-time = "2025-09-27T18:36:55.714Z" }, + { url = "https://files.pythonhosted.org/packages/bc/e6/fa0ffcda717ef64a5108eaa7b4f5ed28d56122c9a6d70ab8b72f9f715c80/markupsafe-3.0.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19", size = 25639, upload-time = "2025-09-27T18:36:56.908Z" }, + { url = "https://files.pythonhosted.org/packages/96/ec/2102e881fe9d25fc16cb4b25d5f5cde50970967ffa5dddafdb771237062d/markupsafe-3.0.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025", size = 23569, upload-time = "2025-09-27T18:36:57.913Z" }, + { url = "https://files.pythonhosted.org/packages/4b/30/6f2fce1f1f205fc9323255b216ca8a235b15860c34b6798f810f05828e32/markupsafe-3.0.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6", size = 23284, upload-time = "2025-09-27T18:36:58.833Z" }, + { url = "https://files.pythonhosted.org/packages/58/47/4a0ccea4ab9f5dcb6f79c0236d954acb382202721e704223a8aafa38b5c8/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f", size = 24801, upload-time = "2025-09-27T18:36:59.739Z" }, + { url = "https://files.pythonhosted.org/packages/6a/70/3780e9b72180b6fecb83a4814d84c3bf4b4ae4bf0b19c27196104149734c/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb", size = 22769, upload-time = "2025-09-27T18:37:00.719Z" }, + { url = "https://files.pythonhosted.org/packages/98/c5/c03c7f4125180fc215220c035beac6b9cb684bc7a067c84fc69414d315f5/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009", size = 23642, upload-time = "2025-09-27T18:37:01.673Z" }, + { url = "https://files.pythonhosted.org/packages/80/d6/2d1b89f6ca4bff1036499b1e29a1d02d282259f3681540e16563f27ebc23/markupsafe-3.0.3-cp313-cp313t-win32.whl", hash = "sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354", size = 14612, upload-time = "2025-09-27T18:37:02.639Z" }, + { url = "https://files.pythonhosted.org/packages/2b/98/e48a4bfba0a0ffcf9925fe2d69240bfaa19c6f7507b8cd09c70684a53c1e/markupsafe-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218", size = 15200, upload-time = "2025-09-27T18:37:03.582Z" }, + { url = "https://files.pythonhosted.org/packages/0e/72/e3cc540f351f316e9ed0f092757459afbc595824ca724cbc5a5d4263713f/markupsafe-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287", size = 13973, upload-time = "2025-09-27T18:37:04.929Z" }, + { url = "https://files.pythonhosted.org/packages/33/8a/8e42d4838cd89b7dde187011e97fe6c3af66d8c044997d2183fbd6d31352/markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe", size = 11619, upload-time = "2025-09-27T18:37:06.342Z" }, + { url = "https://files.pythonhosted.org/packages/b5/64/7660f8a4a8e53c924d0fa05dc3a55c9cee10bbd82b11c5afb27d44b096ce/markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026", size = 12029, upload-time = "2025-09-27T18:37:07.213Z" }, + { url = "https://files.pythonhosted.org/packages/da/ef/e648bfd021127bef5fa12e1720ffed0c6cbb8310c8d9bea7266337ff06de/markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737", size = 24408, upload-time = "2025-09-27T18:37:09.572Z" }, + { url = "https://files.pythonhosted.org/packages/41/3c/a36c2450754618e62008bf7435ccb0f88053e07592e6028a34776213d877/markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97", size = 23005, upload-time = "2025-09-27T18:37:10.58Z" }, + { url = "https://files.pythonhosted.org/packages/bc/20/b7fdf89a8456b099837cd1dc21974632a02a999ec9bf7ca3e490aacd98e7/markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d", size = 22048, upload-time = "2025-09-27T18:37:11.547Z" }, + { url = "https://files.pythonhosted.org/packages/9a/a7/591f592afdc734f47db08a75793a55d7fbcc6902a723ae4cfbab61010cc5/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda", size = 23821, upload-time = "2025-09-27T18:37:12.48Z" }, + { url = "https://files.pythonhosted.org/packages/7d/33/45b24e4f44195b26521bc6f1a82197118f74df348556594bd2262bda1038/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf", size = 21606, upload-time = "2025-09-27T18:37:13.485Z" }, + { url = "https://files.pythonhosted.org/packages/ff/0e/53dfaca23a69fbfbbf17a4b64072090e70717344c52eaaaa9c5ddff1e5f0/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe", size = 23043, upload-time = "2025-09-27T18:37:14.408Z" }, + { url = "https://files.pythonhosted.org/packages/46/11/f333a06fc16236d5238bfe74daccbca41459dcd8d1fa952e8fbd5dccfb70/markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9", size = 14747, upload-time = "2025-09-27T18:37:15.36Z" }, + { url = "https://files.pythonhosted.org/packages/28/52/182836104b33b444e400b14f797212f720cbc9ed6ba34c800639d154e821/markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581", size = 15341, upload-time = "2025-09-27T18:37:16.496Z" }, + { url = "https://files.pythonhosted.org/packages/6f/18/acf23e91bd94fd7b3031558b1f013adfa21a8e407a3fdb32745538730382/markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4", size = 14073, upload-time = "2025-09-27T18:37:17.476Z" }, + { url = "https://files.pythonhosted.org/packages/3c/f0/57689aa4076e1b43b15fdfa646b04653969d50cf30c32a102762be2485da/markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab", size = 11661, upload-time = "2025-09-27T18:37:18.453Z" }, + { url = "https://files.pythonhosted.org/packages/89/c3/2e67a7ca217c6912985ec766c6393b636fb0c2344443ff9d91404dc4c79f/markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175", size = 12069, upload-time = "2025-09-27T18:37:19.332Z" }, + { url = "https://files.pythonhosted.org/packages/f0/00/be561dce4e6ca66b15276e184ce4b8aec61fe83662cce2f7d72bd3249d28/markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634", size = 25670, upload-time = "2025-09-27T18:37:20.245Z" }, + { url = "https://files.pythonhosted.org/packages/50/09/c419f6f5a92e5fadde27efd190eca90f05e1261b10dbd8cbcb39cd8ea1dc/markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50", size = 23598, upload-time = "2025-09-27T18:37:21.177Z" }, + { url = "https://files.pythonhosted.org/packages/22/44/a0681611106e0b2921b3033fc19bc53323e0b50bc70cffdd19f7d679bb66/markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e", size = 23261, upload-time = "2025-09-27T18:37:22.167Z" }, + { url = "https://files.pythonhosted.org/packages/5f/57/1b0b3f100259dc9fffe780cfb60d4be71375510e435efec3d116b6436d43/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5", size = 24835, upload-time = "2025-09-27T18:37:23.296Z" }, + { url = "https://files.pythonhosted.org/packages/26/6a/4bf6d0c97c4920f1597cc14dd720705eca0bf7c787aebc6bb4d1bead5388/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523", size = 22733, upload-time = "2025-09-27T18:37:24.237Z" }, + { url = "https://files.pythonhosted.org/packages/14/c7/ca723101509b518797fedc2fdf79ba57f886b4aca8a7d31857ba3ee8281f/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc", size = 23672, upload-time = "2025-09-27T18:37:25.271Z" }, + { url = "https://files.pythonhosted.org/packages/fb/df/5bd7a48c256faecd1d36edc13133e51397e41b73bb77e1a69deab746ebac/markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d", size = 14819, upload-time = "2025-09-27T18:37:26.285Z" }, + { url = "https://files.pythonhosted.org/packages/1a/8a/0402ba61a2f16038b48b39bccca271134be00c5c9f0f623208399333c448/markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9", size = 15426, upload-time = "2025-09-27T18:37:27.316Z" }, + { url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" }, ] [[package]] @@ -661,7 +668,7 @@ version = "0.1.2" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, + { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, ] [[package]] @@ -670,7 +677,7 @@ version = "1.3.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/e0/47/dd32fa426cc72114383ac549964eecb20ecfd886d1e5ccf5340b55b02f57/mpmath-1.3.0.tar.gz", hash = "sha256:7a28eb2a9774d00c7bc92411c19a89209d5da7c4c9a9e227be8330a23a25b91f", size = 508106, upload-time = "2023-03-07T16:47:11.061Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/43/e3/7d92a15f894aa0c9c4b49b8ee9ac9850d6e63b03c9c32c0367a13ae62209/mpmath-1.3.0-py3-none-any.whl", hash = "sha256:a0b2b9fe80bbcd81a6647ff13108738cfb482d481d826cc0e02f5b35e5c88d2c", size = 536198, upload-time = "2023-03-07T16:47:09.197Z" }, + { url = "https://files.pythonhosted.org/packages/43/e3/7d92a15f894aa0c9c4b49b8ee9ac9850d6e63b03c9c32c0367a13ae62209/mpmath-1.3.0-py3-none-any.whl", hash = "sha256:a0b2b9fe80bbcd81a6647ff13108738cfb482d481d826cc0e02f5b35e5c88d2c", size = 536198, upload-time = "2023-03-07T16:47:09.197Z" }, ] [[package]] @@ -679,7 +686,7 @@ version = "3.6.1" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/6a/51/63fe664f3908c97be9d2e4f1158eb633317598cfa6e1fc14af5383f17512/networkx-3.6.1.tar.gz", hash = "sha256:26b7c357accc0c8cde558ad486283728b65b6a95d85ee1cd66bafab4c8168509", size = 2517025, upload-time = "2025-12-08T17:02:39.908Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/9e/c9/b2622292ea83fbb4ec318f5b9ab867d0a28ab43c5717bb85b0a5f6b3b0a4/networkx-3.6.1-py3-none-any.whl", hash = "sha256:d47fbf302e7d9cbbb9e2555a0d267983d2aa476bac30e90dfbe5669bd57f3762", size = 2068504, upload-time = "2025-12-08T17:02:38.159Z" }, + { url = "https://files.pythonhosted.org/packages/9e/c9/b2622292ea83fbb4ec318f5b9ab867d0a28ab43c5717bb85b0a5f6b3b0a4/networkx-3.6.1-py3-none-any.whl", hash = "sha256:d47fbf302e7d9cbbb9e2555a0d267983d2aa476bac30e90dfbe5669bd57f3762", size = 2068504, upload-time = "2025-12-08T17:02:38.159Z" }, ] [[package]] @@ -688,95 +695,95 @@ version = "1.10.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/24/bf/d1bda4f6168e0b2e9e5958945e01910052158313224ada5ce1fb2e1113b8/nodeenv-1.10.0.tar.gz", hash = "sha256:996c191ad80897d076bdfba80a41994c2b47c68e224c542b48feba42ba00f8bb", size = 55611, upload-time = "2025-12-20T14:08:54.006Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/88/b2/d0896bdcdc8d28a7fc5717c305f1a861c26e18c05047949fb371034d98bd/nodeenv-1.10.0-py2.py3-none-any.whl", hash = "sha256:5bb13e3eed2923615535339b3c620e76779af4cb4c6a90deccc9e36b274d3827", size = 23438, upload-time = "2025-12-20T14:08:52.782Z" }, + { url = "https://files.pythonhosted.org/packages/88/b2/d0896bdcdc8d28a7fc5717c305f1a861c26e18c05047949fb371034d98bd/nodeenv-1.10.0-py2.py3-none-any.whl", hash = "sha256:5bb13e3eed2923615535339b3c620e76779af4cb4c6a90deccc9e36b274d3827", size = 23438, upload-time = "2025-12-20T14:08:52.782Z" }, ] [[package]] name = "numpy" -version = "2.4.4" +version = "2.4.6" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/d7/9f/b8cef5bffa569759033adda9481211426f12f53299629b410340795c2514/numpy-2.4.4.tar.gz", hash = "sha256:2d390634c5182175533585cc89f3608a4682ccb173cc9bb940b2881c8d6f8fa0", size = 20731587, upload-time = "2026-03-29T13:22:01.298Z" } +sdist = { url = "https://files.pythonhosted.org/packages/d0/ad/fed0499ce6a338d2a03ebae59cd15093910c8875328855781952abf6c2fe/numpy-2.4.6.tar.gz", hash = "sha256:f3a3570c4a2a16746ac2c31a7c7c7b0c186b95ce902e33db6f28094ed7387dda", size = 20735807, upload-time = "2026-05-18T23:37:14.07Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/14/1d/d0a583ce4fefcc3308806a749a536c201ed6b5ad6e1322e227ee4848979d/numpy-2.4.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:08f2e31ed5e6f04b118e49821397f12767934cfdd12a1ce86a058f91e004ee50", size = 16684933, upload-time = "2026-03-29T13:19:22.47Z" }, - { url = "https://files.pythonhosted.org/packages/c1/62/2b7a48fbb745d344742c0277f01286dead15f3f68e4f359fbfcf7b48f70f/numpy-2.4.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e823b8b6edc81e747526f70f71a9c0a07ac4e7ad13020aa736bb7c9d67196115", size = 14694532, upload-time = "2026-03-29T13:19:25.581Z" }, - { url = "https://files.pythonhosted.org/packages/e5/87/499737bfba066b4a3bebff24a8f1c5b2dee410b209bc6668c9be692580f0/numpy-2.4.4-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:4a19d9dba1a76618dd86b164d608566f393f8ec6ac7c44f0cc879011c45e65af", size = 5199661, upload-time = "2026-03-29T13:19:28.31Z" }, - { url = "https://files.pythonhosted.org/packages/cd/da/464d551604320d1491bc345efed99b4b7034143a85787aab78d5691d5a0e/numpy-2.4.4-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:d2a8490669bfe99a233298348acc2d824d496dee0e66e31b66a6022c2ad74a5c", size = 6547539, upload-time = "2026-03-29T13:19:30.97Z" }, - { url = "https://files.pythonhosted.org/packages/7d/90/8d23e3b0dafd024bf31bdec225b3bb5c2dbfa6912f8a53b8659f21216cbf/numpy-2.4.4-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:45dbed2ab436a9e826e302fcdcbe9133f9b0006e5af7168afb8963a6520da103", size = 15668806, upload-time = "2026-03-29T13:19:33.887Z" }, - { url = "https://files.pythonhosted.org/packages/d1/73/a9d864e42a01896bb5974475438f16086be9ba1f0d19d0bb7a07427c4a8b/numpy-2.4.4-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c901b15172510173f5cb310eae652908340f8dede90fff9e3bf6c0d8dfd92f83", size = 16632682, upload-time = "2026-03-29T13:19:37.336Z" }, - { url = "https://files.pythonhosted.org/packages/34/fb/14570d65c3bde4e202a031210475ae9cde9b7686a2e7dc97ee67d2833b35/numpy-2.4.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:99d838547ace2c4aace6c4f76e879ddfe02bb58a80c1549928477862b7a6d6ed", size = 17019810, upload-time = "2026-03-29T13:19:40.963Z" }, - { url = "https://files.pythonhosted.org/packages/8a/77/2ba9d87081fd41f6d640c83f26fb7351e536b7ce6dd9061b6af5904e8e46/numpy-2.4.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:0aec54fd785890ecca25a6003fd9a5aed47ad607bbac5cd64f836ad8666f4959", size = 18357394, upload-time = "2026-03-29T13:19:44.859Z" }, - { url = "https://files.pythonhosted.org/packages/a2/23/52666c9a41708b0853fa3b1a12c90da38c507a3074883823126d4e9d5b30/numpy-2.4.4-cp313-cp313-win32.whl", hash = "sha256:07077278157d02f65c43b1b26a3886bce886f95d20aabd11f87932750dfb14ed", size = 5959556, upload-time = "2026-03-29T13:19:47.661Z" }, - { url = "https://files.pythonhosted.org/packages/57/fb/48649b4971cde70d817cf97a2a2fdc0b4d8308569f1dd2f2611959d2e0cf/numpy-2.4.4-cp313-cp313-win_amd64.whl", hash = "sha256:5c70f1cc1c4efbe316a572e2d8b9b9cc44e89b95f79ca3331553fbb63716e2bf", size = 12317311, upload-time = "2026-03-29T13:19:50.67Z" }, - { url = "https://files.pythonhosted.org/packages/ba/d8/11490cddd564eb4de97b4579ef6bfe6a736cc07e94c1598590ae25415e01/numpy-2.4.4-cp313-cp313-win_arm64.whl", hash = "sha256:ef4059d6e5152fa1a39f888e344c73fdc926e1b2dd58c771d67b0acfbf2aa67d", size = 10222060, upload-time = "2026-03-29T13:19:54.229Z" }, - { url = "https://files.pythonhosted.org/packages/99/5d/dab4339177a905aad3e2221c915b35202f1ec30d750dd2e5e9d9a72b804b/numpy-2.4.4-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:4bbc7f303d125971f60ec0aaad5e12c62d0d2c925f0ab1273debd0e4ba37aba5", size = 14822302, upload-time = "2026-03-29T13:19:57.585Z" }, - { url = "https://files.pythonhosted.org/packages/eb/e4/0564a65e7d3d97562ed6f9b0fd0fb0a6f559ee444092f105938b50043876/numpy-2.4.4-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:4d6d57903571f86180eb98f8f0c839fa9ebbfb031356d87f1361be91e433f5b7", size = 5327407, upload-time = "2026-03-29T13:20:00.601Z" }, - { url = "https://files.pythonhosted.org/packages/29/8d/35a3a6ce5ad371afa58b4700f1c820f8f279948cca32524e0a695b0ded83/numpy-2.4.4-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:4636de7fd195197b7535f231b5de9e4b36d2c440b6e566d2e4e4746e6af0ca93", size = 6647631, upload-time = "2026-03-29T13:20:02.855Z" }, - { url = "https://files.pythonhosted.org/packages/f4/da/477731acbd5a58a946c736edfdabb2ac5b34c3d08d1ba1a7b437fa0884df/numpy-2.4.4-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ad2e2ef14e0b04e544ea2fa0a36463f847f113d314aa02e5b402fdf910ef309e", size = 15727691, upload-time = "2026-03-29T13:20:06.004Z" }, - { url = "https://files.pythonhosted.org/packages/e6/db/338535d9b152beabeb511579598418ba0212ce77cf9718edd70262cc4370/numpy-2.4.4-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a285b3b96f951841799528cd1f4f01cd70e7e0204b4abebac9463eecfcf2a40", size = 16681241, upload-time = "2026-03-29T13:20:09.417Z" }, - { url = "https://files.pythonhosted.org/packages/e2/a9/ad248e8f58beb7a0219b413c9c7d8151c5d285f7f946c3e26695bdbbe2df/numpy-2.4.4-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:f8474c4241bc18b750be2abea9d7a9ec84f46ef861dbacf86a4f6e043401f79e", size = 17085767, upload-time = "2026-03-29T13:20:13.126Z" }, - { url = "https://files.pythonhosted.org/packages/b5/1a/3b88ccd3694681356f70da841630e4725a7264d6a885c8d442a697e1146b/numpy-2.4.4-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:4e874c976154687c1f71715b034739b45c7711bec81db01914770373d125e392", size = 18403169, upload-time = "2026-03-29T13:20:17.096Z" }, - { url = "https://files.pythonhosted.org/packages/c2/c9/fcfd5d0639222c6eac7f304829b04892ef51c96a75d479214d77e3ce6e33/numpy-2.4.4-cp313-cp313t-win32.whl", hash = "sha256:9c585a1790d5436a5374bac930dad6ed244c046ed91b2b2a3634eb2971d21008", size = 6083477, upload-time = "2026-03-29T13:20:20.195Z" }, - { url = "https://files.pythonhosted.org/packages/d5/e3/3938a61d1c538aaec8ed6fd6323f57b0c2d2d2219512434c5c878db76553/numpy-2.4.4-cp313-cp313t-win_amd64.whl", hash = "sha256:93e15038125dc1e5345d9b5b68aa7f996ec33b98118d18c6ca0d0b7d6198b7e8", size = 12457487, upload-time = "2026-03-29T13:20:22.946Z" }, - { url = "https://files.pythonhosted.org/packages/97/6a/7e345032cc60501721ef94e0e30b60f6b0bd601f9174ebd36389a2b86d40/numpy-2.4.4-cp313-cp313t-win_arm64.whl", hash = "sha256:0dfd3f9d3adbe2920b68b5cd3d51444e13a10792ec7154cd0a2f6e74d4ab3233", size = 10292002, upload-time = "2026-03-29T13:20:25.909Z" }, - { url = "https://files.pythonhosted.org/packages/6e/06/c54062f85f673dd5c04cbe2f14c3acb8c8b95e3384869bb8cc9bff8cb9df/numpy-2.4.4-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:f169b9a863d34f5d11b8698ead99febeaa17a13ca044961aa8e2662a6c7766a0", size = 16684353, upload-time = "2026-03-29T13:20:29.504Z" }, - { url = "https://files.pythonhosted.org/packages/4c/39/8a320264a84404c74cc7e79715de85d6130fa07a0898f67fb5cd5bd79908/numpy-2.4.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:2483e4584a1cb3092da4470b38866634bafb223cbcd551ee047633fd2584599a", size = 14704914, upload-time = "2026-03-29T13:20:33.547Z" }, - { url = "https://files.pythonhosted.org/packages/91/fb/287076b2614e1d1044235f50f03748f31fa287e3dbe6abeb35cdfa351eca/numpy-2.4.4-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:2d19e6e2095506d1736b7d80595e0f252d76b89f5e715c35e06e937679ea7d7a", size = 5210005, upload-time = "2026-03-29T13:20:36.45Z" }, - { url = "https://files.pythonhosted.org/packages/63/eb/fcc338595309910de6ecabfcef2419a9ce24399680bfb149421fa2df1280/numpy-2.4.4-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:6a246d5914aa1c820c9443ddcee9c02bec3e203b0c080349533fae17727dfd1b", size = 6544974, upload-time = "2026-03-29T13:20:39.014Z" }, - { url = "https://files.pythonhosted.org/packages/44/5d/e7e9044032a716cdfaa3fba27a8e874bf1c5f1912a1ddd4ed071bf8a14a6/numpy-2.4.4-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:989824e9faf85f96ec9c7761cd8d29c531ad857bfa1daa930cba85baaecf1a9a", size = 15684591, upload-time = "2026-03-29T13:20:42.146Z" }, - { url = "https://files.pythonhosted.org/packages/98/7c/21252050676612625449b4807d6b695b9ce8a7c9e1c197ee6216c8a65c7c/numpy-2.4.4-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:27a8d92cd10f1382a67d7cf4db7ce18341b66438bdd9f691d7b0e48d104c2a9d", size = 16637700, upload-time = "2026-03-29T13:20:46.204Z" }, - { url = "https://files.pythonhosted.org/packages/b1/29/56d2bbef9465db24ef25393383d761a1af4f446a1df9b8cded4fe3a5a5d7/numpy-2.4.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e44319a2953c738205bf3354537979eaa3998ed673395b964c1176083dd46252", size = 17035781, upload-time = "2026-03-29T13:20:50.242Z" }, - { url = "https://files.pythonhosted.org/packages/e3/2b/a35a6d7589d21f44cea7d0a98de5ddcbb3d421b2622a5c96b1edf18707c3/numpy-2.4.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:e892aff75639bbef0d2a2cfd55535510df26ff92f63c92cd84ef8d4ba5a5557f", size = 18362959, upload-time = "2026-03-29T13:20:54.019Z" }, - { url = "https://files.pythonhosted.org/packages/64/c9/d52ec581f2390e0f5f85cbfd80fb83d965fc15e9f0e1aec2195faa142cde/numpy-2.4.4-cp314-cp314-win32.whl", hash = "sha256:1378871da56ca8943c2ba674530924bb8ca40cd228358a3b5f302ad60cf875fc", size = 6008768, upload-time = "2026-03-29T13:20:56.912Z" }, - { url = "https://files.pythonhosted.org/packages/fa/22/4cc31a62a6c7b74a8730e31a4274c5dc80e005751e277a2ce38e675e4923/numpy-2.4.4-cp314-cp314-win_amd64.whl", hash = "sha256:715d1c092715954784bc79e1174fc2a90093dc4dc84ea15eb14dad8abdcdeb74", size = 12449181, upload-time = "2026-03-29T13:20:59.548Z" }, - { url = "https://files.pythonhosted.org/packages/70/2e/14cda6f4d8e396c612d1bf97f22958e92148801d7e4f110cabebdc0eef4b/numpy-2.4.4-cp314-cp314-win_arm64.whl", hash = "sha256:2c194dd721e54ecad9ad387c1d35e63dce5c4450c6dc7dd5611283dda239aabb", size = 10496035, upload-time = "2026-03-29T13:21:02.524Z" }, - { url = "https://files.pythonhosted.org/packages/b1/e8/8fed8c8d848d7ecea092dc3469643f9d10bc3a134a815a3b033da1d2039b/numpy-2.4.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:2aa0613a5177c264ff5921051a5719d20095ea586ca88cc802c5c218d1c67d3e", size = 14824958, upload-time = "2026-03-29T13:21:05.671Z" }, - { url = "https://files.pythonhosted.org/packages/05/1a/d8007a5138c179c2bf33ef44503e83d70434d2642877ee8fbb230e7c0548/numpy-2.4.4-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:42c16925aa5a02362f986765f9ebabf20de75cdefdca827d14315c568dcab113", size = 5330020, upload-time = "2026-03-29T13:21:08.635Z" }, - { url = "https://files.pythonhosted.org/packages/99/64/ffb99ac6ae93faf117bcbd5c7ba48a7f45364a33e8e458545d3633615dda/numpy-2.4.4-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:874f200b2a981c647340f841730fc3a2b54c9d940566a3c4149099591e2c4c3d", size = 6650758, upload-time = "2026-03-29T13:21:10.949Z" }, - { url = "https://files.pythonhosted.org/packages/6e/6e/795cc078b78a384052e73b2f6281ff7a700e9bf53bcce2ee579d4f6dd879/numpy-2.4.4-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c9b39d38a9bd2ae1becd7eac1303d031c5c110ad31f2b319c6e7d98b135c934d", size = 15729948, upload-time = "2026-03-29T13:21:14.047Z" }, - { url = "https://files.pythonhosted.org/packages/5f/86/2acbda8cc2af5f3d7bfc791192863b9e3e19674da7b5e533fded124d1299/numpy-2.4.4-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b268594bccac7d7cf5844c7732e3f20c50921d94e36d7ec9b79e9857694b1b2f", size = 16679325, upload-time = "2026-03-29T13:21:17.561Z" }, - { url = "https://files.pythonhosted.org/packages/bc/59/cafd83018f4aa55e0ac6fa92aa066c0a1877b77a615ceff1711c260ffae8/numpy-2.4.4-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:ac6b31e35612a26483e20750126d30d0941f949426974cace8e6b5c58a3657b0", size = 17084883, upload-time = "2026-03-29T13:21:21.106Z" }, - { url = "https://files.pythonhosted.org/packages/f0/85/a42548db84e65ece46ab2caea3d3f78b416a47af387fcbb47ec28e660dc2/numpy-2.4.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:8e3ed142f2728df44263aaf5fb1f5b0b99f4070c553a0d7f033be65338329150", size = 18403474, upload-time = "2026-03-29T13:21:24.828Z" }, - { url = "https://files.pythonhosted.org/packages/ed/ad/483d9e262f4b831000062e5d8a45e342166ec8aaa1195264982bca267e62/numpy-2.4.4-cp314-cp314t-win32.whl", hash = "sha256:dddbbd259598d7240b18c9d87c56a9d2fb3b02fe266f49a7c101532e78c1d871", size = 6155500, upload-time = "2026-03-29T13:21:28.205Z" }, - { url = "https://files.pythonhosted.org/packages/c7/03/2fc4e14c7bd4ff2964b74ba90ecb8552540b6315f201df70f137faa5c589/numpy-2.4.4-cp314-cp314t-win_amd64.whl", hash = "sha256:a7164afb23be6e37ad90b2f10426149fd75aee07ca55653d2aa41e66c4ef697e", size = 12637755, upload-time = "2026-03-29T13:21:31.107Z" }, - { url = "https://files.pythonhosted.org/packages/58/78/548fb8e07b1a341746bfbecb32f2c268470f45fa028aacdbd10d9bc73aab/numpy-2.4.4-cp314-cp314t-win_arm64.whl", hash = "sha256:ba203255017337d39f89bdd58417f03c4426f12beed0440cfd933cb15f8669c7", size = 10566643, upload-time = "2026-03-29T13:21:34.339Z" }, + { url = "https://files.pythonhosted.org/packages/fb/82/bdab26d7438c6791ca31b7c024ca37c1eab8b726ba236129005cd4a06e45/numpy-2.4.6-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:511dbaf848decaaaf4b4ca48032619fb3138710c4bf7da7617765edad1ef96b0", size = 16684648, upload-time = "2026-05-18T23:34:29.41Z" }, + { url = "https://files.pythonhosted.org/packages/1b/30/a80189bcc7f5e4258b3fbc3968d909d1756f54d023299ecc39ad6fdb9ef8/numpy-2.4.6-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:bf162abab1c1a736333192707cef898e735a5ca00f38f27eeedf44b39d9e85eb", size = 14693902, upload-time = "2026-05-18T23:34:33.013Z" }, + { url = "https://files.pythonhosted.org/packages/97/12/70b5d0d7c15e1ebb8a6a84a8caa1d19e181d84fb58bb6d70aca29099dec1/numpy-2.4.6-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:043191bfa8eab18c776647b62723ac9dddece59743b13f49b2016094129c2b3f", size = 5198992, upload-time = "2026-05-18T23:34:36.132Z" }, + { url = "https://files.pythonhosted.org/packages/ba/8c/ebd2a8f8a83541f8d38cc5667e8c2b69cecfd30da6e45693e8158857d44b/numpy-2.4.6-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:6180d8b35af935aed8ece3a85e0a43f87393ae0ac87c8d2c8bd2c993f7270ef3", size = 6546944, upload-time = "2026-05-18T23:34:38.484Z" }, + { url = "https://files.pythonhosted.org/packages/bb/c5/7b863a97a91671a0338f4253bd3b5a3d3852f0692dae91711c9f4a10e787/numpy-2.4.6-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:72fbe16c6fac95aedf5937fa873445cec2110be35d8a4e9433d7501fd98dae6b", size = 15669392, upload-time = "2026-05-18T23:34:41.257Z" }, + { url = "https://files.pythonhosted.org/packages/a5/9d/3584b9984ca4c047aea75214ce1a4c4c73d849bd71b604264b7f5653f8a8/numpy-2.4.6-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a7830bab239b79cda9c08c2da014761cafb48da6150e1da17ac06283f43b6089", size = 16633220, upload-time = "2026-05-18T23:34:45.075Z" }, + { url = "https://files.pythonhosted.org/packages/05/ae/7c67fba23bd98caec7c99261f3a16072ade14813486b0282cb29846de832/numpy-2.4.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ef4aea96ce4d3b074422cb4f2f64e216bf9e213004bb58ecfdf50ea02ea8eb9a", size = 17020800, upload-time = "2026-05-18T23:34:49.065Z" }, + { url = "https://files.pythonhosted.org/packages/d9/5d/3b6725cb31d983c5e66916f5d36f6d7e5521129e4c4404d64f918292a5b6/numpy-2.4.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:dfa20cc6ca228e6b155b11da03825975ce66aea520985dbbddf0f2a5a495c605", size = 18357600, upload-time = "2026-05-18T23:34:52.709Z" }, + { url = "https://files.pythonhosted.org/packages/f7/da/2ccc6c2fe8898dee01d90c75c5f5f914a23daf99e3e0f59516a08760c8b5/numpy-2.4.6-cp313-cp313-win32.whl", hash = "sha256:56b39e5e0622a09a25bf5baf62f4bcf0cb8a41ae6e2819cf49bbc5a74c083f91", size = 5961134, upload-time = "2026-05-18T23:34:55.618Z" }, + { url = "https://files.pythonhosted.org/packages/b5/cd/9cc4dc876fb065d5c220aae4d5e14826b2715331bb7618ce1fb07a679d99/numpy-2.4.6-cp313-cp313-win_amd64.whl", hash = "sha256:c4fc99836233ea196540b17ab0983aff60ed07941751930f5f4d05bc3b3b7359", size = 12318598, upload-time = "2026-05-18T23:34:58.928Z" }, + { url = "https://files.pythonhosted.org/packages/39/1e/c0bcba1f8694116485fe28fd1be698c278fcda4141c5b0e53a2aed8b12a8/numpy-2.4.6-cp313-cp313-win_arm64.whl", hash = "sha256:a7c711e21628b52034bb5ab8d1bce291f752fcc5e92accc615778acee1ff4778", size = 10222272, upload-time = "2026-05-18T23:35:02.167Z" }, + { url = "https://files.pythonhosted.org/packages/63/6d/cc5619247c8f4204e507f5883528372e4ac4bb189e579fb859a12e480b1f/numpy-2.4.6-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:112b06a867b235ef466ed3508ddf0238050df9c727cafb5301ac385b899189a1", size = 14821197, upload-time = "2026-05-18T23:35:05.468Z" }, + { url = "https://files.pythonhosted.org/packages/00/58/f1c39161c87d9e9bed660f1ed4bafc0e403d5ec9650b6dd77aead07d489b/numpy-2.4.6-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:eaf7fa2de5c0be8ae6ff8e9bea2ccd725e980541244521d8d4b5f3354a27babe", size = 5326287, upload-time = "2026-05-18T23:35:08.693Z" }, + { url = "https://files.pythonhosted.org/packages/af/57/3917ab0fd97f271a8694513581b8a36c655f111c446852c302f04ccdb6fc/numpy-2.4.6-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:7265a2f3d436e54ef9f2b52b5c937e6be778781bd97a590319d7348f1c1ca997", size = 6646763, upload-time = "2026-05-18T23:35:11.459Z" }, + { url = "https://files.pythonhosted.org/packages/eb/0f/037e64c494b67581ae18193d770adef354c41f3f2c8ebf865602d949bf8f/numpy-2.4.6-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f74a575920ab21fe304421a3fc28793d82e299cae9eccb37084e9fc7f3617c20", size = 15728070, upload-time = "2026-05-18T23:35:14.79Z" }, + { url = "https://files.pythonhosted.org/packages/21/a6/5d2bae9c9542eb4df16dc9c46dc79c186e9bad53805dfa5399a6023c6db0/numpy-2.4.6-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ede83e07a75dd06bc501566c1eca2afc0d61677c1472ac9ad93fdee6e638a48d", size = 16681752, upload-time = "2026-05-18T23:35:18.836Z" }, + { url = "https://files.pythonhosted.org/packages/92/14/23d1dfb410ae362cd59ce53e936b1513d545eb40db3949ced632e19a459e/numpy-2.4.6-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:68bb27509ac1b9a3443094260f6326150663b06abe40b73a2f81160623da5b67", size = 17086024, upload-time = "2026-05-18T23:35:22.52Z" }, + { url = "https://files.pythonhosted.org/packages/4b/6e/23595a2c642cdf3bc567877064bdd7f91c8b0038a4453cf2daf7248eafe9/numpy-2.4.6-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:a0df0043bdb289bde1f62da130d20df23d58b45429f752bc7a8fc5325a225ecd", size = 18403398, upload-time = "2026-05-18T23:35:26.398Z" }, + { url = "https://files.pythonhosted.org/packages/8a/90/0ac3bc947217e66dec77e7cbc6a1979d1af70b6461b82f620d3bccd5e4c8/numpy-2.4.6-cp313-cp313t-win32.whl", hash = "sha256:29a287e0cf63ff528da061de6b9f64a4618da591ca1046aafc54062e40ca7eab", size = 6084971, upload-time = "2026-05-18T23:35:29.387Z" }, + { url = "https://files.pythonhosted.org/packages/77/71/5673e351671a1d2bd6063b91b44f70c0affea7d1516fa7a6572941ba4aa1/numpy-2.4.6-cp313-cp313t-win_amd64.whl", hash = "sha256:25c692919ac5a01f170a3bfcd62d745b24fd095c353d50812637d6fcab442e75", size = 12458532, upload-time = "2026-05-18T23:35:32.175Z" }, + { url = "https://files.pythonhosted.org/packages/3f/88/19d3503c5046e688f049274b27a3ef3d771152fa80d3ba3d01a3dff61abe/numpy-2.4.6-cp313-cp313t-win_arm64.whl", hash = "sha256:1e978ec1e8bd0e0e4de6bb75de9d30cbb74db6b6a2bb727618613703ca0167dd", size = 10291881, upload-time = "2026-05-18T23:35:35.465Z" }, + { url = "https://files.pythonhosted.org/packages/f8/91/3ab2044d05fd16d343c5ac2e69b127f1b2854040dd20b193257c78028bd3/numpy-2.4.6-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:06ca2f61ec4385a07a6977c55ba998a4466c123642b4a32694d3128fce18c079", size = 16683458, upload-time = "2026-05-18T23:35:38.353Z" }, + { url = "https://files.pythonhosted.org/packages/8e/62/764ce66fa4147ae6d73071a3abf804ffe606f174618697c571acdf26a7c9/numpy-2.4.6-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:38efbc8de75c7a0fc1ac190162d892787f3f47b57cc291231aafee36b80982b7", size = 14704559, upload-time = "2026-05-18T23:35:42.14Z" }, + { url = "https://files.pythonhosted.org/packages/60/61/23f27c172f022e04025b7dc2367f4d63c1a398120607ec896228649a6f48/numpy-2.4.6-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:d581b735e177fdcdce6fed8e7e8880a3fb6ee4e3653a3ac6af01c6f4c03effc5", size = 5209716, upload-time = "2026-05-18T23:35:45.377Z" }, + { url = "https://files.pythonhosted.org/packages/03/71/21cf70dc6ea3e3acb95fc53a265b2fc248b981f0194ceb5b475271b8809d/numpy-2.4.6-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:0a041d3d761dc3c35cc56ce0351506a02bcbc25f7b169f652435141a17db9096", size = 6543947, upload-time = "2026-05-18T23:35:47.926Z" }, + { url = "https://files.pythonhosted.org/packages/d5/91/64288395ee1799bd2e0b04a305dce9666da90c961e1f3fe982a05ee1c036/numpy-2.4.6-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:40fdc1ae7125e518ea98e53e69a4ebc27e1fd50510c47b7ea130cf21e5e1d42b", size = 15685197, upload-time = "2026-05-18T23:35:50.863Z" }, + { url = "https://files.pythonhosted.org/packages/f3/eb/ebffaa97dc55502df69584a8f0dcf07f69a3e0b3e2323670a2722db9aa39/numpy-2.4.6-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a2c306dea656c12c68f51f4cea133cbe78ca7435eb28c735eac1d3ebe73be6e8", size = 16638245, upload-time = "2026-05-18T23:35:54.752Z" }, + { url = "https://files.pythonhosted.org/packages/b8/0b/54f9da33128d7e350fab89c7455902eeae70349ee52bddb448dc4a576f45/numpy-2.4.6-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:33111801a01c12a8a1e3721f0a9232f8cfc8ae2c6b7098167e6f623c6073f402", size = 17036587, upload-time = "2026-05-18T23:35:58.355Z" }, + { url = "https://files.pythonhosted.org/packages/b6/f0/fdebc1052db1cc37c64beb22072d67cd6d1c71adca1299f53dec2b5e20d3/numpy-2.4.6-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ae506e6902902557576a26ff33eda8695e7ecb3cb36c3b573a0765dee114ebdb", size = 18363226, upload-time = "2026-05-18T23:36:02.845Z" }, + { url = "https://files.pythonhosted.org/packages/aa/b4/298628d98c72b57e57f7165ae6a481a1deaf6f3c28262a6e4c739c275930/numpy-2.4.6-cp314-cp314-win32.whl", hash = "sha256:aaf159caa35993cb1f56fb9b8e4610d35758e7ca005412eb1daa856a78c9c4b1", size = 6010196, upload-time = "2026-05-18T23:36:05.92Z" }, + { url = "https://files.pythonhosted.org/packages/df/ac/46de6dda46478f7942f839e094970be2d4a861e005c4b3bf07c92e291a09/numpy-2.4.6-cp314-cp314-win_amd64.whl", hash = "sha256:b507f5c4c1d508876d1819b6bf9a49d365b96320b5d4993426b33a23ca4b8261", size = 12450334, upload-time = "2026-05-18T23:36:09.107Z" }, + { url = "https://files.pythonhosted.org/packages/78/92/b8b798ac784102c0da830d2257d59358e3d3d90d1e2b3f2575dad976c5cf/numpy-2.4.6-cp314-cp314-win_arm64.whl", hash = "sha256:6f41ae150c4e32db4f3310cdaf64b1593a03dbabe29eec77fc9b50fe64061df6", size = 10495678, upload-time = "2026-05-18T23:36:12.766Z" }, + { url = "https://files.pythonhosted.org/packages/30/34/ec28d1aa8115971537c01469ab2011ee96827930f0a124de1000cc2a7ed7/numpy-2.4.6-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ece3d2cfe132e7d51f44a832b303895e6f2d499c5e74dfbdb06ee246147a304a", size = 14823672, upload-time = "2026-05-18T23:36:16.473Z" }, + { url = "https://files.pythonhosted.org/packages/16/bd/f6d1fede4e54e8042a7ff97bb495510f3c220f94bcd9e8b228e87c92cc0d/numpy-2.4.6-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:e3e5193ef5a3dc73bceee50f7fdc2c90dbb76c42df8d8fae3d1067a583df579e", size = 5328731, upload-time = "2026-05-18T23:36:19.767Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f0/e105b9e2fd728a9910103884decd6951d9dd73896b914a98d9a231de02ee/numpy-2.4.6-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:17f9ade344e7d9b464a084d69bcf18fc691cb1db67c62ed80820bf4926d78f0e", size = 6649805, upload-time = "2026-05-18T23:36:22.266Z" }, + { url = "https://files.pythonhosted.org/packages/82/dd/1206a7ca6ab15e3f02069707ca96222e202af681bb73756da7527f3cb837/numpy-2.4.6-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9cd5ffd25db4e7ba6a375693b3fc0fc1791ec636c17db3720da19bde7180ec43", size = 15730496, upload-time = "2026-05-18T23:36:25.713Z" }, + { url = "https://files.pythonhosted.org/packages/51/e7/38d3ea825dcab85a591734decb2f6c67caa7c8367d374df1a1c3842f9b07/numpy-2.4.6-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7d92c3819208a60205a12a245c91ad70cb0a85336659b19b834205573ac8456e", size = 16679616, upload-time = "2026-05-18T23:36:29.652Z" }, + { url = "https://files.pythonhosted.org/packages/93/b7/caabfdf53edf663e0b4eb74d7d405d83baef09eb5e83bcd32d601d72b93e/numpy-2.4.6-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e85b752a1e912b70eaad4fafbd4d1238007ab221de2009b9a2f5ae7461239895", size = 17085145, upload-time = "2026-05-18T23:36:33.449Z" }, + { url = "https://files.pythonhosted.org/packages/f9/45/68d7c33a6bcf3e5aa3bdbd57a367e6f615286dfd6482f97e8ffeb734306e/numpy-2.4.6-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:29cb7f67d10b479ff07c17d33e39f78c07f71c40ef30d63c153d340e96cd3fb4", size = 18403813, upload-time = "2026-05-18T23:36:37.369Z" }, + { url = "https://files.pythonhosted.org/packages/9c/50/0753655aa844c99cd9e018aacf76f130f1bd81d881bb74bc0aef5d73a8ba/numpy-2.4.6-cp314-cp314t-win32.whl", hash = "sha256:260a5d70215b61ab4fadf5c7baacd64821842975eea312125ed3c39a6391b063", size = 6156982, upload-time = "2026-05-18T23:36:40.817Z" }, + { url = "https://files.pythonhosted.org/packages/b2/d4/7c67becf668f973cb490cec3e98dfd799d866f9c989a54d355672cfa0db6/numpy-2.4.6-cp314-cp314t-win_amd64.whl", hash = "sha256:81a1cca95ed5bb92aa8b10dd2cdc9a0d3853a50fad926c28b5d7e8ea54389627", size = 12638908, upload-time = "2026-05-18T23:36:43.996Z" }, + { url = "https://files.pythonhosted.org/packages/43/bb/e1c71a4295b1b1d1393d50dbb4f2a36283c6859d9d3892e84f00ec5a91d5/numpy-2.4.6-cp314-cp314t-win_arm64.whl", hash = "sha256:0c9136e14ed34a9e343a31c533d78a9813a69a3148332bce5e9821cb2f996e66", size = 10565867, upload-time = "2026-05-18T23:36:47.114Z" }, ] [[package]] name = "orjson" -version = "3.11.8" +version = "3.11.9" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/9d/1b/2024d06792d0779f9dbc51531b61c24f76c75b9f4ce05e6f3377a1814cea/orjson-3.11.8.tar.gz", hash = "sha256:96163d9cdc5a202703e9ad1b9ae757d5f0ca62f4fa0cc93d1f27b0e180cc404e", size = 5603832, upload-time = "2026-03-31T16:16:27.878Z" } +sdist = { url = "https://files.pythonhosted.org/packages/7e/0c/964746fcafbd16f8ff53219ad9f6b412b34f345c75f384ad434ceaadb538/orjson-3.11.9.tar.gz", hash = "sha256:4fef17e1f8722c11587a6ef18e35902450221da0028e65dbaaa543619e68e48f", size = 5599163, upload-time = "2026-05-06T15:11:08.309Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/66/7f/95fba509bb2305fab0073558f1e8c3a2ec4b2afe58ed9fcb7d3b8beafe94/orjson-3.11.8-cp313-cp313-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:3f23426851d98478c8970da5991f84784a76682213cd50eb73a1da56b95239dc", size = 229180, upload-time = "2026-03-31T16:15:36.426Z" }, - { url = "https://files.pythonhosted.org/packages/f6/9d/b237215c743ca073697d759b5503abd2cb8a0d7b9c9e21f524bcf176ab66/orjson-3.11.8-cp313-cp313-macosx_15_0_arm64.whl", hash = "sha256:ebaed4cef74a045b83e23537b52ef19a367c7e3f536751e355a2a394f8648559", size = 128754, upload-time = "2026-03-31T16:15:38.049Z" }, - { url = "https://files.pythonhosted.org/packages/42/3d/27d65b6d11e63f133781425f132807aef793ed25075fec686fc8e46dd528/orjson-3.11.8-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:97c8f5d3b62380b70c36ffacb2a356b7c6becec86099b177f73851ba095ef623", size = 131877, upload-time = "2026-03-31T16:15:39.484Z" }, - { url = "https://files.pythonhosted.org/packages/dd/cc/faee30cd8f00421999e40ef0eba7332e3a625ce91a58200a2f52c7fef235/orjson-3.11.8-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:436c4922968a619fb7fef1ccd4b8b3a76c13b67d607073914d675026e911a65c", size = 130361, upload-time = "2026-03-31T16:15:41.274Z" }, - { url = "https://files.pythonhosted.org/packages/5c/bb/a6c55896197f97b6d4b4e7c7fd77e7235517c34f5d6ad5aadd43c54c6d7c/orjson-3.11.8-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1ab359aff0436d80bfe8a23b46b5fea69f1e18aaf1760a709b4787f1318b317f", size = 135521, upload-time = "2026-03-31T16:15:42.758Z" }, - { url = "https://files.pythonhosted.org/packages/9c/7c/ca3a3525aa32ff636ebb1778e77e3587b016ab2edb1b618b36ba96f8f2c0/orjson-3.11.8-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f89b6d0b3a8d81e1929d3ab3d92bbc225688bd80a770c49432543928fe09ac55", size = 146862, upload-time = "2026-03-31T16:15:44.341Z" }, - { url = "https://files.pythonhosted.org/packages/3c/0c/18a9d7f18b5edd37344d1fd5be17e94dc652c67826ab749c6e5948a78112/orjson-3.11.8-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:29c009e7a2ca9ad0ed1376ce20dd692146a5d9fe4310848904b6b4fee5c5c137", size = 132847, upload-time = "2026-03-31T16:15:46.368Z" }, - { url = "https://files.pythonhosted.org/packages/23/91/7e722f352ad67ca573cee44de2a58fb810d0f4eb4e33276c6a557979fd8a/orjson-3.11.8-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:705b895b781b3e395c067129d8551655642dfe9437273211d5404e87ac752b53", size = 133637, upload-time = "2026-03-31T16:15:48.123Z" }, - { url = "https://files.pythonhosted.org/packages/af/04/32845ce13ac5bd1046ddb02ac9432ba856cc35f6d74dde95864fe0ad5523/orjson-3.11.8-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:88006eda83858a9fdf73985ce3804e885c2befb2f506c9a3723cdeb5a2880e3e", size = 141906, upload-time = "2026-03-31T16:15:49.626Z" }, - { url = "https://files.pythonhosted.org/packages/02/5e/c551387ddf2d7106d9039369862245c85738b828844d13b99ccb8d61fd06/orjson-3.11.8-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:55120759e61309af7fcf9e961c6f6af3dde5921cdb3ee863ef63fd9db126cae6", size = 423722, upload-time = "2026-03-31T16:15:51.176Z" }, - { url = "https://files.pythonhosted.org/packages/00/a3/ecfe62434096f8a794d4976728cb59bcfc4a643977f21c2040545d37eb4c/orjson-3.11.8-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:98bdc6cb889d19bed01de46e67574a2eab61f5cc6b768ed50e8ac68e9d6ffab6", size = 147801, upload-time = "2026-03-31T16:15:52.939Z" }, - { url = "https://files.pythonhosted.org/packages/18/6d/0dce10b9f6643fdc59d99333871a38fa5a769d8e2fc34a18e5d2bfdee900/orjson-3.11.8-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:708c95f925a43ab9f34625e45dcdadf09ec8a6e7b664a938f2f8d5650f6c090b", size = 136460, upload-time = "2026-03-31T16:15:54.431Z" }, - { url = "https://files.pythonhosted.org/packages/01/d6/6dde4f31842d87099238f1f07b459d24edc1a774d20687187443ab044191/orjson-3.11.8-cp313-cp313-win32.whl", hash = "sha256:01c4e5a6695dc09098f2e6468a251bc4671c50922d4d745aff1a0a33a0cf5b8d", size = 131956, upload-time = "2026-03-31T16:15:56.081Z" }, - { url = "https://files.pythonhosted.org/packages/c1/f9/4e494a56e013db957fb77186b818b916d4695b8fa2aa612364974160e91b/orjson-3.11.8-cp313-cp313-win_amd64.whl", hash = "sha256:c154a35dd1330707450bb4d4e7dd1f17fa6f42267a40c1e8a1daa5e13719b4b8", size = 127410, upload-time = "2026-03-31T16:15:57.54Z" }, - { url = "https://files.pythonhosted.org/packages/57/7f/803203d00d6edb6e9e7eef421d4e1adbb5ea973e40b3533f3cfd9aeb374e/orjson-3.11.8-cp313-cp313-win_arm64.whl", hash = "sha256:4861bde57f4d253ab041e374f44023460e60e71efaa121f3c5f0ed457c3a701e", size = 127338, upload-time = "2026-03-31T16:15:59.106Z" }, - { url = "https://files.pythonhosted.org/packages/6d/35/b01910c3d6b85dc882442afe5060cbf719c7d1fc85749294beda23d17873/orjson-3.11.8-cp314-cp314-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:ec795530a73c269a55130498842aaa762e4a939f6ce481a7e986eeaa790e9da4", size = 229171, upload-time = "2026-03-31T16:16:00.651Z" }, - { url = "https://files.pythonhosted.org/packages/c2/56/c9ec97bd11240abef39b9e5d99a15462809c45f677420fd148a6c5e6295e/orjson-3.11.8-cp314-cp314-macosx_15_0_arm64.whl", hash = "sha256:c492a0e011c0f9066e9ceaa896fbc5b068c54d365fea5f3444b697ee01bc8625", size = 128746, upload-time = "2026-03-31T16:16:02.673Z" }, - { url = "https://files.pythonhosted.org/packages/3b/e4/66d4f30a90de45e2f0cbd9623588e8ae71eef7679dbe2ae954ed6d66a41f/orjson-3.11.8-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:883206d55b1bd5f5679ad5e6ddd3d1a5e3cac5190482927fdb8c78fb699193b5", size = 131867, upload-time = "2026-03-31T16:16:04.342Z" }, - { url = "https://files.pythonhosted.org/packages/19/30/2a645fc9286b928675e43fa2a3a16fb7b6764aa78cc719dc82141e00f30b/orjson-3.11.8-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5774c1fdcc98b2259800b683b19599c133baeb11d60033e2095fd9d4667b82db", size = 124664, upload-time = "2026-03-31T16:16:05.837Z" }, - { url = "https://files.pythonhosted.org/packages/db/44/77b9a86d84a28d52ba3316d77737f6514e17118119ade3f91b639e859029/orjson-3.11.8-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8ac7381c83dd3d4a6347e6635950aa448f54e7b8406a27c7ecb4a37e9f1ae08b", size = 129701, upload-time = "2026-03-31T16:16:07.407Z" }, - { url = "https://files.pythonhosted.org/packages/b3/ea/eff3d9bfe47e9bc6969c9181c58d9f71237f923f9c86a2d2f490cd898c82/orjson-3.11.8-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:14439063aebcb92401c11afc68ee4e407258d2752e62d748b6942dad20d2a70d", size = 141202, upload-time = "2026-03-31T16:16:09.48Z" }, - { url = "https://files.pythonhosted.org/packages/52/c8/90d4b4c60c84d62068d0cf9e4d8f0a4e05e76971d133ac0c60d818d4db20/orjson-3.11.8-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:fa72e71977bff96567b0f500fc5bfd2fdf915f34052c782a4c6ebbdaa97aa858", size = 127194, upload-time = "2026-03-31T16:16:11.02Z" }, - { url = "https://files.pythonhosted.org/packages/8d/c7/ea9e08d1f0ba981adffb629811148b44774d935171e7b3d780ae43c4c254/orjson-3.11.8-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7679bc2f01bb0d219758f1a5f87bb7c8a81c0a186824a393b366876b4948e14f", size = 133639, upload-time = "2026-03-31T16:16:13.434Z" }, - { url = "https://files.pythonhosted.org/packages/6c/8c/ddbbfd6ba59453c8fc7fe1d0e5983895864e264c37481b2a791db635f046/orjson-3.11.8-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:14f7b8fcb35ef403b42fa5ecfa4ed032332a91f3dc7368fbce4184d59e1eae0d", size = 141914, upload-time = "2026-03-31T16:16:14.955Z" }, - { url = "https://files.pythonhosted.org/packages/4e/31/dbfbefec9df060d34ef4962cd0afcb6fa7a9ec65884cb78f04a7859526c3/orjson-3.11.8-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:c2bdf7b2facc80b5e34f48a2d557727d5c5c57a8a450de122ae81fa26a81c1bc", size = 423800, upload-time = "2026-03-31T16:16:16.594Z" }, - { url = "https://files.pythonhosted.org/packages/87/cf/f74e9ae9803d4ab46b163494adba636c6d7ea955af5cc23b8aaa94cfd528/orjson-3.11.8-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:ccd7ba1b0605813a0715171d39ec4c314cb97a9c85893c2c5c0c3a3729df38bf", size = 147837, upload-time = "2026-03-31T16:16:18.585Z" }, - { url = "https://files.pythonhosted.org/packages/64/e6/9214f017b5db85e84e68602792f742e5dc5249e963503d1b356bee611e01/orjson-3.11.8-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:cdbc8c9c02463fef4d3c53a9ba3336d05496ec8e1f1c53326a1e4acc11f5c600", size = 136441, upload-time = "2026-03-31T16:16:20.151Z" }, - { url = "https://files.pythonhosted.org/packages/24/dd/3590348818f58f837a75fb969b04cdf187ae197e14d60b5e5a794a38b79d/orjson-3.11.8-cp314-cp314-win32.whl", hash = "sha256:0b57f67710a8cd459e4e54eb96d5f77f3624eba0c661ba19a525807e42eccade", size = 131983, upload-time = "2026-03-31T16:16:21.823Z" }, - { url = "https://files.pythonhosted.org/packages/3f/0f/b6cb692116e05d058f31ceee819c70f097fa9167c82f67fabe7516289abc/orjson-3.11.8-cp314-cp314-win_amd64.whl", hash = "sha256:735e2262363dcbe05c35e3a8869898022af78f89dde9e256924dc02e99fe69ca", size = 127396, upload-time = "2026-03-31T16:16:23.685Z" }, - { url = "https://files.pythonhosted.org/packages/c0/d1/facb5b5051fabb0ef9d26c6544d87ef19a939a9a001198655d0d891062dd/orjson-3.11.8-cp314-cp314-win_arm64.whl", hash = "sha256:6ccdea2c213cf9f3d9490cbd5d427693c870753df41e6cb375bd79bcbafc8817", size = 127330, upload-time = "2026-03-31T16:16:25.496Z" }, + { url = "https://files.pythonhosted.org/packages/32/33/93fcc25907235c344ae73122f8a4e01d2d393ef062b4af7d2e2487a32c37/orjson-3.11.9-cp313-cp313-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:4bab1b2d6141fe7b32ae71dac905666ece4f94936efbfb13d55bb7739a3a6021", size = 228458, upload-time = "2026-05-06T15:10:20.079Z" }, + { url = "https://files.pythonhosted.org/packages/8f/27/b1e6dadb3c080313c03fdd8067b85e6a0460c7d8d6a1c3984ef77b904e4d/orjson-3.11.9-cp313-cp313-macosx_15_0_arm64.whl", hash = "sha256:844417969855fc7a41be124aafe83dc424592a7f77cd4501900c67307122b92c", size = 128368, upload-time = "2026-05-06T15:10:21.549Z" }, + { url = "https://files.pythonhosted.org/packages/21/0f/c9ede0bf052f6b4051e64a7d4fa91b725cccf8321a6a786e86eb03519f00/orjson-3.11.9-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ffe02797b5e9f3a9d8292ddcd289b474ad13e81ad83cd1891a240811f1d2cb81", size = 132070, upload-time = "2026-05-06T15:10:23.371Z" }, + { url = "https://files.pythonhosted.org/packages/fd/26/d398e28048dc18205bbe812f2c88cb9b40313db2470778e25964796458fe/orjson-3.11.9-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0e4eed3b200023042814d2fc8a5d2e880f13b52e1ed2485e83da4f3962f7dc1a", size = 127892, upload-time = "2026-05-06T15:10:24.714Z" }, + { url = "https://files.pythonhosted.org/packages/66/60/52b0054c4c700d5aa7fc5b7ca96917400d8f061307778578e67a10e25852/orjson-3.11.9-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8aff7da9952a5ad1cef8e68017724d96c7b9a66e99e91d6252e1b133d67a7b10", size = 135217, upload-time = "2026-05-06T15:10:26.084Z" }, + { url = "https://files.pythonhosted.org/packages/d5/97/1e3dc2b2a28b7b2528f403d2fc1d79ec5f39af3bc143ab65d3ec26426385/orjson-3.11.9-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4d4e98d6f3b8afed8bc8cd9718ec0cdf46661826beefb53fe8eafb37f2bf0362", size = 145980, upload-time = "2026-05-06T15:10:28.062Z" }, + { url = "https://files.pythonhosted.org/packages/fc/39/31fbfe7850f2de32dee7e7e5c09f26d403ab01e440ac96001c6b01ad3c99/orjson-3.11.9-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3a81d52442a7c99b3662333235b3adf96a1715864658b35bb797212be7bddb97", size = 132738, upload-time = "2026-05-06T15:10:29.727Z" }, + { url = "https://files.pythonhosted.org/packages/a1/08/dca0082dd2a194acb93e5457e73455388e2e2ca464a2672449a9ddbb679d/orjson-3.11.9-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4e39364e726a8fff737309aff059ff67d8a8c8d5b677be7bb49a8b3e84b7e218", size = 134033, upload-time = "2026-05-06T15:10:31.152Z" }, + { url = "https://files.pythonhosted.org/packages/11/d4/5bdb0626801230139987385554c5d4c42255218ac906525bf4347f22cd95/orjson-3.11.9-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4fd66214623f1b17501df9f0543bef0b833979ab5b6ded1e1d123222866aa8c9", size = 141492, upload-time = "2026-05-06T15:10:32.641Z" }, + { url = "https://files.pythonhosted.org/packages/fa/88/a21fb53b3ede6703aede6dce4710ed4111e5b201cfa6bbff5e544f9d47d7/orjson-3.11.9-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:8ecc30f10465fa1e0ce13fd01d9e22c316e5053a719a8d915d4545a09a5ff677", size = 415087, upload-time = "2026-05-06T15:10:34.438Z" }, + { url = "https://files.pythonhosted.org/packages/3d/57/1b30daf70f0d8180e9a73cefbfbdd99e4bf19eb020466502b01fba7e0e50/orjson-3.11.9-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:97db4c94a7db398a5bd636273324f0b3fd58b350bbbac8bb380ceb825a9b40f4", size = 148031, upload-time = "2026-05-06T15:10:36.358Z" }, + { url = "https://files.pythonhosted.org/packages/04/83/45fbb6d962e260807f99441db9613cee868ceda4baceda59b3720a563f97/orjson-3.11.9-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9f78cf8fec5bd627f4082b8dfeac7871b43d7f3274904492a43dab39f18a19a0", size = 136915, upload-time = "2026-05-06T15:10:38.013Z" }, + { url = "https://files.pythonhosted.org/packages/5f/cc/2d10025f9056d376e4127ec05a5808b218d46f035fdc08178a5411b34250/orjson-3.11.9-cp313-cp313-win32.whl", hash = "sha256:d4087e5c0209a0a8efe4de3303c234b9c44d1174161dcd851e8eea07c7560b32", size = 131613, upload-time = "2026-05-06T15:10:39.569Z" }, + { url = "https://files.pythonhosted.org/packages/67/bd/2775ff28bfe883b9aa1ff348300542eb2ef1ee18d8ae0e3a49846817a865/orjson-3.11.9-cp313-cp313-win_amd64.whl", hash = "sha256:051b102c93b4f634e89f3866b07b9a9a98915ada541f4ec30f177067b2694979", size = 127086, upload-time = "2026-05-06T15:10:41.262Z" }, + { url = "https://files.pythonhosted.org/packages/91/2b/d26799e580939e32a7da9a39531bc9e58e15ca32ffaa6a8cb3e9bb0d22cd/orjson-3.11.9-cp313-cp313-win_arm64.whl", hash = "sha256:cce9127885941bd28f080cecf1f1d288336b7e0d812c345b08be88b572796254", size = 126696, upload-time = "2026-05-06T15:10:42.651Z" }, + { url = "https://files.pythonhosted.org/packages/8e/eb/5da01e356015aee6ecfa1187ced87aef51364e306f5e695dd52719bf0e78/orjson-3.11.9-cp314-cp314-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:b6ef1979adc4bc243523f1a2ba91418030a8e29b0a99cbe7e0e2d6807d4dce6e", size = 228465, upload-time = "2026-05-06T15:10:44.097Z" }, + { url = "https://files.pythonhosted.org/packages/64/62/3e0e0c14c957133bcd855395c62b55ed4e3b0af23ffea11b032cb1dcbdb1/orjson-3.11.9-cp314-cp314-macosx_15_0_arm64.whl", hash = "sha256:f36b7f32c7c0db4a719f1fc5824db4a9c6f8bd1a354debb91faf26ebf3a4c71e", size = 128364, upload-time = "2026-05-06T15:10:45.839Z" }, + { url = "https://files.pythonhosted.org/packages/5a/5a/07d8aa117211a8ed7630bda80c8c0b14d04e0f8dcf99bcf49656e4a710eb/orjson-3.11.9-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:08f4d8ebb44925c794e535b2bebc507cebf32209df81de22ae285fb0d8d66de0", size = 132063, upload-time = "2026-05-06T15:10:47.267Z" }, + { url = "https://files.pythonhosted.org/packages/d6/ec/4acaf21483e18aa945be74a474c74b434f284b549f275a0a39b9f98956e9/orjson-3.11.9-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6cc7923789694fd58f001cbcac7e47abc13af4d560ebbfcf3b41a8b1a0748124", size = 122356, upload-time = "2026-05-06T15:10:48.765Z" }, + { url = "https://files.pythonhosted.org/packages/13/d8/5f0555e7638801323b7a75850f92e7dfa891bc84fe27a1ba4449170d1200/orjson-3.11.9-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ea5c46eb2d3af39e806b986f4b09d5c2706a1f5afde3cbf7544ce6616127173c", size = 129592, upload-time = "2026-05-06T15:10:50.13Z" }, + { url = "https://files.pythonhosted.org/packages/b6/30/ed9860412a3603ceb3c5955bfd72d28b9d0e7ba6ed81add14f83d7114236/orjson-3.11.9-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f5d89a2ed90731df3be64bab0aa44f78bff39fdc9d71c291f4a8023aa46425b7", size = 140491, upload-time = "2026-05-06T15:10:51.582Z" }, + { url = "https://files.pythonhosted.org/packages/d0/17/adc514dea7ac7c505527febf884934b815d34f0c7b8693c1a8b39c5c4a57/orjson-3.11.9-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:25e4aed0312d292c09f61af25bba34e0b2c88546041472b09088c39a4d828af1", size = 127309, upload-time = "2026-05-06T15:10:53.329Z" }, + { url = "https://files.pythonhosted.org/packages/76/3e/c0b690253f0b82d86e99949af13533363acfb5432ecb5d53dd5b3bce9c34/orjson-3.11.9-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:aaea64f3f467d22e70eeed68bdccb3bc4f83f650446c4a03c59f2cba28a108db", size = 134030, upload-time = "2026-05-06T15:10:54.988Z" }, + { url = "https://files.pythonhosted.org/packages/c1/7a/bc82a0bb25e9faaf92dc4d9ef002732efc09737706af83e346788641d4a7/orjson-3.11.9-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a028425d1b440c5d92a6be1e1a020739dfe67ea87d96c6dbe828c1b30041728b", size = 141482, upload-time = "2026-05-06T15:10:56.663Z" }, + { url = "https://files.pythonhosted.org/packages/01/55/e69188b939f77d5d32a9833745ace31ea5ccae3ab613a1ec185d3cd2c4fb/orjson-3.11.9-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:5b192c6cf397e4455b11523c5cf2b18ed084c1bbd61b6c0926344d2129481972", size = 415178, upload-time = "2026-05-06T15:10:58.446Z" }, + { url = "https://files.pythonhosted.org/packages/2e/1a/b8a5a7ac527e80b9cb11d51e3f6689b709279183264b9ec5c7bc680bb8b5/orjson-3.11.9-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:ea407d4ccf5891d667d045fecae97a7a1e5e87b3b97f97ae1803c2e741130be0", size = 148089, upload-time = "2026-05-06T15:11:00.441Z" }, + { url = "https://files.pythonhosted.org/packages/97/4e/00503f64204bf859b37213a63927028f30fb6268cd8677fb0a5ad48155e1/orjson-3.11.9-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5f63aaf97afd9f6dec5b1a68e1b8da12bfccb4cb9a9a65c3e0b6c847849e7586", size = 136921, upload-time = "2026-05-06T15:11:02.176Z" }, + { url = "https://files.pythonhosted.org/packages/0d/ba/a23b82a0a8d0ed7bed4e5f5035aae751cad4ff6a1e8d2ecd14d8860f5929/orjson-3.11.9-cp314-cp314-win32.whl", hash = "sha256:e30ab17845bb9fa54ccf67fa4f9f5282652d54faa6d17452f47d0f369d038673", size = 131638, upload-time = "2026-05-06T15:11:03.696Z" }, + { url = "https://files.pythonhosted.org/packages/f3/c3/0c6798456bade745c75c452342dabacce5798196483e77e643be1f53877d/orjson-3.11.9-cp314-cp314-win_amd64.whl", hash = "sha256:32ef5f4283a3be81913947d19608eacb7c6608026851123790cd9cc8982af34b", size = 127078, upload-time = "2026-05-06T15:11:05.123Z" }, + { url = "https://files.pythonhosted.org/packages/16/21/5a3f1e8913103b703a436a5664238e5b965ec392b555fe68943ea3691e6b/orjson-3.11.9-cp314-cp314-win_arm64.whl", hash = "sha256:eebdbdeef0094e4f5aefa20dcd4eb2368ab5e7a3b4edea27f1e7b2892e009cf9", size = 126687, upload-time = "2026-05-06T15:11:06.602Z" }, ] [[package]] @@ -785,60 +792,60 @@ version = "26.2" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" }, + { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" }, ] [[package]] name = "pandas" -version = "3.0.2" +version = "3.0.3" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "numpy" }, - { name = "python-dateutil" }, - { name = "tzdata", marker = "sys_platform == 'emscripten' or sys_platform == 'win32'" }, + { name = "numpy" }, + { name = "python-dateutil" }, + { name = "tzdata", marker = "sys_platform == 'emscripten' or sys_platform == 'win32'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/da/99/b342345300f13440fe9fe385c3c481e2d9a595ee3bab4d3219247ac94e9a/pandas-3.0.2.tar.gz", hash = "sha256:f4753e73e34c8d83221ba58f232433fca2748be8b18dbca02d242ed153945043", size = 4645855, upload-time = "2026-03-31T06:48:30.816Z" } +sdist = { url = "https://files.pythonhosted.org/packages/f8/87/4341c6252d1c47b08768c3d25ac487362bf403f0313ddae4a2a26c9b1b4c/pandas-3.0.3.tar.gz", hash = "sha256:696a4a00a2a2a35d4e5deb3fc946641b96c944f02230e4f76137fe35d806c4fc", size = 4651414, upload-time = "2026-05-11T18:54:29.21Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/bf/ca/3e639a1ea6fcd0617ca4e8ca45f62a74de33a56ae6cd552735470b22c8d3/pandas-3.0.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:b5918ba197c951dec132b0c5929a00c0bf05d5942f590d3c10a807f6e15a57d3", size = 10321105, upload-time = "2026-03-31T06:46:57.327Z" }, - { url = "https://files.pythonhosted.org/packages/0b/77/dbc82ff2fb0e63c6564356682bf201edff0ba16c98630d21a1fb312a8182/pandas-3.0.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d606a041c89c0a474a4702d532ab7e73a14fe35c8d427b972a625c8e46373668", size = 9864088, upload-time = "2026-03-31T06:46:59.935Z" }, - { url = "https://files.pythonhosted.org/packages/5c/2b/341f1b04bbca2e17e13cd3f08c215b70ef2c60c5356ef1e8c6857449edc7/pandas-3.0.2-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:710246ba0616e86891b58ab95f2495143bb2bc83ab6b06747c74216f583a6ac9", size = 10369066, upload-time = "2026-03-31T06:47:02.792Z" }, - { url = "https://files.pythonhosted.org/packages/12/c5/cbb1ffefb20a93d3f0e1fdcda699fb84976210d411b008f97f48bf6ce27e/pandas-3.0.2-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5d3cfe227c725b1f3dff4278b43d8c784656a42a9325b63af6b1492a8232209e", size = 10876780, upload-time = "2026-03-31T06:47:06.205Z" }, - { url = "https://files.pythonhosted.org/packages/98/fe/2249ae5e0a69bd0ddf17353d0a5d26611d70970111f5b3600cdc8be883e7/pandas-3.0.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:c3b723df9087a9a9a840e263ebd9f88b64a12075d1bf2ea401a5a42f254f084d", size = 11375181, upload-time = "2026-03-31T06:47:09.383Z" }, - { url = "https://files.pythonhosted.org/packages/de/64/77a38b09e70b6464883b8d7584ab543e748e42c1b5d337a2ee088e0df741/pandas-3.0.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a3096110bf9eac0070b7208465f2740e2d8a670d5cb6530b5bb884eca495fd39", size = 11928899, upload-time = "2026-03-31T06:47:12.686Z" }, - { url = "https://files.pythonhosted.org/packages/5e/52/42855bf626868413f761addd574acc6195880ae247a5346477a4361c3acb/pandas-3.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:07a10f5c36512eead51bc578eb3354ad17578b22c013d89a796ab5eee90cd991", size = 9746574, upload-time = "2026-03-31T06:47:15.64Z" }, - { url = "https://files.pythonhosted.org/packages/88/39/21304ae06a25e8bf9fc820d69b29b2c495b2ae580d1e143146c309941760/pandas-3.0.2-cp313-cp313-win_arm64.whl", hash = "sha256:5fdbfa05931071aba28b408e59226186b01eb5e92bea2ab78b65863ca3228d84", size = 9047156, upload-time = "2026-03-31T06:47:18.595Z" }, - { url = "https://files.pythonhosted.org/packages/72/20/7defa8b27d4f330a903bb68eea33be07d839c5ea6bdda54174efcec0e1d2/pandas-3.0.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:dbc20dea3b9e27d0e66d74c42b2d0c1bed9c2ffe92adea33633e3bedeb5ac235", size = 10756238, upload-time = "2026-03-31T06:47:22.012Z" }, - { url = "https://files.pythonhosted.org/packages/e9/95/49433c14862c636afc0e9b2db83ff16b3ad92959364e52b2955e44c8e94c/pandas-3.0.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:b75c347eff42497452116ce05ef461822d97ce5b9ff8df6edacb8076092c855d", size = 10408520, upload-time = "2026-03-31T06:47:25.197Z" }, - { url = "https://files.pythonhosted.org/packages/3b/f8/462ad2b5881d6b8ec8e5f7ed2ea1893faa02290d13870a1600fe72ad8efc/pandas-3.0.2-cp313-cp313t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d1478075142e83a5571782ad007fb201ed074bdeac7ebcc8890c71442e96adf7", size = 10324154, upload-time = "2026-03-31T06:47:28.097Z" }, - { url = "https://files.pythonhosted.org/packages/0a/65/d1e69b649cbcddda23ad6e4c40ef935340f6f652a006e5cbc3555ac8adb3/pandas-3.0.2-cp313-cp313t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5880314e69e763d4c8b27937090de570f1fb8d027059a7ada3f7f8e98bdcb677", size = 10714449, upload-time = "2026-03-31T06:47:30.85Z" }, - { url = "https://files.pythonhosted.org/packages/47/a4/85b59bc65b8190ea3689882db6cdf32a5003c0ccd5a586c30fdcc3ffc4fc/pandas-3.0.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:b5329e26898896f06035241a626d7c335daa479b9bbc82be7c2742d048e41172", size = 11338475, upload-time = "2026-03-31T06:47:34.026Z" }, - { url = "https://files.pythonhosted.org/packages/1e/c4/bc6966c6e38e5d9478b935272d124d80a589511ed1612a5d21d36f664c68/pandas-3.0.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:81526c4afd31971f8b62671442a4b2b51e0aa9acc3819c9f0f12a28b6fcf85f1", size = 11786568, upload-time = "2026-03-31T06:47:36.941Z" }, - { url = "https://files.pythonhosted.org/packages/e8/74/09298ca9740beed1d3504e073d67e128aa07e5ca5ca2824b0c674c0b8676/pandas-3.0.2-cp313-cp313t-win_amd64.whl", hash = "sha256:7cadd7e9a44ec13b621aec60f9150e744cfc7a3dd32924a7e2f45edff31823b0", size = 10488652, upload-time = "2026-03-31T06:47:40.612Z" }, - { url = "https://files.pythonhosted.org/packages/bb/40/c6ea527147c73b24fc15c891c3fcffe9c019793119c5742b8784a062c7db/pandas-3.0.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:db0dbfd2a6cdf3770aa60464d50333d8f3d9165b2f2671bcc299b72de5a6677b", size = 10326084, upload-time = "2026-03-31T06:47:43.834Z" }, - { url = "https://files.pythonhosted.org/packages/95/25/bdb9326c3b5455f8d4d3549fce7abcf967259de146fe2cf7a82368141948/pandas-3.0.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0555c5882688a39317179ab4a0ed41d3ebc8812ab14c69364bbee8fb7a3f6288", size = 9914146, upload-time = "2026-03-31T06:47:46.67Z" }, - { url = "https://files.pythonhosted.org/packages/8d/77/3a227ff3337aa376c60d288e1d61c5d097131d0ac71f954d90a8f369e422/pandas-3.0.2-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:01f31a546acd5574ef77fe199bc90b55527c225c20ccda6601cf6b0fd5ed597c", size = 10444081, upload-time = "2026-03-31T06:47:49.681Z" }, - { url = "https://files.pythonhosted.org/packages/15/88/3cdd54fa279341afa10acf8d2b503556b1375245dccc9315659f795dd2e9/pandas-3.0.2-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:deeca1b5a931fdf0c2212c8a659ade6d3b1edc21f0914ce71ef24456ca7a6535", size = 10897535, upload-time = "2026-03-31T06:47:53.033Z" }, - { url = "https://files.pythonhosted.org/packages/06/9d/98cc7a7624f7932e40f434299260e2917b090a579d75937cb8a57b9d2de3/pandas-3.0.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:0f48afd9bb13300ffb5a3316973324c787054ba6665cda0da3fbd67f451995db", size = 11446992, upload-time = "2026-03-31T06:47:56.193Z" }, - { url = "https://files.pythonhosted.org/packages/9a/cd/19ff605cc3760e80602e6826ddef2824d8e7050ed80f2e11c4b079741dc3/pandas-3.0.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:6c4d8458b97a35717b62469a4ea0e85abd5ed8687277f5ccfc67f8a5126f8c53", size = 11968257, upload-time = "2026-03-31T06:47:59.137Z" }, - { url = "https://files.pythonhosted.org/packages/db/60/aba6a38de456e7341285102bede27514795c1eaa353bc0e7638b6b785356/pandas-3.0.2-cp314-cp314-win_amd64.whl", hash = "sha256:b35d14bb5d8285d9494fe93815a9e9307c0876e10f1e8e89ac5b88f728ec8dcf", size = 9865893, upload-time = "2026-03-31T06:48:02.038Z" }, - { url = "https://files.pythonhosted.org/packages/08/71/e5ec979dd2e8a093dacb8864598c0ff59a0cee0bbcdc0bfec16a51684d4f/pandas-3.0.2-cp314-cp314-win_arm64.whl", hash = "sha256:63d141b56ef686f7f0d714cfb8de4e320475b86bf4b620aa0b7da89af8cbdbbb", size = 9188644, upload-time = "2026-03-31T06:48:05.045Z" }, - { url = "https://files.pythonhosted.org/packages/f1/6c/7b45d85db19cae1eb524f2418ceaa9d85965dcf7b764ed151386b7c540f0/pandas-3.0.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:140f0cffb1fa2524e874dde5b477d9defe10780d8e9e220d259b2c0874c89d9d", size = 10776246, upload-time = "2026-03-31T06:48:07.789Z" }, - { url = "https://files.pythonhosted.org/packages/a8/3e/7b00648b086c106e81766f25322b48aa8dfa95b55e621dbdf2fdd413a117/pandas-3.0.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ae37e833ff4fed0ba352f6bdd8b73ba3ab3256a85e54edfd1ab51ae40cca0af8", size = 10424801, upload-time = "2026-03-31T06:48:10.897Z" }, - { url = "https://files.pythonhosted.org/packages/da/6e/558dd09a71b53b4008e7fc8a98ec6d447e9bfb63cdaeea10e5eb9b2dabe8/pandas-3.0.2-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4d888a5c678a419a5bb41a2a93818e8ed9fd3172246555c0b37b7cc27027effd", size = 10345643, upload-time = "2026-03-31T06:48:13.7Z" }, - { url = "https://files.pythonhosted.org/packages/be/e3/921c93b4d9a280409451dc8d07b062b503bbec0531d2627e73a756e99a82/pandas-3.0.2-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b444dc64c079e84df91baa8bf613d58405645461cabca929d9178f2cd392398d", size = 10743641, upload-time = "2026-03-31T06:48:16.659Z" }, - { url = "https://files.pythonhosted.org/packages/56/ca/fd17286f24fa3b4d067965d8d5d7e14fe557dd4f979a0b068ac0deaf8228/pandas-3.0.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:4544c7a54920de8eeacaa1466a6b7268ecfbc9bc64ab4dbb89c6bbe94d5e0660", size = 11361993, upload-time = "2026-03-31T06:48:19.475Z" }, - { url = "https://files.pythonhosted.org/packages/e4/a5/2f6ed612056819de445a433ca1f2821ac3dab7f150d569a59e9cc105de1d/pandas-3.0.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:734be7551687c00fbd760dc0522ed974f82ad230d4a10f54bf51b80d44a08702", size = 11815274, upload-time = "2026-03-31T06:48:22.695Z" }, - { url = "https://files.pythonhosted.org/packages/00/2f/b622683e99ec3ce00b0854bac9e80868592c5b051733f2cf3a868e5fea26/pandas-3.0.2-cp314-cp314t-win_amd64.whl", hash = "sha256:57a07209bebcbcf768d2d13c9b78b852f9a15978dac41b9e6421a81ad4cdd276", size = 10888530, upload-time = "2026-03-31T06:48:25.806Z" }, - { url = "https://files.pythonhosted.org/packages/cb/2b/f8434233fab2bd66a02ec014febe4e5adced20e2693e0e90a07d118ed30e/pandas-3.0.2-cp314-cp314t-win_arm64.whl", hash = "sha256:5371b72c2d4d415d08765f32d689217a43227484e81b2305b52076e328f6f482", size = 9455341, upload-time = "2026-03-31T06:48:28.418Z" }, + { url = "https://files.pythonhosted.org/packages/c5/90/62d8302883c44308c477e222c3daf7c813a34c8e96985882fbd53d964352/pandas-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:67b3b64c11910cfa29f4e94a14d3bff9ee693b6fc76055e7cad549cee0aec5fa", size = 10331071, upload-time = "2026-05-11T18:52:58.838Z" }, + { url = "https://files.pythonhosted.org/packages/7f/ae/6a6493c783a101f165e4356953ba3c74d6f77f0042fa7d753da9dfbb640c/pandas-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:39436b377d56d2a2e52d0395bdbee171f01068e99af5250509aceeb929f765c7", size = 9875690, upload-time = "2026-05-11T18:53:01.431Z" }, + { url = "https://files.pythonhosted.org/packages/62/7c/5df8e9f56c69a2769fbe9382a5ef8f2658c007e376434e1e2cbb57ad895f/pandas-3.0.3-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d4be06d68f9ddcfc645b87534911da79a8fbffc7573c80e0edcf42a5020624d8", size = 10381634, upload-time = "2026-05-11T18:53:04.393Z" }, + { url = "https://files.pythonhosted.org/packages/99/68/1237369725aa617bb358263d535803e3053fdbc593513ec5ed9c9896b5b6/pandas-3.0.3-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a4eeb6830daf35a71cc09649bd823e2b542dac246cdee9614c6e4bd65028cd6a", size = 10891243, upload-time = "2026-05-11T18:53:07.643Z" }, + { url = "https://files.pythonhosted.org/packages/25/93/77d108e8af7222b4a503ebde0e30215b1c2e4f8e53a526431890f22d5586/pandas-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:1928e07221f82db493cd4af1e23c1bfca524a19a4699887975bff68f49a72bfb", size = 11388659, upload-time = "2026-05-11T18:53:10.634Z" }, + { url = "https://files.pythonhosted.org/packages/d0/bd/eff5b4399f332ac386c853f6cd2bd3fa2ca0061b9f36ecd9c4d7c4265649/pandas-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:51b1fe551acb77dac643c6fda86084d8d446c10fe64b06a9cc29c4cc8540e7f2", size = 11942880, upload-time = "2026-05-11T18:53:13.536Z" }, + { url = "https://files.pythonhosted.org/packages/2c/20/559ace4200982c3887d0b86bfd0d856a2143ef8ddab63cc07934951a964c/pandas-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:a82d532a3351d435432cd913edbccaf8b8e01d4dd0e5ced5a8d2e8ecd94c7e44", size = 9757091, upload-time = "2026-05-11T18:53:16.306Z" }, + { url = "https://files.pythonhosted.org/packages/3a/66/69055a09fe200f29f922a3eeec4804611900b95f52d932ece3393c3c0c19/pandas-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:275c14e0fce14a2ec20eee474aecd305478ea3c1e6f6a9d8fe219a165542717e", size = 9057282, upload-time = "2026-05-11T18:53:18.768Z" }, + { url = "https://files.pythonhosted.org/packages/57/0e/efe801b0e6811e8e650cd21b7f2608e30f08a7067e2bf6e8752b0d56ee3c/pandas-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:46997386d528eb40376ecd6b033cf4a8a1e5282580f68f43de875b78cba2199d", size = 10767016, upload-time = "2026-05-11T18:53:21.227Z" }, + { url = "https://files.pythonhosted.org/packages/ea/dc/eb55135a1d5f0f0519f28da1f609a206d2cad1f9c35c32d51e38dd7261ae/pandas-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:261e308dfb22448384b7580cf719d2f998fe2966c92893c3e77d14008af1f066", size = 10420210, upload-time = "2026-05-11T18:53:23.982Z" }, + { url = "https://files.pythonhosted.org/packages/c6/3e/b1d5d955ce33ffecb407465a60bc32769d74fcf68224b7ae67ae11d4dea4/pandas-3.0.3-cp313-cp313t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dd1a5d1def6a46002e964510bdc67c368aa0951df5d1d9f8365336f5a1f490cd", size = 10336126, upload-time = "2026-05-11T18:53:26.731Z" }, + { url = "https://files.pythonhosted.org/packages/f5/76/a01261711ab60a22d71b862f0de20e4c504bf80457270ad8cb42110f6abc/pandas-3.0.3-cp313-cp313t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d72828c20c6d6e83e1e22a6a3b47b326b71664112fa9705dcbccfd7a39b62085", size = 10728051, upload-time = "2026-05-11T18:53:29.125Z" }, + { url = "https://files.pythonhosted.org/packages/e9/21/ea191195e587b18cf682e97f433f81b2d0fbe341380e80a3e0d6e4403c8e/pandas-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:d26cbe1fcfc12e8fd900e2454163e466b2d3af84f7c75481df7683ffc073d870", size = 11350796, upload-time = "2026-05-11T18:53:32.056Z" }, + { url = "https://files.pythonhosted.org/packages/64/69/f0eaaf54939f0e8c6768fd06be9af2cef9b36048b96dfb9e1b2c685a807e/pandas-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:3e91cec1879ada0624fc3dc9953c5cbd60208e59c0db28f540c5d6d47502422f", size = 11799741, upload-time = "2026-05-11T18:53:34.985Z" }, + { url = "https://files.pythonhosted.org/packages/45/a4/865e0e510cae5fc2194de4db28be638952de942571ba9125934fd9c01d47/pandas-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:08d789b41f87e0905880e293cedf6197ce71fe67cc081358b1e148a491b9bd13", size = 10499958, upload-time = "2026-05-11T18:53:37.857Z" }, + { url = "https://files.pythonhosted.org/packages/86/54/effdcc3c0ff7a08037889200e148ebe94c16c4f653be078c7b3675955df1/pandas-3.0.3-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:3650109c0f22879df8bd6179ab9ee3d7f1d1d4e7e0094a3f0032d9f51e2e64ac", size = 10336065, upload-time = "2026-05-11T18:53:41.099Z" }, + { url = "https://files.pythonhosted.org/packages/68/10/bf2d6738d72748b961a3751ab89522d58c54efc36a8e1a12161216cd45cf/pandas-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:bab900348131a7db1f69a7309ef141fd5680f1487094193bcbbb61791573bf8f", size = 9926101, upload-time = "2026-05-11T18:53:43.515Z" }, + { url = "https://files.pythonhosted.org/packages/ae/e9/e35cf11c8a136e757b956f5f0efdcaa50aecde85ea055f1898dfc68262f3/pandas-3.0.3-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ba7e08b9ac1d54569cd1e256e3668975ed624d6826f7b68df0342b012007bddb", size = 10457553, upload-time = "2026-05-11T18:53:46.394Z" }, + { url = "https://files.pythonhosted.org/packages/58/3b/1cdec6772bdbaf7b25dab360c59f03cadf05492dd724c6540af905389b07/pandas-3.0.3-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d71c63ae4ebdbf70209742096f1fc46a83a0613c99d4b23766cced9ff8cd62a", size = 10914065, upload-time = "2026-05-11T18:53:49.134Z" }, + { url = "https://files.pythonhosted.org/packages/c4/c2/1ef644445fcd72e3627bceec77e3560636f87ddce4ed841afe76b83b5bf9/pandas-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e3a2ec42c98ffa2565a67e08e218d06d72576d758d90facb7c00805194d8f360", size = 11459188, upload-time = "2026-05-11T18:53:52.527Z" }, + { url = "https://files.pythonhosted.org/packages/7e/49/4d8d4f42cbc9c4adc7a1870f269c02cbd6cd40d059622c06fb298addcbad/pandas-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:335f62418ed562cfc3c49e9e196375c28b729dcef8543abf4f9438e381bf3c76", size = 11982966, upload-time = "2026-05-11T18:53:55.043Z" }, + { url = "https://files.pythonhosted.org/packages/38/55/792619469bab9882d8bbd5865d45a72f6478762d04a9af4bf0d08c503e95/pandas-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:3c20a521bbb85902f79f7270c80a59e1b5452d96d170c034f207181870f97ac5", size = 9876755, upload-time = "2026-05-11T18:53:58.067Z" }, + { url = "https://files.pythonhosted.org/packages/2a/af/33c469653b0ba03b50c3a98192d4c07f0c75c66b263ceb097fce0ee97d31/pandas-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:a2d2dff8a04f3917b55ab3910c32990f8ddf7eceba114947838cefa976a68977", size = 9198658, upload-time = "2026-05-11T18:54:00.733Z" }, + { url = "https://files.pythonhosted.org/packages/a2/fa/b8c257bd76b8bd060c3a9151c1fca05e9b9c5e3af5d0f549c0356f6d143d/pandas-3.0.3-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:0d589105b3c14645af1738ff279b2995102d8f7a03b0a66dc8d95550eb513e04", size = 10787242, upload-time = "2026-05-11T18:54:03.564Z" }, + { url = "https://files.pythonhosted.org/packages/54/eb/f19206ffb0bf1919002969aa448b4702c6594845156a6f8050674855aac3/pandas-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:13fc1e853d9e04743d11ba75a985ccbc2a317fe07d8af61e445a6fd24dacd6a6", size = 10436369, upload-time = "2026-05-11T18:54:06.311Z" }, + { url = "https://files.pythonhosted.org/packages/fd/24/c7c39fb4fe22b71a0c2d78bf0c585c600092d85f94f086d2b3b2f6ca27e2/pandas-3.0.3-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:819959dab7bbd0049c15623fbac4e29a191b9528160a61fb1032242d8ced2d9c", size = 10358306, upload-time = "2026-05-11T18:54:09.085Z" }, + { url = "https://files.pythonhosted.org/packages/16/ec/dd2a9eb7fa1204df88c0864164e35b228ac581062ac612ba0a67fd812e4c/pandas-3.0.3-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:60ae316d3fd75d1858d450d0db0103ea2be3e7d4a95ec2f064f7e2ae63f7b028", size = 10758394, upload-time = "2026-05-11T18:54:11.956Z" }, + { url = "https://files.pythonhosted.org/packages/95/6e/00c61ea8e85b4f6d8d35e11852a1a4998fc7fafc91c6a602d1cc9c972d64/pandas-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bd3a518890b400d32f9023722dc9a9a5c969f00b415419a3c06c043f09bb5d7d", size = 11375717, upload-time = "2026-05-11T18:54:14.539Z" }, + { url = "https://files.pythonhosted.org/packages/31/89/8fc1c268969fac43688d65fd92e67df24bd128d53cb4d2eee534cd307399/pandas-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:9c39be2d709d01fa972a0cabc522389fceca4f3969332ba25a7d6c5802cf976a", size = 11828897, upload-time = "2026-05-11T18:54:17.146Z" }, + { url = "https://files.pythonhosted.org/packages/56/3b/e7d20dea247a3e6dc0bd8a6953854afbedc03951def4e7371e05e7263e25/pandas-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4db8c527972a821cf5286b40ccc57642a39bc62e62022b42f99f8a67fca8c3a1", size = 10900855, upload-time = "2026-05-11T18:54:19.72Z" }, + { url = "https://files.pythonhosted.org/packages/0f/54/68a0978d1ef8502b8492099beaa6e7a0c1b32e3b5d4f677f5810cb08711c/pandas-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:b2c95f8bfc1ee412bf482605d7bfd30c12d1d26bd59fdd91efeef1d4718decb1", size = 9466464, upload-time = "2026-05-11T18:54:22.754Z" }, ] [[package]] name = "platformdirs" -version = "4.9.6" +version = "4.10.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/9f/4a/0883b8e3802965322523f0b200ecf33d31f10991d0401162f4b23c698b42/platformdirs-4.9.6.tar.gz", hash = "sha256:3bfa75b0ad0db84096ae777218481852c0ebc6c727b3168c1b9e0118e458cf0a", size = 29400, upload-time = "2026-04-09T00:04:10.812Z" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/47/e4501f49c178ae1d9f4a75073fda4204f52647993f075a9db4d14930e0c5/platformdirs-4.10.0.tar.gz", hash = "sha256:31e761a6a0ca04faf7353ea759bdba55652be214725111e5aac52dfa29d4bef7", size = 31224, upload-time = "2026-05-28T03:32:53.587Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/75/a6/a0a304dc33b49145b21f4808d763822111e67d1c3a32b524a1baf947b6e1/platformdirs-4.9.6-py3-none-any.whl", hash = "sha256:e61adb1d5e5cb3441b4b7710bea7e4c12250ca49439228cc1021c00dcfac0917", size = 21348, upload-time = "2026-04-09T00:04:09.463Z" }, + { url = "https://files.pythonhosted.org/packages/81/e6/cd9575ac904136b3cbf7aa7ee819ef86eedb7274e46f230e94ea4342e729/platformdirs-4.10.0-py3-none-any.whl", hash = "sha256:fb516cdb12eb0d857d0cd85a7c57cea4d060bee4578d6cf5a14dfdf8cbf8784a", size = 22743, upload-time = "2026-05-28T03:32:52.175Z" }, ] [[package]] @@ -847,22 +854,22 @@ version = "1.6.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, ] [[package]] name = "posthog" -version = "7.15.3" +version = "7.16.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "backoff" }, - { name = "distro" }, - { name = "requests" }, - { name = "typing-extensions" }, + { name = "backoff" }, + { name = "distro" }, + { name = "requests" }, + { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/c5/ad/0eedae8cc9d2878d5b52c8607bd21f76101cfe4d875e5ff77fec9da3a83c/posthog-7.15.3.tar.gz", hash = "sha256:809dcaf08ca2d8bc0ea8228c28419181b74a79dfd1c0687a3d459a7bbe2e2953", size = 217645, upload-time = "2026-05-21T15:35:04.914Z" } +sdist = { url = "https://files.pythonhosted.org/packages/b4/4f/a954175c862a3565d02c3f627874d85f18313472a0c4b08f45d84aaf3315/posthog-7.16.1.tar.gz", hash = "sha256:3619d3c619ad01f36c6d465e084950882417c63021eb3cfacacb23f900ec52d4", size = 226343, upload-time = "2026-05-27T18:46:20.129Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ad/b4/8dc673bed0f296c1acbb1107aef1c56db576731e894fe765206be5a91774/posthog-7.15.3-py3-none-any.whl", hash = "sha256:fd59fe4f5be637e4a2706b1457301d8308853ff23659036ecfcf6ac0a2d45eee", size = 254591, upload-time = "2026-05-21T15:35:02.846Z" }, + { url = "https://files.pythonhosted.org/packages/3e/28/0f840699a1d0db3c1e5483c6208f0804a51f21ccfa34e6aa356161606adc/posthog-7.16.1-py3-none-any.whl", hash = "sha256:fd5aa4510033f3b039fda2fbfce45f493d140d4782f681e69639793dda317d67", size = 264231, upload-time = "2026-05-27T18:46:17.933Z" }, ] [[package]] @@ -870,132 +877,132 @@ name = "pre-commit" version = "4.6.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "cfgv" }, - { name = "identify" }, - { name = "nodeenv" }, - { name = "pyyaml" }, - { name = "virtualenv" }, + { name = "cfgv" }, + { name = "identify" }, + { name = "nodeenv" }, + { name = "pyyaml" }, + { name = "virtualenv" }, ] sdist = { url = "https://files.pythonhosted.org/packages/8e/22/2de9408ac81acbb8a7d05d4cc064a152ccf33b3d480ebe0cd292153db239/pre_commit-4.6.0.tar.gz", hash = "sha256:718d2208cef53fdc38206e40524a6d4d9576d103eb16f0fec11c875e7716e9d9", size = 198525, upload-time = "2026-04-21T20:31:41.613Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/80/6e/4b28b62ecb6aae56769c34a8ff1d661473ec1e9519e2d5f8b2c150086b26/pre_commit-4.6.0-py2.py3-none-any.whl", hash = "sha256:e2cf246f7299edcabcf15f9b0571fdce06058527f0a06535068a86d38089f29b", size = 226472, upload-time = "2026-04-21T20:31:40.092Z" }, + { url = "https://files.pythonhosted.org/packages/80/6e/4b28b62ecb6aae56769c34a8ff1d661473ec1e9519e2d5f8b2c150086b26/pre_commit-4.6.0-py2.py3-none-any.whl", hash = "sha256:e2cf246f7299edcabcf15f9b0571fdce06058527f0a06535068a86d38089f29b", size = 226472, upload-time = "2026-04-21T20:31:40.092Z" }, ] [[package]] name = "psycopg" -version = "3.3.3" +version = "3.3.4" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "tzdata", marker = "sys_platform == 'win32'" }, + { name = "tzdata", marker = "sys_platform == 'win32'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/d3/b6/379d0a960f8f435ec78720462fd94c4863e7a31237cf81bf76d0af5883bf/psycopg-3.3.3.tar.gz", hash = "sha256:5e9a47458b3c1583326513b2556a2a9473a1001a56c9efe9e587245b43148dd9", size = 165624, upload-time = "2026-02-18T16:52:16.546Z" } +sdist = { url = "https://files.pythonhosted.org/packages/db/2f/cb91e5502ec9de1de6f1b76cfbf69531932725361168bb06963620c77e2e/psycopg-3.3.4.tar.gz", hash = "sha256:e21207764952cff81b6b8bdacad9a3939f2793367fdac2987b3aac36a651b5bc", size = 165799, upload-time = "2026-05-01T23:31:55.179Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c8/5b/181e2e3becb7672b502f0ed7f16ed7352aca7c109cfb94cf3878a9186db9/psycopg-3.3.3-py3-none-any.whl", hash = "sha256:f96525a72bcfade6584ab17e89de415ff360748c766f0106959144dcbb38c698", size = 212768, upload-time = "2026-02-18T16:46:27.365Z" }, + { url = "https://files.pythonhosted.org/packages/5c/e0/7b3dee031daae7743609ce3c746565d4a3ed7c2c186479eb48e34e838c64/psycopg-3.3.4-py3-none-any.whl", hash = "sha256:b6bbc25ccf05c8fad3b061d9db2ef0909a555171b84b07f29458a447253d679a", size = 213001, upload-time = "2026-05-01T23:20:50.816Z" }, ] [package.optional-dependencies] binary = [ - { name = "psycopg-binary", marker = "implementation_name != 'pypy'" }, + { name = "psycopg-binary", marker = "implementation_name != 'pypy'" }, ] [[package]] name = "psycopg-binary" -version = "3.3.3" +version = "3.3.4" source = { registry = "https://pypi.org/simple" } wheels = [ - { url = "https://files.pythonhosted.org/packages/03/0a/cac9fdf1df16a269ba0e5f0f06cac61f826c94cadb39df028cdfe19d3a33/psycopg_binary-3.3.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:05f32239aec25c5fb15f7948cffdc2dc0dac098e48b80a140e4ba32b572a2e7d", size = 4590414, upload-time = "2026-02-18T16:50:01.441Z" }, - { url = "https://files.pythonhosted.org/packages/9c/c0/d8f8508fbf440edbc0099b1abff33003cd80c9e66eb3a1e78834e3fb4fb9/psycopg_binary-3.3.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7c84f9d214f2d1de2fafebc17fa68ac3f6561a59e291553dfc45ad299f4898c1", size = 4669021, upload-time = "2026-02-18T16:50:08.803Z" }, - { url = "https://files.pythonhosted.org/packages/04/05/097016b77e343b4568feddf12c72171fc513acef9a4214d21b9478569068/psycopg_binary-3.3.3-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:e77957d2ba17cada11be09a5066d93026cdb61ada7c8893101d7fe1c6e1f3925", size = 5467453, upload-time = "2026-02-18T16:50:14.985Z" }, - { url = "https://files.pythonhosted.org/packages/91/23/73244e5feb55b5ca109cede6e97f32ef45189f0fdac4c80d75c99862729d/psycopg_binary-3.3.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:42961609ac07c232a427da7c87a468d3c82fee6762c220f38e37cfdacb2b178d", size = 5151135, upload-time = "2026-02-18T16:50:24.82Z" }, - { url = "https://files.pythonhosted.org/packages/11/49/5309473b9803b207682095201d8708bbc7842ddf3f192488a69204e36455/psycopg_binary-3.3.3-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ae07a3114313dd91fce686cab2f4c44af094398519af0e0f854bc707e1aeedf1", size = 6737315, upload-time = "2026-02-18T16:50:35.106Z" }, - { url = "https://files.pythonhosted.org/packages/d4/5d/03abe74ef34d460b33c4d9662bf6ec1dd38888324323c1a1752133c10377/psycopg_binary-3.3.3-cp313-cp313-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d257c58d7b36a621dcce1d01476ad8b60f12d80eb1406aee4cf796f88b2ae482", size = 4979783, upload-time = "2026-02-18T16:50:42.067Z" }, - { url = "https://files.pythonhosted.org/packages/f0/6c/3fbf8e604e15f2f3752900434046c00c90bb8764305a1b81112bff30ba24/psycopg_binary-3.3.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:07c7211f9327d522c9c47560cae00a4ecf6687f4e02d779d035dd3177b41cb12", size = 4509023, upload-time = "2026-02-18T16:50:50.116Z" }, - { url = "https://files.pythonhosted.org/packages/9c/6b/1a06b43b7c7af756c80b67eac8bfaa51d77e68635a8a8d246e4f0bb7604a/psycopg_binary-3.3.3-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:8e7e9eca9b363dbedeceeadd8be97149d2499081f3c52d141d7cd1f395a91f83", size = 4185874, upload-time = "2026-02-18T16:50:55.97Z" }, - { url = "https://files.pythonhosted.org/packages/2b/d3/bf49e3dcaadba510170c8d111e5e69e5ae3f981c1554c5bb71c75ce354bb/psycopg_binary-3.3.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:cb85b1d5702877c16f28d7b92ba030c1f49ebcc9b87d03d8c10bf45a2f1c7508", size = 3925668, upload-time = "2026-02-18T16:51:03.299Z" }, - { url = "https://files.pythonhosted.org/packages/f8/92/0aac830ed6a944fe334404e1687a074e4215630725753f0e3e9a9a595b62/psycopg_binary-3.3.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4d4606c84d04b80f9138d72f1e28c6c02dc5ae0c7b8f3f8aaf89c681ce1cd1b1", size = 4234973, upload-time = "2026-02-18T16:51:09.097Z" }, - { url = "https://files.pythonhosted.org/packages/2e/96/102244653ee5a143ece5afe33f00f52fe64e389dfce8dbc87580c6d70d3d/psycopg_binary-3.3.3-cp313-cp313-win_amd64.whl", hash = "sha256:74eae563166ebf74e8d950ff359be037b85723d99ca83f57d9b244a871d6c13b", size = 3551342, upload-time = "2026-02-18T16:51:13.892Z" }, - { url = "https://files.pythonhosted.org/packages/a2/71/7a57e5b12275fe7e7d84d54113f0226080423a869118419c9106c083a21c/psycopg_binary-3.3.3-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:497852c5eaf1f0c2d88ab74a64a8097c099deac0c71de1cbcf18659a8a04a4b2", size = 4607368, upload-time = "2026-02-18T16:51:19.295Z" }, - { url = "https://files.pythonhosted.org/packages/c7/04/cb834f120f2b2c10d4003515ef9ca9d688115b9431735e3936ae48549af8/psycopg_binary-3.3.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:258d1ea53464d29768bf25930f43291949f4c7becc706f6e220c515a63a24edd", size = 4687047, upload-time = "2026-02-18T16:51:23.84Z" }, - { url = "https://files.pythonhosted.org/packages/40/e9/47a69692d3da9704468041aa5ed3ad6fc7f6bb1a5ae788d261a26bbca6c7/psycopg_binary-3.3.3-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:111c59897a452196116db12e7f608da472fbff000693a21040e35fc978b23430", size = 5487096, upload-time = "2026-02-18T16:51:29.645Z" }, - { url = "https://files.pythonhosted.org/packages/0b/b6/0e0dd6a2f802864a4ae3dbadf4ec620f05e3904c7842b326aafc43e5f464/psycopg_binary-3.3.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:17bb6600e2455993946385249a3c3d0af52cd70c1c1cdbf712e9d696d0b0bf1b", size = 5168720, upload-time = "2026-02-18T16:51:36.499Z" }, - { url = "https://files.pythonhosted.org/packages/6f/0d/977af38ac19a6b55d22dff508bd743fd7c1901e1b73657e7937c7cccb0a3/psycopg_binary-3.3.3-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:642050398583d61c9856210568eb09a8e4f2fe8224bf3be21b67a370e677eead", size = 6762076, upload-time = "2026-02-18T16:51:43.167Z" }, - { url = "https://files.pythonhosted.org/packages/34/40/912a39d48322cf86895c0eaf2d5b95cb899402443faefd4b09abbba6b6e1/psycopg_binary-3.3.3-cp314-cp314-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:533efe6dc3a7cba5e2a84e38970786bb966306863e45f3db152007e9f48638a6", size = 4997623, upload-time = "2026-02-18T16:51:47.707Z" }, - { url = "https://files.pythonhosted.org/packages/98/0c/c14d0e259c65dc7be854d926993f151077887391d5a081118907a9d89603/psycopg_binary-3.3.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:5958dbf28b77ce2033482f6cb9ef04d43f5d8f4b7636e6963d5626f000efb23e", size = 4532096, upload-time = "2026-02-18T16:51:51.421Z" }, - { url = "https://files.pythonhosted.org/packages/39/21/8b7c50a194cfca6ea0fd4d1f276158307785775426e90700ab2eba5cd623/psycopg_binary-3.3.3-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:a6af77b6626ce92b5817bf294b4d45ec1a6161dba80fc2d82cdffdd6814fd023", size = 4208884, upload-time = "2026-02-18T16:51:57.336Z" }, - { url = "https://files.pythonhosted.org/packages/c7/2c/a4981bf42cf30ebba0424971d7ce70a222ae9b82594c42fc3f2105d7b525/psycopg_binary-3.3.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:47f06fcbe8542b4d96d7392c476a74ada521c5aebdb41c3c0155f6595fc14c8d", size = 3944542, upload-time = "2026-02-18T16:52:04.266Z" }, - { url = "https://files.pythonhosted.org/packages/60/e9/b7c29b56aa0b85a4e0c4d89db691c1ceef08f46a356369144430c155a2f5/psycopg_binary-3.3.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:e7800e6c6b5dc4b0ca7cc7370f770f53ac83886b76afda0848065a674231e856", size = 4254339, upload-time = "2026-02-18T16:52:10.444Z" }, - { url = "https://files.pythonhosted.org/packages/98/5a/291d89f44d3820fffb7a04ebc8f3ef5dda4f542f44a5daea0c55a84abf45/psycopg_binary-3.3.3-cp314-cp314-win_amd64.whl", hash = "sha256:165f22ab5a9513a3d7425ffb7fcc7955ed8ccaeef6d37e369d6cc1dff1582383", size = 3652796, upload-time = "2026-02-18T16:52:14.02Z" }, + { url = "https://files.pythonhosted.org/packages/09/43/13e9c406fbbf354580476e248a16b64802a376873ebe6339e30bb655572d/psycopg_binary-3.3.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:fbd1d4ed566895ad2d3bf4ddfd8bae90026930ddf29df3b9d91d32c8c47866a7", size = 4590377, upload-time = "2026-05-01T23:29:18.782Z" }, + { url = "https://files.pythonhosted.org/packages/22/be/2923cd7c3683e7afdecf4f10796a18de02f5c5ddc0969aa2ad0a8cdd3bbd/psycopg_binary-3.3.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:75a9067e236f9b9ae3535b66fe99bddb33d39c0de10112e49b9ab11eee53dc31", size = 4669023, upload-time = "2026-05-01T23:29:25.884Z" }, + { url = "https://files.pythonhosted.org/packages/96/a0/2c913d6fe13d6a8bd13597d36739bf47af063ad9399e402cfecab16f3c1e/psycopg_binary-3.3.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:b56b603ebcea8aa10b46228b8410ba7f13e7c2ee54389d4d9be0927fd8ce2a70", size = 5467423, upload-time = "2026-05-01T23:29:33.416Z" }, + { url = "https://files.pythonhosted.org/packages/e7/38/205d10bc1ad0df4a21c5c51659126bd3ea0ef98fcad1e852f78c249bb9c3/psycopg_binary-3.3.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c677c4ad433cb7150c8cd304a0769ae3bcfbe5ea0676eb53faa7b1443b16d0d3", size = 5151137, upload-time = "2026-05-01T23:29:42.013Z" }, + { url = "https://files.pythonhosted.org/packages/36/fc/f0381ddcd45eff3bb70dbca6823a996048d7f507b2ec3fc92c6fabc0fe87/psycopg_binary-3.3.4-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:26df2717e59c0473e4465a97dfb1b7afebaa479277870fd5784d1436470db47c", size = 6736671, upload-time = "2026-05-01T23:29:51.626Z" }, + { url = "https://files.pythonhosted.org/packages/95/40/fa545ae152c24327651e5624e4902121e808270be36c10b12e9939be09bc/psycopg_binary-3.3.4-cp313-cp313-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1dc1f79fd16bb1f3f4421417a514607539f17804d95c7ed617265369d1981cae", size = 4979601, upload-time = "2026-05-01T23:29:56.961Z" }, + { url = "https://files.pythonhosted.org/packages/86/e4/2f8a47ee97f90cd2b933d0463081d35631ff419de2b8c984a5f369857de0/psycopg_binary-3.3.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:136f199a407b5348b9b857c504aff60c77622a28482e7195839ce1b51238c4cc", size = 4510513, upload-time = "2026-05-01T23:30:07.243Z" }, + { url = "https://files.pythonhosted.org/packages/0e/0e/94e842ff4a7f98ed162580ca2e8b8864b28c1e0350f2443f8ee47f821167/psycopg_binary-3.3.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:b6f5a29e9c775b9f12a1a717aa7a2c80f9e1db6f27ba44a5b59c80ac61d2ffcf", size = 4187243, upload-time = "2026-05-01T23:30:15.352Z" }, + { url = "https://files.pythonhosted.org/packages/d0/83/fc6c174b672e29b7de996ea77b6cbddf46c891751c3355f6974292baa6b4/psycopg_binary-3.3.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:ee17a2cf4943cde261adfad1bbc5bf38d6b3776d7afff74c7cabcbeaeb08c260", size = 3927347, upload-time = "2026-05-01T23:30:21.186Z" }, + { url = "https://files.pythonhosted.org/packages/e9/65/768364d4a97a15b1a7f47ba52688c1686f22941d8332a8398cefc468e25f/psycopg_binary-3.3.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5c4ab71be17bdca30cb34c34c4e1496e2f5d6f20c199c12bad226070b22ef9bf", size = 4236393, upload-time = "2026-05-01T23:30:26.211Z" }, + { url = "https://files.pythonhosted.org/packages/bd/3b/218efbc9e645becd80cdf651acda05f85cfe546b7a9c0458c7cbc8fe1f74/psycopg_binary-3.3.4-cp313-cp313-win_amd64.whl", hash = "sha256:dbfdb9b6cc79f31104a7b162a2b921b765fcc62af6c00540a167a8de47e4ed38", size = 3564592, upload-time = "2026-05-01T23:30:31.764Z" }, + { url = "https://files.pythonhosted.org/packages/48/a6/828c9185701dab71b234c2a76c38a08b098ebfec5020716b4e93807492b5/psycopg_binary-3.3.4-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:28b7398fdd19db3232c884fb24550bdfe951221f510e195e233299e4c9b78f97", size = 4607292, upload-time = "2026-05-01T23:30:38.962Z" }, + { url = "https://files.pythonhosted.org/packages/92/58/5b40dbc9d839045c9dae956960e4fb6d20bcabe6c59a2aa34fc3a371913f/psycopg_binary-3.3.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1fbaa292a3c8bb61b45df1ad3da1908ccee7cb889db9425e3557d9e34e2a4829", size = 4687023, upload-time = "2026-05-01T23:30:47.227Z" }, + { url = "https://files.pythonhosted.org/packages/85/a9/793f0ac107a9003b48441d0d1f9f616d96e0f37458dd8dc12528ceff55fb/psycopg_binary-3.3.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:94596f9e7633ee3f6440711d43bb70aa31cc0a46a900ab8b4201a366ace5c9e7", size = 5486985, upload-time = "2026-05-01T23:30:55.517Z" }, + { url = "https://files.pythonhosted.org/packages/8f/26/42e8533497e2592334f68ec529cf5f840f7fa4e99575a4bb61aa184dbfbf/psycopg_binary-3.3.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8c0056529e68dbe9184cd4019a1f3d8f3a4ead2f6fc7a5afcf27d3314edd1277", size = 5168745, upload-time = "2026-05-01T23:31:01.904Z" }, + { url = "https://files.pythonhosted.org/packages/15/af/b7151776cc08d5935d45c833ec818a9beb417cf7c08239af1aafbdae78ee/psycopg_binary-3.3.4-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2c09aad7051326e7603c14e50636db9c01f78272dc54b3accff03d46370461e6", size = 6761486, upload-time = "2026-05-01T23:31:14.511Z" }, + { url = "https://files.pythonhosted.org/packages/d0/ed/c92533b9124712d592cbf1cd6c76da933a2e0acea81dfe1fbe7e735f0cff/psycopg_binary-3.3.4-cp314-cp314-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:514404ed543efd620c85602b747df2a23cf1241b4067199e1a66f2d2757aaa41", size = 4997427, upload-time = "2026-05-01T23:31:20.901Z" }, + { url = "https://files.pythonhosted.org/packages/a2/23/ccadfd0de416aa188356daa199453af24087b042e296088706d190ae0295/psycopg_binary-3.3.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:46893c26858be12cc49ca4226ed6a60b4bfccadd946b3bebb783a60b38788228", size = 4533549, upload-time = "2026-05-01T23:31:26.204Z" }, + { url = "https://files.pythonhosted.org/packages/fd/a0/c8f43cee36386f7bc891ab41a9d31ea07cf9826038e732da79f26b1e5f34/psycopg_binary-3.3.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:df1d567fc430f6df15c9fcf67d87685fc49bdb325adc0db5af1adfb2f44eb5c9", size = 4210256, upload-time = "2026-05-01T23:31:33.884Z" }, + { url = "https://files.pythonhosted.org/packages/4e/2c/c1547871be3790676e8868b38655496422f94f0978dfb66b74bdba2f1676/psycopg_binary-3.3.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:6b9016b1714da4dd5ecaaa75b82098aa5a0b87854ce9b092e21c27c4ae23e014", size = 3946204, upload-time = "2026-05-01T23:31:39.626Z" }, + { url = "https://files.pythonhosted.org/packages/c4/b1/f6670f00fa7ea601584623f6c11602ab92117d83eaff885e0210f6de7418/psycopg_binary-3.3.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:47c656a8a7ba6eb0cff1801a4caaa9c8bdc12d03080e273aff1c8ac39971a77e", size = 4255811, upload-time = "2026-05-01T23:31:44.986Z" }, + { url = "https://files.pythonhosted.org/packages/eb/e6/5fff07a70d1f945ed90ae131c3bd76cab32beff7c58c6db15ad5820b6d1f/psycopg_binary-3.3.4-cp314-cp314-win_amd64.whl", hash = "sha256:c37e024c07308cd06cf3ec51bfd0e7f6157585a4d84d1bce4a7f5f7913719bf8", size = 3666849, upload-time = "2026-05-01T23:31:51.165Z" }, ] [[package]] name = "pydantic" -version = "2.13.3" +version = "2.13.4" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "annotated-types" }, - { name = "pydantic-core" }, - { name = "typing-extensions" }, - { name = "typing-inspection" }, + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/d9/e4/40d09941a2cebcb20609b86a559817d5b9291c49dd6f8c87e5feffbe703a/pydantic-2.13.3.tar.gz", hash = "sha256:af09e9d1d09f4e7fe37145c1f577e1d61ceb9a41924bf0094a36506285d0a84d", size = 844068, upload-time = "2026-04-20T14:46:43.632Z" } +sdist = { url = "https://files.pythonhosted.org/packages/18/a5/b60d21ac674192f8ab0ba4e9fd860690f9b4a6e51ca5df118733b487d8d6/pydantic-2.13.4.tar.gz", hash = "sha256:c40756b57adaa8b1efeeced5c196f3f3b7c435f90e84ea7f443901bec8099ef6", size = 844775, upload-time = "2026-05-06T13:43:05.343Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/f3/0a/fd7d723f8f8153418fb40cf9c940e82004fce7e987026b08a68a36dd3fe7/pydantic-2.13.3-py3-none-any.whl", hash = "sha256:6db14ac8dfc9a1e57f87ea2c0de670c251240f43cb0c30a5130e9720dc612927", size = 471981, upload-time = "2026-04-20T14:46:41.402Z" }, + { url = "https://files.pythonhosted.org/packages/fd/7b/122376b1fd3c62c1ed9dc80c931ace4844b3c55407b6fb2d199377c9736f/pydantic-2.13.4-py3-none-any.whl", hash = "sha256:45a282cde31d808236fd7ea9d919b128653c8b38b393d1c4ab335c62924d9aba", size = 472262, upload-time = "2026-05-06T13:43:02.641Z" }, ] [[package]] name = "pydantic-core" -version = "2.46.3" +version = "2.46.4" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "typing-extensions" }, + { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/2a/ef/f7abb56c49382a246fd2ce9c799691e3c3e7175ec74b14d99e798bcddb1a/pydantic_core-2.46.3.tar.gz", hash = "sha256:41c178f65b8c29807239d47e6050262eb6bf84eb695e41101e62e38df4a5bc2c", size = 471412, upload-time = "2026-04-20T14:40:56.672Z" } +sdist = { url = "https://files.pythonhosted.org/packages/9d/56/921726b776ace8d8f5db44c4ef961006580d91dc52b803c489fafd1aa249/pydantic_core-2.46.4.tar.gz", hash = "sha256:62f875393d7f270851f20523dd2e29f082bcc82292d66db2b64ea71f64b6e1c1", size = 471464, upload-time = "2026-05-06T13:37:06.98Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/9b/3c/9b5e8eb9821936d065439c3b0fb1490ffa64163bfe7e1595985a47896073/pydantic_core-2.46.3-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:12bc98de041458b80c86c56b24df1d23832f3e166cbaff011f25d187f5c62c37", size = 2102109, upload-time = "2026-04-20T14:41:24.219Z" }, - { url = "https://files.pythonhosted.org/packages/91/97/1c41d1f5a19f241d8069f1e249853bcce378cdb76eec8ab636d7bc426280/pydantic_core-2.46.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:85348b8f89d2c3508b65b16c3c33a4da22b8215138d8b996912bb1532868885f", size = 1951820, upload-time = "2026-04-20T14:42:14.236Z" }, - { url = "https://files.pythonhosted.org/packages/30/b4/d03a7ae14571bc2b6b3c7b122441154720619afe9a336fa3a95434df5e2f/pydantic_core-2.46.3-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1105677a6df914b1fb71a81b96c8cce7726857e1717d86001f29be06a25ee6f8", size = 1977785, upload-time = "2026-04-20T14:42:31.648Z" }, - { url = "https://files.pythonhosted.org/packages/ae/0c/4086f808834b59e3c8f1aa26df8f4b6d998cdcf354a143d18ef41529d1fe/pydantic_core-2.46.3-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:87082cd65669a33adeba5470769e9704c7cf026cc30afb9cc77fd865578ebaad", size = 2062761, upload-time = "2026-04-20T14:40:37.093Z" }, - { url = "https://files.pythonhosted.org/packages/fa/71/a649be5a5064c2df0db06e0a512c2281134ed2fcc981f52a657936a7527c/pydantic_core-2.46.3-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:60e5f66e12c4f5212d08522963380eaaeac5ebd795826cfd19b2dfb0c7a52b9c", size = 2232989, upload-time = "2026-04-20T14:42:59.254Z" }, - { url = "https://files.pythonhosted.org/packages/a2/84/7756e75763e810b3a710f4724441d1ecc5883b94aacb07ca71c5fb5cfb69/pydantic_core-2.46.3-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b6cdf19bf84128d5e7c37e8a73a0c5c10d51103a650ac585d42dd6ae233f2b7f", size = 2303975, upload-time = "2026-04-20T14:41:32.287Z" }, - { url = "https://files.pythonhosted.org/packages/6c/35/68a762e0c1e31f35fa0dac733cbd9f5b118042853698de9509c8e5bf128b/pydantic_core-2.46.3-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:031bb17f4885a43773c8c763089499f242aee2ea85cf17154168775dccdecf35", size = 2095325, upload-time = "2026-04-20T14:42:47.685Z" }, - { url = "https://files.pythonhosted.org/packages/77/bf/1bf8c9a8e91836c926eae5e3e51dce009bf495a60ca56060689d3df3f340/pydantic_core-2.46.3-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:bcf2a8b2982a6673693eae7348ef3d8cf3979c1d63b54fca7c397a635cc68687", size = 2133368, upload-time = "2026-04-20T14:41:22.766Z" }, - { url = "https://files.pythonhosted.org/packages/e5/50/87d818d6bab915984995157ceb2380f5aac4e563dddbed6b56f0ed057aba/pydantic_core-2.46.3-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:28e8cf2f52d72ced402a137145923a762cbb5081e48b34312f7a0c8f55928ec3", size = 2173908, upload-time = "2026-04-20T14:42:52.044Z" }, - { url = "https://files.pythonhosted.org/packages/91/88/a311fb306d0bd6185db41fa14ae888fb81d0baf648a761ae760d30819d33/pydantic_core-2.46.3-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:17eaface65d9fc5abb940003020309c1bf7a211f5f608d7870297c367e6f9022", size = 2186422, upload-time = "2026-04-20T14:43:29.55Z" }, - { url = "https://files.pythonhosted.org/packages/8f/79/28fd0d81508525ab2054fef7c77a638c8b5b0afcbbaeee493cf7c3fef7e1/pydantic_core-2.46.3-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:93fd339f23408a07e98950a89644f92c54d8729719a40b30c0a30bb9ebc55d23", size = 2332709, upload-time = "2026-04-20T14:42:16.134Z" }, - { url = "https://files.pythonhosted.org/packages/b3/21/795bf5fe5c0f379308b8ef19c50dedab2e7711dbc8d0c2acf08f1c7daa05/pydantic_core-2.46.3-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:23cbdb3aaa74dfe0837975dbf69b469753bbde8eacace524519ffdb6b6e89eb7", size = 2372428, upload-time = "2026-04-20T14:41:10.974Z" }, - { url = "https://files.pythonhosted.org/packages/45/b3/ed14c659cbe7605e3ef063077680a64680aec81eb1a04763a05190d49b7f/pydantic_core-2.46.3-cp313-cp313-win32.whl", hash = "sha256:610eda2e3838f401105e6326ca304f5da1e15393ae25dacae5c5c63f2c275b13", size = 1965601, upload-time = "2026-04-20T14:41:42.128Z" }, - { url = "https://files.pythonhosted.org/packages/ef/bb/adb70d9a762ddd002d723fbf1bd492244d37da41e3af7b74ad212609027e/pydantic_core-2.46.3-cp313-cp313-win_amd64.whl", hash = "sha256:68cc7866ed863db34351294187f9b729964c371ba33e31c26f478471c52e1ed0", size = 2071517, upload-time = "2026-04-20T14:43:36.096Z" }, - { url = "https://files.pythonhosted.org/packages/52/eb/66faefabebfe68bd7788339c9c9127231e680b11906368c67ce112fdb47f/pydantic_core-2.46.3-cp313-cp313-win_arm64.whl", hash = "sha256:f64b5537ac62b231572879cd08ec05600308636a5d63bcbdb15063a466977bec", size = 2035802, upload-time = "2026-04-20T14:43:38.507Z" }, - { url = "https://files.pythonhosted.org/packages/7f/db/a7bcb4940183fda36022cd18ba8dd12f2dff40740ec7b58ce7457befa416/pydantic_core-2.46.3-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:afa3aa644f74e290cdede48a7b0bee37d1c35e71b05105f6b340d484af536d9b", size = 2097614, upload-time = "2026-04-20T14:44:38.374Z" }, - { url = "https://files.pythonhosted.org/packages/24/35/e4066358a22e3e99519db370494c7528f5a2aa1367370e80e27e20283543/pydantic_core-2.46.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:ced3310e51aa425f7f77da8bbbb5212616655bedbe82c70944320bc1dbe5e018", size = 1951896, upload-time = "2026-04-20T14:40:53.996Z" }, - { url = "https://files.pythonhosted.org/packages/87/92/37cf4049d1636996e4b888c05a501f40a43ff218983a551d57f9d5e14f0d/pydantic_core-2.46.3-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e29908922ce9da1a30b4da490bd1d3d82c01dcfdf864d2a74aacee674d0bfa34", size = 1979314, upload-time = "2026-04-20T14:41:49.446Z" }, - { url = "https://files.pythonhosted.org/packages/d8/36/9ff4d676dfbdfb2d591cf43f3d90ded01e15b1404fd101180ed2d62a2fd3/pydantic_core-2.46.3-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0c9ff69140423eea8ed2d5477df3ba037f671f5e897d206d921bc9fdc39613e7", size = 2056133, upload-time = "2026-04-20T14:42:23.574Z" }, - { url = "https://files.pythonhosted.org/packages/bc/f0/405b442a4d7ba855b06eec8b2bf9c617d43b8432d099dfdc7bf999293495/pydantic_core-2.46.3-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b675ab0a0d5b1c8fdb81195dc5bcefea3f3c240871cdd7ff9a2de8aa50772eb2", size = 2228726, upload-time = "2026-04-20T14:44:22.816Z" }, - { url = "https://files.pythonhosted.org/packages/e7/f8/65cd92dd5a0bd89ba277a98ecbfaf6fc36bbd3300973c7a4b826d6ab1391/pydantic_core-2.46.3-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0087084960f209a9a4af50ecd1fb063d9ad3658c07bb81a7a53f452dacbfb2ba", size = 2301214, upload-time = "2026-04-20T14:44:48.792Z" }, - { url = "https://files.pythonhosted.org/packages/fd/86/ef96a4c6e79e7a2d0410826a68fbc0eccc0fd44aa733be199d5fcac3bb87/pydantic_core-2.46.3-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ed42e6cc8e1b0e2b9b96e2276bad70ae625d10d6d524aed0c93de974ae029f9f", size = 2099927, upload-time = "2026-04-20T14:41:40.196Z" }, - { url = "https://files.pythonhosted.org/packages/6d/53/269caf30e0096e0a8a8f929d1982a27b3879872cca2d917d17c2f9fdf4fe/pydantic_core-2.46.3-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:f1771ce258afb3e4201e67d154edbbae712a76a6081079fe247c2f53c6322c22", size = 2128789, upload-time = "2026-04-20T14:41:15.868Z" }, - { url = "https://files.pythonhosted.org/packages/00/b0/1a6d9b6a587e118482910c244a1c5acf4d192604174132efd12bf0ac486f/pydantic_core-2.46.3-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a7610b6a5242a6c736d8ad47fd5fff87fcfe8f833b281b1c409c3d6835d9227f", size = 2173815, upload-time = "2026-04-20T14:44:25.152Z" }, - { url = "https://files.pythonhosted.org/packages/87/56/e7e00d4041a7e62b5a40815590114db3b535bf3ca0bf4dca9f16cef25246/pydantic_core-2.46.3-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:ff5e7783bcc5476e1db448bf268f11cb257b1c276d3e89f00b5727be86dd0127", size = 2181608, upload-time = "2026-04-20T14:41:28.933Z" }, - { url = "https://files.pythonhosted.org/packages/e8/22/4bd23c3d41f7c185d60808a1de83c76cf5aeabf792f6c636a55c3b1ec7f9/pydantic_core-2.46.3-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:9d2e32edcc143bc01e95300671915d9ca052d4f745aa0a49c48d4803f8a85f2c", size = 2326968, upload-time = "2026-04-20T14:42:03.962Z" }, - { url = "https://files.pythonhosted.org/packages/24/ac/66cd45129e3915e5ade3b292cb3bc7fd537f58f8f8dbdaba6170f7cabb74/pydantic_core-2.46.3-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:6e42d83d1c6b87fa56b521479cff237e626a292f3b31b6345c15a99121b454c1", size = 2369842, upload-time = "2026-04-20T14:41:35.52Z" }, - { url = "https://files.pythonhosted.org/packages/a2/51/dd4248abb84113615473aa20d5545b7c4cd73c8644003b5259686f93996c/pydantic_core-2.46.3-cp314-cp314-win32.whl", hash = "sha256:07bc6d2a28c3adb4f7c6ae46aa4f2d2929af127f587ed44057af50bf1ce0f505", size = 1959661, upload-time = "2026-04-20T14:41:00.042Z" }, - { url = "https://files.pythonhosted.org/packages/20/eb/59980e5f1ae54a3b86372bd9f0fa373ea2d402e8cdcd3459334430f91e91/pydantic_core-2.46.3-cp314-cp314-win_amd64.whl", hash = "sha256:8940562319bc621da30714617e6a7eaa6b98c84e8c685bcdc02d7ed5e7c7c44e", size = 2071686, upload-time = "2026-04-20T14:43:16.471Z" }, - { url = "https://files.pythonhosted.org/packages/8c/db/1cf77e5247047dfee34bc01fa9bca134854f528c8eb053e144298893d370/pydantic_core-2.46.3-cp314-cp314-win_arm64.whl", hash = "sha256:5dcbbcf4d22210ced8f837c96db941bdb078f419543472aca5d9a0bb7cddc7df", size = 2026907, upload-time = "2026-04-20T14:43:31.732Z" }, - { url = "https://files.pythonhosted.org/packages/57/c0/b3df9f6a543276eadba0a48487b082ca1f201745329d97dbfa287034a230/pydantic_core-2.46.3-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:d0fe3dce1e836e418f912c1ad91c73357d03e556a4d286f441bf34fed2dbeecf", size = 2095047, upload-time = "2026-04-20T14:42:37.982Z" }, - { url = "https://files.pythonhosted.org/packages/66/57/886a938073b97556c168fd99e1a7305bb363cd30a6d2c76086bf0587b32a/pydantic_core-2.46.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:9ce92e58abc722dac1bf835a6798a60b294e48eb0e625ec9fd994b932ac5feee", size = 1934329, upload-time = "2026-04-20T14:43:49.655Z" }, - { url = "https://files.pythonhosted.org/packages/0b/7c/b42eaa5c34b13b07ecb51da21761297a9b8eb43044c864a035999998f328/pydantic_core-2.46.3-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a03e6467f0f5ab796a486146d1b887b2dc5e5f9b3288898c1b1c3ad974e53e4a", size = 1974847, upload-time = "2026-04-20T14:42:10.737Z" }, - { url = "https://files.pythonhosted.org/packages/e6/9b/92b42db6543e7de4f99ae977101a2967b63122d4b6cf7773812da2d7d5b5/pydantic_core-2.46.3-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2798b6ba041b9d70acfb9071a2ea13c8456dd1e6a5555798e41ba7b0790e329c", size = 2041742, upload-time = "2026-04-20T14:40:44.262Z" }, - { url = "https://files.pythonhosted.org/packages/0f/19/46fbe1efabb5aa2834b43b9454e70f9a83ad9c338c1291e48bdc4fecf167/pydantic_core-2.46.3-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9be3e221bdc6d69abf294dcf7aff6af19c31a5cdcc8f0aa3b14be29df4bd03b1", size = 2236235, upload-time = "2026-04-20T14:41:27.307Z" }, - { url = "https://files.pythonhosted.org/packages/77/da/b3f95bc009ad60ec53120f5d16c6faa8cabdbe8a20d83849a1f2b8728148/pydantic_core-2.46.3-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f13936129ce841f2a5ddf6f126fea3c43cd128807b5a59588c37cf10178c2e64", size = 2282633, upload-time = "2026-04-20T14:44:33.271Z" }, - { url = "https://files.pythonhosted.org/packages/cc/6e/401336117722e28f32fb8220df676769d28ebdf08f2f4469646d404c43a3/pydantic_core-2.46.3-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:28b5f2ef03416facccb1c6ef744c69793175fd27e44ef15669201601cf423acb", size = 2109679, upload-time = "2026-04-20T14:44:41.065Z" }, - { url = "https://files.pythonhosted.org/packages/fc/53/b289f9bc8756a32fe718c46f55afaeaf8d489ee18d1a1e7be1db73f42cc4/pydantic_core-2.46.3-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:830d1247d77ad23852314f069e9d7ddafeec5f684baf9d7e7065ed46a049c4e6", size = 2108342, upload-time = "2026-04-20T14:42:50.144Z" }, - { url = "https://files.pythonhosted.org/packages/10/5b/8292fc7c1f9111f1b2b7c1b0dcf1179edcd014fc3ea4517499f50b829d71/pydantic_core-2.46.3-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d0793c90c1a3c74966e7975eaef3ed30ebdff3260a0f815a62a22adc17e4c01c", size = 2157208, upload-time = "2026-04-20T14:42:08.133Z" }, - { url = "https://files.pythonhosted.org/packages/2b/9e/f80044e9ec07580f057a89fc131f78dda7a58751ddf52bbe05eaf31db50f/pydantic_core-2.46.3-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:d2d0aead851b66f5245ec0c4fb2612ef457f8bbafefdf65a2bf9d6bac6140f47", size = 2167237, upload-time = "2026-04-20T14:42:25.412Z" }, - { url = "https://files.pythonhosted.org/packages/f8/84/6781a1b037f3b96be9227edbd1101f6d3946746056231bf4ac48cdff1a8d/pydantic_core-2.46.3-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:2f40e4246676beb31c5ce77c38a55ca4e465c6b38d11ea1bd935420568e0b1ab", size = 2312540, upload-time = "2026-04-20T14:40:40.313Z" }, - { url = "https://files.pythonhosted.org/packages/3e/db/19c0839feeb728e7df03255581f198dfdf1c2aeb1e174a8420b63c5252e5/pydantic_core-2.46.3-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:cf489cf8986c543939aeee17a09c04d6ffb43bfef8ca16fcbcc5cfdcbed24dba", size = 2369556, upload-time = "2026-04-20T14:41:09.427Z" }, - { url = "https://files.pythonhosted.org/packages/e0/15/3228774cb7cd45f5f721ddf1b2242747f4eb834d0c491f0c02d606f09fed/pydantic_core-2.46.3-cp314-cp314t-win32.whl", hash = "sha256:ffe0883b56cfc05798bf994164d2b2ff03efe2d22022a2bb080f3b626176dd56", size = 1949756, upload-time = "2026-04-20T14:41:25.717Z" }, - { url = "https://files.pythonhosted.org/packages/b8/2a/c79cf53fd91e5a87e30d481809f52f9a60dd221e39de66455cf04deaad37/pydantic_core-2.46.3-cp314-cp314t-win_amd64.whl", hash = "sha256:706d9d0ce9cf4593d07270d8e9f53b161f90c57d315aeec4fb4fd7a8b10240d8", size = 2051305, upload-time = "2026-04-20T14:43:18.627Z" }, - { url = "https://files.pythonhosted.org/packages/0b/db/d8182a7f1d9343a032265aae186eb063fe26ca4c40f256b21e8da4498e89/pydantic_core-2.46.3-cp314-cp314t-win_arm64.whl", hash = "sha256:77706aeb41df6a76568434701e0917da10692da28cb69d5fb6919ce5fdb07374", size = 2026310, upload-time = "2026-04-20T14:41:01.778Z" }, + { url = "https://files.pythonhosted.org/packages/51/a2/5d30b469c5267a17b39dec53208222f76a8d351dfac4af661888c5aee77d/pydantic_core-2.46.4-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:5d5902252db0d3cedf8d4a1bc68f70eeb430f7e4c7104c8c476753519b423008", size = 2106306, upload-time = "2026-05-06T13:37:48.029Z" }, + { url = "https://files.pythonhosted.org/packages/c1/81/4fa520eaffa8bd7d1525e644cd6d39e7d60b1592bc5b516693c7340b50f1/pydantic_core-2.46.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c94f0688e7b8d0a67abf40e57a7eaaecd17cc9586706a31b76c031f63df052b4", size = 1951906, upload-time = "2026-05-06T13:37:17.012Z" }, + { url = "https://files.pythonhosted.org/packages/03/d5/fd02da45b659668b05923b17ba3a0100a0a3d5541e3bd8fcc4ecb711309e/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f027324c56cd5406ca49c124b0db10e56c69064fec039acc571c29020cc87c76", size = 1976802, upload-time = "2026-05-06T13:37:35.113Z" }, + { url = "https://files.pythonhosted.org/packages/21/f2/95727e1368be3d3ed485eaab7adbd7dda408f33f7a36e8b48e0144002b91/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e739fee756ba1010f8bcccb534252e85a35fe45ae92c295a06059ce58b74ccd3", size = 2052446, upload-time = "2026-05-06T13:37:12.313Z" }, + { url = "https://files.pythonhosted.org/packages/9c/86/5d99feea3f77c7234b8718075b23db11532773c1a0dbd9b9490215dc2eeb/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9d56801be94b86a9da183e5f3766e6310752b99ff647e38b09a9500d88e46e76", size = 2232757, upload-time = "2026-05-06T13:39:01.149Z" }, + { url = "https://files.pythonhosted.org/packages/d2/3a/508ac615935ef7588cf6d9e9b91309fdc2da751af865e02a9098de88258c/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2412e734dcb48da14d4e4006b82b46b74f2518b8a26ee7e58c6844a6cd6d03c4", size = 2309275, upload-time = "2026-05-06T13:37:41.406Z" }, + { url = "https://files.pythonhosted.org/packages/07/f8/41db9de19d7987d6b04715a02b3b40aea467000275d9d758ffaa31af7d50/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9551187363ffc0de2a00b2e47c25aeaeb1020b69b668762966df15fc5659dd5a", size = 2094467, upload-time = "2026-05-06T13:39:18.847Z" }, + { url = "https://files.pythonhosted.org/packages/2c/e2/f35033184cb11d0052daf4416e8e10a502ea2ac006fc4f459aee872727d1/pydantic_core-2.46.4-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:0186750b482eefa11d7f435892b09c5c606193ef3375bcf94aa00ae6bfb66262", size = 2134417, upload-time = "2026-05-06T13:40:17.944Z" }, + { url = "https://files.pythonhosted.org/packages/7e/7b/6ceeb1cc90e193862f444ebe373d8fdf613f0a82572dde03fb10734c6c71/pydantic_core-2.46.4-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5855698a4856556d86e8e6cd8434bc3ac0314ee8e12089ae0e143f64c6256e4e", size = 2179782, upload-time = "2026-05-06T13:40:32.618Z" }, + { url = "https://files.pythonhosted.org/packages/5a/f2/c8d7773ede6af08036423a00ae0ceffce266c3c52a096c435d68c896083f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:cbaf13819775b7f769bf4a1f066cb6df7a28d4480081a589828ef190226881cd", size = 2188782, upload-time = "2026-05-06T13:36:51.018Z" }, + { url = "https://files.pythonhosted.org/packages/59/31/0c864784e31f09f05cdd87606f08923b9c9e7f6e51dd27f20f62f975ce9f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:633147d34cf4550417f12e2b1a0383973bdf5cdfde212cb09e9a581cf10820be", size = 2328334, upload-time = "2026-05-06T13:40:37.764Z" }, + { url = "https://files.pythonhosted.org/packages/c2/eb/4f6c8a41efa30baa755590f4141abf3a8c370fab610915733e74134a7270/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:82cf5301172168103724d49a1444d3378cb20cdee30b116a1bd6031236298a5d", size = 2372986, upload-time = "2026-05-06T13:39:34.152Z" }, + { url = "https://files.pythonhosted.org/packages/5b/24/b375a480d53113860c299764bfe9f349a3dc9108b3adc0d7f0d786492ebf/pydantic_core-2.46.4-cp313-cp313-win32.whl", hash = "sha256:9fa8ae11da9e2b3126c6426f147e0fba88d96d65921799bb30c6abd1cb2c97fb", size = 1973693, upload-time = "2026-05-06T13:37:55.072Z" }, + { url = "https://files.pythonhosted.org/packages/7e/e8/cff247591966f2d22ec8c003cd7587e27b7ba7b81ab2fb888e3ab75dc285/pydantic_core-2.46.4-cp313-cp313-win_amd64.whl", hash = "sha256:6b3ace8194b0e5204818c92802dcdca7fc6d88aabbb799d7c795540d9cd6d292", size = 2071819, upload-time = "2026-05-06T13:38:49.139Z" }, + { url = "https://files.pythonhosted.org/packages/c6/1a/f4aee670d5670e9e148e0c82c7db98d780be566c6e6a97ee8035528ca0b3/pydantic_core-2.46.4-cp313-cp313-win_arm64.whl", hash = "sha256:184c081504d17f1c1066e430e117142b2c77d9448a97f7b65c6ac9fd9aee238d", size = 2027411, upload-time = "2026-05-06T13:40:45.796Z" }, + { url = "https://files.pythonhosted.org/packages/8d/74/228a26ddad29c6672b805d9fd78e8d251cd04004fa7eed0e622096cd0250/pydantic_core-2.46.4-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:428e04521a40150c85216fc8b85e8d39fece235a9cf5e383761238c7fa9b96fb", size = 2102079, upload-time = "2026-05-06T13:38:41.019Z" }, + { url = "https://files.pythonhosted.org/packages/ad/1f/8970b150a4b4365623ae00fc88603491f763c627311ae8031e3111356d6e/pydantic_core-2.46.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:23ace664830ee0bfe014a0c7bc248b1f7f25ed7ad103852c317624a1083af462", size = 1952179, upload-time = "2026-05-06T13:36:59.812Z" }, + { url = "https://files.pythonhosted.org/packages/95/30/5211a831ae054928054b2f79731661087a2bc5c01e825c672b3a4a8f1b3e/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce5c1d2a8b27468f433ca974829c44060b8097eedc39933e3c206a90ee49c4a9", size = 1978926, upload-time = "2026-05-06T13:37:39.933Z" }, + { url = "https://files.pythonhosted.org/packages/57/e9/689668733b1eb67adeef047db3c2e8788fcf65a7fd9c9e2b46b7744fe245/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7283d57845ecf5a163403eb0702dfc220cc4fbdd18919cb5ccea4f95ee1cdab4", size = 2046785, upload-time = "2026-05-06T13:38:01.995Z" }, + { url = "https://files.pythonhosted.org/packages/60/d9/6715260422ff50a2109878fd24d948a6c3446bb2664f34ee78cd972b3acd/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8daafc69c93ee8a0204506a3b6b30f586ef54028f52aeeeb5c4cfc5184fd5914", size = 2228733, upload-time = "2026-05-06T13:40:50.371Z" }, + { url = "https://files.pythonhosted.org/packages/18/ae/fdb2f64316afca925640f8e70bb1a564b0ec2721c1389e25b8eb4bf9a299/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cd2213145bcc2ba85884d0ac63d222fece9209678f77b9b4d76f054c561adb28", size = 2307534, upload-time = "2026-05-06T13:37:21.531Z" }, + { url = "https://files.pythonhosted.org/packages/89/1d/8eff589b45bb8190a9d12c49cfad0f176a5cbd1534908a6b5125e2886239/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7a5f930472650a82629163023e630d160863fce524c616f4e5186e5de9d9a49b", size = 2099732, upload-time = "2026-05-06T13:39:31.942Z" }, + { url = "https://files.pythonhosted.org/packages/06/d5/ee5a3366637fee41dee51a1fc91562dcf12ddbc68fda34e6b253da2324bb/pydantic_core-2.46.4-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:c1b3f518abeca3aa13c712fd202306e145abf59a18b094a6bafb2d2bbf59192c", size = 2129627, upload-time = "2026-05-06T13:37:25.033Z" }, + { url = "https://files.pythonhosted.org/packages/94/33/2414be571d2c6a6c4d08be21f9292b6d3fdb08949a97b6dfe985017821db/pydantic_core-2.46.4-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1a7dd0b3ee80d90150e3495a3a13ac34dbcbfd4f012996a6a1d8900e91b5c0fb", size = 2179141, upload-time = "2026-05-06T13:37:14.046Z" }, + { url = "https://files.pythonhosted.org/packages/7b/79/7daa95be995be0eecc4cf75064cb33f9bbbfe3fe0158caf2f0d4a996a5c7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:3fb702cd90b0446a3a1c5e470bfa0dd23c0233b676a9099ddcc964fa6ca13898", size = 2184325, upload-time = "2026-05-06T13:36:53.615Z" }, + { url = "https://files.pythonhosted.org/packages/9f/cb/d0a382f5c0de8a222dc61c65348e0ce831b1f68e0a018450d31c2cace3a5/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:b8458003118a712e66286df6a707db01c52c0f52f7db8e4a38f0da1d3b94fc4e", size = 2323990, upload-time = "2026-05-06T13:40:29.971Z" }, + { url = "https://files.pythonhosted.org/packages/05/db/d9ba624cc4a5aced1598e88c04fdbd8310c8a69b9d38b9a3d39ce3a61ed7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:372429a130e469c9cd698925ce5fc50940b7a1336b0d82038e63d5bbc4edc519", size = 2369978, upload-time = "2026-05-06T13:37:23.027Z" }, + { url = "https://files.pythonhosted.org/packages/f2/20/d15df15ba918c423461905802bfd2981c3af0bfa0e40d05e13edbfa48bc3/pydantic_core-2.46.4-cp314-cp314-win32.whl", hash = "sha256:85bb3611ff1802f3ee7fdd7dbff26b56f343fb432d57a4728fdd49b6ef35e2f4", size = 1966354, upload-time = "2026-05-06T13:38:03.499Z" }, + { url = "https://files.pythonhosted.org/packages/fc/b6/6b8de4c0a7d7ab3004c439c80c5c1e0a3e8d78bbae19379b01960383d9e5/pydantic_core-2.46.4-cp314-cp314-win_amd64.whl", hash = "sha256:811ff8e9c313ab425368bcbb36e5c4ebd7108c2bbf4e4089cfbb0b01eff63fac", size = 2072238, upload-time = "2026-05-06T13:39:40.807Z" }, + { url = "https://files.pythonhosted.org/packages/32/36/51eb763beec1f4cf59b1db243a7dcc39cbb41230f050a09b9d69faaf0a48/pydantic_core-2.46.4-cp314-cp314-win_arm64.whl", hash = "sha256:bfec22eab3c8cc2ceec0248aec886624116dc079afa027ecc8ad4a7e62010f8a", size = 2018251, upload-time = "2026-05-06T13:37:26.72Z" }, + { url = "https://files.pythonhosted.org/packages/e8/91/855af51d625b23aa987116a19e231d2aaef9c4a415273ddc189b79a45fee/pydantic_core-2.46.4-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:af8244b2bef6aaad6d92cda81372de7f8c8d36c9f0c3ea36e827c60e7d9467a0", size = 2099593, upload-time = "2026-05-06T13:39:47.682Z" }, + { url = "https://files.pythonhosted.org/packages/fb/1b/8784a54c65edb5f49f0a14d6977cf1b209bba85a4c77445b255c2de58ab3/pydantic_core-2.46.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5a4330cdbc57162e4b3aa303f588ba752257694c9c9be3e7ebb11b4aca659b5d", size = 1935226, upload-time = "2026-05-06T13:40:40.428Z" }, + { url = "https://files.pythonhosted.org/packages/e8/e7/1955d28d1afc56dd4b3ad7cc0cf39df1b9852964cf16e5d13912756d6d6b/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:29c61fc04a3d840155ff08e475a04809278972fe6aef51e2720554e96367e34b", size = 1974605, upload-time = "2026-05-06T13:37:32.029Z" }, + { url = "https://files.pythonhosted.org/packages/93/e2/3fedbf0ba7a22850e6e9fd78117f1c0f10f950182344d8a6c535d468fdd8/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c50f2528cf200c5eed56faf3f4e22fcd5f38c157a8b78576e6ba3168ec35f000", size = 2030777, upload-time = "2026-05-06T13:38:55.239Z" }, + { url = "https://files.pythonhosted.org/packages/f8/61/46be275fcaaba0b4f5b9669dd852267ce1ff616592dccf7a7845588df091/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0cbe8b01f948de4286c74cdd6c667aceb38f5c1e26f0693b3983d9d74887c65e", size = 2236641, upload-time = "2026-05-06T13:37:08.096Z" }, + { url = "https://files.pythonhosted.org/packages/60/db/12e93e46a8bac9988be3c016860f83293daea8c716c029c9ace279036f2f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:617d7e2ca7dcb8c5cf6bcb8c59b8832c94b36196bbf1cbd1bfb56ed341905edd", size = 2286404, upload-time = "2026-05-06T13:40:20.221Z" }, + { url = "https://files.pythonhosted.org/packages/e2/4a/4d8b19008f38d31c53b8219cfedc2e3d5de5fe99d90076b7e767de29274f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7027560ee92211647d0d34e3f7cd6f50da56399d26a9c8ad0da286d3869a53f3", size = 2109219, upload-time = "2026-05-06T13:38:12.153Z" }, + { url = "https://files.pythonhosted.org/packages/88/70/3cbc40978fefb7bb09c6708d40d4ad1a5d70fd7213c3d17f971de868ec1f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:f99626688942fb746e545232e7726926f3be91b5975f8b55327665fafda991c7", size = 2110594, upload-time = "2026-05-06T13:40:02.971Z" }, + { url = "https://files.pythonhosted.org/packages/9d/20/b8d36736216e29491125531685b2f9e61aa5b4b2599893f8268551da3338/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fc3e9034a63de20e15e8ade85358bc6efc614008cab72898b4b4952bea0509ff", size = 2159542, upload-time = "2026-05-06T13:39:27.506Z" }, + { url = "https://files.pythonhosted.org/packages/1d/a2/367df868eb584dacf6bf82a389272406d7178e301c4ac82545ab98bc2dd9/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:97e7cf2be5c77b7d1a9713a05605d49460d02c6078d38d8bef3cbe323c548424", size = 2168146, upload-time = "2026-05-06T13:38:31.93Z" }, + { url = "https://files.pythonhosted.org/packages/c1/b8/4460f77f7e201893f649a29ab355dddd3beee8a97bcb1a320db414f9a06e/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:3bf92c5d0e00fefaab325a4d27828fe6b6e2a21848686b5b60d2d9eeb09d76c6", size = 2306309, upload-time = "2026-05-06T13:37:44.717Z" }, + { url = "https://files.pythonhosted.org/packages/64/c4/be2639293acd87dc8ddbcec41a73cee9b2ebf996fe6d892a1a74e88ad3f7/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:3ecbc122d18468d06ca279dc26a8c2e2d5acb10943bb35e36ae92096dc3b5565", size = 2369736, upload-time = "2026-05-06T13:37:05.645Z" }, + { url = "https://files.pythonhosted.org/packages/30/a6/9f9f380dbb301f67023bf8f707aaa75daadf84f7152d95c410fd7e81d994/pydantic_core-2.46.4-cp314-cp314t-win32.whl", hash = "sha256:e846ae7835bf0703ae43f534ab79a867146dadd59dc9ca5c8b53d5c8f7c9ef02", size = 1955575, upload-time = "2026-05-06T13:38:51.116Z" }, + { url = "https://files.pythonhosted.org/packages/40/1f/f1eb9eb350e795d1af8586289746f5c5677d16043040d63710e22abc43c9/pydantic_core-2.46.4-cp314-cp314t-win_amd64.whl", hash = "sha256:2108ba5c1c1eca18030634489dc544844144ee36357f2f9f780b93e7ddbb44b5", size = 2051624, upload-time = "2026-05-06T13:38:21.672Z" }, + { url = "https://files.pythonhosted.org/packages/f6/d2/42dd53d0a85c27606f316d3aa5d2869c4e8470a5ed6dec30e4a1abe19192/pydantic_core-2.46.4-cp314-cp314t-win_arm64.whl", hash = "sha256:4fcbe087dbc2068af7eda3aa87634eba216dbda64d1ae73c8684b621d33f6596", size = 2017325, upload-time = "2026-05-06T13:40:52.723Z" }, ] [[package]] @@ -1004,7 +1011,7 @@ version = "2.20.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, + { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, ] [[package]] @@ -1012,15 +1019,15 @@ name = "pytest" version = "9.0.3" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "colorama", marker = "sys_platform == 'win32'" }, - { name = "iniconfig" }, - { name = "packaging" }, - { name = "pluggy" }, - { name = "pygments" }, + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, ] sdist = { url = "https://files.pythonhosted.org/packages/7d/0d/549bd94f1a0a402dc8cf64563a117c0f3765662e2e668477624baeec44d5/pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c", size = 1572165, upload-time = "2026-04-07T17:16:18.027Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" }, + { url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" }, ] [[package]] @@ -1028,13 +1035,13 @@ name = "pytest-cov" version = "7.1.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "coverage" }, - { name = "pluggy" }, - { name = "pytest" }, + { name = "coverage" }, + { name = "pluggy" }, + { name = "pytest" }, ] sdist = { url = "https://files.pythonhosted.org/packages/b1/51/a849f96e117386044471c8ec2bd6cfebacda285da9525c9106aeb28da671/pytest_cov-7.1.0.tar.gz", hash = "sha256:30674f2b5f6351aa09702a9c8c364f6a01c27aae0c1366ae8016160d1efc56b2", size = 55592, upload-time = "2026-03-21T20:11:16.284Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/9d/7a/d968e294073affff457b041c2be9868a40c1c71f4a35fcc1e45e5493067b/pytest_cov-7.1.0-py3-none-any.whl", hash = "sha256:a0461110b7865f9a271aa1b51e516c9a95de9d696734a2f71e3e78f46e1d4678", size = 22876, upload-time = "2026-03-21T20:11:14.438Z" }, + { url = "https://files.pythonhosted.org/packages/9d/7a/d968e294073affff457b041c2be9868a40c1c71f4a35fcc1e45e5493067b/pytest_cov-7.1.0-py3-none-any.whl", hash = "sha256:a0461110b7865f9a271aa1b51e516c9a95de9d696734a2f71e3e78f46e1d4678", size = 22876, upload-time = "2026-03-21T20:11:14.438Z" }, ] [[package]] @@ -1042,24 +1049,24 @@ name = "python-dateutil" version = "2.9.0.post0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "six" }, + { name = "six" }, ] sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload-time = "2024-03-01T18:36:20.211Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" }, + { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" }, ] [[package]] name = "python-discovery" -version = "1.2.2" +version = "1.4.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "filelock" }, - { name = "platformdirs" }, + { name = "filelock" }, + { name = "platformdirs" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/de/ef/3bae0e537cfe91e8431efcba4434463d2c5a65f5a89edd47c6cf2f03c55f/python_discovery-1.2.2.tar.gz", hash = "sha256:876e9c57139eb757cb5878cbdd9ae5379e5d96266c99ef731119e04fffe533bb", size = 58872, upload-time = "2026-04-07T17:28:49.249Z" } +sdist = { url = "https://files.pythonhosted.org/packages/a6/12/38c1a0b1e64806780c9563e3fc9f6e472251839662587cfbe9bfaf2ae10a/python_discovery-1.4.0.tar.gz", hash = "sha256:eb8bc7daad3c226c147e45bb4e970a1feb1bf4048ee178e6db59e197b8010ce3", size = 68455, upload-time = "2026-05-28T01:15:37.639Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d8/db/795879cc3ddfe338599bddea6388cc5100b088db0a4caf6e6c1af1c27e04/python_discovery-1.2.2-py3-none-any.whl", hash = "sha256:e1ae95d9af875e78f15e19aed0c6137ab1bb49c200f21f5061786490c9585c7a", size = 31894, upload-time = "2026-04-07T17:28:48.09Z" }, + { url = "https://files.pythonhosted.org/packages/c8/8d/3d316429f65029532bb1e28ff77b797d86b5ac3915bb44ca4e19aa283d43/python_discovery-1.4.0-py3-none-any.whl", hash = "sha256:26ed78d703e234879a66244c7d4114563fb13ec5cd30a2d1357e5fb4850782da", size = 33217, upload-time = "2026-05-28T01:15:36.573Z" }, ] [[package]] @@ -1068,7 +1075,7 @@ version = "1.2.2" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/82/ed/0301aeeac3e5353ef3d94b6ec08bbcabd04a72018415dcb29e588514bba8/python_dotenv-1.2.2.tar.gz", hash = "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3", size = 50135, upload-time = "2026-03-01T16:00:26.196Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" }, + { url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" }, ] [[package]] @@ -1077,121 +1084,121 @@ version = "6.0.3" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" }, - { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" }, - { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" }, - { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" }, - { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" }, - { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" }, - { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" }, - { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" }, - { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" }, - { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, - { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" }, - { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" }, - { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" }, - { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" }, - { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" }, - { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" }, - { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" }, - { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" }, - { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" }, - { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" }, - { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" }, - { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" }, - { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" }, - { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" }, - { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" }, - { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" }, - { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" }, - { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, + { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" }, + { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" }, + { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" }, + { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" }, + { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" }, + { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" }, + { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" }, + { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" }, + { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, + { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" }, + { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" }, + { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" }, + { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" }, + { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" }, + { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" }, + { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" }, + { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" }, + { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" }, + { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" }, + { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" }, + { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" }, + { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" }, + { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" }, + { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, ] [[package]] name = "regex" -version = "2026.4.4" +version = "2026.5.9" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/cb/0e/3a246dbf05666918bd3664d9d787f84a9108f6f43cc953a077e4a7dfdb7e/regex-2026.4.4.tar.gz", hash = "sha256:e08270659717f6973523ce3afbafa53515c4dc5dcad637dc215b6fd50f689423", size = 416000, upload-time = "2026-04-03T20:56:28.155Z" } +sdist = { url = "https://files.pythonhosted.org/packages/dc/0e/49aee608ad09480e7fd276898c99ec6192985fa331abe4eb3a986094490b/regex-2026.5.9.tar.gz", hash = "sha256:a8234aa23ec39894bfe4a3f1b85616a7032481964a13ac6fc9f10de4f6fca270", size = 416074, upload-time = "2026-05-09T23:15:19.37Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/9d/83/c4373bc5f31f2cf4b66f9b7c31005bd87fe66f0dce17701f7db4ee79ee29/regex-2026.4.4-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:62f5519042c101762509b1d717b45a69c0139d60414b3c604b81328c01bd1943", size = 490273, upload-time = "2026-04-03T20:54:11.202Z" }, - { url = "https://files.pythonhosted.org/packages/46/f8/fe62afbcc3cf4ad4ac9adeaafd98aa747869ae12d3e8e2ac293d0593c435/regex-2026.4.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:3790ba9fb5dd76715a7afe34dbe603ba03f8820764b1dc929dd08106214ed031", size = 291954, upload-time = "2026-04-03T20:54:13.412Z" }, - { url = "https://files.pythonhosted.org/packages/5a/92/4712b9fe6a33d232eeb1c189484b80c6c4b8422b90e766e1195d6e758207/regex-2026.4.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8fae3c6e795d7678963f2170152b0d892cf6aee9ee8afc8c45e6be38d5107fe7", size = 289487, upload-time = "2026-04-03T20:54:15.824Z" }, - { url = "https://files.pythonhosted.org/packages/88/2c/f83b93f85e01168f1070f045a42d4c937b69fdb8dd7ae82d307253f7e36e/regex-2026.4.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:298c3ec2d53225b3bf91142eb9691025bab610e0c0c51592dde149db679b3d17", size = 796646, upload-time = "2026-04-03T20:54:18.229Z" }, - { url = "https://files.pythonhosted.org/packages/df/55/61a2e17bf0c4dc57e11caf8dd11771280d8aaa361785f9e3bc40d653f4a7/regex-2026.4.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e9638791082eaf5b3ac112c587518ee78e083a11c4b28012d8fe2a0f536dfb17", size = 865904, upload-time = "2026-04-03T20:54:20.019Z" }, - { url = "https://files.pythonhosted.org/packages/45/32/1ac8ed1b5a346b5993a3d256abe0a0f03b0b73c8cc88d928537368ac65b6/regex-2026.4.4-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ae3e764bd4c5ff55035dc82a8d49acceb42a5298edf6eb2fc4d328ee5dd7afae", size = 912304, upload-time = "2026-04-03T20:54:22.403Z" }, - { url = "https://files.pythonhosted.org/packages/26/47/2ee5c613ab546f0eddebf9905d23e07beb933416b1246c2d8791d01979b4/regex-2026.4.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ffa81f81b80047ba89a3c69ae6a0f78d06f4a42ce5126b0eb2a0a10ad44e0b2e", size = 801126, upload-time = "2026-04-03T20:54:24.308Z" }, - { url = "https://files.pythonhosted.org/packages/75/cd/41dacd129ca9fd20bd7d02f83e0fad83e034ac8a084ec369c90f55ef37e2/regex-2026.4.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f56ebf9d70305307a707911b88469213630aba821e77de7d603f9d2f0730687d", size = 776772, upload-time = "2026-04-03T20:54:26.319Z" }, - { url = "https://files.pythonhosted.org/packages/89/6d/5af0b588174cb5f46041fa7dd64d3fd5cd2fe51f18766703d1edc387f324/regex-2026.4.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:773d1dfd652bbffb09336abf890bfd64785c7463716bf766d0eb3bc19c8b7f27", size = 785228, upload-time = "2026-04-03T20:54:28.387Z" }, - { url = "https://files.pythonhosted.org/packages/b7/3b/f5a72b7045bd59575fc33bf1345f156fcfd5a8484aea6ad84b12c5a82114/regex-2026.4.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:d51d20befd5275d092cdffba57ded05f3c436317ee56466c8928ac32d960edaf", size = 860032, upload-time = "2026-04-03T20:54:30.641Z" }, - { url = "https://files.pythonhosted.org/packages/39/a4/72a317003d6fcd7a573584a85f59f525dfe8f67e355ca74eb6b53d66a5e2/regex-2026.4.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:0a51cdb3c1e9161154f976cb2bef9894bc063ac82f31b733087ffb8e880137d0", size = 765714, upload-time = "2026-04-03T20:54:32.789Z" }, - { url = "https://files.pythonhosted.org/packages/25/1e/5672e16f34dbbcb2560cc7e6a2fbb26dfa8b270711e730101da4423d3973/regex-2026.4.4-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:ae5266a82596114e41fb5302140e9630204c1b5f325c770bec654b95dd54b0aa", size = 852078, upload-time = "2026-04-03T20:54:34.546Z" }, - { url = "https://files.pythonhosted.org/packages/f7/0d/c813f0af7c6cc7ed7b9558bac2e5120b60ad0fa48f813e4d4bd55446f214/regex-2026.4.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:c882cd92ec68585e9c1cf36c447ec846c0d94edd706fe59e0c198e65822fd23b", size = 789181, upload-time = "2026-04-03T20:54:36.642Z" }, - { url = "https://files.pythonhosted.org/packages/ea/6d/a344608d1adbd2a95090ddd906cec09a11be0e6517e878d02a5123e0917f/regex-2026.4.4-cp313-cp313-win32.whl", hash = "sha256:05568c4fbf3cb4fa9e28e3af198c40d3237cf6041608a9022285fe567ec3ad62", size = 266690, upload-time = "2026-04-03T20:54:38.343Z" }, - { url = "https://files.pythonhosted.org/packages/31/07/54049f89b46235ca6f45cd6c88668a7050e77d4a15555e47dd40fde75263/regex-2026.4.4-cp313-cp313-win_amd64.whl", hash = "sha256:3384df51ed52db0bea967e21458ab0a414f67cdddfd94401688274e55147bb81", size = 277733, upload-time = "2026-04-03T20:54:40.11Z" }, - { url = "https://files.pythonhosted.org/packages/0e/21/61366a8e20f4d43fb597708cac7f0e2baadb491ecc9549b4980b2be27d16/regex-2026.4.4-cp313-cp313-win_arm64.whl", hash = "sha256:acd38177bd2c8e69a411d6521760806042e244d0ef94e2dd03ecdaa8a3c99427", size = 270565, upload-time = "2026-04-03T20:54:41.883Z" }, - { url = "https://files.pythonhosted.org/packages/f1/1e/3a2b9672433bef02f5d39aa1143ca2c08f311c1d041c464a42be9ae648dc/regex-2026.4.4-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:f94a11a9d05afcfcfa640e096319720a19cc0c9f7768e1a61fceee6a3afc6c7c", size = 494126, upload-time = "2026-04-03T20:54:43.602Z" }, - { url = "https://files.pythonhosted.org/packages/4e/4b/c132a4f4fe18ad3340d89fcb56235132b69559136036b845be3c073142ed/regex-2026.4.4-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:36bcb9d6d1307ab629edc553775baada2aefa5c50ccc0215fbfd2afcfff43141", size = 293882, upload-time = "2026-04-03T20:54:45.41Z" }, - { url = "https://files.pythonhosted.org/packages/f4/5f/eaa38092ce7a023656280f2341dbbd4ad5f05d780a70abba7bb4f4bea54c/regex-2026.4.4-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:261c015b3e2ed0919157046d768774ecde57f03d8fa4ba78d29793447f70e717", size = 292334, upload-time = "2026-04-03T20:54:47.051Z" }, - { url = "https://files.pythonhosted.org/packages/5f/f6/dd38146af1392dac33db7074ab331cec23cced3759167735c42c5460a243/regex-2026.4.4-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c228cf65b4a54583763645dcd73819b3b381ca8b4bb1b349dee1c135f4112c07", size = 811691, upload-time = "2026-04-03T20:54:49.074Z" }, - { url = "https://files.pythonhosted.org/packages/7a/f0/dc54c2e69f5eeec50601054998ec3690d5344277e782bd717e49867c1d29/regex-2026.4.4-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:dd2630faeb6876fb0c287f664d93ddce4d50cd46c6e88e60378c05c9047e08ca", size = 871227, upload-time = "2026-04-03T20:54:51.035Z" }, - { url = "https://files.pythonhosted.org/packages/a1/af/cb16bd5dc61621e27df919a4449bbb7e5a1034c34d307e0a706e9cc0f3e3/regex-2026.4.4-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:6a50ab11b7779b849472337191f3a043e27e17f71555f98d0092fa6d73364520", size = 917435, upload-time = "2026-04-03T20:54:52.994Z" }, - { url = "https://files.pythonhosted.org/packages/5c/71/8b260897f22996b666edd9402861668f45a2ca259f665ac029e6104a2d7d/regex-2026.4.4-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0734f63afe785138549fbe822a8cfeaccd1bae814c5057cc0ed5b9f2de4fc883", size = 816358, upload-time = "2026-04-03T20:54:54.884Z" }, - { url = "https://files.pythonhosted.org/packages/1c/60/775f7f72a510ef238254906c2f3d737fc80b16ca85f07d20e318d2eea894/regex-2026.4.4-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c4ee50606cb1967db7e523224e05f32089101945f859928e65657a2cbb3d278b", size = 785549, upload-time = "2026-04-03T20:54:57.01Z" }, - { url = "https://files.pythonhosted.org/packages/58/42/34d289b3627c03cf381e44da534a0021664188fa49ba41513da0b4ec6776/regex-2026.4.4-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:6c1818f37be3ca02dcb76d63f2c7aaba4b0dc171b579796c6fbe00148dfec6b1", size = 801364, upload-time = "2026-04-03T20:54:58.981Z" }, - { url = "https://files.pythonhosted.org/packages/fc/20/f6ecf319b382a8f1ab529e898b222c3f30600fcede7834733c26279e7465/regex-2026.4.4-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:f5bfc2741d150d0be3e4a0401a5c22b06e60acb9aa4daa46d9e79a6dcd0f135b", size = 866221, upload-time = "2026-04-03T20:55:00.88Z" }, - { url = "https://files.pythonhosted.org/packages/92/6a/9f16d3609d549bd96d7a0b2aee1625d7512ba6a03efc01652149ef88e74d/regex-2026.4.4-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:504ffa8a03609a087cad81277a629b6ce884b51a24bd388a7980ad61748618ff", size = 772530, upload-time = "2026-04-03T20:55:03.213Z" }, - { url = "https://files.pythonhosted.org/packages/fa/f6/aa9768bc96a4c361ac96419fbaf2dcdc33970bb813df3ba9b09d5d7b6d96/regex-2026.4.4-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:70aadc6ff12e4b444586e57fc30771f86253f9f0045b29016b9605b4be5f7dfb", size = 856989, upload-time = "2026-04-03T20:55:05.087Z" }, - { url = "https://files.pythonhosted.org/packages/4d/b4/c671db3556be2473ae3e4bb7a297c518d281452871501221251ea4ecba57/regex-2026.4.4-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:f4f83781191007b6ef43b03debc35435f10cad9b96e16d147efe84a1d48bdde4", size = 803241, upload-time = "2026-04-03T20:55:07.162Z" }, - { url = "https://files.pythonhosted.org/packages/2a/5c/83e3b1d89fa4f6e5a1bc97b4abd4a9a97b3c1ac7854164f694f5f0ba98a0/regex-2026.4.4-cp313-cp313t-win32.whl", hash = "sha256:e014a797de43d1847df957c0a2a8e861d1c17547ee08467d1db2c370b7568baa", size = 269921, upload-time = "2026-04-03T20:55:09.62Z" }, - { url = "https://files.pythonhosted.org/packages/28/07/077c387121f42cdb4d92b1301133c0d93b5709d096d1669ab847dda9fe2e/regex-2026.4.4-cp313-cp313t-win_amd64.whl", hash = "sha256:b15b88b0d52b179712632832c1d6e58e5774f93717849a41096880442da41ab0", size = 281240, upload-time = "2026-04-03T20:55:11.521Z" }, - { url = "https://files.pythonhosted.org/packages/9d/22/ead4a4abc7c59a4d882662aa292ca02c8b617f30b6e163bc1728879e9353/regex-2026.4.4-cp313-cp313t-win_arm64.whl", hash = "sha256:586b89cdadf7d67bf86ae3342a4dcd2b8d70a832d90c18a0ae955105caf34dbe", size = 272440, upload-time = "2026-04-03T20:55:13.365Z" }, - { url = "https://files.pythonhosted.org/packages/f0/f5/ed97c2dc47b5fbd4b73c0d7d75f9ebc8eca139f2bbef476bba35f28c0a77/regex-2026.4.4-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:2da82d643fa698e5e5210e54af90181603d5853cf469f5eedf9bfc8f59b4b8c7", size = 490343, upload-time = "2026-04-03T20:55:15.241Z" }, - { url = "https://files.pythonhosted.org/packages/80/e9/de4828a7385ec166d673a5790ad06ac48cdaa98bc0960108dd4b9cc1aef7/regex-2026.4.4-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:54a1189ad9d9357760557c91103d5e421f0a2dabe68a5cdf9103d0dcf4e00752", size = 291909, upload-time = "2026-04-03T20:55:17.558Z" }, - { url = "https://files.pythonhosted.org/packages/b4/d6/5cfbfc97f3201a4d24b596a77957e092030dcc4205894bc035cedcfce62f/regex-2026.4.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:76d67d5afb1fe402d10a6403bae668d000441e2ab115191a804287d53b772951", size = 289692, upload-time = "2026-04-03T20:55:20.561Z" }, - { url = "https://files.pythonhosted.org/packages/8e/ac/f2212d9fd56fe897e36d0110ba30ba2d247bd6410c5bd98499c7e5a1e1f2/regex-2026.4.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e7cd3e4ee8d80447a83bbc9ab0c8459781fa77087f856c3e740d7763be0df27f", size = 796979, upload-time = "2026-04-03T20:55:22.56Z" }, - { url = "https://files.pythonhosted.org/packages/c9/e3/a016c12675fbac988a60c7e1c16e67823ff0bc016beb27bd7a001dbdabc6/regex-2026.4.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2e19e18c568d2866d8b6a6dfad823db86193503f90823a8f66689315ba28fbe8", size = 866744, upload-time = "2026-04-03T20:55:24.646Z" }, - { url = "https://files.pythonhosted.org/packages/af/a4/0b90ca4cf17adc3cb43de80ec71018c37c88ad64987e8d0d481a95ca60b5/regex-2026.4.4-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:7698a6f38730fd1385d390d1ed07bb13dce39aa616aca6a6d89bea178464b9a4", size = 911613, upload-time = "2026-04-03T20:55:27.033Z" }, - { url = "https://files.pythonhosted.org/packages/8e/3b/2b3dac0b82d41ab43aa87c6ecde63d71189d03fe8854b8ca455a315edac3/regex-2026.4.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:173a66f3651cdb761018078e2d9487f4cf971232c990035ec0eb1cdc6bf929a9", size = 800551, upload-time = "2026-04-03T20:55:29.532Z" }, - { url = "https://files.pythonhosted.org/packages/25/fe/5365eb7aa0e753c4b5957815c321519ecab033c279c60e1b1ae2367fa810/regex-2026.4.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fa7922bbb2cc84fa062d37723f199d4c0cd200245ce269c05db82d904db66b83", size = 776911, upload-time = "2026-04-03T20:55:31.526Z" }, - { url = "https://files.pythonhosted.org/packages/aa/b3/7fb0072156bba065e3b778a7bc7b0a6328212be5dd6a86fd207e0c4f2dab/regex-2026.4.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:59f67cd0a0acaf0e564c20bbd7f767286f23e91e2572c5703bf3e56ea7557edb", size = 785751, upload-time = "2026-04-03T20:55:33.797Z" }, - { url = "https://files.pythonhosted.org/packages/02/1a/9f83677eb699273e56e858f7bd95acdbee376d42f59e8bfca2fd80d79df3/regex-2026.4.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:475e50f3f73f73614f7cba5524d6de49dee269df00272a1b85e3d19f6d498465", size = 860484, upload-time = "2026-04-03T20:55:35.745Z" }, - { url = "https://files.pythonhosted.org/packages/3b/7a/93937507b61cfcff8b4c5857f1b452852b09f741daa9acae15c971d8554e/regex-2026.4.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:a1c0c7d67b64d85ac2e1879923bad2f08a08f3004055f2f406ef73c850114bd4", size = 765939, upload-time = "2026-04-03T20:55:37.972Z" }, - { url = "https://files.pythonhosted.org/packages/86/ea/81a7f968a351c6552b1670ead861e2a385be730ee28402233020c67f9e0f/regex-2026.4.4-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:1371c2ccbb744d66ee63631cc9ca12aa233d5749972626b68fe1a649dd98e566", size = 851417, upload-time = "2026-04-03T20:55:39.92Z" }, - { url = "https://files.pythonhosted.org/packages/4c/7e/323c18ce4b5b8f44517a36342961a0306e931e499febbd876bb149d900f0/regex-2026.4.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:59968142787042db793348a3f5b918cf24ced1f23247328530e063f89c128a95", size = 789056, upload-time = "2026-04-03T20:55:42.303Z" }, - { url = "https://files.pythonhosted.org/packages/c0/af/e7510f9b11b1913b0cd44eddb784b2d650b2af6515bfce4cffcc5bfd1d38/regex-2026.4.4-cp314-cp314-win32.whl", hash = "sha256:59efe72d37fd5a91e373e5146f187f921f365f4abc1249a5ab446a60f30dd5f8", size = 272130, upload-time = "2026-04-03T20:55:44.995Z" }, - { url = "https://files.pythonhosted.org/packages/9a/51/57dae534c915e2d3a21490e88836fa2ae79dde3b66255ecc0c0a155d2c10/regex-2026.4.4-cp314-cp314-win_amd64.whl", hash = "sha256:e0aab3ff447845049d676827d2ff714aab4f73f340e155b7de7458cf53baa5a4", size = 280992, upload-time = "2026-04-03T20:55:47.316Z" }, - { url = "https://files.pythonhosted.org/packages/0a/5e/abaf9f4c3792e34edb1434f06717fae2b07888d85cb5cec29f9204931bf8/regex-2026.4.4-cp314-cp314-win_arm64.whl", hash = "sha256:a7a5bb6aa0cf62208bb4fa079b0c756734f8ad0e333b425732e8609bd51ee22f", size = 273563, upload-time = "2026-04-03T20:55:49.273Z" }, - { url = "https://files.pythonhosted.org/packages/ff/06/35da85f9f217b9538b99cbb170738993bcc3b23784322decb77619f11502/regex-2026.4.4-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:97850d0638391bdc7d35dc1c1039974dcb921eaafa8cc935ae4d7f272b1d60b3", size = 494191, upload-time = "2026-04-03T20:55:51.258Z" }, - { url = "https://files.pythonhosted.org/packages/54/5b/1bc35f479eef8285c4baf88d8c002023efdeebb7b44a8735b36195486ae7/regex-2026.4.4-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:ee7337f88f2a580679f7bbfe69dc86c043954f9f9c541012f49abc554a962f2e", size = 293877, upload-time = "2026-04-03T20:55:53.214Z" }, - { url = "https://files.pythonhosted.org/packages/39/5b/f53b9ad17480b3ddd14c90da04bfb55ac6894b129e5dea87bcaf7d00e336/regex-2026.4.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7429f4e6192c11d659900c0648ba8776243bf396ab95558b8c51a345afeddde6", size = 292410, upload-time = "2026-04-03T20:55:55.736Z" }, - { url = "https://files.pythonhosted.org/packages/bb/56/52377f59f60a7c51aa4161eecf0b6032c20b461805aca051250da435ffc9/regex-2026.4.4-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dc4f10fbd5dd13dcf4265b4cc07d69ca70280742870c97ae10093e3d66000359", size = 811831, upload-time = "2026-04-03T20:55:57.802Z" }, - { url = "https://files.pythonhosted.org/packages/dd/63/8026310bf066f702a9c361f83a8c9658f3fe4edb349f9c1e5d5273b7c40c/regex-2026.4.4-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a152560af4f9742b96f3827090f866eeec5becd4765c8e0d3473d9d280e76a5a", size = 871199, upload-time = "2026-04-03T20:56:00.333Z" }, - { url = "https://files.pythonhosted.org/packages/20/9f/a514bbb00a466dbb506d43f187a04047f7be1505f10a9a15615ead5080ee/regex-2026.4.4-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:54170b3e95339f415d54651f97df3bff7434a663912f9358237941bbf9143f55", size = 917649, upload-time = "2026-04-03T20:56:02.445Z" }, - { url = "https://files.pythonhosted.org/packages/cb/6b/8399f68dd41a2030218839b9b18360d79b86d22b9fab5ef477c7f23ca67c/regex-2026.4.4-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:07f190d65f5a72dcb9cf7106bfc3d21e7a49dd2879eda2207b683f32165e4d99", size = 816388, upload-time = "2026-04-03T20:56:04.595Z" }, - { url = "https://files.pythonhosted.org/packages/1e/9c/103963f47c24339a483b05edd568594c2be486188f688c0170fd504b2948/regex-2026.4.4-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9a2741ce5a29d3c84b0b94261ba630ab459a1b847a0d6beca7d62d188175c790", size = 785746, upload-time = "2026-04-03T20:56:07.13Z" }, - { url = "https://files.pythonhosted.org/packages/fa/ee/7f6054c0dec0cee3463c304405e4ff42e27cff05bf36fcb34be549ab17bd/regex-2026.4.4-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:b26c30df3a28fd9793113dac7385a4deb7294a06c0f760dd2b008bd49a9139bc", size = 801483, upload-time = "2026-04-03T20:56:09.365Z" }, - { url = "https://files.pythonhosted.org/packages/30/c2/51d3d941cf6070dc00c3338ecf138615fc3cce0421c3df6abe97a08af61a/regex-2026.4.4-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:421439d1bee44b19f4583ccf42670ca464ffb90e9fdc38d37f39d1ddd1e44f1f", size = 866331, upload-time = "2026-04-03T20:56:12.039Z" }, - { url = "https://files.pythonhosted.org/packages/16/e8/76d50dcc122ac33927d939f350eebcfe3dbcbda96913e03433fc36de5e63/regex-2026.4.4-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:b40379b53ecbc747fd9bdf4a0ea14eb8188ca1bd0f54f78893a39024b28f4863", size = 772673, upload-time = "2026-04-03T20:56:14.558Z" }, - { url = "https://files.pythonhosted.org/packages/a5/6e/5f6bf75e20ea6873d05ba4ec78378c375cbe08cdec571c83fbb01606e563/regex-2026.4.4-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:08c55c13d2eef54f73eeadc33146fb0baaa49e7335eb1aff6ae1324bf0ddbe4a", size = 857146, upload-time = "2026-04-03T20:56:16.663Z" }, - { url = "https://files.pythonhosted.org/packages/0b/33/3c76d9962949e487ebba353a18e89399f292287204ac8f2f4cfc3a51c233/regex-2026.4.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:9776b85f510062f5a75ef112afe5f494ef1635607bf1cc220c1391e9ac2f5e81", size = 803463, upload-time = "2026-04-03T20:56:18.923Z" }, - { url = "https://files.pythonhosted.org/packages/19/eb/ef32dcd2cb69b69bc0c3e55205bce94a7def48d495358946bc42186dcccc/regex-2026.4.4-cp314-cp314t-win32.whl", hash = "sha256:385edaebde5db5be103577afc8699fea73a0e36a734ba24870be7ffa61119d74", size = 275709, upload-time = "2026-04-03T20:56:20.996Z" }, - { url = "https://files.pythonhosted.org/packages/a0/86/c291bf740945acbf35ed7dbebf8e2eea2f3f78041f6bd7cdab80cb274dc0/regex-2026.4.4-cp314-cp314t-win_amd64.whl", hash = "sha256:5d354b18839328927832e2fa5f7c95b7a3ccc39e7a681529e1685898e6436d45", size = 285622, upload-time = "2026-04-03T20:56:23.641Z" }, - { url = "https://files.pythonhosted.org/packages/d5/e7/ec846d560ae6a597115153c02ca6138a7877a1748b2072d9521c10a93e58/regex-2026.4.4-cp314-cp314t-win_arm64.whl", hash = "sha256:af0384cb01a33600c49505c27c6c57ab0b27bf84a74e28524c92ca897ebdac9d", size = 275773, upload-time = "2026-04-03T20:56:26.07Z" }, + { url = "https://files.pythonhosted.org/packages/aa/da/797e91ecec6f84135da778ddce78c20e0af5d2a15c26f87a81bc3eadb6db/regex-2026.5.9-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:d626b84406444b165fc0ba981604edea39f0588ff1f92baa23fe50799ea9afdb", size = 490303, upload-time = "2026-05-09T23:13:04.382Z" }, + { url = "https://files.pythonhosted.org/packages/44/da/bf30abaaa737b58f4a4b8c4a03659e02fd92092c822e0197ed9e0daab917/regex-2026.5.9-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:d7bdc0ab8f3dd7e1b4f9ab88634e13374669db86bb3c72e8292f07ae313f539f", size = 292019, upload-time = "2026-05-09T23:13:06.022Z" }, + { url = "https://files.pythonhosted.org/packages/2d/e7/d0eaf5713828417b9e5648cf81fa9bacd4961f6ab98c380c2034f8716e35/regex-2026.5.9-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a8820737949116ffff55fe18f9fc644530063ba6ebfcb8314239416e78f1347c", size = 289468, upload-time = "2026-05-09T23:13:08.214Z" }, + { url = "https://files.pythonhosted.org/packages/d3/9b/b3fdd62b003baa1a9b593cd8c8699c9651c2e80cc21a5c715707983c42d7/regex-2026.5.9-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:aa0fbdbac82cb3e4450d0ccde7d7a35607f4cb2dd9fba4b8b69bfaf8c9fa6aed", size = 796749, upload-time = "2026-05-09T23:13:10.573Z" }, + { url = "https://files.pythonhosted.org/packages/d4/30/66ab84588765f5b4b271a9ca09ef7ce2b87caa95176ec3d2ad65d7bc4902/regex-2026.5.9-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:57e8915c7986aa33d25e4d3629cef711cd2863f2961b10409f0c04cb8b7d9020", size = 865445, upload-time = "2026-05-09T23:13:12.523Z" }, + { url = "https://files.pythonhosted.org/packages/1a/89/f05169e8588aac365f35ffc7f3bc3184f095ef4cfded7cfaa3c7fd5dbd89/regex-2026.5.9-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:508f56a89ba9cb26e4168cbc37dbd60a28d82430a9e18ad1d25fe0883c314ca2", size = 912322, upload-time = "2026-05-09T23:13:14.281Z" }, + { url = "https://files.pythonhosted.org/packages/30/e1/c93444052cf41581f3c884ab3fb5823daf0992f11cd4388d4275ca610558/regex-2026.5.9-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b6d189041f15691cfa2b6c4290448ec221244d225b3f5fe9e7771b34ffcdf6e2", size = 801269, upload-time = "2026-05-09T23:13:16.569Z" }, + { url = "https://files.pythonhosted.org/packages/50/fe/0cf96b882f540e62e8b9956599798203d599c44cf4c77917ca27400ff69b/regex-2026.5.9-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e82db382b44d0111b22601c509c89f64434816c9e0eef9d1989cda8cc6ff1c04", size = 777085, upload-time = "2026-05-09T23:13:18.675Z" }, + { url = "https://files.pythonhosted.org/packages/23/5c/d78d4924e7fc875557b9e9b768423925fdfaac5549d06da7810019a9bd26/regex-2026.5.9-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:2acfb48634f64996b57f90f39afa692ff362162722581921fe92239a59960f3c", size = 785153, upload-time = "2026-05-09T23:13:20.525Z" }, + { url = "https://files.pythonhosted.org/packages/bf/e0/5214774090e7b4524dcea3e3c4aa74141d43043f8beb49c1599db1c8b53a/regex-2026.5.9-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:d29eebfc9525db68cad3c97eedd7f754fa265aa5cd0cf4f863b2421e1b48fc9f", size = 860164, upload-time = "2026-05-09T23:13:22.263Z" }, + { url = "https://files.pythonhosted.org/packages/6e/e1/4a57a83350319b1271f0d7a249b8672513ed928b237a741631270de6caea/regex-2026.5.9-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:debb893095e944091c16e641a6e33c1b0f4cb61ab945ec5afbf53ce7068834d8", size = 765731, upload-time = "2026-05-09T23:13:24.277Z" }, + { url = "https://files.pythonhosted.org/packages/12/f4/499e74a20c156fc75836ee04a72a38d1a063978f600937f9760467beb1b0/regex-2026.5.9-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:d659eee77986549c9ea45b861c7567e44d6287c3dc9a4565478853f7b9fe2ff6", size = 852062, upload-time = "2026-05-09T23:13:26.125Z" }, + { url = "https://files.pythonhosted.org/packages/5b/92/7eebc0d0a01e78629695f342ba17e0deaff8fb45e79cc0d7b98287da6e3e/regex-2026.5.9-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:2efa205e6d98b24d1f3ab395c11aa15cdf10935bca283d0285e0499c284fba21", size = 789577, upload-time = "2026-05-09T23:13:27.814Z" }, + { url = "https://files.pythonhosted.org/packages/05/a4/018e71f7d2ad48c1ebe6d3ae0026f9b7cb4802fd15c7cc02fdf724355102/regex-2026.5.9-cp313-cp313-win32.whl", hash = "sha256:f3844f134e834076677dd369976e9f5068679fcb8e50102fdf6b7ac96a3ec127", size = 266691, upload-time = "2026-05-09T23:13:29.549Z" }, + { url = "https://files.pythonhosted.org/packages/e6/1d/861a93719fb9ee7dbfc3761b3797b7a3e112a5d42c6129459d2d741be9b5/regex-2026.5.9-cp313-cp313-win_amd64.whl", hash = "sha256:3527bb4942d2c14552155406cdedd906567456821848aed1cb4933a391bf5eca", size = 277747, upload-time = "2026-05-09T23:13:31.859Z" }, + { url = "https://files.pythonhosted.org/packages/d9/c6/0a2436ae4da1ba76e51cb98943c6838a9a721faa40ebe2dce07694ae34e3/regex-2026.5.9-cp313-cp313-win_arm64.whl", hash = "sha256:56a33f191f17d8c417f99945ebdc1e691d3af9605d86ec68c7e54a57e3e17af6", size = 270500, upload-time = "2026-05-09T23:13:33.525Z" }, + { url = "https://files.pythonhosted.org/packages/e8/e9/d21346f7b60ed58789371358ed66b09d00f832e1bd7c06e55d9da5679882/regex-2026.5.9-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:01f28d868834624c934b8d2e0aa1c8341337e37831f4a012f18a5afcba4cbaf3", size = 494172, upload-time = "2026-05-09T23:13:35.935Z" }, + { url = "https://files.pythonhosted.org/packages/c4/43/fd1177a2032037c681baecdb3422ee4e1424aec4e4f470ef47793d325274/regex-2026.5.9-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:48036f6374aaa79eb3b754ec29c61d1c6b1606749d705a13f8854fa2539671f6", size = 293952, upload-time = "2026-05-09T23:13:38.307Z" }, + { url = "https://files.pythonhosted.org/packages/f2/7d/9fbf919768368d3f8a4f6c692cf2aa61e482b2b81ec6a298ace4cbf02480/regex-2026.5.9-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:b96350aa424e79d4fd6b567b344dcbe2b2d6bfc48dfe7717587e1fa6d43da6ff", size = 292314, upload-time = "2026-05-09T23:13:40.353Z" }, + { url = "https://files.pythonhosted.org/packages/e2/6c/e41bfeecb589716843e7c4df09ba46ff2a42961457afece19059d85caeef/regex-2026.5.9-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8f3af7a4903c5c04a11a196a5aa75cdd7dd3f8508132f9fb3259d9f5908e3b88", size = 811681, upload-time = "2026-05-09T23:13:42.543Z" }, + { url = "https://files.pythonhosted.org/packages/87/83/a5c1c525fba0aa656e88ad0face0b1829788ef4c2fb6b26df58aa1151b84/regex-2026.5.9-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7e87577720152d2caae19fe2baaf1f8d5ca12091e9e229f03915c37d1e4b9178", size = 871135, upload-time = "2026-05-09T23:13:44.326Z" }, + { url = "https://files.pythonhosted.org/packages/18/d4/80882e799e440dd878b0979cbebf8fa4d54624a332c83037c7a701649e3f/regex-2026.5.9-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c8b9b9d294cfea3cd19c718ade7cc93492b2c4991abd9a68d0b3477ae6d8e100", size = 917265, upload-time = "2026-05-09T23:13:47.295Z" }, + { url = "https://files.pythonhosted.org/packages/ae/ff/8db60211e2286e396aad7dc7725356c502bff0901ea05bd6cdc2e1a042b9/regex-2026.5.9-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:728d8bfd28a8845c8b6bc5dc7ce010453d206396786c0765c2740cb65f37791e", size = 816311, upload-time = "2026-05-09T23:13:49.885Z" }, + { url = "https://files.pythonhosted.org/packages/4c/47/742ef579c61730f8d268e5cf1f9ce0e37e2ea041ad0f5644724f2378e463/regex-2026.5.9-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7e30b874d341fac767d7df5a0870540541c2c054b80cfaac116e8d367a8a7ff2", size = 785498, upload-time = "2026-05-09T23:13:52.25Z" }, + { url = "https://files.pythonhosted.org/packages/7f/ab/cb0999802dcb0fb95b1ab005e8d4163d8afdd67efc2cb6b6630ac13f8cb1/regex-2026.5.9-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:fd190e88a895a8901325fad284a3f74ea52b1da8525b76cc811fa9b1edf0ce2b", size = 801348, upload-time = "2026-05-09T23:13:54.127Z" }, + { url = "https://files.pythonhosted.org/packages/7d/62/8ca59a24c55bc34d166eefaf3717bd77772f329fdbf984d86581e0a3571c/regex-2026.5.9-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:8e76e8161ad00694cfce6767d5dea860c6391ac5b83e5c3a39661e696f11fc7e", size = 866493, upload-time = "2026-05-09T23:13:56.067Z" }, + { url = "https://files.pythonhosted.org/packages/8d/3d/30f2ae62cef3278bb5bb821f467277a55fb73f01032cf85997e15e8289a8/regex-2026.5.9-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:ddda5340e6c01a293027dd46232fa79eaff1b48058ce7a98f572b6445b088041", size = 772811, upload-time = "2026-05-09T23:13:57.867Z" }, + { url = "https://files.pythonhosted.org/packages/d8/ae/7d2089bcd78ad0c0161bc684339df50032acb438a7bd3305e7ddb1193cec/regex-2026.5.9-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:205109e96b3cf5adf8f4cd62bedde9487feb282b9497a3535451e5a24cd706a0", size = 856584, upload-time = "2026-05-09T23:13:59.679Z" }, + { url = "https://files.pythonhosted.org/packages/a9/29/92ff47f75990131ea4f24ba17819e5a9d141e10819807e09addd73409af6/regex-2026.5.9-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:dfbe4579b9f08036aa7d101d1835437a20783574ac66327e6b29b4018a138081", size = 803453, upload-time = "2026-05-09T23:14:01.978Z" }, + { url = "https://files.pythonhosted.org/packages/04/99/eff29f1037dcab36702c9ee5d6858cf1ce2336ea8ea2987f64245b99ea5e/regex-2026.5.9-cp313-cp313t-win32.whl", hash = "sha256:ed2c9e8068b614c574d8d30e543d617cf5379b0535d46f97ef00e904745a08b5", size = 269951, upload-time = "2026-05-09T23:14:03.661Z" }, + { url = "https://files.pythonhosted.org/packages/0e/9d/8870b8981d27b22cda77bb26a5ac7ebfa9c7d9e0dea195a834a82380e748/regex-2026.5.9-cp313-cp313t-win_amd64.whl", hash = "sha256:b46b0f094dc1d3b90356c85a0bd2c9bafc4a6a190b9d6f8ddd5a033b6e088ed4", size = 281240, upload-time = "2026-05-09T23:14:05.56Z" }, + { url = "https://files.pythonhosted.org/packages/72/b1/3379415e8f135c13ac551353397cc4fe97b4978f3cac73c5fcbcded548b8/regex-2026.5.9-cp313-cp313t-win_arm64.whl", hash = "sha256:872acc074bd29ffc9913ecdfedf6ea77502312ca44a4aa0d3779089c6069d8de", size = 272383, upload-time = "2026-05-09T23:14:07.843Z" }, + { url = "https://files.pythonhosted.org/packages/13/3e/9c3cd292d8808b3645a2ce517e200179b6d0e903f176300bd8b542e14de5/regex-2026.5.9-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:1bd7587a2948b4085195d5a3374eaf4a425dc3e55784c038175355ecf3bbbf8a", size = 490376, upload-time = "2026-05-09T23:14:09.64Z" }, + { url = "https://files.pythonhosted.org/packages/60/70/d43ee8a2ca0a8b68d167f21658b85520ac0574617c7f320367c5047f7556/regex-2026.5.9-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:dea2e88e1cce4522496cce630e11e67b98b7076620bc4336c3f674bc21a375f4", size = 291964, upload-time = "2026-05-09T23:14:11.424Z" }, + { url = "https://files.pythonhosted.org/packages/21/91/9d50b433828d8e74196904e168a43abf1e6e88b2a15d47ed742456720c37/regex-2026.5.9-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:2099f7e7ff7b6aa3192312650a56e91cc091e49d50b04e4f6f8b6e28b3b27f1c", size = 289682, upload-time = "2026-05-09T23:14:13.123Z" }, + { url = "https://files.pythonhosted.org/packages/3e/d2/b835e3cafbb9d977736912436259ff551d60919f7d7b3d37d46659c63564/regex-2026.5.9-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ecd353045824e4477562a2ac718c25799cdaaa41f7aa925a806a8a3e6848a5b9", size = 796996, upload-time = "2026-05-09T23:14:14.923Z" }, + { url = "https://files.pythonhosted.org/packages/2c/a6/9f992d00019166b9de01c546dd4549bc679f2a68df11b877740b0760b7c2/regex-2026.5.9-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:65c8c8c37377794bd5b2f3ebe51919042bf17aec802e23c833d89782ed0c78af", size = 866089, upload-time = "2026-05-09T23:14:17.757Z" }, + { url = "https://files.pythonhosted.org/packages/e0/08/4d32af657e049b19cb62b02e46e38fe1518797bfb2203ee93a510b21b0dc/regex-2026.5.9-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5b73ab8afcf66c622db143d1c6fda4e58e4d537ee4f125229ad47b1ab80f34c0", size = 911530, upload-time = "2026-05-09T23:14:20.353Z" }, + { url = "https://files.pythonhosted.org/packages/d9/27/2af43dd1dc201d1fecefda64a45f4ad0995855b92724f795a777b402ee69/regex-2026.5.9-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0de5cf193997384ed2ca6f1cd4f78055b255d93d82d5a8cd6ba0d11c10b167e4", size = 800643, upload-time = "2026-05-09T23:14:22.265Z" }, + { url = "https://files.pythonhosted.org/packages/a4/dd/23a249047013b5321d4a60c4d2437462086f601b061776a525e5fba2a59f/regex-2026.5.9-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d641a8c9a61618047796d572a39a79b26167b0411d2c3031937b2fe2d081e2cf", size = 777223, upload-time = "2026-05-09T23:14:24.179Z" }, + { url = "https://files.pythonhosted.org/packages/94/6a/e85ed9538cd19586d0465076a4578a12e093ce776d15f3f8ce92733a8dd6/regex-2026.5.9-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:24b2355ef5cc9aa5b8f07d17704face1c166fdcc2290fa7bd6e6c925655a8346", size = 785760, upload-time = "2026-05-09T23:14:26.065Z" }, + { url = "https://files.pythonhosted.org/packages/2a/c4/f25473209438638e947c55f9156fd8f236f74169229028cc99116380868e/regex-2026.5.9-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:a24852d3c29ad9e47593593d8a247c44ccc3d0548ef12c822d6ed0810affe676", size = 860891, upload-time = "2026-05-09T23:14:28.17Z" }, + { url = "https://files.pythonhosted.org/packages/f9/f7/f4f86e3c74419c37370e91f150ae0c2ef7d34b2e0e4cdd5da046a02e4022/regex-2026.5.9-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:916714069da19329ef7de197dcbc77bb3104145c7c2c864dbfbe318f46b88b14", size = 765891, upload-time = "2026-05-09T23:14:30.06Z" }, + { url = "https://files.pythonhosted.org/packages/26/70/704d8e13765939146b1cd0ef4e2feb71d7929727d2290f026eed10095955/regex-2026.5.9-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:fa411799ca8da32a8d38d020a88faa5b6f91657d284761352940ecf9f7c3bbdd", size = 851380, upload-time = "2026-05-09T23:14:32.123Z" }, + { url = "https://files.pythonhosted.org/packages/26/29/1a13582a8460038edc38e49f64ceb0dd7c60f5caba77571f4bf6601965d9/regex-2026.5.9-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:1e6da47d679b7010ef27556b6e0f99771b744936db1792a10ceac6547ae1503e", size = 789350, upload-time = "2026-05-09T23:14:34.799Z" }, + { url = "https://files.pythonhosted.org/packages/73/56/3dcafe34fc72e271d62ad9a291801e88a1457bb251c132f15fcc2e5aad1a/regex-2026.5.9-cp314-cp314-win32.whl", hash = "sha256:98bd73080e8756255137e1bd3f3f00295bbc5aa383c0e0f973920e9134d7c4ad", size = 272130, upload-time = "2026-05-09T23:14:36.729Z" }, + { url = "https://files.pythonhosted.org/packages/d0/9c/02eebf0be95efe416c664db7fb8b6b05b7a0b06a7544f2884f2558b0526f/regex-2026.5.9-cp314-cp314-win_amd64.whl", hash = "sha256:ff8d372ac2acdc048d1c19916f27ee61bc5722728458ba6ca5052f2c72d51763", size = 280999, upload-time = "2026-05-09T23:14:39.126Z" }, + { url = "https://files.pythonhosted.org/packages/70/5a/1dd1abee76cb7a846a0bcf42fdc87e5720c3c33c24f3e37814310a513d9f/regex-2026.5.9-cp314-cp314-win_arm64.whl", hash = "sha256:e1d93bf647916292e8edcec150c07ddf3dc50179ccaf770c04a7f9e452155372", size = 273500, upload-time = "2026-05-09T23:14:41.059Z" }, + { url = "https://files.pythonhosted.org/packages/86/c1/c5f619b0057a7965cb78ec559c1d7a45ce8c99a35bea95483d64959a93d9/regex-2026.5.9-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:83d0ee4a57d1c87cb549e195ec300b8f0ec3a82eba66d835e4e2ed8634fe4499", size = 494269, upload-time = "2026-05-09T23:14:42.869Z" }, + { url = "https://files.pythonhosted.org/packages/05/2c/5d01f1aee33de4bbe60c8452945bfc8477ca7c5ae4450f6bfe711036cb36/regex-2026.5.9-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:d3d7eb5c9a7f6df82ed3cfac9beb93882a5cbcb5b8b157b56cb2b3b276574ac1", size = 293954, upload-time = "2026-05-09T23:14:44.822Z" }, + { url = "https://files.pythonhosted.org/packages/7a/fe/e8988b2ae2108c6ef71bd4aa8d87fbe257976dd0810e826cd75f701c68b6/regex-2026.5.9-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:075160bf16658e16d35233300b8453aac25de4cbea808d22348b6979668e924d", size = 292405, upload-time = "2026-05-09T23:14:47.211Z" }, + { url = "https://files.pythonhosted.org/packages/79/34/d2b0937faa7859263f7f0a3c6b103a1296306be6952dc173d0154e9a2f49/regex-2026.5.9-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:45375819235558a4ff1c4971dc32881f022613abdb180128f5cb4768c1765a1c", size = 811855, upload-time = "2026-05-09T23:14:49.21Z" }, + { url = "https://files.pythonhosted.org/packages/80/fe/daf53a47457a8486db66c66c01ceb9c2303eecee3f87197f1e77eb1a736d/regex-2026.5.9-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ead4b163ac30a29574510cd4b3e2e985ac5290c05fc7095557d6a5f403fc31b5", size = 871189, upload-time = "2026-05-09T23:14:51.555Z" }, + { url = "https://files.pythonhosted.org/packages/1c/75/058fc4470cbfbf57d800aff1a0022b929a3f9fa553ee10a0cdf2070eb31f/regex-2026.5.9-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8c6e4218fbdfbcd4f6c19efca40930d24a621bf4b48cb76bc6640543bd28ef20", size = 917485, upload-time = "2026-05-09T23:14:53.633Z" }, + { url = "https://files.pythonhosted.org/packages/88/e7/179cfda3a28bc843b5c6cfe7f79f23489c791ed95f151083803660878432/regex-2026.5.9-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6351571c8a42b505eb555c0dc47d740d0fb66977dc142919eea6f4325b7c56a0", size = 816369, upload-time = "2026-05-09T23:14:56.198Z" }, + { url = "https://files.pythonhosted.org/packages/41/90/6f0cc422071688266d344fca8462d787cba0a2c144acb25721f9a61ec265/regex-2026.5.9-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:002205cafd2a9e78c6290c7d1df277bf3277b3b7a30e0b4bb0dac2e2e3f7cb2d", size = 785869, upload-time = "2026-05-09T23:14:58.602Z" }, + { url = "https://files.pythonhosted.org/packages/02/67/a31f1760f09c27b251ef39e9beb541f462cf977381d067faa764c2c0e393/regex-2026.5.9-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8abd33fef90b2a9efac5557d6033ca82d1195ed3a15fea5af15ba7b463c6a63b", size = 801427, upload-time = "2026-05-09T23:15:00.642Z" }, + { url = "https://files.pythonhosted.org/packages/e3/c4/1a80654597b6bc1e1ea0494824c31200e8a956abe290afae9b19a166a148/regex-2026.5.9-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:31037c82eccb44b7ea2e9e221d7c01429430e989a1f4b91ea5a855f6017b509a", size = 866482, upload-time = "2026-05-09T23:15:03.384Z" }, + { url = "https://files.pythonhosted.org/packages/d1/11/960724e06482c08466ff5611e242e86f80062949cdf6b4b9cc317b9dd93d/regex-2026.5.9-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:5604dfd046dc37eca90250fc3be938b076c8059fa772ac0ed6f499b0f0fb0415", size = 773022, upload-time = "2026-05-09T23:15:05.625Z" }, + { url = "https://files.pythonhosted.org/packages/50/a8/a9979c3e7918280e93159ebcab5ef1a65116dd4f3bd6091be0eae4a126e8/regex-2026.5.9-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:0e1b1b4e496afbb24f4a62aba855ee4f88f25578927697b340702e48c9ee6bc2", size = 856642, upload-time = "2026-05-09T23:15:07.966Z" }, + { url = "https://files.pythonhosted.org/packages/fe/d4/a9b732f2f0072c0ab12227483abb24fffcb9f73f8a2b203df0a6d0434735/regex-2026.5.9-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:be3372b9df6ddecff6486d37e19095a7b4973137caf5512407a89f4455361f41", size = 803552, upload-time = "2026-05-09T23:15:10.215Z" }, + { url = "https://files.pythonhosted.org/packages/d5/fe/1b3113817447a1d4155e4ac76d2e072f42c0bcba2f43fa8a0e756ea2cd91/regex-2026.5.9-cp314-cp314t-win32.whl", hash = "sha256:3ddd90103f9e5c471c49c7852ecc1fe27c7e45eb99e977aefe7caa4e779f4f58", size = 275746, upload-time = "2026-05-09T23:15:12.609Z" }, + { url = "https://files.pythonhosted.org/packages/92/73/93d42045302636c91f2e5ef588b65b84b01428f28ec77de256b1dfdfbe5c/regex-2026.5.9-cp314-cp314t-win_amd64.whl", hash = "sha256:ca518ed29c46eecba6010b15f1b9a479314d2de409536e71b6a13aa04e3b8a77", size = 285685, upload-time = "2026-05-09T23:15:15.086Z" }, + { url = "https://files.pythonhosted.org/packages/da/80/35b4c33c804a165a7f55289afda3ea9e3eb6d15800341a2d66455c0f1f30/regex-2026.5.9-cp314-cp314t-win_arm64.whl", hash = "sha256:5e41809d2683fcde7d5a8c87a6567ba1fb1ce0de9f31bff578de00a4b2d76daa", size = 275713, upload-time = "2026-05-09T23:15:16.98Z" }, ] [[package]] name = "requests" -version = "2.33.1" +version = "2.34.2" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "certifi" }, - { name = "charset-normalizer" }, - { name = "idna" }, - { name = "urllib3" }, + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/5f/a4/98b9c7c6428a668bf7e42ebb7c79d576a1c3c1e3ae2d47e674b468388871/requests-2.33.1.tar.gz", hash = "sha256:18817f8c57c6263968bc123d237e3b8b08ac046f5456bd1e307ee8f4250d3517", size = 134120, upload-time = "2026-03-30T16:09:15.531Z" } +sdist = { url = "https://files.pythonhosted.org/packages/ac/c3/e2a2b89f2d3e2179abd6d00ebd70bff6273f37fb3e0cc209f48b39d00cbf/requests-2.34.2.tar.gz", hash = "sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed", size = 142856, upload-time = "2026-05-14T19:25:27.735Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d7/8e/7540e8a2036f79a125c1d2ebadf69ed7901608859186c856fa0388ef4197/requests-2.33.1-py3-none-any.whl", hash = "sha256:4e6d1ef462f3626a1f0a0a9c42dd93c63bad33f9f1c1937509b8c5c8718ab56a", size = 64947, upload-time = "2026-03-30T16:09:13.83Z" }, + { url = "https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0", size = 73075, upload-time = "2026-05-14T19:25:26.443Z" }, ] [[package]] @@ -1199,37 +1206,37 @@ name = "rich" version = "15.0.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "markdown-it-py" }, - { name = "pygments" }, + { name = "markdown-it-py" }, + { name = "pygments" }, ] sdist = { url = "https://files.pythonhosted.org/packages/c0/8f/0722ca900cc807c13a6a0c696dacf35430f72e0ec571c4275d2371fca3e9/rich-15.0.0.tar.gz", hash = "sha256:edd07a4824c6b40189fb7ac9bc4c52536e9780fbbfbddf6f1e2502c31b068c36", size = 230680, upload-time = "2026-04-12T08:24:00.75Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/82/3b/64d4899d73f91ba49a8c18a8ff3f0ea8f1c1d75481760df8c68ef5235bf5/rich-15.0.0-py3-none-any.whl", hash = "sha256:33bd4ef74232fb73fe9279a257718407f169c09b78a87ad3d296f548e27de0bb", size = 310654, upload-time = "2026-04-12T08:24:02.83Z" }, + { url = "https://files.pythonhosted.org/packages/82/3b/64d4899d73f91ba49a8c18a8ff3f0ea8f1c1d75481760df8c68ef5235bf5/rich-15.0.0-py3-none-any.whl", hash = "sha256:33bd4ef74232fb73fe9279a257718407f169c09b78a87ad3d296f548e27de0bb", size = 310654, upload-time = "2026-04-12T08:24:02.83Z" }, ] [[package]] name = "ruff" -version = "0.15.12" +version = "0.15.14" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/99/43/3291f1cc9106f4c63bdce7a8d0df5047fe8422a75b091c16b5e9355e0b11/ruff-0.15.12.tar.gz", hash = "sha256:ecea26adb26b4232c0c2ca19ccbc0083a68344180bba2a600605538ce51a40a6", size = 4643852, upload-time = "2026-04-24T18:17:14.305Z" } +sdist = { url = "https://files.pythonhosted.org/packages/dc/8a/8bce2894573e9dae6ff4d77fe34ad727d79b9e6238ad288c5638990d90f6/ruff-0.15.14.tar.gz", hash = "sha256:48e866b165be4a9bdbf310f7d3c9a07edef2fe8cd63ffeb4e00bb590506ebf9f", size = 4700910, upload-time = "2026-05-21T14:34:55.177Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c3/6e/e78ffb61d4686f3d96ba3df2c801161843746dcbcbb17a1e927d4829312b/ruff-0.15.12-py3-none-linux_armv6l.whl", hash = "sha256:f86f176e188e94d6bdbc09f09bfd9dc729059ad93d0e7390b5a73efe19f8861c", size = 10640713, upload-time = "2026-04-24T18:17:22.841Z" }, - { url = "https://files.pythonhosted.org/packages/ae/08/a317bc231fb9e7b93e4ef3089501e51922ff88d6936ce5cf870c4fe55419/ruff-0.15.12-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:e3bcd123364c3770b8e1b7baaf343cc99a35f197c5c6e8af79015c666c423a6c", size = 11069267, upload-time = "2026-04-24T18:17:30.105Z" }, - { url = "https://files.pythonhosted.org/packages/aa/a4/f828e9718d3dce1f5f11c39c4f65afd32783c8b2aebb2e3d259e492c47bd/ruff-0.15.12-py3-none-macosx_11_0_arm64.whl", hash = "sha256:fe87510d000220aa1ed530d4448a7c696a0cae1213e5ec30e5874287b66557b5", size = 10397182, upload-time = "2026-04-24T18:17:07.177Z" }, - { url = "https://files.pythonhosted.org/packages/71/e0/3310fc6d1b5e1fdea22bf3b1b807c7e187b581021b0d7d4514cccdb5fb71/ruff-0.15.12-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:84a1630093121375a3e2a95b4a6dc7b59e2b4ee76216e32d81aae550a832d002", size = 10758012, upload-time = "2026-04-24T18:16:55.759Z" }, - { url = "https://files.pythonhosted.org/packages/11/c1/a606911aee04c324ddaa883ae418f3569792fd3c4a10c50e0dd0a2311e1e/ruff-0.15.12-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:fb129f40f114f089ebe0ca56c0d251cf2061b17651d464bb6478dc01e69f11f5", size = 10447479, upload-time = "2026-04-24T18:16:51.677Z" }, - { url = "https://files.pythonhosted.org/packages/9d/68/4201e8444f0894f21ab4aeeaee68aa4f10b51613514a20d80bd628d57e88/ruff-0.15.12-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b0c862b172d695db7598426b8af465e7e9ac00a3ea2a3630ee67eb82e366aaa6", size = 11234040, upload-time = "2026-04-24T18:17:16.529Z" }, - { url = "https://files.pythonhosted.org/packages/34/ff/8a6d6cf4ccc23fd67060874e832c18919d1557a0611ebef03fdb01fff11e/ruff-0.15.12-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2849ea9f3484c3aca43a82f484210370319e7170df4dfe4843395ddf6c57bc33", size = 12087377, upload-time = "2026-04-24T18:17:04.944Z" }, - { url = "https://files.pythonhosted.org/packages/85/f6/c669cf73f5152f623d34e69866a46d5e6185816b19fcd5b6dd8a2d299922/ruff-0.15.12-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9e77c7e51c07fe396826d5969a5b846d9cd4c402535835fb6e21ce8b28fef847", size = 11367784, upload-time = "2026-04-24T18:17:25.409Z" }, - { url = "https://files.pythonhosted.org/packages/e8/39/c61d193b8a1daaa8977f7dea9e8d8ba866e02ea7b65d32f6861693aa4c12/ruff-0.15.12-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:83b2f4f2f3b1026b5fb449b467d9264bf22067b600f7b6f41fc5958909f449d0", size = 11344088, upload-time = "2026-04-24T18:17:12.258Z" }, - { url = "https://files.pythonhosted.org/packages/c2/8d/49afab3645e31e12c590acb6d3b5b69d7aab5b81926dbaf7461f9441f37a/ruff-0.15.12-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:9ba3b8f1afd7e2e43d8943e55f249e13f9682fde09711644a6e7290eb4f3e339", size = 11271770, upload-time = "2026-04-24T18:17:02.457Z" }, - { url = "https://files.pythonhosted.org/packages/46/06/33f41fe94403e2b755481cdfb9b7ef3e4e0ed031c4581124658d935d52b4/ruff-0.15.12-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:e852ba9fdc890655e1d78f2df1499efbe0e54126bd405362154a75e2bde159c5", size = 10719355, upload-time = "2026-04-24T18:17:27.648Z" }, - { url = "https://files.pythonhosted.org/packages/0d/59/18aa4e014debbf559670e4048e39260a85c7fcee84acfd761ac01e7b8d35/ruff-0.15.12-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:dd8aed930da53780d22fc70bdf84452c843cf64f8cb4eb38984319c24c5cd5fd", size = 10462758, upload-time = "2026-04-24T18:17:32.347Z" }, - { url = "https://files.pythonhosted.org/packages/25/e7/cc9f16fd0f3b5fddcbd7ec3d6ae30c8f3fde1047f32a4093a98d633c6570/ruff-0.15.12-py3-none-musllinux_1_2_i686.whl", hash = "sha256:01da3988d225628b709493d7dc67c3b9b12c0210016b08690ef9bd27970b262b", size = 10953498, upload-time = "2026-04-24T18:17:20.674Z" }, - { url = "https://files.pythonhosted.org/packages/72/7a/a9ba7f98c7a575978698f4230c5e8cc54bbc761af34f560818f933dafa0c/ruff-0.15.12-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:9cae0f92bd5700d1213188b31cd3bdd2b315361296d10b96b8e2337d3d11f53e", size = 11447765, upload-time = "2026-04-24T18:17:09.755Z" }, - { url = "https://files.pythonhosted.org/packages/ea/f9/0ae446942c846b8266059ad8a30702a35afae55f5cdc54c5adf8d7afdc27/ruff-0.15.12-py3-none-win32.whl", hash = "sha256:d0185894e038d7043ba8fd6aee7499ece6462dc0ea9f1e260c7451807c714c20", size = 10657277, upload-time = "2026-04-24T18:17:18.591Z" }, - { url = "https://files.pythonhosted.org/packages/33/f1/9614e03e1cdcbf9437570b5400ced8a720b5db22b28d8e0f1bda429f660d/ruff-0.15.12-py3-none-win_amd64.whl", hash = "sha256:c87a162d61ab3adca47c03f7f717c68672edec7d1b5499e652331780fe74950d", size = 11837758, upload-time = "2026-04-24T18:17:00.113Z" }, - { url = "https://files.pythonhosted.org/packages/c0/98/6beb4b351e472e5f4c4613f7c35a5290b8be2497e183825310c4c3a3984b/ruff-0.15.12-py3-none-win_arm64.whl", hash = "sha256:a538f7a82d061cee7be55542aca1d86d1393d55d81d4fcc314370f4340930d4f", size = 11120821, upload-time = "2026-04-24T18:16:57.979Z" }, + { url = "https://files.pythonhosted.org/packages/b9/c8/74a92c6ff9fcfb4f1f947126d3ebee8389276e161ecc85de5bda7cda51bd/ruff-0.15.14-py3-none-linux_armv6l.whl", hash = "sha256:8dd2db9416e487c8d4b01fa7056bb02c4d05969d4f8d17a08c229c2f4ff3c108", size = 10739177, upload-time = "2026-05-21T14:34:37.332Z" }, + { url = "https://files.pythonhosted.org/packages/45/91/254a35c20acc38a7223c9d2d594af12e794432464f2cdeb52af1dc4a892d/ruff-0.15.14-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:be4ff55af755bd71a00ab3dc6bd7ffc467bd76e0df6881e286c2e3d23e8fb43b", size = 11144969, upload-time = "2026-05-21T14:34:43.978Z" }, + { url = "https://files.pythonhosted.org/packages/56/9e/d13e40f83b8d0a94430e6778ce1d94a43b38cf2efe63278bdd2b4c65abbf/ruff-0.15.14-py3-none-macosx_11_0_arm64.whl", hash = "sha256:48d5909d7d06276ce7dde6d32bfa4b0d4cb2651145cd8ee4b440722cbc77832f", size = 10478207, upload-time = "2026-05-21T14:34:48.378Z" }, + { url = "https://files.pythonhosted.org/packages/8d/f1/b15a7839fa4f332f8acec78e20564f26bb2d866e3d21710b877fd0263000/ruff-0.15.14-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ca8cbfa94c4f90984a67561978602746d4cd27103568f745fa90eee3f0d4107d", size = 10818459, upload-time = "2026-05-21T14:34:22.318Z" }, + { url = "https://files.pythonhosted.org/packages/45/33/53d651177f84f94b400a0e27f8824eeada3dddc9d5ee8aeb048f4352a520/ruff-0.15.14-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9a6bbc0333f1ab053423bcbf6226477d266ca7cec7738c4c8e3f55647803f3c4", size = 10541800, upload-time = "2026-05-21T14:34:20.209Z" }, + { url = "https://files.pythonhosted.org/packages/b8/a6/868f87e0bf9786ed24b5d0d0ad8676b8a94fd1912f42cddf9cfc7857818a/ruff-0.15.14-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8a24a4f7605d7003a6674d4387651effd939dead3fddd0f36561eb77a9a2e542", size = 11342149, upload-time = "2026-05-21T14:34:46.365Z" }, + { url = "https://files.pythonhosted.org/packages/a7/8b/38cd5c19faffdcc05a408d2b78edccc69492ab9720eadb49ea15ef80d768/ruff-0.15.14-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:049b5326e53ed80978f2fc041a280603f69dd6b0c95464342a2bb4572d9d9e2f", size = 12212563, upload-time = "2026-05-21T14:34:28.579Z" }, + { url = "https://files.pythonhosted.org/packages/3e/4d/a3c5b874a556d5731e3e657aaf04311bb76f0a5c3ec220ed43051be6b64b/ruff-0.15.14-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d4ed42e6696c8dfa5f06728e6441993901f548eb92d73bc472cb5a38d1395fbf", size = 11493299, upload-time = "2026-05-21T14:34:41.836Z" }, + { url = "https://files.pythonhosted.org/packages/1e/c0/56472c251d09858a53e51efbd485b09e1995d8731668b76d52e5dd6ee0f1/ruff-0.15.14-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:715c543cf450c4888251f91c52f1942a800541d9bddd7ac060aa4e6b77ae7cba", size = 11455931, upload-time = "2026-05-21T14:34:57.276Z" }, + { url = "https://files.pythonhosted.org/packages/2c/4a/e2e7b4d8dbf233d4eace59c75bc3435fa6d8bd3bae82d351d4e4300c0fd1/ruff-0.15.14-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:72ebab6013ec887d439d8b7593737a0a4ffb06d45d209d4e4bf2e92813082d3f", size = 11400794, upload-time = "2026-05-21T14:34:39.773Z" }, + { url = "https://files.pythonhosted.org/packages/97/c7/83c0539fe34c3e09136204d1e75d6052492364e0b3cb05e9465423f567d7/ruff-0.15.14-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:49072d36abdbe97a8dd7f480afe9c675699c0c495d4c84076e2c1203c4550581", size = 10804759, upload-time = "2026-05-21T14:34:31.045Z" }, + { url = "https://files.pythonhosted.org/packages/86/a6/18f2bfc095a2ab4a78745644e428205532ce6653a5d0fa8501572891534d/ruff-0.15.14-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:958522aee105068640c2c2ceae08f413ae44d922f52a1374ac13d6a96032fc93", size = 10539517, upload-time = "2026-05-21T14:34:53.064Z" }, + { url = "https://files.pythonhosted.org/packages/54/3a/5a8b3b69c654d4e4bf1d246ac5b49cbcdac6eaab6905925f8915f31e3b80/ruff-0.15.14-py3-none-musllinux_1_2_i686.whl", hash = "sha256:f3707da619a143a2e8830e2abab8224478d69ace2d28cb6c20543ae97c36bf61", size = 11065169, upload-time = "2026-05-21T14:34:24.484Z" }, + { url = "https://files.pythonhosted.org/packages/ed/c5/8864e4e7925b836ea354b31d57641ec03830564e281a8b6f061f8c3e0ec1/ruff-0.15.14-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:bb01d645694e3ec0102105d07ef2d53703970407d59c04e59d3ba0b7a1d53553", size = 11560214, upload-time = "2026-05-21T14:34:50.975Z" }, + { url = "https://files.pythonhosted.org/packages/36/38/012bf76752e1f89ed50b77b99532d90f3a3e287bc7918e1fc0948ac866ac/ruff-0.15.14-py3-none-win32.whl", hash = "sha256:6d0c1ad2a0ab718d39b6d8fd2217981ce4d625cd96a720095f798fb47d8b13e6", size = 10805548, upload-time = "2026-05-21T14:34:33.453Z" }, + { url = "https://files.pythonhosted.org/packages/d1/b7/4ea2c170f10ad760fff2a5250beb18897719dc8b52b53a24cddbb9dd3f19/ruff-0.15.14-py3-none-win_amd64.whl", hash = "sha256:802342981e056db3851a7836e5b070f8f15f67d4a685ae2a6160939d364b2902", size = 11939523, upload-time = "2026-05-21T14:34:18.077Z" }, + { url = "https://files.pythonhosted.org/packages/62/d5/bc97ff895ec35cf3925d4bd60f3b39d822f377a446906ec9bcc87405e59b/ruff-0.15.14-py3-none-win_arm64.whl", hash = "sha256:ff47b90a9ef6a40c9e2f3b479c1fb78531adf055b94c1eba0a7ba04b31951826", size = 11208607, upload-time = "2026-05-21T14:34:26.525Z" }, ] [[package]] @@ -1238,20 +1245,20 @@ version = "0.7.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/29/9c/6e74567782559a63bd040a236edca26fd71bc7ba88de2ef35d75df3bca5e/safetensors-0.7.0.tar.gz", hash = "sha256:07663963b67e8bd9f0b8ad15bb9163606cd27cc5a1b96235a50d8369803b96b0", size = 200878, upload-time = "2025-11-19T15:18:43.199Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/fa/47/aef6c06649039accf914afef490268e1067ed82be62bcfa5b7e886ad15e8/safetensors-0.7.0-cp38-abi3-macosx_10_12_x86_64.whl", hash = "sha256:c82f4d474cf725255d9e6acf17252991c3c8aac038d6ef363a4bf8be2f6db517", size = 467781, upload-time = "2025-11-19T15:18:35.84Z" }, - { url = "https://files.pythonhosted.org/packages/e8/00/374c0c068e30cd31f1e1b46b4b5738168ec79e7689ca82ee93ddfea05109/safetensors-0.7.0-cp38-abi3-macosx_11_0_arm64.whl", hash = "sha256:94fd4858284736bb67a897a41608b5b0c2496c9bdb3bf2af1fa3409127f20d57", size = 447058, upload-time = "2025-11-19T15:18:34.416Z" }, - { url = "https://files.pythonhosted.org/packages/f1/06/578ffed52c2296f93d7fd2d844cabfa92be51a587c38c8afbb8ae449ca89/safetensors-0.7.0-cp38-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e07d91d0c92a31200f25351f4acb2bc6aff7f48094e13ebb1d0fb995b54b6542", size = 491748, upload-time = "2025-11-19T15:18:09.79Z" }, - { url = "https://files.pythonhosted.org/packages/ae/33/1debbbb70e4791dde185edb9413d1fe01619255abb64b300157d7f15dddd/safetensors-0.7.0-cp38-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8469155f4cb518bafb4acf4865e8bb9d6804110d2d9bdcaa78564b9fd841e104", size = 503881, upload-time = "2025-11-19T15:18:16.145Z" }, - { url = "https://files.pythonhosted.org/packages/8e/1c/40c2ca924d60792c3be509833df711b553c60effbd91da6f5284a83f7122/safetensors-0.7.0-cp38-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:54bef08bf00a2bff599982f6b08e8770e09cc012d7bba00783fc7ea38f1fb37d", size = 623463, upload-time = "2025-11-19T15:18:21.11Z" }, - { url = "https://files.pythonhosted.org/packages/9b/3a/13784a9364bd43b0d61eef4bea2845039bc2030458b16594a1bd787ae26e/safetensors-0.7.0-cp38-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:42cb091236206bb2016d245c377ed383aa7f78691748f3bb6ee1bfa51ae2ce6a", size = 532855, upload-time = "2025-11-19T15:18:25.719Z" }, - { url = "https://files.pythonhosted.org/packages/a0/60/429e9b1cb3fc651937727befe258ea24122d9663e4d5709a48c9cbfceecb/safetensors-0.7.0-cp38-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dac7252938f0696ddea46f5e855dd3138444e82236e3be475f54929f0c510d48", size = 507152, upload-time = "2025-11-19T15:18:33.023Z" }, - { url = "https://files.pythonhosted.org/packages/3c/a8/4b45e4e059270d17af60359713ffd83f97900d45a6afa73aaa0d737d48b6/safetensors-0.7.0-cp38-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1d060c70284127fa805085d8f10fbd0962792aed71879d00864acda69dbab981", size = 541856, upload-time = "2025-11-19T15:18:31.075Z" }, - { url = "https://files.pythonhosted.org/packages/06/87/d26d8407c44175d8ae164a95b5a62707fcc445f3c0c56108e37d98070a3d/safetensors-0.7.0-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:cdab83a366799fa730f90a4ebb563e494f28e9e92c4819e556152ad55e43591b", size = 674060, upload-time = "2025-11-19T15:18:37.211Z" }, - { url = "https://files.pythonhosted.org/packages/11/f5/57644a2ff08dc6325816ba7217e5095f17269dada2554b658442c66aed51/safetensors-0.7.0-cp38-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:672132907fcad9f2aedcb705b2d7b3b93354a2aec1b2f706c4db852abe338f85", size = 771715, upload-time = "2025-11-19T15:18:38.689Z" }, - { url = "https://files.pythonhosted.org/packages/86/31/17883e13a814bd278ae6e266b13282a01049b0c81341da7fd0e3e71a80a3/safetensors-0.7.0-cp38-abi3-musllinux_1_2_i686.whl", hash = "sha256:5d72abdb8a4d56d4020713724ba81dac065fedb7f3667151c4a637f1d3fb26c0", size = 714377, upload-time = "2025-11-19T15:18:40.162Z" }, - { url = "https://files.pythonhosted.org/packages/4a/d8/0c8a7dc9b41dcac53c4cbf9df2b9c83e0e0097203de8b37a712b345c0be5/safetensors-0.7.0-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:b0f6d66c1c538d5a94a73aa9ddca8ccc4227e6c9ff555322ea40bdd142391dd4", size = 677368, upload-time = "2025-11-19T15:18:41.627Z" }, - { url = "https://files.pythonhosted.org/packages/05/e5/cb4b713c8a93469e3c5be7c3f8d77d307e65fe89673e731f5c2bfd0a9237/safetensors-0.7.0-cp38-abi3-win32.whl", hash = "sha256:c74af94bf3ac15ac4d0f2a7c7b4663a15f8c2ab15ed0fc7531ca61d0835eccba", size = 326423, upload-time = "2025-11-19T15:18:45.74Z" }, - { url = "https://files.pythonhosted.org/packages/5d/e6/ec8471c8072382cb91233ba7267fd931219753bb43814cbc71757bfd4dab/safetensors-0.7.0-cp38-abi3-win_amd64.whl", hash = "sha256:d1239932053f56f3456f32eb9625590cc7582e905021f94636202a864d470755", size = 341380, upload-time = "2025-11-19T15:18:44.427Z" }, + { url = "https://files.pythonhosted.org/packages/fa/47/aef6c06649039accf914afef490268e1067ed82be62bcfa5b7e886ad15e8/safetensors-0.7.0-cp38-abi3-macosx_10_12_x86_64.whl", hash = "sha256:c82f4d474cf725255d9e6acf17252991c3c8aac038d6ef363a4bf8be2f6db517", size = 467781, upload-time = "2025-11-19T15:18:35.84Z" }, + { url = "https://files.pythonhosted.org/packages/e8/00/374c0c068e30cd31f1e1b46b4b5738168ec79e7689ca82ee93ddfea05109/safetensors-0.7.0-cp38-abi3-macosx_11_0_arm64.whl", hash = "sha256:94fd4858284736bb67a897a41608b5b0c2496c9bdb3bf2af1fa3409127f20d57", size = 447058, upload-time = "2025-11-19T15:18:34.416Z" }, + { url = "https://files.pythonhosted.org/packages/f1/06/578ffed52c2296f93d7fd2d844cabfa92be51a587c38c8afbb8ae449ca89/safetensors-0.7.0-cp38-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e07d91d0c92a31200f25351f4acb2bc6aff7f48094e13ebb1d0fb995b54b6542", size = 491748, upload-time = "2025-11-19T15:18:09.79Z" }, + { url = "https://files.pythonhosted.org/packages/ae/33/1debbbb70e4791dde185edb9413d1fe01619255abb64b300157d7f15dddd/safetensors-0.7.0-cp38-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8469155f4cb518bafb4acf4865e8bb9d6804110d2d9bdcaa78564b9fd841e104", size = 503881, upload-time = "2025-11-19T15:18:16.145Z" }, + { url = "https://files.pythonhosted.org/packages/8e/1c/40c2ca924d60792c3be509833df711b553c60effbd91da6f5284a83f7122/safetensors-0.7.0-cp38-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:54bef08bf00a2bff599982f6b08e8770e09cc012d7bba00783fc7ea38f1fb37d", size = 623463, upload-time = "2025-11-19T15:18:21.11Z" }, + { url = "https://files.pythonhosted.org/packages/9b/3a/13784a9364bd43b0d61eef4bea2845039bc2030458b16594a1bd787ae26e/safetensors-0.7.0-cp38-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:42cb091236206bb2016d245c377ed383aa7f78691748f3bb6ee1bfa51ae2ce6a", size = 532855, upload-time = "2025-11-19T15:18:25.719Z" }, + { url = "https://files.pythonhosted.org/packages/a0/60/429e9b1cb3fc651937727befe258ea24122d9663e4d5709a48c9cbfceecb/safetensors-0.7.0-cp38-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dac7252938f0696ddea46f5e855dd3138444e82236e3be475f54929f0c510d48", size = 507152, upload-time = "2025-11-19T15:18:33.023Z" }, + { url = "https://files.pythonhosted.org/packages/3c/a8/4b45e4e059270d17af60359713ffd83f97900d45a6afa73aaa0d737d48b6/safetensors-0.7.0-cp38-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1d060c70284127fa805085d8f10fbd0962792aed71879d00864acda69dbab981", size = 541856, upload-time = "2025-11-19T15:18:31.075Z" }, + { url = "https://files.pythonhosted.org/packages/06/87/d26d8407c44175d8ae164a95b5a62707fcc445f3c0c56108e37d98070a3d/safetensors-0.7.0-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:cdab83a366799fa730f90a4ebb563e494f28e9e92c4819e556152ad55e43591b", size = 674060, upload-time = "2025-11-19T15:18:37.211Z" }, + { url = "https://files.pythonhosted.org/packages/11/f5/57644a2ff08dc6325816ba7217e5095f17269dada2554b658442c66aed51/safetensors-0.7.0-cp38-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:672132907fcad9f2aedcb705b2d7b3b93354a2aec1b2f706c4db852abe338f85", size = 771715, upload-time = "2025-11-19T15:18:38.689Z" }, + { url = "https://files.pythonhosted.org/packages/86/31/17883e13a814bd278ae6e266b13282a01049b0c81341da7fd0e3e71a80a3/safetensors-0.7.0-cp38-abi3-musllinux_1_2_i686.whl", hash = "sha256:5d72abdb8a4d56d4020713724ba81dac065fedb7f3667151c4a637f1d3fb26c0", size = 714377, upload-time = "2025-11-19T15:18:40.162Z" }, + { url = "https://files.pythonhosted.org/packages/4a/d8/0c8a7dc9b41dcac53c4cbf9df2b9c83e0e0097203de8b37a712b345c0be5/safetensors-0.7.0-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:b0f6d66c1c538d5a94a73aa9ddca8ccc4227e6c9ff555322ea40bdd142391dd4", size = 677368, upload-time = "2025-11-19T15:18:41.627Z" }, + { url = "https://files.pythonhosted.org/packages/05/e5/cb4b713c8a93469e3c5be7c3f8d77d307e65fe89673e731f5c2bfd0a9237/safetensors-0.7.0-cp38-abi3-win32.whl", hash = "sha256:c74af94bf3ac15ac4d0f2a7c7b4663a15f8c2ab15ed0fc7531ca61d0835eccba", size = 326423, upload-time = "2025-11-19T15:18:45.74Z" }, + { url = "https://files.pythonhosted.org/packages/5d/e6/ec8471c8072382cb91233ba7267fd931219753bb43814cbc71757bfd4dab/safetensors-0.7.0-cp38-abi3-win_amd64.whl", hash = "sha256:d1239932053f56f3456f32eb9625590cc7582e905021f94636202a864d470755", size = 341380, upload-time = "2025-11-19T15:18:44.427Z" }, ] [[package]] @@ -1259,37 +1266,37 @@ name = "scikit-learn" version = "1.8.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "joblib" }, - { name = "numpy" }, - { name = "scipy" }, - { name = "threadpoolctl" }, + { name = "joblib" }, + { name = "numpy" }, + { name = "scipy" }, + { name = "threadpoolctl" }, ] sdist = { url = "https://files.pythonhosted.org/packages/0e/d4/40988bf3b8e34feec1d0e6a051446b1f66225f8529b9309becaeef62b6c4/scikit_learn-1.8.0.tar.gz", hash = "sha256:9bccbb3b40e3de10351f8f5068e105d0f4083b1a65fa07b6634fbc401a6287fd", size = 7335585, upload-time = "2025-12-10T07:08:53.618Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/03/aa/e22e0768512ce9255eba34775be2e85c2048da73da1193e841707f8f039c/scikit_learn-1.8.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0d6ae97234d5d7079dc0040990a6f7aeb97cb7fa7e8945f1999a429b23569e0a", size = 8513770, upload-time = "2025-12-10T07:08:03.251Z" }, - { url = "https://files.pythonhosted.org/packages/58/37/31b83b2594105f61a381fc74ca19e8780ee923be2d496fcd8d2e1147bd99/scikit_learn-1.8.0-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:edec98c5e7c128328124a029bceb09eda2d526997780fef8d65e9a69eead963e", size = 8044458, upload-time = "2025-12-10T07:08:05.336Z" }, - { url = "https://files.pythonhosted.org/packages/2d/5a/3f1caed8765f33eabb723596666da4ebbf43d11e96550fb18bdec42b467b/scikit_learn-1.8.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:74b66d8689d52ed04c271e1329f0c61635bcaf5b926db9b12d58914cdc01fe57", size = 8610341, upload-time = "2025-12-10T07:08:07.732Z" }, - { url = "https://files.pythonhosted.org/packages/38/cf/06896db3f71c75902a8e9943b444a56e727418f6b4b4a90c98c934f51ed4/scikit_learn-1.8.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8fdf95767f989b0cfedb85f7ed8ca215d4be728031f56ff5a519ee1e3276dc2e", size = 8900022, upload-time = "2025-12-10T07:08:09.862Z" }, - { url = "https://files.pythonhosted.org/packages/1c/f9/9b7563caf3ec8873e17a31401858efab6b39a882daf6c1bfa88879c0aa11/scikit_learn-1.8.0-cp313-cp313-win_amd64.whl", hash = "sha256:2de443b9373b3b615aec1bb57f9baa6bb3a9bd093f1269ba95c17d870422b271", size = 7989409, upload-time = "2025-12-10T07:08:12.028Z" }, - { url = "https://files.pythonhosted.org/packages/49/bd/1f4001503650e72c4f6009ac0c4413cb17d2d601cef6f71c0453da2732fc/scikit_learn-1.8.0-cp313-cp313-win_arm64.whl", hash = "sha256:eddde82a035681427cbedded4e6eff5e57fa59216c2e3e90b10b19ab1d0a65c3", size = 7619760, upload-time = "2025-12-10T07:08:13.688Z" }, - { url = "https://files.pythonhosted.org/packages/d2/7d/a630359fc9dcc95496588c8d8e3245cc8fd81980251079bc09c70d41d951/scikit_learn-1.8.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:7cc267b6108f0a1499a734167282c00c4ebf61328566b55ef262d48e9849c735", size = 8826045, upload-time = "2025-12-10T07:08:15.215Z" }, - { url = "https://files.pythonhosted.org/packages/cc/56/a0c86f6930cfcd1c7054a2bc417e26960bb88d32444fe7f71d5c2cfae891/scikit_learn-1.8.0-cp313-cp313t-macosx_12_0_arm64.whl", hash = "sha256:fe1c011a640a9f0791146011dfd3c7d9669785f9fed2b2a5f9e207536cf5c2fd", size = 8420324, upload-time = "2025-12-10T07:08:17.561Z" }, - { url = "https://files.pythonhosted.org/packages/46/1e/05962ea1cebc1cf3876667ecb14c283ef755bf409993c5946ade3b77e303/scikit_learn-1.8.0-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:72358cce49465d140cc4e7792015bb1f0296a9742d5622c67e31399b75468b9e", size = 8680651, upload-time = "2025-12-10T07:08:19.952Z" }, - { url = "https://files.pythonhosted.org/packages/fe/56/a85473cd75f200c9759e3a5f0bcab2d116c92a8a02ee08ccd73b870f8bb4/scikit_learn-1.8.0-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:80832434a6cc114f5219211eec13dcbc16c2bac0e31ef64c6d346cde3cf054cb", size = 8925045, upload-time = "2025-12-10T07:08:22.11Z" }, - { url = "https://files.pythonhosted.org/packages/cc/b7/64d8cfa896c64435ae57f4917a548d7ac7a44762ff9802f75a79b77cb633/scikit_learn-1.8.0-cp313-cp313t-win_amd64.whl", hash = "sha256:ee787491dbfe082d9c3013f01f5991658b0f38aa8177e4cd4bf434c58f551702", size = 8507994, upload-time = "2025-12-10T07:08:23.943Z" }, - { url = "https://files.pythonhosted.org/packages/5e/37/e192ea709551799379958b4c4771ec507347027bb7c942662c7fbeba31cb/scikit_learn-1.8.0-cp313-cp313t-win_arm64.whl", hash = "sha256:bf97c10a3f5a7543f9b88cbf488d33d175e9146115a451ae34568597ba33dcde", size = 7869518, upload-time = "2025-12-10T07:08:25.71Z" }, - { url = "https://files.pythonhosted.org/packages/24/05/1af2c186174cc92dcab2233f327336058c077d38f6fe2aceb08e6ab4d509/scikit_learn-1.8.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:c22a2da7a198c28dd1a6e1136f19c830beab7fdca5b3e5c8bba8394f8a5c45b3", size = 8528667, upload-time = "2025-12-10T07:08:27.541Z" }, - { url = "https://files.pythonhosted.org/packages/a8/25/01c0af38fe969473fb292bba9dc2b8f9b451f3112ff242c647fee3d0dfe7/scikit_learn-1.8.0-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:6b595b07a03069a2b1740dc08c2299993850ea81cce4fe19b2421e0c970de6b7", size = 8066524, upload-time = "2025-12-10T07:08:29.822Z" }, - { url = "https://files.pythonhosted.org/packages/be/ce/a0623350aa0b68647333940ee46fe45086c6060ec604874e38e9ab7d8e6c/scikit_learn-1.8.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:29ffc74089f3d5e87dfca4c2c8450f88bdc61b0fc6ed5d267f3988f19a1309f6", size = 8657133, upload-time = "2025-12-10T07:08:31.865Z" }, - { url = "https://files.pythonhosted.org/packages/b8/cb/861b41341d6f1245e6ca80b1c1a8c4dfce43255b03df034429089ca2a2c5/scikit_learn-1.8.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fb65db5d7531bccf3a4f6bec3462223bea71384e2cda41da0f10b7c292b9e7c4", size = 8923223, upload-time = "2025-12-10T07:08:34.166Z" }, - { url = "https://files.pythonhosted.org/packages/76/18/a8def8f91b18cd1ba6e05dbe02540168cb24d47e8dcf69e8d00b7da42a08/scikit_learn-1.8.0-cp314-cp314-win_amd64.whl", hash = "sha256:56079a99c20d230e873ea40753102102734c5953366972a71d5cb39a32bc40c6", size = 8096518, upload-time = "2025-12-10T07:08:36.339Z" }, - { url = "https://files.pythonhosted.org/packages/d1/77/482076a678458307f0deb44e29891d6022617b2a64c840c725495bee343f/scikit_learn-1.8.0-cp314-cp314-win_arm64.whl", hash = "sha256:3bad7565bc9cf37ce19a7c0d107742b320c1285df7aab1a6e2d28780df167242", size = 7754546, upload-time = "2025-12-10T07:08:38.128Z" }, - { url = "https://files.pythonhosted.org/packages/2d/d1/ef294ca754826daa043b2a104e59960abfab4cf653891037d19dd5b6f3cf/scikit_learn-1.8.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:4511be56637e46c25721e83d1a9cea9614e7badc7040c4d573d75fbe257d6fd7", size = 8848305, upload-time = "2025-12-10T07:08:41.013Z" }, - { url = "https://files.pythonhosted.org/packages/5b/e2/b1f8b05138ee813b8e1a4149f2f0d289547e60851fd1bb268886915adbda/scikit_learn-1.8.0-cp314-cp314t-macosx_12_0_arm64.whl", hash = "sha256:a69525355a641bf8ef136a7fa447672fb54fe8d60cab5538d9eb7c6438543fb9", size = 8432257, upload-time = "2025-12-10T07:08:42.873Z" }, - { url = "https://files.pythonhosted.org/packages/26/11/c32b2138a85dcb0c99f6afd13a70a951bfdff8a6ab42d8160522542fb647/scikit_learn-1.8.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c2656924ec73e5939c76ac4c8b026fc203b83d8900362eb2599d8aee80e4880f", size = 8678673, upload-time = "2025-12-10T07:08:45.362Z" }, - { url = "https://files.pythonhosted.org/packages/c7/57/51f2384575bdec454f4fe4e7a919d696c9ebce914590abf3e52d47607ab8/scikit_learn-1.8.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:15fc3b5d19cc2be65404786857f2e13c70c83dd4782676dd6814e3b89dc8f5b9", size = 8922467, upload-time = "2025-12-10T07:08:47.408Z" }, - { url = "https://files.pythonhosted.org/packages/35/4d/748c9e2872637a57981a04adc038dacaa16ba8ca887b23e34953f0b3f742/scikit_learn-1.8.0-cp314-cp314t-win_amd64.whl", hash = "sha256:00d6f1d66fbcf4eba6e356e1420d33cc06c70a45bb1363cd6f6a8e4ebbbdece2", size = 8774395, upload-time = "2025-12-10T07:08:49.337Z" }, - { url = "https://files.pythonhosted.org/packages/60/22/d7b2ebe4704a5e50790ba089d5c2ae308ab6bb852719e6c3bd4f04c3a363/scikit_learn-1.8.0-cp314-cp314t-win_arm64.whl", hash = "sha256:f28dd15c6bb0b66ba09728cf09fd8736c304be29409bd8445a080c1280619e8c", size = 8002647, upload-time = "2025-12-10T07:08:51.601Z" }, + { url = "https://files.pythonhosted.org/packages/03/aa/e22e0768512ce9255eba34775be2e85c2048da73da1193e841707f8f039c/scikit_learn-1.8.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0d6ae97234d5d7079dc0040990a6f7aeb97cb7fa7e8945f1999a429b23569e0a", size = 8513770, upload-time = "2025-12-10T07:08:03.251Z" }, + { url = "https://files.pythonhosted.org/packages/58/37/31b83b2594105f61a381fc74ca19e8780ee923be2d496fcd8d2e1147bd99/scikit_learn-1.8.0-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:edec98c5e7c128328124a029bceb09eda2d526997780fef8d65e9a69eead963e", size = 8044458, upload-time = "2025-12-10T07:08:05.336Z" }, + { url = "https://files.pythonhosted.org/packages/2d/5a/3f1caed8765f33eabb723596666da4ebbf43d11e96550fb18bdec42b467b/scikit_learn-1.8.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:74b66d8689d52ed04c271e1329f0c61635bcaf5b926db9b12d58914cdc01fe57", size = 8610341, upload-time = "2025-12-10T07:08:07.732Z" }, + { url = "https://files.pythonhosted.org/packages/38/cf/06896db3f71c75902a8e9943b444a56e727418f6b4b4a90c98c934f51ed4/scikit_learn-1.8.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8fdf95767f989b0cfedb85f7ed8ca215d4be728031f56ff5a519ee1e3276dc2e", size = 8900022, upload-time = "2025-12-10T07:08:09.862Z" }, + { url = "https://files.pythonhosted.org/packages/1c/f9/9b7563caf3ec8873e17a31401858efab6b39a882daf6c1bfa88879c0aa11/scikit_learn-1.8.0-cp313-cp313-win_amd64.whl", hash = "sha256:2de443b9373b3b615aec1bb57f9baa6bb3a9bd093f1269ba95c17d870422b271", size = 7989409, upload-time = "2025-12-10T07:08:12.028Z" }, + { url = "https://files.pythonhosted.org/packages/49/bd/1f4001503650e72c4f6009ac0c4413cb17d2d601cef6f71c0453da2732fc/scikit_learn-1.8.0-cp313-cp313-win_arm64.whl", hash = "sha256:eddde82a035681427cbedded4e6eff5e57fa59216c2e3e90b10b19ab1d0a65c3", size = 7619760, upload-time = "2025-12-10T07:08:13.688Z" }, + { url = "https://files.pythonhosted.org/packages/d2/7d/a630359fc9dcc95496588c8d8e3245cc8fd81980251079bc09c70d41d951/scikit_learn-1.8.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:7cc267b6108f0a1499a734167282c00c4ebf61328566b55ef262d48e9849c735", size = 8826045, upload-time = "2025-12-10T07:08:15.215Z" }, + { url = "https://files.pythonhosted.org/packages/cc/56/a0c86f6930cfcd1c7054a2bc417e26960bb88d32444fe7f71d5c2cfae891/scikit_learn-1.8.0-cp313-cp313t-macosx_12_0_arm64.whl", hash = "sha256:fe1c011a640a9f0791146011dfd3c7d9669785f9fed2b2a5f9e207536cf5c2fd", size = 8420324, upload-time = "2025-12-10T07:08:17.561Z" }, + { url = "https://files.pythonhosted.org/packages/46/1e/05962ea1cebc1cf3876667ecb14c283ef755bf409993c5946ade3b77e303/scikit_learn-1.8.0-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:72358cce49465d140cc4e7792015bb1f0296a9742d5622c67e31399b75468b9e", size = 8680651, upload-time = "2025-12-10T07:08:19.952Z" }, + { url = "https://files.pythonhosted.org/packages/fe/56/a85473cd75f200c9759e3a5f0bcab2d116c92a8a02ee08ccd73b870f8bb4/scikit_learn-1.8.0-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:80832434a6cc114f5219211eec13dcbc16c2bac0e31ef64c6d346cde3cf054cb", size = 8925045, upload-time = "2025-12-10T07:08:22.11Z" }, + { url = "https://files.pythonhosted.org/packages/cc/b7/64d8cfa896c64435ae57f4917a548d7ac7a44762ff9802f75a79b77cb633/scikit_learn-1.8.0-cp313-cp313t-win_amd64.whl", hash = "sha256:ee787491dbfe082d9c3013f01f5991658b0f38aa8177e4cd4bf434c58f551702", size = 8507994, upload-time = "2025-12-10T07:08:23.943Z" }, + { url = "https://files.pythonhosted.org/packages/5e/37/e192ea709551799379958b4c4771ec507347027bb7c942662c7fbeba31cb/scikit_learn-1.8.0-cp313-cp313t-win_arm64.whl", hash = "sha256:bf97c10a3f5a7543f9b88cbf488d33d175e9146115a451ae34568597ba33dcde", size = 7869518, upload-time = "2025-12-10T07:08:25.71Z" }, + { url = "https://files.pythonhosted.org/packages/24/05/1af2c186174cc92dcab2233f327336058c077d38f6fe2aceb08e6ab4d509/scikit_learn-1.8.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:c22a2da7a198c28dd1a6e1136f19c830beab7fdca5b3e5c8bba8394f8a5c45b3", size = 8528667, upload-time = "2025-12-10T07:08:27.541Z" }, + { url = "https://files.pythonhosted.org/packages/a8/25/01c0af38fe969473fb292bba9dc2b8f9b451f3112ff242c647fee3d0dfe7/scikit_learn-1.8.0-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:6b595b07a03069a2b1740dc08c2299993850ea81cce4fe19b2421e0c970de6b7", size = 8066524, upload-time = "2025-12-10T07:08:29.822Z" }, + { url = "https://files.pythonhosted.org/packages/be/ce/a0623350aa0b68647333940ee46fe45086c6060ec604874e38e9ab7d8e6c/scikit_learn-1.8.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:29ffc74089f3d5e87dfca4c2c8450f88bdc61b0fc6ed5d267f3988f19a1309f6", size = 8657133, upload-time = "2025-12-10T07:08:31.865Z" }, + { url = "https://files.pythonhosted.org/packages/b8/cb/861b41341d6f1245e6ca80b1c1a8c4dfce43255b03df034429089ca2a2c5/scikit_learn-1.8.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fb65db5d7531bccf3a4f6bec3462223bea71384e2cda41da0f10b7c292b9e7c4", size = 8923223, upload-time = "2025-12-10T07:08:34.166Z" }, + { url = "https://files.pythonhosted.org/packages/76/18/a8def8f91b18cd1ba6e05dbe02540168cb24d47e8dcf69e8d00b7da42a08/scikit_learn-1.8.0-cp314-cp314-win_amd64.whl", hash = "sha256:56079a99c20d230e873ea40753102102734c5953366972a71d5cb39a32bc40c6", size = 8096518, upload-time = "2025-12-10T07:08:36.339Z" }, + { url = "https://files.pythonhosted.org/packages/d1/77/482076a678458307f0deb44e29891d6022617b2a64c840c725495bee343f/scikit_learn-1.8.0-cp314-cp314-win_arm64.whl", hash = "sha256:3bad7565bc9cf37ce19a7c0d107742b320c1285df7aab1a6e2d28780df167242", size = 7754546, upload-time = "2025-12-10T07:08:38.128Z" }, + { url = "https://files.pythonhosted.org/packages/2d/d1/ef294ca754826daa043b2a104e59960abfab4cf653891037d19dd5b6f3cf/scikit_learn-1.8.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:4511be56637e46c25721e83d1a9cea9614e7badc7040c4d573d75fbe257d6fd7", size = 8848305, upload-time = "2025-12-10T07:08:41.013Z" }, + { url = "https://files.pythonhosted.org/packages/5b/e2/b1f8b05138ee813b8e1a4149f2f0d289547e60851fd1bb268886915adbda/scikit_learn-1.8.0-cp314-cp314t-macosx_12_0_arm64.whl", hash = "sha256:a69525355a641bf8ef136a7fa447672fb54fe8d60cab5538d9eb7c6438543fb9", size = 8432257, upload-time = "2025-12-10T07:08:42.873Z" }, + { url = "https://files.pythonhosted.org/packages/26/11/c32b2138a85dcb0c99f6afd13a70a951bfdff8a6ab42d8160522542fb647/scikit_learn-1.8.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c2656924ec73e5939c76ac4c8b026fc203b83d8900362eb2599d8aee80e4880f", size = 8678673, upload-time = "2025-12-10T07:08:45.362Z" }, + { url = "https://files.pythonhosted.org/packages/c7/57/51f2384575bdec454f4fe4e7a919d696c9ebce914590abf3e52d47607ab8/scikit_learn-1.8.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:15fc3b5d19cc2be65404786857f2e13c70c83dd4782676dd6814e3b89dc8f5b9", size = 8922467, upload-time = "2025-12-10T07:08:47.408Z" }, + { url = "https://files.pythonhosted.org/packages/35/4d/748c9e2872637a57981a04adc038dacaa16ba8ca887b23e34953f0b3f742/scikit_learn-1.8.0-cp314-cp314t-win_amd64.whl", hash = "sha256:00d6f1d66fbcf4eba6e356e1420d33cc06c70a45bb1363cd6f6a8e4ebbbdece2", size = 8774395, upload-time = "2025-12-10T07:08:49.337Z" }, + { url = "https://files.pythonhosted.org/packages/60/22/d7b2ebe4704a5e50790ba089d5c2ae308ab6bb852719e6c3bd4f04c3a363/scikit_learn-1.8.0-cp314-cp314t-win_arm64.whl", hash = "sha256:f28dd15c6bb0b66ba09728cf09fd8736c304be29409bd8445a080c1280619e8c", size = 8002647, upload-time = "2025-12-10T07:08:51.601Z" }, ] [[package]] @@ -1297,70 +1304,70 @@ name = "scipy" version = "1.17.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "numpy" }, + { name = "numpy" }, ] sdist = { url = "https://files.pythonhosted.org/packages/7a/97/5a3609c4f8d58b039179648e62dd220f89864f56f7357f5d4f45c29eb2cc/scipy-1.17.1.tar.gz", hash = "sha256:95d8e012d8cb8816c226aef832200b1d45109ed4464303e997c5b13122b297c0", size = 30573822, upload-time = "2026-02-23T00:26:24.851Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/76/27/07ee1b57b65e92645f219b37148a7e7928b82e2b5dbeccecb4dff7c64f0b/scipy-1.17.1-cp313-cp313-macosx_10_14_x86_64.whl", hash = "sha256:5e3c5c011904115f88a39308379c17f91546f77c1667cea98739fe0fccea804c", size = 31590199, upload-time = "2026-02-23T00:19:17.192Z" }, - { url = "https://files.pythonhosted.org/packages/ec/ae/db19f8ab842e9b724bf5dbb7db29302a91f1e55bc4d04b1025d6d605a2c5/scipy-1.17.1-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:6fac755ca3d2c3edcb22f479fceaa241704111414831ddd3bc6056e18516892f", size = 28154001, upload-time = "2026-02-23T00:19:22.241Z" }, - { url = "https://files.pythonhosted.org/packages/5b/58/3ce96251560107b381cbd6e8413c483bbb1228a6b919fa8652b0d4090e7f/scipy-1.17.1-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:7ff200bf9d24f2e4d5dc6ee8c3ac64d739d3a89e2326ba68aaf6c4a2b838fd7d", size = 20325719, upload-time = "2026-02-23T00:19:26.329Z" }, - { url = "https://files.pythonhosted.org/packages/b2/83/15087d945e0e4d48ce2377498abf5ad171ae013232ae31d06f336e64c999/scipy-1.17.1-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:4b400bdc6f79fa02a4d86640310dde87a21fba0c979efff5248908c6f15fad1b", size = 22683595, upload-time = "2026-02-23T00:19:30.304Z" }, - { url = "https://files.pythonhosted.org/packages/b4/e0/e58fbde4a1a594c8be8114eb4aac1a55bcd6587047efc18a61eb1f5c0d30/scipy-1.17.1-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2b64ca7d4aee0102a97f3ba22124052b4bd2152522355073580bf4845e2550b6", size = 32896429, upload-time = "2026-02-23T00:19:35.536Z" }, - { url = "https://files.pythonhosted.org/packages/f5/5f/f17563f28ff03c7b6799c50d01d5d856a1d55f2676f537ca8d28c7f627cd/scipy-1.17.1-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:581b2264fc0aa555f3f435a5944da7504ea3a065d7029ad60e7c3d1ae09c5464", size = 35203952, upload-time = "2026-02-23T00:19:42.259Z" }, - { url = "https://files.pythonhosted.org/packages/8d/a5/9afd17de24f657fdfe4df9a3f1ea049b39aef7c06000c13db1530d81ccca/scipy-1.17.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:beeda3d4ae615106d7094f7e7cef6218392e4465cc95d25f900bebabfded0950", size = 34979063, upload-time = "2026-02-23T00:19:47.547Z" }, - { url = "https://files.pythonhosted.org/packages/8b/13/88b1d2384b424bf7c924f2038c1c409f8d88bb2a8d49d097861dd64a57b2/scipy-1.17.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6609bc224e9568f65064cfa72edc0f24ee6655b47575954ec6339534b2798369", size = 37598449, upload-time = "2026-02-23T00:19:53.238Z" }, - { url = "https://files.pythonhosted.org/packages/35/e5/d6d0e51fc888f692a35134336866341c08655d92614f492c6860dc45bb2c/scipy-1.17.1-cp313-cp313-win_amd64.whl", hash = "sha256:37425bc9175607b0268f493d79a292c39f9d001a357bebb6b88fdfaff13f6448", size = 36510943, upload-time = "2026-02-23T00:20:50.89Z" }, - { url = "https://files.pythonhosted.org/packages/2a/fd/3be73c564e2a01e690e19cc618811540ba5354c67c8680dce3281123fb79/scipy-1.17.1-cp313-cp313-win_arm64.whl", hash = "sha256:5cf36e801231b6a2059bf354720274b7558746f3b1a4efb43fcf557ccd484a87", size = 24545621, upload-time = "2026-02-23T00:20:55.871Z" }, - { url = "https://files.pythonhosted.org/packages/6f/6b/17787db8b8114933a66f9dcc479a8272e4b4da75fe03b0c282f7b0ade8cd/scipy-1.17.1-cp313-cp313t-macosx_10_14_x86_64.whl", hash = "sha256:d59c30000a16d8edc7e64152e30220bfbd724c9bbb08368c054e24c651314f0a", size = 31936708, upload-time = "2026-02-23T00:19:58.694Z" }, - { url = "https://files.pythonhosted.org/packages/38/2e/524405c2b6392765ab1e2b722a41d5da33dc5c7b7278184a8ad29b6cb206/scipy-1.17.1-cp313-cp313t-macosx_12_0_arm64.whl", hash = "sha256:010f4333c96c9bb1a4516269e33cb5917b08ef2166d5556ca2fd9f082a9e6ea0", size = 28570135, upload-time = "2026-02-23T00:20:03.934Z" }, - { url = "https://files.pythonhosted.org/packages/fd/c3/5bd7199f4ea8556c0c8e39f04ccb014ac37d1468e6cfa6a95c6b3562b76e/scipy-1.17.1-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:2ceb2d3e01c5f1d83c4189737a42d9cb2fc38a6eeed225e7515eef71ad301dce", size = 20741977, upload-time = "2026-02-23T00:20:07.935Z" }, - { url = "https://files.pythonhosted.org/packages/d9/b8/8ccd9b766ad14c78386599708eb745f6b44f08400a5fd0ade7cf89b6fc93/scipy-1.17.1-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:844e165636711ef41f80b4103ed234181646b98a53c8f05da12ca5ca289134f6", size = 23029601, upload-time = "2026-02-23T00:20:12.161Z" }, - { url = "https://files.pythonhosted.org/packages/6d/a0/3cb6f4d2fb3e17428ad2880333cac878909ad1a89f678527b5328b93c1d4/scipy-1.17.1-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:158dd96d2207e21c966063e1635b1063cd7787b627b6f07305315dd73d9c679e", size = 33019667, upload-time = "2026-02-23T00:20:17.208Z" }, - { url = "https://files.pythonhosted.org/packages/f3/c3/2d834a5ac7bf3a0c806ad1508efc02dda3c8c61472a56132d7894c312dea/scipy-1.17.1-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:74cbb80d93260fe2ffa334efa24cb8f2f0f622a9b9febf8b483c0b865bfb3475", size = 35264159, upload-time = "2026-02-23T00:20:23.087Z" }, - { url = "https://files.pythonhosted.org/packages/4d/77/d3ed4becfdbd217c52062fafe35a72388d1bd82c2d0ba5ca19d6fcc93e11/scipy-1.17.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:dbc12c9f3d185f5c737d801da555fb74b3dcfa1a50b66a1a93e09190f41fab50", size = 35102771, upload-time = "2026-02-23T00:20:28.636Z" }, - { url = "https://files.pythonhosted.org/packages/bd/12/d19da97efde68ca1ee5538bb261d5d2c062f0c055575128f11a2730e3ac1/scipy-1.17.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:94055a11dfebe37c656e70317e1996dc197e1a15bbcc351bcdd4610e128fe1ca", size = 37665910, upload-time = "2026-02-23T00:20:34.743Z" }, - { url = "https://files.pythonhosted.org/packages/06/1c/1172a88d507a4baaf72c5a09bb6c018fe2ae0ab622e5830b703a46cc9e44/scipy-1.17.1-cp313-cp313t-win_amd64.whl", hash = "sha256:e30bdeaa5deed6bc27b4cc490823cd0347d7dae09119b8803ae576ea0ce52e4c", size = 36562980, upload-time = "2026-02-23T00:20:40.575Z" }, - { url = "https://files.pythonhosted.org/packages/70/b0/eb757336e5a76dfa7911f63252e3b7d1de00935d7705cf772db5b45ec238/scipy-1.17.1-cp313-cp313t-win_arm64.whl", hash = "sha256:a720477885a9d2411f94a93d16f9d89bad0f28ca23c3f8daa521e2dcc3f44d49", size = 24856543, upload-time = "2026-02-23T00:20:45.313Z" }, - { url = "https://files.pythonhosted.org/packages/cf/83/333afb452af6f0fd70414dc04f898647ee1423979ce02efa75c3b0f2c28e/scipy-1.17.1-cp314-cp314-macosx_10_14_x86_64.whl", hash = "sha256:a48a72c77a310327f6a3a920092fa2b8fd03d7deaa60f093038f22d98e096717", size = 31584510, upload-time = "2026-02-23T00:21:01.015Z" }, - { url = "https://files.pythonhosted.org/packages/ed/a6/d05a85fd51daeb2e4ea71d102f15b34fedca8e931af02594193ae4fd25f7/scipy-1.17.1-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:45abad819184f07240d8a696117a7aacd39787af9e0b719d00285549ed19a1e9", size = 28170131, upload-time = "2026-02-23T00:21:05.888Z" }, - { url = "https://files.pythonhosted.org/packages/db/7b/8624a203326675d7746a254083a187398090a179335b2e4a20e2ddc46e83/scipy-1.17.1-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:3fd1fcdab3ea951b610dc4cef356d416d5802991e7e32b5254828d342f7b7e0b", size = 20342032, upload-time = "2026-02-23T00:21:09.904Z" }, - { url = "https://files.pythonhosted.org/packages/c9/35/2c342897c00775d688d8ff3987aced3426858fd89d5a0e26e020b660b301/scipy-1.17.1-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:7bdf2da170b67fdf10bca777614b1c7d96ae3ca5794fd9587dce41eb2966e866", size = 22678766, upload-time = "2026-02-23T00:21:14.313Z" }, - { url = "https://files.pythonhosted.org/packages/ef/f2/7cdb8eb308a1a6ae1e19f945913c82c23c0c442a462a46480ce487fdc0ac/scipy-1.17.1-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:adb2642e060a6549c343603a3851ba76ef0b74cc8c079a9a58121c7ec9fe2350", size = 32957007, upload-time = "2026-02-23T00:21:19.663Z" }, - { url = "https://files.pythonhosted.org/packages/0b/2e/7eea398450457ecb54e18e9d10110993fa65561c4f3add5e8eccd2b9cd41/scipy-1.17.1-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:eee2cfda04c00a857206a4330f0c5e3e56535494e30ca445eb19ec624ae75118", size = 35221333, upload-time = "2026-02-23T00:21:25.278Z" }, - { url = "https://files.pythonhosted.org/packages/d9/77/5b8509d03b77f093a0d52e606d3c4f79e8b06d1d38c441dacb1e26cacf46/scipy-1.17.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:d2650c1fb97e184d12d8ba010493ee7b322864f7d3d00d3f9bb97d9c21de4068", size = 35042066, upload-time = "2026-02-23T00:21:31.358Z" }, - { url = "https://files.pythonhosted.org/packages/f9/df/18f80fb99df40b4070328d5ae5c596f2f00fffb50167e31439e932f29e7d/scipy-1.17.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:08b900519463543aa604a06bec02461558a6e1cef8fdbb8098f77a48a83c8118", size = 37612763, upload-time = "2026-02-23T00:21:37.247Z" }, - { url = "https://files.pythonhosted.org/packages/4b/39/f0e8ea762a764a9dc52aa7dabcfad51a354819de1f0d4652b6a1122424d6/scipy-1.17.1-cp314-cp314-win_amd64.whl", hash = "sha256:3877ac408e14da24a6196de0ddcace62092bfc12a83823e92e49e40747e52c19", size = 37290984, upload-time = "2026-02-23T00:22:35.023Z" }, - { url = "https://files.pythonhosted.org/packages/7c/56/fe201e3b0f93d1a8bcf75d3379affd228a63d7e2d80ab45467a74b494947/scipy-1.17.1-cp314-cp314-win_arm64.whl", hash = "sha256:f8885db0bc2bffa59d5c1b72fad7a6a92d3e80e7257f967dd81abb553a90d293", size = 25192877, upload-time = "2026-02-23T00:22:39.798Z" }, - { url = "https://files.pythonhosted.org/packages/96/ad/f8c414e121f82e02d76f310f16db9899c4fcde36710329502a6b2a3c0392/scipy-1.17.1-cp314-cp314t-macosx_10_14_x86_64.whl", hash = "sha256:1cc682cea2ae55524432f3cdff9e9a3be743d52a7443d0cba9017c23c87ae2f6", size = 31949750, upload-time = "2026-02-23T00:21:42.289Z" }, - { url = "https://files.pythonhosted.org/packages/7c/b0/c741e8865d61b67c81e255f4f0a832846c064e426636cd7de84e74d209be/scipy-1.17.1-cp314-cp314t-macosx_12_0_arm64.whl", hash = "sha256:2040ad4d1795a0ae89bfc7e8429677f365d45aa9fd5e4587cf1ea737f927b4a1", size = 28585858, upload-time = "2026-02-23T00:21:47.706Z" }, - { url = "https://files.pythonhosted.org/packages/ed/1b/3985219c6177866628fa7c2595bfd23f193ceebbe472c98a08824b9466ff/scipy-1.17.1-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:131f5aaea57602008f9822e2115029b55d4b5f7c070287699fe45c661d051e39", size = 20757723, upload-time = "2026-02-23T00:21:52.039Z" }, - { url = "https://files.pythonhosted.org/packages/c0/19/2a04aa25050d656d6f7b9e7b685cc83d6957fb101665bfd9369ca6534563/scipy-1.17.1-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:9cdc1a2fcfd5c52cfb3045feb399f7b3ce822abdde3a193a6b9a60b3cb5854ca", size = 23043098, upload-time = "2026-02-23T00:21:56.185Z" }, - { url = "https://files.pythonhosted.org/packages/86/f1/3383beb9b5d0dbddd030335bf8a8b32d4317185efe495374f134d8be6cce/scipy-1.17.1-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e3dcd57ab780c741fde8dc68619de988b966db759a3c3152e8e9142c26295ad", size = 33030397, upload-time = "2026-02-23T00:22:01.404Z" }, - { url = "https://files.pythonhosted.org/packages/41/68/8f21e8a65a5a03f25a79165ec9d2b28c00e66dc80546cf5eb803aeeff35b/scipy-1.17.1-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a9956e4d4f4a301ebf6cde39850333a6b6110799d470dbbb1e25326ac447f52a", size = 35281163, upload-time = "2026-02-23T00:22:07.024Z" }, - { url = "https://files.pythonhosted.org/packages/84/8d/c8a5e19479554007a5632ed7529e665c315ae7492b4f946b0deb39870e39/scipy-1.17.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:a4328d245944d09fd639771de275701ccadf5f781ba0ff092ad141e017eccda4", size = 35116291, upload-time = "2026-02-23T00:22:12.585Z" }, - { url = "https://files.pythonhosted.org/packages/52/52/e57eceff0e342a1f50e274264ed47497b59e6a4e3118808ee58ddda7b74a/scipy-1.17.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:a77cbd07b940d326d39a1d1b37817e2ee4d79cb30e7338f3d0cddffae70fcaa2", size = 37682317, upload-time = "2026-02-23T00:22:18.513Z" }, - { url = "https://files.pythonhosted.org/packages/11/2f/b29eafe4a3fbc3d6de9662b36e028d5f039e72d345e05c250e121a230dd4/scipy-1.17.1-cp314-cp314t-win_amd64.whl", hash = "sha256:eb092099205ef62cd1782b006658db09e2fed75bffcae7cc0d44052d8aa0f484", size = 37345327, upload-time = "2026-02-23T00:22:24.442Z" }, - { url = "https://files.pythonhosted.org/packages/07/39/338d9219c4e87f3e708f18857ecd24d22a0c3094752393319553096b98af/scipy-1.17.1-cp314-cp314t-win_arm64.whl", hash = "sha256:200e1050faffacc162be6a486a984a0497866ec54149a01270adc8a59b7c7d21", size = 25489165, upload-time = "2026-02-23T00:22:29.563Z" }, + { url = "https://files.pythonhosted.org/packages/76/27/07ee1b57b65e92645f219b37148a7e7928b82e2b5dbeccecb4dff7c64f0b/scipy-1.17.1-cp313-cp313-macosx_10_14_x86_64.whl", hash = "sha256:5e3c5c011904115f88a39308379c17f91546f77c1667cea98739fe0fccea804c", size = 31590199, upload-time = "2026-02-23T00:19:17.192Z" }, + { url = "https://files.pythonhosted.org/packages/ec/ae/db19f8ab842e9b724bf5dbb7db29302a91f1e55bc4d04b1025d6d605a2c5/scipy-1.17.1-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:6fac755ca3d2c3edcb22f479fceaa241704111414831ddd3bc6056e18516892f", size = 28154001, upload-time = "2026-02-23T00:19:22.241Z" }, + { url = "https://files.pythonhosted.org/packages/5b/58/3ce96251560107b381cbd6e8413c483bbb1228a6b919fa8652b0d4090e7f/scipy-1.17.1-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:7ff200bf9d24f2e4d5dc6ee8c3ac64d739d3a89e2326ba68aaf6c4a2b838fd7d", size = 20325719, upload-time = "2026-02-23T00:19:26.329Z" }, + { url = "https://files.pythonhosted.org/packages/b2/83/15087d945e0e4d48ce2377498abf5ad171ae013232ae31d06f336e64c999/scipy-1.17.1-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:4b400bdc6f79fa02a4d86640310dde87a21fba0c979efff5248908c6f15fad1b", size = 22683595, upload-time = "2026-02-23T00:19:30.304Z" }, + { url = "https://files.pythonhosted.org/packages/b4/e0/e58fbde4a1a594c8be8114eb4aac1a55bcd6587047efc18a61eb1f5c0d30/scipy-1.17.1-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2b64ca7d4aee0102a97f3ba22124052b4bd2152522355073580bf4845e2550b6", size = 32896429, upload-time = "2026-02-23T00:19:35.536Z" }, + { url = "https://files.pythonhosted.org/packages/f5/5f/f17563f28ff03c7b6799c50d01d5d856a1d55f2676f537ca8d28c7f627cd/scipy-1.17.1-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:581b2264fc0aa555f3f435a5944da7504ea3a065d7029ad60e7c3d1ae09c5464", size = 35203952, upload-time = "2026-02-23T00:19:42.259Z" }, + { url = "https://files.pythonhosted.org/packages/8d/a5/9afd17de24f657fdfe4df9a3f1ea049b39aef7c06000c13db1530d81ccca/scipy-1.17.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:beeda3d4ae615106d7094f7e7cef6218392e4465cc95d25f900bebabfded0950", size = 34979063, upload-time = "2026-02-23T00:19:47.547Z" }, + { url = "https://files.pythonhosted.org/packages/8b/13/88b1d2384b424bf7c924f2038c1c409f8d88bb2a8d49d097861dd64a57b2/scipy-1.17.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6609bc224e9568f65064cfa72edc0f24ee6655b47575954ec6339534b2798369", size = 37598449, upload-time = "2026-02-23T00:19:53.238Z" }, + { url = "https://files.pythonhosted.org/packages/35/e5/d6d0e51fc888f692a35134336866341c08655d92614f492c6860dc45bb2c/scipy-1.17.1-cp313-cp313-win_amd64.whl", hash = "sha256:37425bc9175607b0268f493d79a292c39f9d001a357bebb6b88fdfaff13f6448", size = 36510943, upload-time = "2026-02-23T00:20:50.89Z" }, + { url = "https://files.pythonhosted.org/packages/2a/fd/3be73c564e2a01e690e19cc618811540ba5354c67c8680dce3281123fb79/scipy-1.17.1-cp313-cp313-win_arm64.whl", hash = "sha256:5cf36e801231b6a2059bf354720274b7558746f3b1a4efb43fcf557ccd484a87", size = 24545621, upload-time = "2026-02-23T00:20:55.871Z" }, + { url = "https://files.pythonhosted.org/packages/6f/6b/17787db8b8114933a66f9dcc479a8272e4b4da75fe03b0c282f7b0ade8cd/scipy-1.17.1-cp313-cp313t-macosx_10_14_x86_64.whl", hash = "sha256:d59c30000a16d8edc7e64152e30220bfbd724c9bbb08368c054e24c651314f0a", size = 31936708, upload-time = "2026-02-23T00:19:58.694Z" }, + { url = "https://files.pythonhosted.org/packages/38/2e/524405c2b6392765ab1e2b722a41d5da33dc5c7b7278184a8ad29b6cb206/scipy-1.17.1-cp313-cp313t-macosx_12_0_arm64.whl", hash = "sha256:010f4333c96c9bb1a4516269e33cb5917b08ef2166d5556ca2fd9f082a9e6ea0", size = 28570135, upload-time = "2026-02-23T00:20:03.934Z" }, + { url = "https://files.pythonhosted.org/packages/fd/c3/5bd7199f4ea8556c0c8e39f04ccb014ac37d1468e6cfa6a95c6b3562b76e/scipy-1.17.1-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:2ceb2d3e01c5f1d83c4189737a42d9cb2fc38a6eeed225e7515eef71ad301dce", size = 20741977, upload-time = "2026-02-23T00:20:07.935Z" }, + { url = "https://files.pythonhosted.org/packages/d9/b8/8ccd9b766ad14c78386599708eb745f6b44f08400a5fd0ade7cf89b6fc93/scipy-1.17.1-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:844e165636711ef41f80b4103ed234181646b98a53c8f05da12ca5ca289134f6", size = 23029601, upload-time = "2026-02-23T00:20:12.161Z" }, + { url = "https://files.pythonhosted.org/packages/6d/a0/3cb6f4d2fb3e17428ad2880333cac878909ad1a89f678527b5328b93c1d4/scipy-1.17.1-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:158dd96d2207e21c966063e1635b1063cd7787b627b6f07305315dd73d9c679e", size = 33019667, upload-time = "2026-02-23T00:20:17.208Z" }, + { url = "https://files.pythonhosted.org/packages/f3/c3/2d834a5ac7bf3a0c806ad1508efc02dda3c8c61472a56132d7894c312dea/scipy-1.17.1-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:74cbb80d93260fe2ffa334efa24cb8f2f0f622a9b9febf8b483c0b865bfb3475", size = 35264159, upload-time = "2026-02-23T00:20:23.087Z" }, + { url = "https://files.pythonhosted.org/packages/4d/77/d3ed4becfdbd217c52062fafe35a72388d1bd82c2d0ba5ca19d6fcc93e11/scipy-1.17.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:dbc12c9f3d185f5c737d801da555fb74b3dcfa1a50b66a1a93e09190f41fab50", size = 35102771, upload-time = "2026-02-23T00:20:28.636Z" }, + { url = "https://files.pythonhosted.org/packages/bd/12/d19da97efde68ca1ee5538bb261d5d2c062f0c055575128f11a2730e3ac1/scipy-1.17.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:94055a11dfebe37c656e70317e1996dc197e1a15bbcc351bcdd4610e128fe1ca", size = 37665910, upload-time = "2026-02-23T00:20:34.743Z" }, + { url = "https://files.pythonhosted.org/packages/06/1c/1172a88d507a4baaf72c5a09bb6c018fe2ae0ab622e5830b703a46cc9e44/scipy-1.17.1-cp313-cp313t-win_amd64.whl", hash = "sha256:e30bdeaa5deed6bc27b4cc490823cd0347d7dae09119b8803ae576ea0ce52e4c", size = 36562980, upload-time = "2026-02-23T00:20:40.575Z" }, + { url = "https://files.pythonhosted.org/packages/70/b0/eb757336e5a76dfa7911f63252e3b7d1de00935d7705cf772db5b45ec238/scipy-1.17.1-cp313-cp313t-win_arm64.whl", hash = "sha256:a720477885a9d2411f94a93d16f9d89bad0f28ca23c3f8daa521e2dcc3f44d49", size = 24856543, upload-time = "2026-02-23T00:20:45.313Z" }, + { url = "https://files.pythonhosted.org/packages/cf/83/333afb452af6f0fd70414dc04f898647ee1423979ce02efa75c3b0f2c28e/scipy-1.17.1-cp314-cp314-macosx_10_14_x86_64.whl", hash = "sha256:a48a72c77a310327f6a3a920092fa2b8fd03d7deaa60f093038f22d98e096717", size = 31584510, upload-time = "2026-02-23T00:21:01.015Z" }, + { url = "https://files.pythonhosted.org/packages/ed/a6/d05a85fd51daeb2e4ea71d102f15b34fedca8e931af02594193ae4fd25f7/scipy-1.17.1-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:45abad819184f07240d8a696117a7aacd39787af9e0b719d00285549ed19a1e9", size = 28170131, upload-time = "2026-02-23T00:21:05.888Z" }, + { url = "https://files.pythonhosted.org/packages/db/7b/8624a203326675d7746a254083a187398090a179335b2e4a20e2ddc46e83/scipy-1.17.1-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:3fd1fcdab3ea951b610dc4cef356d416d5802991e7e32b5254828d342f7b7e0b", size = 20342032, upload-time = "2026-02-23T00:21:09.904Z" }, + { url = "https://files.pythonhosted.org/packages/c9/35/2c342897c00775d688d8ff3987aced3426858fd89d5a0e26e020b660b301/scipy-1.17.1-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:7bdf2da170b67fdf10bca777614b1c7d96ae3ca5794fd9587dce41eb2966e866", size = 22678766, upload-time = "2026-02-23T00:21:14.313Z" }, + { url = "https://files.pythonhosted.org/packages/ef/f2/7cdb8eb308a1a6ae1e19f945913c82c23c0c442a462a46480ce487fdc0ac/scipy-1.17.1-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:adb2642e060a6549c343603a3851ba76ef0b74cc8c079a9a58121c7ec9fe2350", size = 32957007, upload-time = "2026-02-23T00:21:19.663Z" }, + { url = "https://files.pythonhosted.org/packages/0b/2e/7eea398450457ecb54e18e9d10110993fa65561c4f3add5e8eccd2b9cd41/scipy-1.17.1-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:eee2cfda04c00a857206a4330f0c5e3e56535494e30ca445eb19ec624ae75118", size = 35221333, upload-time = "2026-02-23T00:21:25.278Z" }, + { url = "https://files.pythonhosted.org/packages/d9/77/5b8509d03b77f093a0d52e606d3c4f79e8b06d1d38c441dacb1e26cacf46/scipy-1.17.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:d2650c1fb97e184d12d8ba010493ee7b322864f7d3d00d3f9bb97d9c21de4068", size = 35042066, upload-time = "2026-02-23T00:21:31.358Z" }, + { url = "https://files.pythonhosted.org/packages/f9/df/18f80fb99df40b4070328d5ae5c596f2f00fffb50167e31439e932f29e7d/scipy-1.17.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:08b900519463543aa604a06bec02461558a6e1cef8fdbb8098f77a48a83c8118", size = 37612763, upload-time = "2026-02-23T00:21:37.247Z" }, + { url = "https://files.pythonhosted.org/packages/4b/39/f0e8ea762a764a9dc52aa7dabcfad51a354819de1f0d4652b6a1122424d6/scipy-1.17.1-cp314-cp314-win_amd64.whl", hash = "sha256:3877ac408e14da24a6196de0ddcace62092bfc12a83823e92e49e40747e52c19", size = 37290984, upload-time = "2026-02-23T00:22:35.023Z" }, + { url = "https://files.pythonhosted.org/packages/7c/56/fe201e3b0f93d1a8bcf75d3379affd228a63d7e2d80ab45467a74b494947/scipy-1.17.1-cp314-cp314-win_arm64.whl", hash = "sha256:f8885db0bc2bffa59d5c1b72fad7a6a92d3e80e7257f967dd81abb553a90d293", size = 25192877, upload-time = "2026-02-23T00:22:39.798Z" }, + { url = "https://files.pythonhosted.org/packages/96/ad/f8c414e121f82e02d76f310f16db9899c4fcde36710329502a6b2a3c0392/scipy-1.17.1-cp314-cp314t-macosx_10_14_x86_64.whl", hash = "sha256:1cc682cea2ae55524432f3cdff9e9a3be743d52a7443d0cba9017c23c87ae2f6", size = 31949750, upload-time = "2026-02-23T00:21:42.289Z" }, + { url = "https://files.pythonhosted.org/packages/7c/b0/c741e8865d61b67c81e255f4f0a832846c064e426636cd7de84e74d209be/scipy-1.17.1-cp314-cp314t-macosx_12_0_arm64.whl", hash = "sha256:2040ad4d1795a0ae89bfc7e8429677f365d45aa9fd5e4587cf1ea737f927b4a1", size = 28585858, upload-time = "2026-02-23T00:21:47.706Z" }, + { url = "https://files.pythonhosted.org/packages/ed/1b/3985219c6177866628fa7c2595bfd23f193ceebbe472c98a08824b9466ff/scipy-1.17.1-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:131f5aaea57602008f9822e2115029b55d4b5f7c070287699fe45c661d051e39", size = 20757723, upload-time = "2026-02-23T00:21:52.039Z" }, + { url = "https://files.pythonhosted.org/packages/c0/19/2a04aa25050d656d6f7b9e7b685cc83d6957fb101665bfd9369ca6534563/scipy-1.17.1-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:9cdc1a2fcfd5c52cfb3045feb399f7b3ce822abdde3a193a6b9a60b3cb5854ca", size = 23043098, upload-time = "2026-02-23T00:21:56.185Z" }, + { url = "https://files.pythonhosted.org/packages/86/f1/3383beb9b5d0dbddd030335bf8a8b32d4317185efe495374f134d8be6cce/scipy-1.17.1-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e3dcd57ab780c741fde8dc68619de988b966db759a3c3152e8e9142c26295ad", size = 33030397, upload-time = "2026-02-23T00:22:01.404Z" }, + { url = "https://files.pythonhosted.org/packages/41/68/8f21e8a65a5a03f25a79165ec9d2b28c00e66dc80546cf5eb803aeeff35b/scipy-1.17.1-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a9956e4d4f4a301ebf6cde39850333a6b6110799d470dbbb1e25326ac447f52a", size = 35281163, upload-time = "2026-02-23T00:22:07.024Z" }, + { url = "https://files.pythonhosted.org/packages/84/8d/c8a5e19479554007a5632ed7529e665c315ae7492b4f946b0deb39870e39/scipy-1.17.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:a4328d245944d09fd639771de275701ccadf5f781ba0ff092ad141e017eccda4", size = 35116291, upload-time = "2026-02-23T00:22:12.585Z" }, + { url = "https://files.pythonhosted.org/packages/52/52/e57eceff0e342a1f50e274264ed47497b59e6a4e3118808ee58ddda7b74a/scipy-1.17.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:a77cbd07b940d326d39a1d1b37817e2ee4d79cb30e7338f3d0cddffae70fcaa2", size = 37682317, upload-time = "2026-02-23T00:22:18.513Z" }, + { url = "https://files.pythonhosted.org/packages/11/2f/b29eafe4a3fbc3d6de9662b36e028d5f039e72d345e05c250e121a230dd4/scipy-1.17.1-cp314-cp314t-win_amd64.whl", hash = "sha256:eb092099205ef62cd1782b006658db09e2fed75bffcae7cc0d44052d8aa0f484", size = 37345327, upload-time = "2026-02-23T00:22:24.442Z" }, + { url = "https://files.pythonhosted.org/packages/07/39/338d9219c4e87f3e708f18857ecd24d22a0c3094752393319553096b98af/scipy-1.17.1-cp314-cp314t-win_arm64.whl", hash = "sha256:200e1050faffacc162be6a486a984a0497866ec54149a01270adc8a59b7c7d21", size = 25489165, upload-time = "2026-02-23T00:22:29.563Z" }, ] [[package]] name = "sentence-transformers" -version = "5.4.1" +version = "5.5.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "huggingface-hub" }, - { name = "numpy" }, - { name = "scikit-learn" }, - { name = "scipy" }, - { name = "torch", version = "2.11.0", source = { registry = "https://download.pytorch.org/whl/cpu" }, marker = "sys_platform == 'darwin'" }, - { name = "torch", version = "2.11.0+cpu", source = { registry = "https://download.pytorch.org/whl/cpu" }, marker = "sys_platform != 'darwin'" }, - { name = "tqdm" }, - { name = "transformers" }, - { name = "typing-extensions" }, + { name = "huggingface-hub" }, + { name = "numpy" }, + { name = "scikit-learn" }, + { name = "scipy" }, + { name = "torch", version = "2.12.0", source = { registry = "https://download.pytorch.org/whl/cpu" }, marker = "sys_platform == 'darwin'" }, + { name = "torch", version = "2.12.0+cpu", source = { registry = "https://download.pytorch.org/whl/cpu" }, marker = "sys_platform != 'darwin'" }, + { name = "tqdm" }, + { name = "transformers" }, + { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/4d/68/7f98c221940ce783b492ad6140384daf2e2918cd7175009d6a362c22b9ee/sentence_transformers-5.4.1.tar.gz", hash = "sha256:436bcb1182a0ff42a8fb2b1c43498a70d0a75b688d182f2cd0d1dd115af61ddc", size = 428910, upload-time = "2026-04-14T13:34:59.006Z" } +sdist = { url = "https://files.pythonhosted.org/packages/cf/d4/7ef93157485e978c016f49da05363c1e4e7237beb5343b64b5631101f0f1/sentence_transformers-5.5.1.tar.gz", hash = "sha256:02b7740dfc60bdbbcb6061625f5d97a5c1a4e2d3baac5f9391b912bb5eae2290", size = 445161, upload-time = "2026-05-20T07:37:44.465Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c5/d9/3a9b6f2ccdedc9dc00fe37b2fc58f58f8efbff44565cf4bf39d8568bb13a/sentence_transformers-5.4.1-py3-none-any.whl", hash = "sha256:a6d640fc363849b63affb8e140e9d328feabab86f83d58ac3e16b1c28140b790", size = 571311, upload-time = "2026-04-14T13:34:57.731Z" }, + { url = "https://files.pythonhosted.org/packages/bf/03/ee99a6b030e7a2e056547729f8a4709dd93e13d9c6f07590f74c395c4017/sentence_transformers-5.5.1-py3-none-any.whl", hash = "sha256:4fe11d433badc5282d32f7fc08bc714216b7a5aca426f9df77a45a554756deb7", size = 588887, upload-time = "2026-05-20T07:37:43.004Z" }, ] [[package]] @@ -1369,7 +1376,7 @@ version = "81.0.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/0d/1c/73e719955c59b8e424d015ab450f51c0af856ae46ea2da83eba51cc88de1/setuptools-81.0.0.tar.gz", hash = "sha256:487b53915f52501f0a79ccfd0c02c165ffe06631443a886740b91af4b7a5845a", size = 1198299, upload-time = "2026-02-06T21:10:39.601Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e1/e3/c164c88b2e5ce7b24d667b9bd83589cf4f3520d97cad01534cd3c4f55fdb/setuptools-81.0.0-py3-none-any.whl", hash = "sha256:fdd925d5c5d9f62e4b74b30d6dd7828ce236fd6ed998a08d81de62ce5a6310d6", size = 1062021, upload-time = "2026-02-06T21:10:37.175Z" }, + { url = "https://files.pythonhosted.org/packages/e1/e3/c164c88b2e5ce7b24d667b9bd83589cf4f3520d97cad01534cd3c4f55fdb/setuptools-81.0.0-py3-none-any.whl", hash = "sha256:fdd925d5c5d9f62e4b74b30d6dd7828ce236fd6ed998a08d81de62ce5a6310d6", size = 1062021, upload-time = "2026-02-06T21:10:37.175Z" }, ] [[package]] @@ -1378,7 +1385,7 @@ version = "1.5.4" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/58/15/8b3609fd3830ef7b27b655beb4b4e9c62313a4e8da8c676e142cc210d58e/shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de", size = 10310, upload-time = "2023-10-24T04:13:40.426Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755, upload-time = "2023-10-24T04:13:38.866Z" }, + { url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755, upload-time = "2023-10-24T04:13:38.866Z" }, ] [[package]] @@ -1387,28 +1394,28 @@ version = "1.17.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, + { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, ] [[package]] name = "sqlglot" -version = "30.6.0" +version = "30.8.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/3c/66/6ece15f197874e56c76e1d0269cebf284ba992a80dfadca9d1972fdf7edf/sqlglot-30.6.0.tar.gz", hash = "sha256:246d34d39927422a50a3fa155f37b2f6346fba85f1a755b13c941eb32ef93361", size = 5835307, upload-time = "2026-04-20T20:11:08.164Z" } +sdist = { url = "https://files.pythonhosted.org/packages/0f/64/89299aefc6ebdf4fc899f5dc14c7fcb7eb9da9290a2b4d615ae7ab884b17/sqlglot-30.8.0.tar.gz", hash = "sha256:1c5f93fb742dd9aaa75eee6bb33a637794a858b9a86375fac23a2dc0f7bc127e", size = 5869750, upload-time = "2026-05-13T09:04:38.923Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/dc/e7/64fe971cbca33a0446b06f4a5ff8e3fa4a1dbd0a039ceabcc3e6cf4087a9/sqlglot-30.6.0-py3-none-any.whl", hash = "sha256:e005fc2f47994f90d7d8df341f1cbe937518497b0b7b1507d4c03c4c9dfd2778", size = 673920, upload-time = "2026-04-20T20:11:05.758Z" }, + { url = "https://files.pythonhosted.org/packages/88/4e/80705091aaf9c95e125d243f0aa871bc9f3670b4c9d963e6bad3b3dce8ff/sqlglot-30.8.0-py3-none-any.whl", hash = "sha256:af903378c331d5b72277a1b41118f07bc3e50cf4478e2d47eed12c96ee6a22a4", size = 687831, upload-time = "2026-05-13T09:04:36.336Z" }, ] [[package]] name = "starlette" -version = "1.0.0" +version = "1.2.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "anyio" }, + { name = "anyio" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/81/69/17425771797c36cded50b7fe44e850315d039f28b15901ab44839e70b593/starlette-1.0.0.tar.gz", hash = "sha256:6a4beaf1f81bb472fd19ea9b918b50dc3a77a6f2e190a12954b25e6ed5eea149", size = 2655289, upload-time = "2026-03-22T18:29:46.779Z" } +sdist = { url = "https://files.pythonhosted.org/packages/c5/bf/616a066c2760f6c2b1ae3437cc28149734d069fbb46511712beae118a68c/starlette-1.2.0.tar.gz", hash = "sha256:3c5a6b23fff42492914e93890bb80cbfea72dbf37de268eec06185d62a4ca553", size = 2668923, upload-time = "2026-05-28T11:42:50.568Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/0b/c9/584bc9651441b4ba60cc4d557d8a547b5aff901af35bda3a4ee30c819b82/starlette-1.0.0-py3-none-any.whl", hash = "sha256:d3ec55e0bb321692d275455ddfd3df75fff145d009685eb40dc91fc66b03d38b", size = 72651, upload-time = "2026-03-22T18:29:45.111Z" }, + { url = "https://files.pythonhosted.org/packages/9f/85/492183764d5d01d4514be3730fdb8e228a80605783099551c51627578b5d/starlette-1.2.0-py3-none-any.whl", hash = "sha256:36e0c76ac59157e75dc4b3bdeafba97fb04eaf1878045f15dbef666a6f092ed7", size = 73213, upload-time = "2026-05-28T11:42:48.801Z" }, ] [[package]] @@ -1416,11 +1423,11 @@ name = "sympy" version = "1.14.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "mpmath" }, + { name = "mpmath" }, ] sdist = { url = "https://files.pythonhosted.org/packages/83/d3/803453b36afefb7c2bb238361cd4ae6125a569b4db67cd9e79846ba2d68c/sympy-1.14.0.tar.gz", hash = "sha256:d3d3fe8df1e5a0b42f0e7bdf50541697dbe7d23746e894990c030e2b05e72517", size = 7793921, upload-time = "2025-04-27T18:05:01.611Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/a2/09/77d55d46fd61b4a135c444fc97158ef34a095e5681d0a6c10b75bf356191/sympy-1.14.0-py3-none-any.whl", hash = "sha256:e091cc3e99d2141a0ba2847328f5479b05d94a6635cb96148ccb3f34671bd8f5", size = 6299353, upload-time = "2025-04-27T18:04:59.103Z" }, + { url = "https://files.pythonhosted.org/packages/a2/09/77d55d46fd61b4a135c444fc97158ef34a095e5681d0a6c10b75bf356191/sympy-1.14.0-py3-none-any.whl", hash = "sha256:e091cc3e99d2141a0ba2847328f5479b05d94a6635cb96148ccb3f34671bd8f5", size = 6299353, upload-time = "2025-04-27T18:04:59.103Z" }, ] [[package]] @@ -1429,7 +1436,7 @@ version = "3.6.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/b7/4d/08c89e34946fce2aec4fbb45c9016efd5f4d7f24af8e5d93296e935631d8/threadpoolctl-3.6.0.tar.gz", hash = "sha256:8ab8b4aa3491d812b623328249fab5302a68d2d71745c8a4c719a2fcaba9f44e", size = 21274, upload-time = "2025-03-13T13:49:23.031Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/32/d5/f9a850d79b0851d1d4ef6456097579a9005b31fea68726a4ae5f2d82ddd9/threadpoolctl-3.6.0-py3-none-any.whl", hash = "sha256:43a0b8fd5a2928500110039e43a5eed8480b918967083ea48dc3ab9f13c4a7fb", size = 18638, upload-time = "2025-03-13T13:49:21.846Z" }, + { url = "https://files.pythonhosted.org/packages/32/d5/f9a850d79b0851d1d4ef6456097579a9005b31fea68726a4ae5f2d82ddd9/threadpoolctl-3.6.0-py3-none-any.whl", hash = "sha256:43a0b8fd5a2928500110039e43a5eed8480b918967083ea48dc3ab9f13c4a7fb", size = 18638, upload-time = "2025-03-13T13:49:21.846Z" }, ] [[package]] @@ -1437,89 +1444,90 @@ name = "tokenizers" version = "0.22.2" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "huggingface-hub" }, + { name = "huggingface-hub" }, ] sdist = { url = "https://files.pythonhosted.org/packages/73/6f/f80cfef4a312e1fb34baf7d85c72d4411afde10978d4657f8cdd811d3ccc/tokenizers-0.22.2.tar.gz", hash = "sha256:473b83b915e547aa366d1eee11806deaf419e17be16310ac0a14077f1e28f917", size = 372115, upload-time = "2026-01-05T10:45:15.988Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/92/97/5dbfabf04c7e348e655e907ed27913e03db0923abb5dfdd120d7b25630e1/tokenizers-0.22.2-cp39-abi3-macosx_10_12_x86_64.whl", hash = "sha256:544dd704ae7238755d790de45ba8da072e9af3eea688f698b137915ae959281c", size = 3100275, upload-time = "2026-01-05T10:41:02.158Z" }, - { url = "https://files.pythonhosted.org/packages/2e/47/174dca0502ef88b28f1c9e06b73ce33500eedfac7a7692108aec220464e7/tokenizers-0.22.2-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:1e418a55456beedca4621dbab65a318981467a2b188e982a23e117f115ce5001", size = 2981472, upload-time = "2026-01-05T10:41:00.276Z" }, - { url = "https://files.pythonhosted.org/packages/d6/84/7990e799f1309a8b87af6b948f31edaa12a3ed22d11b352eaf4f4b2e5753/tokenizers-0.22.2-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2249487018adec45d6e3554c71d46eb39fa8ea67156c640f7513eb26f318cec7", size = 3290736, upload-time = "2026-01-05T10:40:32.165Z" }, - { url = "https://files.pythonhosted.org/packages/78/59/09d0d9ba94dcd5f4f1368d4858d24546b4bdc0231c2354aa31d6199f0399/tokenizers-0.22.2-cp39-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:25b85325d0815e86e0bac263506dd114578953b7b53d7de09a6485e4a160a7dd", size = 3168835, upload-time = "2026-01-05T10:40:38.847Z" }, - { url = "https://files.pythonhosted.org/packages/47/50/b3ebb4243e7160bda8d34b731e54dd8ab8b133e50775872e7a434e524c28/tokenizers-0.22.2-cp39-abi3-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:bfb88f22a209ff7b40a576d5324bf8286b519d7358663db21d6246fb17eea2d5", size = 3521673, upload-time = "2026-01-05T10:40:56.614Z" }, - { url = "https://files.pythonhosted.org/packages/e0/fa/89f4cb9e08df770b57adb96f8cbb7e22695a4cb6c2bd5f0c4f0ebcf33b66/tokenizers-0.22.2-cp39-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1c774b1276f71e1ef716e5486f21e76333464f47bece56bbd554485982a9e03e", size = 3724818, upload-time = "2026-01-05T10:40:44.507Z" }, - { url = "https://files.pythonhosted.org/packages/64/04/ca2363f0bfbe3b3d36e95bf67e56a4c88c8e3362b658e616d1ac185d47f2/tokenizers-0.22.2-cp39-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:df6c4265b289083bf710dff49bc51ef252f9d5be33a45ee2bed151114a56207b", size = 3379195, upload-time = "2026-01-05T10:40:51.139Z" }, - { url = "https://files.pythonhosted.org/packages/2e/76/932be4b50ef6ccedf9d3c6639b056a967a86258c6d9200643f01269211ca/tokenizers-0.22.2-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:369cc9fc8cc10cb24143873a0d95438bb8ee257bb80c71989e3ee290e8d72c67", size = 3274982, upload-time = "2026-01-05T10:40:58.331Z" }, - { url = "https://files.pythonhosted.org/packages/1d/28/5f9f5a4cc211b69e89420980e483831bcc29dade307955cc9dc858a40f01/tokenizers-0.22.2-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:29c30b83d8dcd061078b05ae0cb94d3c710555fbb44861139f9f83dcca3dc3e4", size = 9478245, upload-time = "2026-01-05T10:41:04.053Z" }, - { url = "https://files.pythonhosted.org/packages/6c/fb/66e2da4704d6aadebf8cb39f1d6d1957df667ab24cff2326b77cda0dcb85/tokenizers-0.22.2-cp39-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:37ae80a28c1d3265bb1f22464c856bd23c02a05bb211e56d0c5301a435be6c1a", size = 9560069, upload-time = "2026-01-05T10:45:10.673Z" }, - { url = "https://files.pythonhosted.org/packages/16/04/fed398b05caa87ce9b1a1bb5166645e38196081b225059a6edaff6440fac/tokenizers-0.22.2-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:791135ee325f2336f498590eb2f11dc5c295232f288e75c99a36c5dbce63088a", size = 9899263, upload-time = "2026-01-05T10:45:12.559Z" }, - { url = "https://files.pythonhosted.org/packages/05/a1/d62dfe7376beaaf1394917e0f8e93ee5f67fea8fcf4107501db35996586b/tokenizers-0.22.2-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:38337540fbbddff8e999d59970f3c6f35a82de10053206a7562f1ea02d046fa5", size = 10033429, upload-time = "2026-01-05T10:45:14.333Z" }, - { url = "https://files.pythonhosted.org/packages/fd/18/a545c4ea42af3df6effd7d13d250ba77a0a86fb20393143bbb9a92e434d4/tokenizers-0.22.2-cp39-abi3-win32.whl", hash = "sha256:a6bf3f88c554a2b653af81f3204491c818ae2ac6fbc09e76ef4773351292bc92", size = 2502363, upload-time = "2026-01-05T10:45:20.593Z" }, - { url = "https://files.pythonhosted.org/packages/65/71/0670843133a43d43070abeb1949abfdef12a86d490bea9cd9e18e37c5ff7/tokenizers-0.22.2-cp39-abi3-win_amd64.whl", hash = "sha256:c9ea31edff2968b44a88f97d784c2f16dc0729b8b143ed004699ebca91f05c48", size = 2747786, upload-time = "2026-01-05T10:45:18.411Z" }, - { url = "https://files.pythonhosted.org/packages/72/f4/0de46cfa12cdcbcd464cc59fde36912af405696f687e53a091fb432f694c/tokenizers-0.22.2-cp39-abi3-win_arm64.whl", hash = "sha256:9ce725d22864a1e965217204946f830c37876eee3b2ba6fc6255e8e903d5fcbc", size = 2612133, upload-time = "2026-01-05T10:45:17.232Z" }, + { url = "https://files.pythonhosted.org/packages/92/97/5dbfabf04c7e348e655e907ed27913e03db0923abb5dfdd120d7b25630e1/tokenizers-0.22.2-cp39-abi3-macosx_10_12_x86_64.whl", hash = "sha256:544dd704ae7238755d790de45ba8da072e9af3eea688f698b137915ae959281c", size = 3100275, upload-time = "2026-01-05T10:41:02.158Z" }, + { url = "https://files.pythonhosted.org/packages/2e/47/174dca0502ef88b28f1c9e06b73ce33500eedfac7a7692108aec220464e7/tokenizers-0.22.2-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:1e418a55456beedca4621dbab65a318981467a2b188e982a23e117f115ce5001", size = 2981472, upload-time = "2026-01-05T10:41:00.276Z" }, + { url = "https://files.pythonhosted.org/packages/d6/84/7990e799f1309a8b87af6b948f31edaa12a3ed22d11b352eaf4f4b2e5753/tokenizers-0.22.2-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2249487018adec45d6e3554c71d46eb39fa8ea67156c640f7513eb26f318cec7", size = 3290736, upload-time = "2026-01-05T10:40:32.165Z" }, + { url = "https://files.pythonhosted.org/packages/78/59/09d0d9ba94dcd5f4f1368d4858d24546b4bdc0231c2354aa31d6199f0399/tokenizers-0.22.2-cp39-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:25b85325d0815e86e0bac263506dd114578953b7b53d7de09a6485e4a160a7dd", size = 3168835, upload-time = "2026-01-05T10:40:38.847Z" }, + { url = "https://files.pythonhosted.org/packages/47/50/b3ebb4243e7160bda8d34b731e54dd8ab8b133e50775872e7a434e524c28/tokenizers-0.22.2-cp39-abi3-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:bfb88f22a209ff7b40a576d5324bf8286b519d7358663db21d6246fb17eea2d5", size = 3521673, upload-time = "2026-01-05T10:40:56.614Z" }, + { url = "https://files.pythonhosted.org/packages/e0/fa/89f4cb9e08df770b57adb96f8cbb7e22695a4cb6c2bd5f0c4f0ebcf33b66/tokenizers-0.22.2-cp39-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1c774b1276f71e1ef716e5486f21e76333464f47bece56bbd554485982a9e03e", size = 3724818, upload-time = "2026-01-05T10:40:44.507Z" }, + { url = "https://files.pythonhosted.org/packages/64/04/ca2363f0bfbe3b3d36e95bf67e56a4c88c8e3362b658e616d1ac185d47f2/tokenizers-0.22.2-cp39-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:df6c4265b289083bf710dff49bc51ef252f9d5be33a45ee2bed151114a56207b", size = 3379195, upload-time = "2026-01-05T10:40:51.139Z" }, + { url = "https://files.pythonhosted.org/packages/2e/76/932be4b50ef6ccedf9d3c6639b056a967a86258c6d9200643f01269211ca/tokenizers-0.22.2-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:369cc9fc8cc10cb24143873a0d95438bb8ee257bb80c71989e3ee290e8d72c67", size = 3274982, upload-time = "2026-01-05T10:40:58.331Z" }, + { url = "https://files.pythonhosted.org/packages/1d/28/5f9f5a4cc211b69e89420980e483831bcc29dade307955cc9dc858a40f01/tokenizers-0.22.2-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:29c30b83d8dcd061078b05ae0cb94d3c710555fbb44861139f9f83dcca3dc3e4", size = 9478245, upload-time = "2026-01-05T10:41:04.053Z" }, + { url = "https://files.pythonhosted.org/packages/6c/fb/66e2da4704d6aadebf8cb39f1d6d1957df667ab24cff2326b77cda0dcb85/tokenizers-0.22.2-cp39-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:37ae80a28c1d3265bb1f22464c856bd23c02a05bb211e56d0c5301a435be6c1a", size = 9560069, upload-time = "2026-01-05T10:45:10.673Z" }, + { url = "https://files.pythonhosted.org/packages/16/04/fed398b05caa87ce9b1a1bb5166645e38196081b225059a6edaff6440fac/tokenizers-0.22.2-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:791135ee325f2336f498590eb2f11dc5c295232f288e75c99a36c5dbce63088a", size = 9899263, upload-time = "2026-01-05T10:45:12.559Z" }, + { url = "https://files.pythonhosted.org/packages/05/a1/d62dfe7376beaaf1394917e0f8e93ee5f67fea8fcf4107501db35996586b/tokenizers-0.22.2-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:38337540fbbddff8e999d59970f3c6f35a82de10053206a7562f1ea02d046fa5", size = 10033429, upload-time = "2026-01-05T10:45:14.333Z" }, + { url = "https://files.pythonhosted.org/packages/fd/18/a545c4ea42af3df6effd7d13d250ba77a0a86fb20393143bbb9a92e434d4/tokenizers-0.22.2-cp39-abi3-win32.whl", hash = "sha256:a6bf3f88c554a2b653af81f3204491c818ae2ac6fbc09e76ef4773351292bc92", size = 2502363, upload-time = "2026-01-05T10:45:20.593Z" }, + { url = "https://files.pythonhosted.org/packages/65/71/0670843133a43d43070abeb1949abfdef12a86d490bea9cd9e18e37c5ff7/tokenizers-0.22.2-cp39-abi3-win_amd64.whl", hash = "sha256:c9ea31edff2968b44a88f97d784c2f16dc0729b8b143ed004699ebca91f05c48", size = 2747786, upload-time = "2026-01-05T10:45:18.411Z" }, + { url = "https://files.pythonhosted.org/packages/72/f4/0de46cfa12cdcbcd464cc59fde36912af405696f687e53a091fb432f694c/tokenizers-0.22.2-cp39-abi3-win_arm64.whl", hash = "sha256:9ce725d22864a1e965217204946f830c37876eee3b2ba6fc6255e8e903d5fcbc", size = 2612133, upload-time = "2026-01-05T10:45:17.232Z" }, ] [[package]] name = "torch" -version = "2.11.0" +version = "2.12.0" source = { registry = "https://download.pytorch.org/whl/cpu" } resolution-markers = [ - "python_full_version >= '3.14' and sys_platform == 'darwin'", - "python_full_version < '3.14' and sys_platform == 'darwin'", + "python_full_version >= '3.14' and sys_platform == 'darwin'", + "python_full_version < '3.14' and sys_platform == 'darwin'", ] dependencies = [ - { name = "filelock", marker = "sys_platform == 'darwin'" }, - { name = "fsspec", marker = "sys_platform == 'darwin'" }, - { name = "jinja2", marker = "sys_platform == 'darwin'" }, - { name = "networkx", marker = "sys_platform == 'darwin'" }, - { name = "setuptools", marker = "sys_platform == 'darwin'" }, - { name = "sympy", marker = "sys_platform == 'darwin'" }, - { name = "typing-extensions", marker = "sys_platform == 'darwin'" }, + { name = "filelock", marker = "sys_platform == 'darwin'" }, + { name = "fsspec", marker = "sys_platform == 'darwin'" }, + { name = "jinja2", marker = "sys_platform == 'darwin'" }, + { name = "networkx", marker = "sys_platform == 'darwin'" }, + { name = "setuptools", marker = "sys_platform == 'darwin'" }, + { name = "sympy", marker = "sys_platform == 'darwin'" }, + { name = "typing-extensions", marker = "sys_platform == 'darwin'" }, ] wheels = [ - { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.11.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:442ec9dc78592564fdad69cf0beaa9da2f82ab810ccb4f13903869a90bf3f15d", upload-time = "2026-03-23T15:17:02Z" }, - { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.11.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:cc3a195701bba2239c313ee311487f80f8aaebe9e89b9073dddbcf2f93b5a0ba", upload-time = "2026-03-23T15:17:06Z" }, - { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.11.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:072a0d6e4865e8b0dc0dbfe6ebed68fae235124222835ef03e5814d414d8c012", upload-time = "2026-03-23T15:17:10Z" }, - { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.11.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:23ec7789017da9d95b6d543d790814785e6f30905c5443efa8257d1490d73f79", upload-time = "2026-03-23T15:17:14Z" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.12.0-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:90dd587a5f61bfe1307148b581e2084fc5bc4a06e2b90a20e9a36b81087ff16b", upload-time = "2026-05-12T16:20:17Z" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.12.0-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:10ee1448a9f304d3b987eb4656f664ba6e4d7b410ca7a5a7c642199777a2cf88", upload-time = "2026-05-12T16:20:21Z" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.12.0-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:f7dfae4a519197dfa050e98d8e36378a0fb5899625a875c2b54445005a2e404e", upload-time = "2026-05-12T16:20:26Z" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.12.0-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:b4556715c8572758625d62b6e0ae3b1f76c440221913a6fb5e100f321fb4fb02", upload-time = "2026-05-12T16:20:31Z" }, ] [[package]] name = "torch" -version = "2.11.0+cpu" +version = "2.12.0+cpu" source = { registry = "https://download.pytorch.org/whl/cpu" } resolution-markers = [ - "python_full_version >= '3.14' and sys_platform == 'win32'", - "python_full_version >= '3.14' and sys_platform == 'emscripten'", - "python_full_version >= '3.14' and sys_platform != 'darwin' and sys_platform != 'emscripten' and sys_platform != 'win32'", - "python_full_version < '3.14' and sys_platform == 'win32'", - "python_full_version < '3.14' and sys_platform == 'emscripten'", - "python_full_version < '3.14' and sys_platform != 'darwin' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version >= '3.14' and sys_platform == 'win32'", + "python_full_version >= '3.14' and sys_platform == 'emscripten'", + "python_full_version >= '3.14' and sys_platform != 'darwin' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version < '3.14' and sys_platform == 'win32'", + "python_full_version < '3.14' and sys_platform == 'emscripten'", + "python_full_version < '3.14' and sys_platform != 'darwin' and sys_platform != 'emscripten' and sys_platform != 'win32'", ] dependencies = [ - { name = "filelock", marker = "sys_platform != 'darwin'" }, - { name = "fsspec", marker = "sys_platform != 'darwin'" }, - { name = "jinja2", marker = "sys_platform != 'darwin'" }, - { name = "networkx", marker = "sys_platform != 'darwin'" }, - { name = "setuptools", marker = "sys_platform != 'darwin'" }, - { name = "sympy", marker = "sys_platform != 'darwin'" }, - { name = "typing-extensions", marker = "sys_platform != 'darwin'" }, + { name = "filelock", marker = "sys_platform != 'darwin'" }, + { name = "fsspec", marker = "sys_platform != 'darwin'" }, + { name = "jinja2", marker = "sys_platform != 'darwin'" }, + { name = "networkx", marker = "sys_platform != 'darwin'" }, + { name = "setuptools", marker = "sys_platform != 'darwin'" }, + { name = "sympy", marker = "sys_platform != 'darwin'" }, + { name = "typing-extensions", marker = "sys_platform != 'darwin'" }, ] wheels = [ - { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.11.0%2Bcpu-cp313-cp313-linux_s390x.whl", hash = "sha256:d1eff25ccc454faf21c9666c81bfab8e405e87c12d300708d4559620bc191a36", upload-time = "2026-04-28T00:06:42Z" }, - { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.11.0%2Bcpu-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:48b3e21a311445acdd0b27f13830e21d93adef70d4721e051e9f059baeb9b8f9", upload-time = "2026-04-28T00:06:51Z" }, - { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.11.0%2Bcpu-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:45025d7752dbc6b4c784c03afaee9c5f19730ce084b2e43fc9a2fe1677d9ff86", upload-time = "2026-04-28T00:07:02Z" }, - { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.11.0%2Bcpu-cp313-cp313-win_amd64.whl", hash = "sha256:ed70d4a4fc9f8b826c02fa1a9800a83820fb2fa6ae607680b53390f9ef394d85", upload-time = "2026-04-28T00:07:12Z" }, - { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.11.0%2Bcpu-cp313-cp313t-linux_s390x.whl", hash = "sha256:65d427a196ab0abe359b93c5bffedd76ded02df2b1b1d2d9f11a2609b69f426a", upload-time = "2026-04-28T00:07:19Z" }, - { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.11.0%2Bcpu-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:8f13dc7075ae04ca5f876a9f40b4e47522a04c23e30824b4409f42a3f3e57aa4", upload-time = "2026-04-28T00:07:27Z" }, - { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.11.0%2Bcpu-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:8713bb8679376ea0ec25742100b6cfb8447e0904c48bddefb9eb0ac1abbfa60a", upload-time = "2026-04-28T00:07:37Z" }, - { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.11.0%2Bcpu-cp313-cp313t-win_amd64.whl", hash = "sha256:62ec1f1694c185f601eab74eb7fc0e8e10c64c06ae82f13c3592774c231c4877", upload-time = "2026-04-28T00:07:47Z" }, - { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.11.0%2Bcpu-cp314-cp314-linux_s390x.whl", hash = "sha256:c9a14c367f470623b978e273a4e1915995b4ba7a0ae999178b06c273eea3536f", upload-time = "2026-04-28T00:07:54Z" }, - { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.11.0%2Bcpu-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:71676f6a9a84bbd385e010198b51fa1c2324fb8f3c512a32d2c81af65f68f4c9", upload-time = "2026-04-28T00:08:02Z" }, - { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.11.0%2Bcpu-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:f8481ea9088e4e5b81178a75aabdbb658bde8639bc1a15fd5d8f930abc966735", upload-time = "2026-04-28T00:08:11Z" }, - { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.11.0%2Bcpu-cp314-cp314-win_amd64.whl", hash = "sha256:7575af4c9f7f7500ed62b1dafeb069aa0ba35b368a5f09793b3976b3d50f4fe4", upload-time = "2026-04-28T00:08:20Z" }, - { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.11.0%2Bcpu-cp314-cp314t-linux_s390x.whl", hash = "sha256:825f1596878280a3a4c861441674888bc2d792e4ab7b045cb35feeab3f4f5dd7", upload-time = "2026-04-28T00:08:27Z" }, - { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.11.0%2Bcpu-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:c8a0bdfb2fd915b6c2cd27c856f63f729c366a4917772eba6b2b02aa3bce70d5", upload-time = "2026-04-28T00:08:36Z" }, - { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.11.0%2Bcpu-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:768f22924a25cad2adeb9c6cbac5159e71067c8d4019b1511960d7435a5ca652", upload-time = "2026-04-28T00:08:47Z" }, - { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.11.0%2Bcpu-cp314-cp314t-win_amd64.whl", hash = "sha256:6db45e7b2526d996fbf47c3d08737807a60a4e17996a6d91a97027fe260832c8", upload-time = "2026-04-28T00:08:57Z" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.12.0%2Bcpu-cp313-cp313-linux_s390x.whl", hash = "sha256:5e0da19e1c3bfdc9b92638c552579eac678354485d61fc8921b0461fd6c40449", upload-time = "2026-05-12T23:17:05Z" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.12.0%2Bcpu-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:68b7ddd4db4603a03e106e74c7098c8d8c8943d33c1e5ada009ca4cd885759c3", upload-time = "2026-05-12T23:17:12Z" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.12.0%2Bcpu-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:ada78018bdfa30d1c766596cd32d910dbf5b03424cd859231b6d2a00533de922", upload-time = "2026-05-12T23:17:20Z" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.12.0%2Bcpu-cp313-cp313-win_amd64.whl", hash = "sha256:59bc266826e683899d49ee0af9829f3eafd0a16e15b5db9dc591c8d955003b66", upload-time = "2026-05-12T23:17:27Z" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.12.0%2Bcpu-cp313-cp313-win_arm64.whl", hash = "sha256:97a5160abf3ca9d59a2cd7b4b4de89d9dfe290d36a1ac720262a55fbcee10b6c", upload-time = "2026-05-12T23:17:31Z" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.12.0%2Bcpu-cp313-cp313t-linux_s390x.whl", hash = "sha256:32b9b7a0974cd6149cb98def0a28a49d92d7c14a384273d5539da9624239e950", upload-time = "2026-05-12T23:17:36Z" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.12.0%2Bcpu-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:93ed8dc52c113580daf6124982b3232629045dccc5cd83a8f5ed478f7bac7340", upload-time = "2026-05-12T23:17:43Z" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.12.0%2Bcpu-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:6a1c86abd4ed15a0736cf2663ad69642ae5d1288c99e30346070e6241018a0a9", upload-time = "2026-05-12T23:17:54Z" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.12.0%2Bcpu-cp313-cp313t-win_amd64.whl", hash = "sha256:768dce4b7b3353795f667d1cb0dd7dfba06f570cd39539576097335e05bb71fe", upload-time = "2026-05-12T23:18:02Z" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.12.0%2Bcpu-cp314-cp314-linux_s390x.whl", hash = "sha256:ee1f329acfd0c2a1ccaa3393bcaf9857ea58759549bb2d67e271a6eab42382b3", upload-time = "2026-05-12T23:18:08Z" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.12.0%2Bcpu-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:797c066367792c92eb97cafba7fd0caa8d7455e6078a4ee880630077378dc372", upload-time = "2026-05-12T23:18:15Z" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.12.0%2Bcpu-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:a8f419ce3f25388d36e67153ec63b3a1b17059c49f5a7759a7e91ac4843660d3", upload-time = "2026-05-12T23:18:22Z" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.12.0%2Bcpu-cp314-cp314-win_amd64.whl", hash = "sha256:1dd196c43e74e7b3b526ff434e7efbdef3f3792a2efbecfc983d7dce501840d2", upload-time = "2026-05-12T23:18:30Z" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.12.0%2Bcpu-cp314-cp314t-linux_s390x.whl", hash = "sha256:d0d2080cb13c94ebc0c884d237e404490743d0f40192c8a180abf3b6b6f334cf", upload-time = "2026-05-12T23:18:35Z" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.12.0%2Bcpu-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:f7bc15972acad257723775237cdd120024cca844b8bc64701822fa596bcb7e14", upload-time = "2026-05-12T23:18:42Z" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.12.0%2Bcpu-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:4d79f961250d1763487ecbc90af019a80009f9e87cadc5366b3ec4ba5671fea6", upload-time = "2026-05-12T23:18:50Z" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.12.0%2Bcpu-cp314-cp314t-win_amd64.whl", hash = "sha256:46b8f4c41ac36bb5d5b47f5437b3de5541b313275e59c1d2aefd3bef32b0f531", upload-time = "2026-05-12T23:18:58Z" }, ] [[package]] @@ -1527,46 +1535,46 @@ name = "tqdm" version = "4.67.3" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "colorama", marker = "sys_platform == 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/09/a9/6ba95a270c6f1fbcd8dac228323f2777d886cb206987444e4bce66338dd4/tqdm-4.67.3.tar.gz", hash = "sha256:7d825f03f89244ef73f1d4ce193cb1774a8179fd96f31d7e1dcde62092b960bb", size = 169598, upload-time = "2026-02-03T17:35:53.048Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/16/e1/3079a9ff9b8e11b846c6ac5c8b5bfb7ff225eee721825310c91b3b50304f/tqdm-4.67.3-py3-none-any.whl", hash = "sha256:ee1e4c0e59148062281c49d80b25b67771a127c85fc9676d3be5f243206826bf", size = 78374, upload-time = "2026-02-03T17:35:50.982Z" }, + { url = "https://files.pythonhosted.org/packages/16/e1/3079a9ff9b8e11b846c6ac5c8b5bfb7ff225eee721825310c91b3b50304f/tqdm-4.67.3-py3-none-any.whl", hash = "sha256:ee1e4c0e59148062281c49d80b25b67771a127c85fc9676d3be5f243206826bf", size = 78374, upload-time = "2026-02-03T17:35:50.982Z" }, ] [[package]] name = "transformers" -version = "5.6.2" +version = "5.9.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "huggingface-hub" }, - { name = "numpy" }, - { name = "packaging" }, - { name = "pyyaml" }, - { name = "regex" }, - { name = "safetensors" }, - { name = "tokenizers" }, - { name = "tqdm" }, - { name = "typer" }, + { name = "huggingface-hub" }, + { name = "numpy" }, + { name = "packaging" }, + { name = "pyyaml" }, + { name = "regex" }, + { name = "safetensors" }, + { name = "tokenizers" }, + { name = "tqdm" }, + { name = "typer" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/a4/e9/c6c80a07690142a7d05444271f47b9f3c8aac7dea01d52e1137ee480ad78/transformers-5.6.2.tar.gz", hash = "sha256:e657134c3e5a6bc00a3c35f4e2674bb51adfcd89898495b788a18552bac2b91a", size = 8311867, upload-time = "2026-04-23T18:33:29.332Z" } +sdist = { url = "https://files.pythonhosted.org/packages/51/58/7f843608f2e8421f86bb97060b54649be6239ec612b82bf9d41e65c26c00/transformers-5.9.0.tar.gz", hash = "sha256:25997cb8fa6053533171634b6162d7df54346530ec2aa9b42bb834e63668c842", size = 8642240, upload-time = "2026-05-20T14:50:49.278Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/5d/95/0b0218149b0d6f14df35f5b8f676fa83df4f19ed253c3cc447107ef86eca/transformers-5.6.2-py3-none-any.whl", hash = "sha256:f8d3a1bb96778fed9b8aabfd0dd6e19843e4b0f2bb6b59f32b8a92051b0f348f", size = 10364898, upload-time = "2026-04-23T18:33:26.081Z" }, + { url = "https://files.pythonhosted.org/packages/02/ca/2eaa5359f2ccb8c2e1656bc26305ad0cf438aa392ce4b29ae67a315c186e/transformers-5.9.0-py3-none-any.whl", hash = "sha256:1d19509bcff7028ebc6b277d71caa712e8353778463d38764237d14b42b52788", size = 10787648, upload-time = "2026-05-20T14:50:45.337Z" }, ] [[package]] name = "typer" -version = "0.25.0" +version = "0.25.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "annotated-doc" }, - { name = "click" }, - { name = "rich" }, - { name = "shellingham" }, + { name = "annotated-doc" }, + { name = "click" }, + { name = "rich" }, + { name = "shellingham" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/7b/27/ede8cec7596e0041ba7e7b80b47d132562f56ff454313a16f6084e555c9f/typer-0.25.0.tar.gz", hash = "sha256:123eaf9f19bb40fd268310e12a542c0c6b4fab9c98d9d23342a01ff95e3ce930", size = 120150, upload-time = "2026-04-26T08:46:14.767Z" } +sdist = { url = "https://files.pythonhosted.org/packages/e4/51/9aed62104cea109b820bbd6c14245af756112017d309da813ef107d42e7e/typer-0.25.1.tar.gz", hash = "sha256:9616eb8853a09ffeabab1698952f33c6f29ffdbceb4eaeecf571880e8d7664cc", size = 122276, upload-time = "2026-04-30T19:32:16.964Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/9a/72/193d4e586ec5a4db834a36bbeb47641a62f951f114ffd0fe5b1b46e8d56f/typer-0.25.0-py3-none-any.whl", hash = "sha256:ac01b48823d3db9a83c9e164338057eadbb1c9957a2a6b4eeb486669c560b5dc", size = 55993, upload-time = "2026-04-26T08:46:15.889Z" }, + { url = "https://files.pythonhosted.org/packages/3f/f9/2b3ff4e56e5fa7debfaf9eb135d0da96f3e9a1d5b27222223c7296336e5f/typer-0.25.1-py3-none-any.whl", hash = "sha256:75caa44ed46a03fb2dab8808753ffacdbfea88495e74c85a28c5eefcf5f39c89", size = 58409, upload-time = "2026-04-30T19:32:18.271Z" }, ] [[package]] @@ -1575,7 +1583,7 @@ version = "4.15.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, + { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, ] [[package]] @@ -1583,11 +1591,11 @@ name = "typing-inspection" version = "0.4.2" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "typing-extensions" }, + { name = "typing-extensions" }, ] sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, + { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, ] [[package]] @@ -1596,7 +1604,7 @@ version = "2026.2" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/ba/19/1b9b0e29f30c6d35cb345486df41110984ea67ae69dddbc0e8a100999493/tzdata-2026.2.tar.gz", hash = "sha256:9173fde7d80d9018e02a662e168e5a2d04f87c41ea174b139fbef642eda62d10", size = 198254, upload-time = "2026-04-24T15:22:08.651Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ce/e4/dccd7f47c4b64213ac01ef921a1337ee6e30e8c6466046018326977efd95/tzdata-2026.2-py2.py3-none-any.whl", hash = "sha256:bbe9af844f658da81a5f95019480da3a89415801f6cc966806612cc7169bffe7", size = 349321, upload-time = "2026-04-24T15:22:05.876Z" }, + { url = "https://files.pythonhosted.org/packages/ce/e4/dccd7f47c4b64213ac01ef921a1337ee6e30e8c6466046018326977efd95/tzdata-2026.2-py2.py3-none-any.whl", hash = "sha256:bbe9af844f658da81a5f95019480da3a89415801f6cc966806612cc7169bffe7", size = 349321, upload-time = "2026-04-24T15:22:05.876Z" }, ] [[package]] @@ -1605,31 +1613,31 @@ version = "2.7.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602, upload-time = "2026-05-07T16:13:18.596Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" }, + { url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" }, ] [[package]] name = "uvicorn" -version = "0.46.0" +version = "0.48.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "click" }, - { name = "h11" }, + { name = "click" }, + { name = "h11" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/1f/93/041fca8274050e40e6791f267d82e0e2e27dd165627bd640d3e0e378d877/uvicorn-0.46.0.tar.gz", hash = "sha256:fb9da0926999cc6cb22dc7cd71a94a632f078e6ae47ff683c5c420750fb7413d", size = 88758, upload-time = "2026-04-23T07:16:00.151Z" } +sdist = { url = "https://files.pythonhosted.org/packages/e6/bf/f6544ba992ddb9a6077343a576f9844f7f8f06ab819aefd00206e9255f18/uvicorn-0.48.0.tar.gz", hash = "sha256:a5504207195d08c2511bf9125ede5ac4a4b71725d519e758d01dcf0bc2d31c37", size = 91074, upload-time = "2026-05-24T12:08:41.925Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/31/a3/5b1562db76a5a488274b2332a97199b32d0442aca0ed193697fd47786316/uvicorn-0.46.0-py3-none-any.whl", hash = "sha256:bbebbcbed972d162afca128605223022bedd345b7bc7855ce66deb31487a9048", size = 70926, upload-time = "2026-04-23T07:15:58.355Z" }, + { url = "https://files.pythonhosted.org/packages/01/be/72532be3da7acc5fdfbccdb95215cd04f995a0886532a5b423f929cda4cc/uvicorn-0.48.0-py3-none-any.whl", hash = "sha256:48097851328b87ec36117d3d575234519eb58c2b22d79666e9bbc6c49a761dad", size = 71410, upload-time = "2026-05-24T12:08:40.258Z" }, ] [package.optional-dependencies] standard = [ - { name = "colorama", marker = "sys_platform == 'win32'" }, - { name = "httptools" }, - { name = "python-dotenv" }, - { name = "pyyaml" }, - { name = "uvloop", marker = "platform_python_implementation != 'PyPy' and sys_platform != 'cygwin' and sys_platform != 'win32'" }, - { name = "watchfiles" }, - { name = "websockets" }, + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "httptools" }, + { name = "python-dotenv" }, + { name = "pyyaml" }, + { name = "uvloop", marker = "platform_python_implementation != 'PyPy' and sys_platform != 'cygwin' and sys_platform != 'win32'" }, + { name = "watchfiles" }, + { name = "websockets" }, ] [[package]] @@ -1638,96 +1646,111 @@ version = "0.22.1" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/06/f0/18d39dbd1971d6d62c4629cc7fa67f74821b0dc1f5a77af43719de7936a7/uvloop-0.22.1.tar.gz", hash = "sha256:6c84bae345b9147082b17371e3dd5d42775bddce91f885499017f4607fdaf39f", size = 2443250, upload-time = "2025-10-16T22:17:19.342Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/89/8c/182a2a593195bfd39842ea68ebc084e20c850806117213f5a299dfc513d9/uvloop-0.22.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:561577354eb94200d75aca23fbde86ee11be36b00e52a4eaf8f50fb0c86b7705", size = 1358611, upload-time = "2025-10-16T22:16:36.833Z" }, - { url = "https://files.pythonhosted.org/packages/d2/14/e301ee96a6dc95224b6f1162cd3312f6d1217be3907b79173b06785f2fe7/uvloop-0.22.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:1cdf5192ab3e674ca26da2eada35b288d2fa49fdd0f357a19f0e7c4e7d5077c8", size = 751811, upload-time = "2025-10-16T22:16:38.275Z" }, - { url = "https://files.pythonhosted.org/packages/b7/02/654426ce265ac19e2980bfd9ea6590ca96a56f10c76e63801a2df01c0486/uvloop-0.22.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e2ea3d6190a2968f4a14a23019d3b16870dd2190cd69c8180f7c632d21de68d", size = 4288562, upload-time = "2025-10-16T22:16:39.375Z" }, - { url = "https://files.pythonhosted.org/packages/15/c0/0be24758891ef825f2065cd5db8741aaddabe3e248ee6acc5e8a80f04005/uvloop-0.22.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0530a5fbad9c9e4ee3f2b33b148c6a64d47bbad8000ea63704fa8260f4cf728e", size = 4366890, upload-time = "2025-10-16T22:16:40.547Z" }, - { url = "https://files.pythonhosted.org/packages/d2/53/8369e5219a5855869bcee5f4d317f6da0e2c669aecf0ef7d371e3d084449/uvloop-0.22.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bc5ef13bbc10b5335792360623cc378d52d7e62c2de64660616478c32cd0598e", size = 4119472, upload-time = "2025-10-16T22:16:41.694Z" }, - { url = "https://files.pythonhosted.org/packages/f8/ba/d69adbe699b768f6b29a5eec7b47dd610bd17a69de51b251126a801369ea/uvloop-0.22.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1f38ec5e3f18c8a10ded09742f7fb8de0108796eb673f30ce7762ce1b8550cad", size = 4239051, upload-time = "2025-10-16T22:16:43.224Z" }, - { url = "https://files.pythonhosted.org/packages/90/cd/b62bdeaa429758aee8de8b00ac0dd26593a9de93d302bff3d21439e9791d/uvloop-0.22.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:3879b88423ec7e97cd4eba2a443aa26ed4e59b45e6b76aabf13fe2f27023a142", size = 1362067, upload-time = "2025-10-16T22:16:44.503Z" }, - { url = "https://files.pythonhosted.org/packages/0d/f8/a132124dfda0777e489ca86732e85e69afcd1ff7686647000050ba670689/uvloop-0.22.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:4baa86acedf1d62115c1dc6ad1e17134476688f08c6efd8a2ab076e815665c74", size = 752423, upload-time = "2025-10-16T22:16:45.968Z" }, - { url = "https://files.pythonhosted.org/packages/a3/94/94af78c156f88da4b3a733773ad5ba0b164393e357cc4bd0ab2e2677a7d6/uvloop-0.22.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:297c27d8003520596236bdb2335e6b3f649480bd09e00d1e3a99144b691d2a35", size = 4272437, upload-time = "2025-10-16T22:16:47.451Z" }, - { url = "https://files.pythonhosted.org/packages/b5/35/60249e9fd07b32c665192cec7af29e06c7cd96fa1d08b84f012a56a0b38e/uvloop-0.22.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c1955d5a1dd43198244d47664a5858082a3239766a839b2102a269aaff7a4e25", size = 4292101, upload-time = "2025-10-16T22:16:49.318Z" }, - { url = "https://files.pythonhosted.org/packages/02/62/67d382dfcb25d0a98ce73c11ed1a6fba5037a1a1d533dcbb7cab033a2636/uvloop-0.22.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b31dc2fccbd42adc73bc4e7cdbae4fc5086cf378979e53ca5d0301838c5682c6", size = 4114158, upload-time = "2025-10-16T22:16:50.517Z" }, - { url = "https://files.pythonhosted.org/packages/f0/7a/f1171b4a882a5d13c8b7576f348acfe6074d72eaf52cccef752f748d4a9f/uvloop-0.22.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:93f617675b2d03af4e72a5333ef89450dfaa5321303ede6e67ba9c9d26878079", size = 4177360, upload-time = "2025-10-16T22:16:52.646Z" }, - { url = "https://files.pythonhosted.org/packages/79/7b/b01414f31546caf0919da80ad57cbfe24c56b151d12af68cee1b04922ca8/uvloop-0.22.1-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:37554f70528f60cad66945b885eb01f1bb514f132d92b6eeed1c90fd54ed6289", size = 1454790, upload-time = "2025-10-16T22:16:54.355Z" }, - { url = "https://files.pythonhosted.org/packages/d4/31/0bb232318dd838cad3fa8fb0c68c8b40e1145b32025581975e18b11fab40/uvloop-0.22.1-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:b76324e2dc033a0b2f435f33eb88ff9913c156ef78e153fb210e03c13da746b3", size = 796783, upload-time = "2025-10-16T22:16:55.906Z" }, - { url = "https://files.pythonhosted.org/packages/42/38/c9b09f3271a7a723a5de69f8e237ab8e7803183131bc57c890db0b6bb872/uvloop-0.22.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:badb4d8e58ee08dad957002027830d5c3b06aea446a6a3744483c2b3b745345c", size = 4647548, upload-time = "2025-10-16T22:16:57.008Z" }, - { url = "https://files.pythonhosted.org/packages/c1/37/945b4ca0ac27e3dc4952642d4c900edd030b3da6c9634875af6e13ae80e5/uvloop-0.22.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b91328c72635f6f9e0282e4a57da7470c7350ab1c9f48546c0f2866205349d21", size = 4467065, upload-time = "2025-10-16T22:16:58.206Z" }, - { url = "https://files.pythonhosted.org/packages/97/cc/48d232f33d60e2e2e0b42f4e73455b146b76ebe216487e862700457fbf3c/uvloop-0.22.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:daf620c2995d193449393d6c62131b3fbd40a63bf7b307a1527856ace637fe88", size = 4328384, upload-time = "2025-10-16T22:16:59.36Z" }, - { url = "https://files.pythonhosted.org/packages/e4/16/c1fd27e9549f3c4baf1dc9c20c456cd2f822dbf8de9f463824b0c0357e06/uvloop-0.22.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6cde23eeda1a25c75b2e07d39970f3374105d5eafbaab2a4482be82f272d5a5e", size = 4296730, upload-time = "2025-10-16T22:17:00.744Z" }, + { url = "https://files.pythonhosted.org/packages/89/8c/182a2a593195bfd39842ea68ebc084e20c850806117213f5a299dfc513d9/uvloop-0.22.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:561577354eb94200d75aca23fbde86ee11be36b00e52a4eaf8f50fb0c86b7705", size = 1358611, upload-time = "2025-10-16T22:16:36.833Z" }, + { url = "https://files.pythonhosted.org/packages/d2/14/e301ee96a6dc95224b6f1162cd3312f6d1217be3907b79173b06785f2fe7/uvloop-0.22.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:1cdf5192ab3e674ca26da2eada35b288d2fa49fdd0f357a19f0e7c4e7d5077c8", size = 751811, upload-time = "2025-10-16T22:16:38.275Z" }, + { url = "https://files.pythonhosted.org/packages/b7/02/654426ce265ac19e2980bfd9ea6590ca96a56f10c76e63801a2df01c0486/uvloop-0.22.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e2ea3d6190a2968f4a14a23019d3b16870dd2190cd69c8180f7c632d21de68d", size = 4288562, upload-time = "2025-10-16T22:16:39.375Z" }, + { url = "https://files.pythonhosted.org/packages/15/c0/0be24758891ef825f2065cd5db8741aaddabe3e248ee6acc5e8a80f04005/uvloop-0.22.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0530a5fbad9c9e4ee3f2b33b148c6a64d47bbad8000ea63704fa8260f4cf728e", size = 4366890, upload-time = "2025-10-16T22:16:40.547Z" }, + { url = "https://files.pythonhosted.org/packages/d2/53/8369e5219a5855869bcee5f4d317f6da0e2c669aecf0ef7d371e3d084449/uvloop-0.22.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bc5ef13bbc10b5335792360623cc378d52d7e62c2de64660616478c32cd0598e", size = 4119472, upload-time = "2025-10-16T22:16:41.694Z" }, + { url = "https://files.pythonhosted.org/packages/f8/ba/d69adbe699b768f6b29a5eec7b47dd610bd17a69de51b251126a801369ea/uvloop-0.22.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1f38ec5e3f18c8a10ded09742f7fb8de0108796eb673f30ce7762ce1b8550cad", size = 4239051, upload-time = "2025-10-16T22:16:43.224Z" }, + { url = "https://files.pythonhosted.org/packages/90/cd/b62bdeaa429758aee8de8b00ac0dd26593a9de93d302bff3d21439e9791d/uvloop-0.22.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:3879b88423ec7e97cd4eba2a443aa26ed4e59b45e6b76aabf13fe2f27023a142", size = 1362067, upload-time = "2025-10-16T22:16:44.503Z" }, + { url = "https://files.pythonhosted.org/packages/0d/f8/a132124dfda0777e489ca86732e85e69afcd1ff7686647000050ba670689/uvloop-0.22.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:4baa86acedf1d62115c1dc6ad1e17134476688f08c6efd8a2ab076e815665c74", size = 752423, upload-time = "2025-10-16T22:16:45.968Z" }, + { url = "https://files.pythonhosted.org/packages/a3/94/94af78c156f88da4b3a733773ad5ba0b164393e357cc4bd0ab2e2677a7d6/uvloop-0.22.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:297c27d8003520596236bdb2335e6b3f649480bd09e00d1e3a99144b691d2a35", size = 4272437, upload-time = "2025-10-16T22:16:47.451Z" }, + { url = "https://files.pythonhosted.org/packages/b5/35/60249e9fd07b32c665192cec7af29e06c7cd96fa1d08b84f012a56a0b38e/uvloop-0.22.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c1955d5a1dd43198244d47664a5858082a3239766a839b2102a269aaff7a4e25", size = 4292101, upload-time = "2025-10-16T22:16:49.318Z" }, + { url = "https://files.pythonhosted.org/packages/02/62/67d382dfcb25d0a98ce73c11ed1a6fba5037a1a1d533dcbb7cab033a2636/uvloop-0.22.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b31dc2fccbd42adc73bc4e7cdbae4fc5086cf378979e53ca5d0301838c5682c6", size = 4114158, upload-time = "2025-10-16T22:16:50.517Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/f1171b4a882a5d13c8b7576f348acfe6074d72eaf52cccef752f748d4a9f/uvloop-0.22.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:93f617675b2d03af4e72a5333ef89450dfaa5321303ede6e67ba9c9d26878079", size = 4177360, upload-time = "2025-10-16T22:16:52.646Z" }, + { url = "https://files.pythonhosted.org/packages/79/7b/b01414f31546caf0919da80ad57cbfe24c56b151d12af68cee1b04922ca8/uvloop-0.22.1-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:37554f70528f60cad66945b885eb01f1bb514f132d92b6eeed1c90fd54ed6289", size = 1454790, upload-time = "2025-10-16T22:16:54.355Z" }, + { url = "https://files.pythonhosted.org/packages/d4/31/0bb232318dd838cad3fa8fb0c68c8b40e1145b32025581975e18b11fab40/uvloop-0.22.1-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:b76324e2dc033a0b2f435f33eb88ff9913c156ef78e153fb210e03c13da746b3", size = 796783, upload-time = "2025-10-16T22:16:55.906Z" }, + { url = "https://files.pythonhosted.org/packages/42/38/c9b09f3271a7a723a5de69f8e237ab8e7803183131bc57c890db0b6bb872/uvloop-0.22.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:badb4d8e58ee08dad957002027830d5c3b06aea446a6a3744483c2b3b745345c", size = 4647548, upload-time = "2025-10-16T22:16:57.008Z" }, + { url = "https://files.pythonhosted.org/packages/c1/37/945b4ca0ac27e3dc4952642d4c900edd030b3da6c9634875af6e13ae80e5/uvloop-0.22.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b91328c72635f6f9e0282e4a57da7470c7350ab1c9f48546c0f2866205349d21", size = 4467065, upload-time = "2025-10-16T22:16:58.206Z" }, + { url = "https://files.pythonhosted.org/packages/97/cc/48d232f33d60e2e2e0b42f4e73455b146b76ebe216487e862700457fbf3c/uvloop-0.22.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:daf620c2995d193449393d6c62131b3fbd40a63bf7b307a1527856ace637fe88", size = 4328384, upload-time = "2025-10-16T22:16:59.36Z" }, + { url = "https://files.pythonhosted.org/packages/e4/16/c1fd27e9549f3c4baf1dc9c20c456cd2f822dbf8de9f463824b0c0357e06/uvloop-0.22.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6cde23eeda1a25c75b2e07d39970f3374105d5eafbaab2a4482be82f272d5a5e", size = 4296730, upload-time = "2025-10-16T22:17:00.744Z" }, ] [[package]] name = "virtualenv" -version = "21.2.4" +version = "21.4.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "distlib" }, - { name = "filelock" }, - { name = "platformdirs" }, - { name = "python-discovery" }, + { name = "distlib" }, + { name = "filelock" }, + { name = "platformdirs" }, + { name = "python-discovery" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/0c/98/3a7e644e19cb26133488caff231be390579860bbbb3da35913c49a1d0a46/virtualenv-21.2.4.tar.gz", hash = "sha256:b294ef68192638004d72524ce7ef303e9d0cf5a44c95ce2e54a7500a6381cada", size = 5850742, upload-time = "2026-04-14T22:15:31.438Z" } +sdist = { url = "https://files.pythonhosted.org/packages/95/f0/b47ecf438211a25a97f8f0e4b23c22bc2496ebfea18dd6ec16210f09cc36/virtualenv-21.4.1.tar.gz", hash = "sha256:2ca543c713b72840ceffd94e9bdedfbd09a661defa1f7f69e5429ad4059442e2", size = 7613344, upload-time = "2026-05-28T04:12:49.905Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/27/8d/edd0bd910ff803c308ee9a6b7778621af0d10252219ad9f19ef4d4982a61/virtualenv-21.2.4-py3-none-any.whl", hash = "sha256:29d21e941795206138d0f22f4e45ff7050e5da6c6472299fb7103318763861ac", size = 5831232, upload-time = "2026-04-14T22:15:29.342Z" }, + { url = "https://files.pythonhosted.org/packages/ff/dc/ac4f3a987a87e1a18556896f257c4e15c95ed157b7975347ec6b313b75ce/virtualenv-21.4.1-py3-none-any.whl", hash = "sha256:caf4ff72d1b4039057f41d8e8466e859513d67c0400d9c6b62c02c9d1ebc3e12", size = 7594078, upload-time = "2026-05-28T04:12:47.686Z" }, ] [[package]] name = "watchfiles" -version = "1.1.1" +version = "1.2.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "anyio" }, + { name = "anyio" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/c2/c9/8869df9b2a2d6c59d79220a4db37679e74f807c559ffe5265e08b227a210/watchfiles-1.1.1.tar.gz", hash = "sha256:a173cb5c16c4f40ab19cecf48a534c409f7ea983ab8fed0741304a1c0a31b3f2", size = 94440, upload-time = "2025-10-14T15:06:21.08Z" } +sdist = { url = "https://files.pythonhosted.org/packages/cd/41/5e1a4bb12aac5f1493fa1bdc11154eca3b258ca4eba65d39c473fe19d8e9/watchfiles-1.2.0.tar.gz", hash = "sha256:c995fba777f1ea992f090f9236e9284cf7a5d1a0130dd5a3d82c598cacd76838", size = 108252, upload-time = "2026-05-18T04:32:04.251Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/bb/f4/f750b29225fe77139f7ae5de89d4949f5a99f934c65a1f1c0b248f26f747/watchfiles-1.1.1-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:130e4876309e8686a5e37dba7d5e9bc77e6ed908266996ca26572437a5271e18", size = 404321, upload-time = "2025-10-14T15:05:02.063Z" }, - { url = "https://files.pythonhosted.org/packages/2b/f9/f07a295cde762644aa4c4bb0f88921d2d141af45e735b965fb2e87858328/watchfiles-1.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:5f3bde70f157f84ece3765b42b4a52c6ac1a50334903c6eaf765362f6ccca88a", size = 391783, upload-time = "2025-10-14T15:05:03.052Z" }, - { url = "https://files.pythonhosted.org/packages/bc/11/fc2502457e0bea39a5c958d86d2cb69e407a4d00b85735ca724bfa6e0d1a/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:14e0b1fe858430fc0251737ef3824c54027bedb8c37c38114488b8e131cf8219", size = 449279, upload-time = "2025-10-14T15:05:04.004Z" }, - { url = "https://files.pythonhosted.org/packages/e3/1f/d66bc15ea0b728df3ed96a539c777acfcad0eb78555ad9efcaa1274688f0/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f27db948078f3823a6bb3b465180db8ebecf26dd5dae6f6180bd87383b6b4428", size = 459405, upload-time = "2025-10-14T15:05:04.942Z" }, - { url = "https://files.pythonhosted.org/packages/be/90/9f4a65c0aec3ccf032703e6db02d89a157462fbb2cf20dd415128251cac0/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:059098c3a429f62fc98e8ec62b982230ef2c8df68c79e826e37b895bc359a9c0", size = 488976, upload-time = "2025-10-14T15:05:05.905Z" }, - { url = "https://files.pythonhosted.org/packages/37/57/ee347af605d867f712be7029bb94c8c071732a4b44792e3176fa3c612d39/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bfb5862016acc9b869bb57284e6cb35fdf8e22fe59f7548858e2f971d045f150", size = 595506, upload-time = "2025-10-14T15:05:06.906Z" }, - { url = "https://files.pythonhosted.org/packages/a8/78/cc5ab0b86c122047f75e8fc471c67a04dee395daf847d3e59381996c8707/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:319b27255aacd9923b8a276bb14d21a5f7ff82564c744235fc5eae58d95422ae", size = 474936, upload-time = "2025-10-14T15:05:07.906Z" }, - { url = "https://files.pythonhosted.org/packages/62/da/def65b170a3815af7bd40a3e7010bf6ab53089ef1b75d05dd5385b87cf08/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c755367e51db90e75b19454b680903631d41f9e3607fbd941d296a020c2d752d", size = 456147, upload-time = "2025-10-14T15:05:09.138Z" }, - { url = "https://files.pythonhosted.org/packages/57/99/da6573ba71166e82d288d4df0839128004c67d2778d3b566c138695f5c0b/watchfiles-1.1.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:c22c776292a23bfc7237a98f791b9ad3144b02116ff10d820829ce62dff46d0b", size = 630007, upload-time = "2025-10-14T15:05:10.117Z" }, - { url = "https://files.pythonhosted.org/packages/a8/51/7439c4dd39511368849eb1e53279cd3454b4a4dbace80bab88feeb83c6b5/watchfiles-1.1.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:3a476189be23c3686bc2f4321dd501cb329c0a0469e77b7b534ee10129ae6374", size = 622280, upload-time = "2025-10-14T15:05:11.146Z" }, - { url = "https://files.pythonhosted.org/packages/95/9c/8ed97d4bba5db6fdcdb2b298d3898f2dd5c20f6b73aee04eabe56c59677e/watchfiles-1.1.1-cp313-cp313-win32.whl", hash = "sha256:bf0a91bfb5574a2f7fc223cf95eeea79abfefa404bf1ea5e339c0c1560ae99a0", size = 272056, upload-time = "2025-10-14T15:05:12.156Z" }, - { url = "https://files.pythonhosted.org/packages/1f/f3/c14e28429f744a260d8ceae18bf58c1d5fa56b50d006a7a9f80e1882cb0d/watchfiles-1.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:52e06553899e11e8074503c8e716d574adeeb7e68913115c4b3653c53f9bae42", size = 288162, upload-time = "2025-10-14T15:05:13.208Z" }, - { url = "https://files.pythonhosted.org/packages/dc/61/fe0e56c40d5cd29523e398d31153218718c5786b5e636d9ae8ae79453d27/watchfiles-1.1.1-cp313-cp313-win_arm64.whl", hash = "sha256:ac3cc5759570cd02662b15fbcd9d917f7ecd47efe0d6b40474eafd246f91ea18", size = 277909, upload-time = "2025-10-14T15:05:14.49Z" }, - { url = "https://files.pythonhosted.org/packages/79/42/e0a7d749626f1e28c7108a99fb9bf524b501bbbeb9b261ceecde644d5a07/watchfiles-1.1.1-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:563b116874a9a7ce6f96f87cd0b94f7faf92d08d0021e837796f0a14318ef8da", size = 403389, upload-time = "2025-10-14T15:05:15.777Z" }, - { url = "https://files.pythonhosted.org/packages/15/49/08732f90ce0fbbc13913f9f215c689cfc9ced345fb1bcd8829a50007cc8d/watchfiles-1.1.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3ad9fe1dae4ab4212d8c91e80b832425e24f421703b5a42ef2e4a1e215aff051", size = 389964, upload-time = "2025-10-14T15:05:16.85Z" }, - { url = "https://files.pythonhosted.org/packages/27/0d/7c315d4bd5f2538910491a0393c56bf70d333d51bc5b34bee8e68e8cea19/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce70f96a46b894b36eba678f153f052967a0d06d5b5a19b336ab0dbbd029f73e", size = 448114, upload-time = "2025-10-14T15:05:17.876Z" }, - { url = "https://files.pythonhosted.org/packages/c3/24/9e096de47a4d11bc4df41e9d1e61776393eac4cb6eb11b3e23315b78b2cc/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:cb467c999c2eff23a6417e58d75e5828716f42ed8289fe6b77a7e5a91036ca70", size = 460264, upload-time = "2025-10-14T15:05:18.962Z" }, - { url = "https://files.pythonhosted.org/packages/cc/0f/e8dea6375f1d3ba5fcb0b3583e2b493e77379834c74fd5a22d66d85d6540/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:836398932192dae4146c8f6f737d74baeac8b70ce14831a239bdb1ca882fc261", size = 487877, upload-time = "2025-10-14T15:05:20.094Z" }, - { url = "https://files.pythonhosted.org/packages/ac/5b/df24cfc6424a12deb41503b64d42fbea6b8cb357ec62ca84a5a3476f654a/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:743185e7372b7bc7c389e1badcc606931a827112fbbd37f14c537320fca08620", size = 595176, upload-time = "2025-10-14T15:05:21.134Z" }, - { url = "https://files.pythonhosted.org/packages/8f/b5/853b6757f7347de4e9b37e8cc3289283fb983cba1ab4d2d7144694871d9c/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:afaeff7696e0ad9f02cbb8f56365ff4686ab205fcf9c4c5b6fdfaaa16549dd04", size = 473577, upload-time = "2025-10-14T15:05:22.306Z" }, - { url = "https://files.pythonhosted.org/packages/e1/f7/0a4467be0a56e80447c8529c9fce5b38eab4f513cb3d9bf82e7392a5696b/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3f7eb7da0eb23aa2ba036d4f616d46906013a68caf61b7fdbe42fc8b25132e77", size = 455425, upload-time = "2025-10-14T15:05:23.348Z" }, - { url = "https://files.pythonhosted.org/packages/8e/e0/82583485ea00137ddf69bc84a2db88bd92ab4a6e3c405e5fb878ead8d0e7/watchfiles-1.1.1-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:831a62658609f0e5c64178211c942ace999517f5770fe9436be4c2faeba0c0ef", size = 628826, upload-time = "2025-10-14T15:05:24.398Z" }, - { url = "https://files.pythonhosted.org/packages/28/9a/a785356fccf9fae84c0cc90570f11702ae9571036fb25932f1242c82191c/watchfiles-1.1.1-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:f9a2ae5c91cecc9edd47e041a930490c31c3afb1f5e6d71de3dc671bfaca02bf", size = 622208, upload-time = "2025-10-14T15:05:25.45Z" }, - { url = "https://files.pythonhosted.org/packages/c3/f4/0872229324ef69b2c3edec35e84bd57a1289e7d3fe74588048ed8947a323/watchfiles-1.1.1-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:d1715143123baeeaeadec0528bb7441103979a1d5f6fd0e1f915383fea7ea6d5", size = 404315, upload-time = "2025-10-14T15:05:26.501Z" }, - { url = "https://files.pythonhosted.org/packages/7b/22/16d5331eaed1cb107b873f6ae1b69e9ced582fcf0c59a50cd84f403b1c32/watchfiles-1.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:39574d6370c4579d7f5d0ad940ce5b20db0e4117444e39b6d8f99db5676c52fd", size = 390869, upload-time = "2025-10-14T15:05:27.649Z" }, - { url = "https://files.pythonhosted.org/packages/b2/7e/5643bfff5acb6539b18483128fdc0ef2cccc94a5b8fbda130c823e8ed636/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7365b92c2e69ee952902e8f70f3ba6360d0d596d9299d55d7d386df84b6941fb", size = 449919, upload-time = "2025-10-14T15:05:28.701Z" }, - { url = "https://files.pythonhosted.org/packages/51/2e/c410993ba5025a9f9357c376f48976ef0e1b1aefb73b97a5ae01a5972755/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bfff9740c69c0e4ed32416f013f3c45e2ae42ccedd1167ef2d805c000b6c71a5", size = 460845, upload-time = "2025-10-14T15:05:30.064Z" }, - { url = "https://files.pythonhosted.org/packages/8e/a4/2df3b404469122e8680f0fcd06079317e48db58a2da2950fb45020947734/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b27cf2eb1dda37b2089e3907d8ea92922b673c0c427886d4edc6b94d8dfe5db3", size = 489027, upload-time = "2025-10-14T15:05:31.064Z" }, - { url = "https://files.pythonhosted.org/packages/ea/84/4587ba5b1f267167ee715b7f66e6382cca6938e0a4b870adad93e44747e6/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:526e86aced14a65a5b0ec50827c745597c782ff46b571dbfe46192ab9e0b3c33", size = 595615, upload-time = "2025-10-14T15:05:32.074Z" }, - { url = "https://files.pythonhosted.org/packages/6a/0f/c6988c91d06e93cd0bb3d4a808bcf32375ca1904609835c3031799e3ecae/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:04e78dd0b6352db95507fd8cb46f39d185cf8c74e4cf1e4fbad1d3df96faf510", size = 474836, upload-time = "2025-10-14T15:05:33.209Z" }, - { url = "https://files.pythonhosted.org/packages/b4/36/ded8aebea91919485b7bbabbd14f5f359326cb5ec218cd67074d1e426d74/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5c85794a4cfa094714fb9c08d4a218375b2b95b8ed1666e8677c349906246c05", size = 455099, upload-time = "2025-10-14T15:05:34.189Z" }, - { url = "https://files.pythonhosted.org/packages/98/e0/8c9bdba88af756a2fce230dd365fab2baf927ba42cd47521ee7498fd5211/watchfiles-1.1.1-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:74d5012b7630714b66be7b7b7a78855ef7ad58e8650c73afc4c076a1f480a8d6", size = 630626, upload-time = "2025-10-14T15:05:35.216Z" }, - { url = "https://files.pythonhosted.org/packages/2a/84/a95db05354bf2d19e438520d92a8ca475e578c647f78f53197f5a2f17aaf/watchfiles-1.1.1-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:8fbe85cb3201c7d380d3d0b90e63d520f15d6afe217165d7f98c9c649654db81", size = 622519, upload-time = "2025-10-14T15:05:36.259Z" }, - { url = "https://files.pythonhosted.org/packages/1d/ce/d8acdc8de545de995c339be67711e474c77d643555a9bb74a9334252bd55/watchfiles-1.1.1-cp314-cp314-win32.whl", hash = "sha256:3fa0b59c92278b5a7800d3ee7733da9d096d4aabcfabb9a928918bd276ef9b9b", size = 272078, upload-time = "2025-10-14T15:05:37.63Z" }, - { url = "https://files.pythonhosted.org/packages/c4/c9/a74487f72d0451524be827e8edec251da0cc1fcf111646a511ae752e1a3d/watchfiles-1.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:c2047d0b6cea13b3316bdbafbfa0c4228ae593d995030fda39089d36e64fc03a", size = 287664, upload-time = "2025-10-14T15:05:38.95Z" }, - { url = "https://files.pythonhosted.org/packages/df/b8/8ac000702cdd496cdce998c6f4ee0ca1f15977bba51bdf07d872ebdfc34c/watchfiles-1.1.1-cp314-cp314-win_arm64.whl", hash = "sha256:842178b126593addc05acf6fce960d28bc5fae7afbaa2c6c1b3a7b9460e5be02", size = 277154, upload-time = "2025-10-14T15:05:39.954Z" }, - { url = "https://files.pythonhosted.org/packages/47/a8/e3af2184707c29f0f14b1963c0aace6529f9d1b8582d5b99f31bbf42f59e/watchfiles-1.1.1-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:88863fbbc1a7312972f1c511f202eb30866370ebb8493aef2812b9ff28156a21", size = 403820, upload-time = "2025-10-14T15:05:40.932Z" }, - { url = "https://files.pythonhosted.org/packages/c0/ec/e47e307c2f4bd75f9f9e8afbe3876679b18e1bcec449beca132a1c5ffb2d/watchfiles-1.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:55c7475190662e202c08c6c0f4d9e345a29367438cf8e8037f3155e10a88d5a5", size = 390510, upload-time = "2025-10-14T15:05:41.945Z" }, - { url = "https://files.pythonhosted.org/packages/d5/a0/ad235642118090f66e7b2f18fd5c42082418404a79205cdfca50b6309c13/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3f53fa183d53a1d7a8852277c92b967ae99c2d4dcee2bfacff8868e6e30b15f7", size = 448408, upload-time = "2025-10-14T15:05:43.385Z" }, - { url = "https://files.pythonhosted.org/packages/df/85/97fa10fd5ff3332ae17e7e40e20784e419e28521549780869f1413742e9d/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6aae418a8b323732fa89721d86f39ec8f092fc2af67f4217a2b07fd3e93c6101", size = 458968, upload-time = "2025-10-14T15:05:44.404Z" }, - { url = "https://files.pythonhosted.org/packages/47/c2/9059c2e8966ea5ce678166617a7f75ecba6164375f3b288e50a40dc6d489/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f096076119da54a6080e8920cbdaac3dbee667eb91dcc5e5b78840b87415bd44", size = 488096, upload-time = "2025-10-14T15:05:45.398Z" }, - { url = "https://files.pythonhosted.org/packages/94/44/d90a9ec8ac309bc26db808a13e7bfc0e4e78b6fc051078a554e132e80160/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:00485f441d183717038ed2e887a7c868154f216877653121068107b227a2f64c", size = 596040, upload-time = "2025-10-14T15:05:46.502Z" }, - { url = "https://files.pythonhosted.org/packages/95/68/4e3479b20ca305cfc561db3ed207a8a1c745ee32bf24f2026a129d0ddb6e/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a55f3e9e493158d7bfdb60a1165035f1cf7d320914e7b7ea83fe22c6023b58fc", size = 473847, upload-time = "2025-10-14T15:05:47.484Z" }, - { url = "https://files.pythonhosted.org/packages/4f/55/2af26693fd15165c4ff7857e38330e1b61ab8c37d15dc79118cdba115b7a/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8c91ed27800188c2ae96d16e3149f199d62f86c7af5f5f4d2c61a3ed8cd3666c", size = 455072, upload-time = "2025-10-14T15:05:48.928Z" }, - { url = "https://files.pythonhosted.org/packages/66/1d/d0d200b10c9311ec25d2273f8aad8c3ef7cc7ea11808022501811208a750/watchfiles-1.1.1-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:311ff15a0bae3714ffb603e6ba6dbfba4065ab60865d15a6ec544133bdb21099", size = 629104, upload-time = "2025-10-14T15:05:49.908Z" }, - { url = "https://files.pythonhosted.org/packages/e3/bd/fa9bb053192491b3867ba07d2343d9f2252e00811567d30ae8d0f78136fe/watchfiles-1.1.1-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:a916a2932da8f8ab582f242c065f5c81bed3462849ca79ee357dd9551b0e9b01", size = 622112, upload-time = "2025-10-14T15:05:50.941Z" }, + { url = "https://files.pythonhosted.org/packages/d1/4d/70a7feced9f87e2ff26dba42667290f41694fc64646c67261fbb8cab5d5c/watchfiles-1.2.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:01ea8d66f0693b9b60a6541c8d10263091ca9a9060d242f3c1f3143f9aad2c98", size = 399730, upload-time = "2026-05-18T04:31:38.162Z" }, + { url = "https://files.pythonhosted.org/packages/31/3a/0da302f2307aee316922806ebd5726c542cbd787c938271cf14a074c7daf/watchfiles-1.2.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7ba0480b9a74af058f43b337e937a451e109295c420916d68ad24e3dc02f5e44", size = 392842, upload-time = "2026-05-18T04:30:27.051Z" }, + { url = "https://files.pythonhosted.org/packages/db/ef/d5bdb705c224dbc256aa0c1ec47bf4e61ec52558f2afb44a71a1fe4d7015/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4f34e26a19f91f710c08e0183429f0d1d15df734e6bc78c31e77b9ea9c433658", size = 452989, upload-time = "2026-05-18T04:31:11.945Z" }, + { url = "https://files.pythonhosted.org/packages/71/29/5495f2c1661949ef7a35e4d71111d129cfe7606414a26887a919d0a55406/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b4e77f6a55f858504069abd35d336a637555c09bca453dde1ee1e5ada8a6a1fb", size = 458978, upload-time = "2026-05-18T04:30:52.606Z" }, + { url = "https://files.pythonhosted.org/packages/d5/8c/7f9c07c433811c2fffd93e13fdfb7135de9aab5f2ae41be08960fa0047dc/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0cb4d80e212f116474a545c21c912b445f16bb0cef9e6a73a498164223e14e2f", size = 490248, upload-time = "2026-05-18T04:31:36.003Z" }, + { url = "https://files.pythonhosted.org/packages/3c/11/d93632febc52fbc21be90231bb7c17fd5387f46c9076fd40a5f9c2ae6910/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b974946a10af379d425e2eef5b62f5c6ebeaccf91d45eaad6f5b27ecd4f91aa0", size = 571847, upload-time = "2026-05-18T04:31:10.862Z" }, + { url = "https://files.pythonhosted.org/packages/55/b4/383173e73aabb07ad1d9c7aa859d95437ac46a6d6a1e11005facda0c9d19/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:86bc13c25a8d1fcd70b51d0ce7c9b65e90de5666fcbfd3e34957cc73ee19aeb5", size = 465974, upload-time = "2026-05-18T04:30:17.006Z" }, + { url = "https://files.pythonhosted.org/packages/a7/6c/89b1a230a78f57c52dd8893adb1f92f94411721b6ec12596c56d98c74356/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ca148d73dea36c9763aaa351e4d7a51780ec1584217c45276f4fe8239c768b71", size = 454782, upload-time = "2026-05-18T04:30:35.656Z" }, + { url = "https://files.pythonhosted.org/packages/24/62/1732118367cfff0a9fce3bf62ff4bfded09ef5df21d9d446b858b3f70a96/watchfiles-1.2.0-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:c525543d91961c6955b2636b308569e84a1d1c5f5f2932041ab9ef46422f43e3", size = 465182, upload-time = "2026-05-18T04:30:20.846Z" }, + { url = "https://files.pythonhosted.org/packages/28/96/716f7e5f51339bf22963f3345f9f27d7f3b30e2eadc597e257c881dd3c53/watchfiles-1.2.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:a204794696ffb8f9b10fba6f7cb5216d42f3b2b71860ccac6b6e42f5f10973b0", size = 629841, upload-time = "2026-05-18T04:31:05.397Z" }, + { url = "https://files.pythonhosted.org/packages/4c/fe/c40783950fd771ccf66ab3ec2722d188a9af1c7f96c6e811f36e40c6e03f/watchfiles-1.2.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:10d86db20695afe7997ac9e1717637d6714a8d0220458c33f3d2061f54cec427", size = 658028, upload-time = "2026-05-18T04:31:48.22Z" }, + { url = "https://files.pythonhosted.org/packages/71/72/4508db1856d1d87fcbb3b63f4839bab1b5682cb0e8d224d122263c09654a/watchfiles-1.2.0-cp313-cp313-win32.whl", hash = "sha256:eb283ee99e21ad6443c8cdb06ac5b34b1308c329cbdf03fa02b445363714c799", size = 275183, upload-time = "2026-05-18T04:30:59.57Z" }, + { url = "https://files.pythonhosted.org/packages/f9/36/14b76ca57652e5cc5fd1c11f32a261292c08a0d19a00351013c2549cbfb2/watchfiles-1.2.0-cp313-cp313-win_amd64.whl", hash = "sha256:a0f27f01bee51861392bb6b7c4fdb290b27d1eb194e9e28788d68102a0e898d9", size = 288059, upload-time = "2026-05-18T04:32:07.937Z" }, + { url = "https://files.pythonhosted.org/packages/1b/8d/0a85e395398d8d20fadfe5c5d32c726eee17a519e78fb356f2cf7531bffe/watchfiles-1.2.0-cp313-cp313-win_arm64.whl", hash = "sha256:3651aa7058595e9cfb75d35dd5ada2bf9f48a5b8a0f3562821d3e210c507e077", size = 280186, upload-time = "2026-05-18T04:31:54.484Z" }, + { url = "https://files.pythonhosted.org/packages/37/68/36db056f1fdcc5f07302f56e631774d6835bcd6fa3ace402304621d5f9e5/watchfiles-1.2.0-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:faea288b6f0ab1902ef08f4ca6de005dccf856c4e0c4f21b8c5fce02d90a1b08", size = 399031, upload-time = "2026-05-18T04:30:44.576Z" }, + { url = "https://files.pythonhosted.org/packages/c1/64/01a9d6f66a82a5c101ce939274106cc72759d62427e153f01edd2b9f87c2/watchfiles-1.2.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:01859b11fd9fbca670f4d5da00fbac282cfea9bd67a2125d8b2833a3b5617ea9", size = 391205, upload-time = "2026-05-18T04:30:25.413Z" }, + { url = "https://files.pythonhosted.org/packages/84/2c/0a44fe058cb4bb7b8ede6b6670698bbb7c0400740e378d00022189b7b31d/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fff610d7bb2256a317bb1e96f0d7862c7aa8076733ee5df0fd41bbe76a24a4f4", size = 451892, upload-time = "2026-05-18T04:32:14.005Z" }, + { url = "https://files.pythonhosted.org/packages/67/a1/351e0d56cd35e6488b5c8b4fb11a809a5bc923e8fe8fed9faf8920be0c89/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b141a4891c995a039cd89e9a49e62df1dc8a559a5d1a6e4c7106d16c12777a55", size = 458867, upload-time = "2026-05-18T04:31:22.279Z" }, + { url = "https://files.pythonhosted.org/packages/d5/7d/9d09605187f1b838998624049fcf8bf47b73c1a3b76901fcac1782f62277/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f22943b7770483f6ea0721c6b11d022947a98eb0acae14694de034f4d0d38925", size = 490217, upload-time = "2026-05-18T04:31:43.657Z" }, + { url = "https://files.pythonhosted.org/packages/60/5d/a17a16eccb182f04188cd308ec24b1a71a9b5c4e7098269cf35d9fa56d02/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1bc6195825b7dcd217968bb1f801a60fd4c16e8eeab5bedc7fe917d7d5995ab4", size = 571458, upload-time = "2026-05-18T04:32:11.875Z" }, + { url = "https://files.pythonhosted.org/packages/d3/3d/4dd457062083ab1938e5dfd45032eb425cee2ac817287ca8ff4356183e5d/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d4a4b147f5dca2a5d325a06a832fb43f345751adfbc63204aec30e0d9ca965a2", size = 464707, upload-time = "2026-05-18T04:30:43.492Z" }, + { url = "https://files.pythonhosted.org/packages/c6/71/ea8c57b128f5383de74d0c7d2d9c57ad7c9a65a930c451bd25d524b295b7/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4543579a9bdb0c9560039b4ffddbdb39545707659fbc430ce4c10f3f68d557f9", size = 454663, upload-time = "2026-05-18T04:30:16.061Z" }, + { url = "https://files.pythonhosted.org/packages/53/fd/2e812bf938406d7db351f0703ddd3fc6c061cf30d96153a77bc79a943a44/watchfiles-1.2.0-cp313-cp313t-manylinux_2_31_riscv64.whl", hash = "sha256:20aa0e708b920bde876a4aa82dc7dd6ebea228a63a67cda6632c2fc87b787efa", size = 463537, upload-time = "2026-05-18T04:31:44.9Z" }, + { url = "https://files.pythonhosted.org/packages/86/56/d17a7f1dd1bc3035f1072694a551301272f1739c2d8e319c927cb9e29b38/watchfiles-1.2.0-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:d413349d565dab74297f2a63e84a097936be69bf8f3b3801f27f380e32040f44", size = 629194, upload-time = "2026-05-18T04:31:14.141Z" }, + { url = "https://files.pythonhosted.org/packages/be/06/f1ff66bf5cae50aa4062779a0ecd0bbaf15e466195719074078947d9a17d/watchfiles-1.2.0-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:f28b2725eb8cce327b9b3ab02415c853011dc55c95832fe90de6bc56f5315f72", size = 656194, upload-time = "2026-05-18T04:31:47.14Z" }, + { url = "https://files.pythonhosted.org/packages/e7/54/a9c7ea9a82a4ac65e7004c0a03920b5cdd2f9c3b678757d9cd425aa51d53/watchfiles-1.2.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:b8c8358484d5fa12ef34f05b7f4168eaf1932f408725ff6d023c33ec17bd79d4", size = 400205, upload-time = "2026-05-18T04:32:05.153Z" }, + { url = "https://files.pythonhosted.org/packages/aa/5d/c9ab3534374a4a67450696905d6ef16a04405448b8dc52bd752ae50423d4/watchfiles-1.2.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:9f04b092229ad2c50126dd3c922c8822e51e605993764a33058d4a791ab42281", size = 392508, upload-time = "2026-05-18T04:30:54.849Z" }, + { url = "https://files.pythonhosted.org/packages/26/ca/1ad30103535cf0cecd7b993e8d50edc5351b1820e38f2d22e3df58962feb/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7a7ce236284f002a156f70add88efe5c70879cccbb658be0822c54b1306fc09d", size = 452448, upload-time = "2026-05-18T04:30:53.727Z" }, + { url = "https://files.pythonhosted.org/packages/37/a1/ceee2cdf2afbd715fa07758d39c9859513eae411b23196f7fd039e5feedd/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b9909cc2b48468b575eefa944919e1fe8a36c5849d5c7c168f80a8c1db69398e", size = 459605, upload-time = "2026-05-18T04:30:23.312Z" }, + { url = "https://files.pythonhosted.org/packages/e8/f6/421e30fd1cb3907a84ed92ab3f1983e37ba2dca015e9a894a048418417a2/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0a37faaed405c67e28e6be45a1fa4f206ef5a2860f27c237db9fa30704c38242", size = 490757, upload-time = "2026-05-18T04:30:47.358Z" }, + { url = "https://files.pythonhosted.org/packages/41/b0/55ed1b97ed08be7bba6f9a541cac15f2a858e1d74d2b07b6da70a82aab00/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9649193aa27bd9ff2e80ff29bfaa93085496c7a3a377592823cc58b77ee88add", size = 568672, upload-time = "2026-05-18T04:30:38.915Z" }, + { url = "https://files.pythonhosted.org/packages/d1/cf/d8ae8a80dd7bafab395ea7681c10237311bbf34d37704a8c744e7cf31fc7/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4e4ff8e37f99cf1da89e255e07c9c4b37c214038c4283707bdec308cb1b0ea1f", size = 464197, upload-time = "2026-05-18T04:30:09.914Z" }, + { url = "https://files.pythonhosted.org/packages/7c/8a/3076c496ca8dafe0e8cd03fcebdfc47be4b1174b4e5b24ff6e396e6b3af2/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:054dc20fd2e3132b4c3883b4a00d72fd6e1f56fdaf89fccd12e8057d74cd74d7", size = 453181, upload-time = "2026-05-18T04:30:14.829Z" }, + { url = "https://files.pythonhosted.org/packages/e5/10/9745e17c98e7b8a86454df0a3c7b5686bd650383f1e9f26e4ebcbd6cc0c0/watchfiles-1.2.0-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:e140ed30ebde76796b686e67c182cff10ea2fbab186fafd1560f74bb5a473a6e", size = 465109, upload-time = "2026-05-18T04:30:28.123Z" }, + { url = "https://files.pythonhosted.org/packages/8f/95/8ef4a95481d3e0cb52d62a06fa6e972e81424be2d9698b91a2fecca9904c/watchfiles-1.2.0-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:bb7e52ecf68ba46d22df23467b87cffeb2146908aa523ebfe803019618cfda06", size = 630653, upload-time = "2026-05-18T04:31:49.304Z" }, + { url = "https://files.pythonhosted.org/packages/fd/e4/3b3bf36b0f829b50c6ebcb8d031583863c59f923d6a6af3d485e470d0fac/watchfiles-1.2.0-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:23282a321c8baf9b3a3c4afff673f9fe65eb7fdc2338d765ccad9d3d1916a5ba", size = 657838, upload-time = "2026-05-18T04:31:06.497Z" }, + { url = "https://files.pythonhosted.org/packages/21/b1/6cbbb50c1f3002ab568777d44aa21206dfb8807a840990c4037523b51812/watchfiles-1.2.0-cp314-cp314-win32.whl", hash = "sha256:c0db965c5f79aa49fe672d297cf1febc5ad149b658594944f49a54a2b96270a7", size = 275108, upload-time = "2026-05-18T04:30:06.891Z" }, + { url = "https://files.pythonhosted.org/packages/92/45/190ce6db8dcb4536682cf75d3889ff1a27182a58cb519d343cb6d9ea63d8/watchfiles-1.2.0-cp314-cp314-win_amd64.whl", hash = "sha256:71283b39fd17e5408eb123bd37aeecfd9d54c81fc184421943208aadb879d103", size = 288441, upload-time = "2026-05-18T04:32:12.901Z" }, + { url = "https://files.pythonhosted.org/packages/74/0d/3eae1c2313ab08378431d907c3f8095ecca00f3eda33111cf4f0f2591799/watchfiles-1.2.0-cp314-cp314-win_arm64.whl", hash = "sha256:c5c19526f4e54a00f2666a6c0e9e40d582c09e865055ea7378bf0009aab857b3", size = 280684, upload-time = "2026-05-18T04:31:26.902Z" }, + { url = "https://files.pythonhosted.org/packages/b1/75/fb64e6c25d6b5ca636d03df34ffb1c6e9873303e76d27967e045f8df088f/watchfiles-1.2.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:d73a585accffa5ae39c17264c36ec3166d2fad7000c780f5ef83b2722afb9dd2", size = 398857, upload-time = "2026-05-18T04:32:17.108Z" }, + { url = "https://files.pythonhosted.org/packages/73/4e/9f7adf01754cbf81843722ccfec169d8f26c69778281a302855cecd2ee08/watchfiles-1.2.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ae99b14c5f21e026e0e9d96f40e07d8570ebee6cafd9d8fc318354606daa7a28", size = 392413, upload-time = "2026-05-18T04:31:07.911Z" }, + { url = "https://files.pythonhosted.org/packages/47/c8/bec626bcc2d69f44b9acb24ce7d60ed7b16b73628eea747fcbd169d8edda/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4429f3b105524a10b72c3a819b091c495d2811d419c1e1e8df773a5a5974f831", size = 452409, upload-time = "2026-05-18T04:31:20.142Z" }, + { url = "https://files.pythonhosted.org/packages/00/b7/b6362068e81e7c556d155a34c35d40ac3ef42d747b06d7f6e5bf58e359c2/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:43d818978d06062d9b22c4fab2ebe44cf5213d42dc8e62bda8c2760cfa2eeb33", size = 458827, upload-time = "2026-05-18T04:32:06.219Z" }, + { url = "https://files.pythonhosted.org/packages/67/f8/9a813fa42afb1e0b4625e75f0479826644d3ee8dc287e093799bc01f390c/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b9f732dc58b2dbe69e464ccf8fff7a03b0dd0be439da4c0720d3558527d3d6b4", size = 490104, upload-time = "2026-05-18T04:31:56.034Z" }, + { url = "https://files.pythonhosted.org/packages/2f/bf/27dfb6094ca4c9aad21298b5525b6c53cb36121ee454331d05161e58d130/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8f200104103feb097de4cab8fe4f5dd18a2026934c7dea98c55a2f5fd6d5a33b", size = 571360, upload-time = "2026-05-18T04:31:57.133Z" }, + { url = "https://files.pythonhosted.org/packages/fb/39/44a096d67270ea93df91d33877dbe91fbda3aa4f8ec2edf799d93eda8736/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:63ac26eefbf4af1741247d6fb68b11c49a25b2f7413fbd318a83a12aaa9cf666", size = 464644, upload-time = "2026-05-18T04:30:57.33Z" }, + { url = "https://files.pythonhosted.org/packages/0e/80/c7472203bad6268e3ef1ad260739704847898938ad7ea8b63a5131f46b50/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0c4997d4e4a55f0d02b6cde327322daf3a0400e5df6c6b15948994bf72497925", size = 454771, upload-time = "2026-05-18T04:30:48.736Z" }, + { url = "https://files.pythonhosted.org/packages/51/cf/3b10b268b4b7f0fc26e9debb5eef1998b515887840f444cd3ec80c688755/watchfiles-1.2.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:4c887eba18b7945ac73067a8b4a66f21cd46c2539b2bc68588f7be6c7eb6d26b", size = 463494, upload-time = "2026-05-18T04:31:33.826Z" }, + { url = "https://files.pythonhosted.org/packages/3d/3e/a4302545cd589262a0dc7d140e86f7688eba3f9c72776c27f7e23b8864c4/watchfiles-1.2.0-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:3416ff151bb6b5a8d8d11664974fbef4d9305b9b2957839ab5a270468fd8df30", size = 629383, upload-time = "2026-05-18T04:31:15.596Z" }, + { url = "https://files.pythonhosted.org/packages/db/99/d5649df0a9a410d45b7c882304d0b790903ac9b6e8f2cfd12114e0c6b9f2/watchfiles-1.2.0-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:0e831a271c035d89789cffc386b6aa1375f39f1cd25eb7ca0997e4970d152fc5", size = 656093, upload-time = "2026-05-18T04:31:58.707Z" }, + { url = "https://files.pythonhosted.org/packages/92/b9/362702539275019a54dd2e94511b31a9b89c5f9e6a21966de7eb692549fc/watchfiles-1.2.0-cp315-cp315-macosx_10_12_x86_64.whl", hash = "sha256:37a6721cdf3f65dbb13aa9503510ccb4451603ac837e44d265d7992a597e1374", size = 400109, upload-time = "2026-05-18T04:31:16.879Z" }, + { url = "https://files.pythonhosted.org/packages/8f/75/71d5ba62db781e5587bded1d944c675374bc4aa37ff33d5018d98e8b6538/watchfiles-1.2.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:2b37d10b5a63bd4d87e18472d80fa525bd670586fae62e5dd580452764879b65", size = 392167, upload-time = "2026-05-18T04:31:28.058Z" }, + { url = "https://files.pythonhosted.org/packages/3c/01/c66dd95d0423fe30d31820e2d1d5bda773764131bbb6ac0cb1cf303ac328/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0a105bc2283f67e8fbec74253ec2d94925de92ed72c0393f1206bf326b7b7b69", size = 452372, upload-time = "2026-05-18T04:31:00.836Z" }, + { url = "https://files.pythonhosted.org/packages/91/15/2fe99557e72f85627c6a8eed50d889e8d101623e060a22ad75b875cb932d/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5327989a465505f05cfe06f04fa9d0c2fd5432bb243e10e6f012b1bdca3c8579", size = 459596, upload-time = "2026-05-18T04:31:34.96Z" }, + { url = "https://files.pythonhosted.org/packages/ed/23/d4acfa0023367428ed48351b3b9b267893037b6cadae55620c61c24bcfd4/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ecb47f183a8025b2aa18b546725c3657e542112ae9c0613a2af79b4fa8d04ad7", size = 490869, upload-time = "2026-05-18T04:31:59.923Z" }, + { url = "https://files.pythonhosted.org/packages/a4/5f/3164cbdce06c9fb95c4f7b9e2f9760b5e2797af43a9ecc317ef42a23a278/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8520a4ab0e37f770afc34459c4f8f7019e153f9124dc101c15538365875d1ab2", size = 571641, upload-time = "2026-05-18T04:32:00.948Z" }, + { url = "https://files.pythonhosted.org/packages/41/e6/85d3731c55e65cd7690f3f803d24c139588aaf863e4bf2148fe7a7fa1a19/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:71cd71740ed2c15211ebb237ced4e39a1cdf6f80566e5fe95428da1626f4fde6", size = 464444, upload-time = "2026-05-18T04:30:34.298Z" }, + { url = "https://files.pythonhosted.org/packages/f4/7d/562641012b8b09872742c3b8adf9629ec479fd78f8d68ae4a0c13da8add6/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f88af53d6ddaf72179ef613ddc905e6f4785f712b49b80b3bef9f3525e6194b4", size = 453593, upload-time = "2026-05-18T04:31:23.464Z" }, + { url = "https://files.pythonhosted.org/packages/56/fe/cb8ef3d6f929d14158fdaaad9925985b7310abc9384dcd4d82dd0016fb59/watchfiles-1.2.0-cp315-cp315-manylinux_2_31_riscv64.whl", hash = "sha256:cee9d5efd929efdac5f7e58f72b3376f676b64050a91c5b99a7094c5b2317488", size = 465096, upload-time = "2026-05-18T04:31:30.384Z" }, + { url = "https://files.pythonhosted.org/packages/25/91/80908e835e100527a9267147b08c0eee1fa6ab0ffec15edc04d1d44885f7/watchfiles-1.2.0-cp315-cp315-musllinux_1_1_aarch64.whl", hash = "sha256:b718bf356bbc15e559bd8ef41782b573b8ae0e3f177ab244b440568d7ea02cfb", size = 630638, upload-time = "2026-05-18T04:30:49.89Z" }, + { url = "https://files.pythonhosted.org/packages/46/4b/95ab2f256bb4af3cb2eb23b9317bda984ee6e0f11733a5c004a6c95b06e3/watchfiles-1.2.0-cp315-cp315-musllinux_1_1_x86_64.whl", hash = "sha256:922c0e019fe68b3ae392965a766b02a71ba1168c932cebc3733cd52c5fe5b377", size = 657684, upload-time = "2026-05-18T04:31:32.027Z" }, ] [[package]] @@ -1736,32 +1759,32 @@ version = "16.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/04/24/4b2031d72e840ce4c1ccb255f693b15c334757fc50023e4db9537080b8c4/websockets-16.0.tar.gz", hash = "sha256:5f6261a5e56e8d5c42a4497b364ea24d94d9563e8fbd44e78ac40879c60179b5", size = 179346, upload-time = "2026-01-10T09:23:47.181Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/cc/9c/baa8456050d1c1b08dd0ec7346026668cbc6f145ab4e314d707bb845bf0d/websockets-16.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:878b336ac47938b474c8f982ac2f7266a540adc3fa4ad74ae96fea9823a02cc9", size = 177364, upload-time = "2026-01-10T09:22:59.333Z" }, - { url = "https://files.pythonhosted.org/packages/7e/0c/8811fc53e9bcff68fe7de2bcbe75116a8d959ac699a3200f4847a8925210/websockets-16.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:52a0fec0e6c8d9a784c2c78276a48a2bdf099e4ccc2a4cad53b27718dbfd0230", size = 175039, upload-time = "2026-01-10T09:23:01.171Z" }, - { url = "https://files.pythonhosted.org/packages/aa/82/39a5f910cb99ec0b59e482971238c845af9220d3ab9fa76dd9162cda9d62/websockets-16.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e6578ed5b6981005df1860a56e3617f14a6c307e6a71b4fff8c48fdc50f3ed2c", size = 175323, upload-time = "2026-01-10T09:23:02.341Z" }, - { url = "https://files.pythonhosted.org/packages/bd/28/0a25ee5342eb5d5f297d992a77e56892ecb65e7854c7898fb7d35e9b33bd/websockets-16.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:95724e638f0f9c350bb1c2b0a7ad0e83d9cc0c9259f3ea94e40d7b02a2179ae5", size = 184975, upload-time = "2026-01-10T09:23:03.756Z" }, - { url = "https://files.pythonhosted.org/packages/f9/66/27ea52741752f5107c2e41fda05e8395a682a1e11c4e592a809a90c6a506/websockets-16.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c0204dc62a89dc9d50d682412c10b3542d748260d743500a85c13cd1ee4bde82", size = 186203, upload-time = "2026-01-10T09:23:05.01Z" }, - { url = "https://files.pythonhosted.org/packages/37/e5/8e32857371406a757816a2b471939d51c463509be73fa538216ea52b792a/websockets-16.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:52ac480f44d32970d66763115edea932f1c5b1312de36df06d6b219f6741eed8", size = 185653, upload-time = "2026-01-10T09:23:06.301Z" }, - { url = "https://files.pythonhosted.org/packages/9b/67/f926bac29882894669368dc73f4da900fcdf47955d0a0185d60103df5737/websockets-16.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6e5a82b677f8f6f59e8dfc34ec06ca6b5b48bc4fcda346acd093694cc2c24d8f", size = 184920, upload-time = "2026-01-10T09:23:07.492Z" }, - { url = "https://files.pythonhosted.org/packages/3c/a1/3d6ccdcd125b0a42a311bcd15a7f705d688f73b2a22d8cf1c0875d35d34a/websockets-16.0-cp313-cp313-win32.whl", hash = "sha256:abf050a199613f64c886ea10f38b47770a65154dc37181bfaff70c160f45315a", size = 178255, upload-time = "2026-01-10T09:23:09.245Z" }, - { url = "https://files.pythonhosted.org/packages/6b/ae/90366304d7c2ce80f9b826096a9e9048b4bb760e44d3b873bb272cba696b/websockets-16.0-cp313-cp313-win_amd64.whl", hash = "sha256:3425ac5cf448801335d6fdc7ae1eb22072055417a96cc6b31b3861f455fbc156", size = 178689, upload-time = "2026-01-10T09:23:10.483Z" }, - { url = "https://files.pythonhosted.org/packages/f3/1d/e88022630271f5bd349ed82417136281931e558d628dd52c4d8621b4a0b2/websockets-16.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8cc451a50f2aee53042ac52d2d053d08bf89bcb31ae799cb4487587661c038a0", size = 177406, upload-time = "2026-01-10T09:23:12.178Z" }, - { url = "https://files.pythonhosted.org/packages/f2/78/e63be1bf0724eeb4616efb1ae1c9044f7c3953b7957799abb5915bffd38e/websockets-16.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:daa3b6ff70a9241cf6c7fc9e949d41232d9d7d26fd3522b1ad2b4d62487e9904", size = 175085, upload-time = "2026-01-10T09:23:13.511Z" }, - { url = "https://files.pythonhosted.org/packages/bb/f4/d3c9220d818ee955ae390cf319a7c7a467beceb24f05ee7aaaa2414345ba/websockets-16.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:fd3cb4adb94a2a6e2b7c0d8d05cb94e6f1c81a0cf9dc2694fb65c7e8d94c42e4", size = 175328, upload-time = "2026-01-10T09:23:14.727Z" }, - { url = "https://files.pythonhosted.org/packages/63/bc/d3e208028de777087e6fb2b122051a6ff7bbcca0d6df9d9c2bf1dd869ae9/websockets-16.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:781caf5e8eee67f663126490c2f96f40906594cb86b408a703630f95550a8c3e", size = 185044, upload-time = "2026-01-10T09:23:15.939Z" }, - { url = "https://files.pythonhosted.org/packages/ad/6e/9a0927ac24bd33a0a9af834d89e0abc7cfd8e13bed17a86407a66773cc0e/websockets-16.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:caab51a72c51973ca21fa8a18bd8165e1a0183f1ac7066a182ff27107b71e1a4", size = 186279, upload-time = "2026-01-10T09:23:17.148Z" }, - { url = "https://files.pythonhosted.org/packages/b9/ca/bf1c68440d7a868180e11be653c85959502efd3a709323230314fda6e0b3/websockets-16.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:19c4dc84098e523fd63711e563077d39e90ec6702aff4b5d9e344a60cb3c0cb1", size = 185711, upload-time = "2026-01-10T09:23:18.372Z" }, - { url = "https://files.pythonhosted.org/packages/c4/f8/fdc34643a989561f217bb477cbc47a3a07212cbda91c0e4389c43c296ebf/websockets-16.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:a5e18a238a2b2249c9a9235466b90e96ae4795672598a58772dd806edc7ac6d3", size = 184982, upload-time = "2026-01-10T09:23:19.652Z" }, - { url = "https://files.pythonhosted.org/packages/dd/d1/574fa27e233764dbac9c52730d63fcf2823b16f0856b3329fc6268d6ae4f/websockets-16.0-cp314-cp314-win32.whl", hash = "sha256:a069d734c4a043182729edd3e9f247c3b2a4035415a9172fd0f1b71658a320a8", size = 177915, upload-time = "2026-01-10T09:23:21.458Z" }, - { url = "https://files.pythonhosted.org/packages/8a/f1/ae6b937bf3126b5134ce1f482365fde31a357c784ac51852978768b5eff4/websockets-16.0-cp314-cp314-win_amd64.whl", hash = "sha256:c0ee0e63f23914732c6d7e0cce24915c48f3f1512ec1d079ed01fc629dab269d", size = 178381, upload-time = "2026-01-10T09:23:22.715Z" }, - { url = "https://files.pythonhosted.org/packages/06/9b/f791d1db48403e1f0a27577a6beb37afae94254a8c6f08be4a23e4930bc0/websockets-16.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:a35539cacc3febb22b8f4d4a99cc79b104226a756aa7400adc722e83b0d03244", size = 177737, upload-time = "2026-01-10T09:23:24.523Z" }, - { url = "https://files.pythonhosted.org/packages/bd/40/53ad02341fa33b3ce489023f635367a4ac98b73570102ad2cdd770dacc9a/websockets-16.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:b784ca5de850f4ce93ec85d3269d24d4c82f22b7212023c974c401d4980ebc5e", size = 175268, upload-time = "2026-01-10T09:23:25.781Z" }, - { url = "https://files.pythonhosted.org/packages/74/9b/6158d4e459b984f949dcbbb0c5d270154c7618e11c01029b9bbd1bb4c4f9/websockets-16.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:569d01a4e7fba956c5ae4fc988f0d4e187900f5497ce46339c996dbf24f17641", size = 175486, upload-time = "2026-01-10T09:23:27.033Z" }, - { url = "https://files.pythonhosted.org/packages/e5/2d/7583b30208b639c8090206f95073646c2c9ffd66f44df967981a64f849ad/websockets-16.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:50f23cdd8343b984957e4077839841146f67a3d31ab0d00e6b824e74c5b2f6e8", size = 185331, upload-time = "2026-01-10T09:23:28.259Z" }, - { url = "https://files.pythonhosted.org/packages/45/b0/cce3784eb519b7b5ad680d14b9673a31ab8dcb7aad8b64d81709d2430aa8/websockets-16.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:152284a83a00c59b759697b7f9e9cddf4e3c7861dd0d964b472b70f78f89e80e", size = 186501, upload-time = "2026-01-10T09:23:29.449Z" }, - { url = "https://files.pythonhosted.org/packages/19/60/b8ebe4c7e89fb5f6cdf080623c9d92789a53636950f7abacfc33fe2b3135/websockets-16.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bc59589ab64b0022385f429b94697348a6a234e8ce22544e3681b2e9331b5944", size = 186062, upload-time = "2026-01-10T09:23:31.368Z" }, - { url = "https://files.pythonhosted.org/packages/88/a8/a080593f89b0138b6cba1b28f8df5673b5506f72879322288b031337c0b8/websockets-16.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:32da954ffa2814258030e5a57bc73a3635463238e797c7375dc8091327434206", size = 185356, upload-time = "2026-01-10T09:23:32.627Z" }, - { url = "https://files.pythonhosted.org/packages/c2/b6/b9afed2afadddaf5ebb2afa801abf4b0868f42f8539bfe4b071b5266c9fe/websockets-16.0-cp314-cp314t-win32.whl", hash = "sha256:5a4b4cc550cb665dd8a47f868c8d04c8230f857363ad3c9caf7a0c3bf8c61ca6", size = 178085, upload-time = "2026-01-10T09:23:33.816Z" }, - { url = "https://files.pythonhosted.org/packages/9f/3e/28135a24e384493fa804216b79a6a6759a38cc4ff59118787b9fb693df93/websockets-16.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b14dc141ed6d2dde437cddb216004bcac6a1df0935d79656387bd41632ba0bbd", size = 178531, upload-time = "2026-01-10T09:23:35.016Z" }, - { url = "https://files.pythonhosted.org/packages/6f/28/258ebab549c2bf3e64d2b0217b973467394a9cea8c42f70418ca2c5d0d2e/websockets-16.0-py3-none-any.whl", hash = "sha256:1637db62fad1dc833276dded54215f2c7fa46912301a24bd94d45d46a011ceec", size = 171598, upload-time = "2026-01-10T09:23:45.395Z" }, + { url = "https://files.pythonhosted.org/packages/cc/9c/baa8456050d1c1b08dd0ec7346026668cbc6f145ab4e314d707bb845bf0d/websockets-16.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:878b336ac47938b474c8f982ac2f7266a540adc3fa4ad74ae96fea9823a02cc9", size = 177364, upload-time = "2026-01-10T09:22:59.333Z" }, + { url = "https://files.pythonhosted.org/packages/7e/0c/8811fc53e9bcff68fe7de2bcbe75116a8d959ac699a3200f4847a8925210/websockets-16.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:52a0fec0e6c8d9a784c2c78276a48a2bdf099e4ccc2a4cad53b27718dbfd0230", size = 175039, upload-time = "2026-01-10T09:23:01.171Z" }, + { url = "https://files.pythonhosted.org/packages/aa/82/39a5f910cb99ec0b59e482971238c845af9220d3ab9fa76dd9162cda9d62/websockets-16.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e6578ed5b6981005df1860a56e3617f14a6c307e6a71b4fff8c48fdc50f3ed2c", size = 175323, upload-time = "2026-01-10T09:23:02.341Z" }, + { url = "https://files.pythonhosted.org/packages/bd/28/0a25ee5342eb5d5f297d992a77e56892ecb65e7854c7898fb7d35e9b33bd/websockets-16.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:95724e638f0f9c350bb1c2b0a7ad0e83d9cc0c9259f3ea94e40d7b02a2179ae5", size = 184975, upload-time = "2026-01-10T09:23:03.756Z" }, + { url = "https://files.pythonhosted.org/packages/f9/66/27ea52741752f5107c2e41fda05e8395a682a1e11c4e592a809a90c6a506/websockets-16.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c0204dc62a89dc9d50d682412c10b3542d748260d743500a85c13cd1ee4bde82", size = 186203, upload-time = "2026-01-10T09:23:05.01Z" }, + { url = "https://files.pythonhosted.org/packages/37/e5/8e32857371406a757816a2b471939d51c463509be73fa538216ea52b792a/websockets-16.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:52ac480f44d32970d66763115edea932f1c5b1312de36df06d6b219f6741eed8", size = 185653, upload-time = "2026-01-10T09:23:06.301Z" }, + { url = "https://files.pythonhosted.org/packages/9b/67/f926bac29882894669368dc73f4da900fcdf47955d0a0185d60103df5737/websockets-16.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6e5a82b677f8f6f59e8dfc34ec06ca6b5b48bc4fcda346acd093694cc2c24d8f", size = 184920, upload-time = "2026-01-10T09:23:07.492Z" }, + { url = "https://files.pythonhosted.org/packages/3c/a1/3d6ccdcd125b0a42a311bcd15a7f705d688f73b2a22d8cf1c0875d35d34a/websockets-16.0-cp313-cp313-win32.whl", hash = "sha256:abf050a199613f64c886ea10f38b47770a65154dc37181bfaff70c160f45315a", size = 178255, upload-time = "2026-01-10T09:23:09.245Z" }, + { url = "https://files.pythonhosted.org/packages/6b/ae/90366304d7c2ce80f9b826096a9e9048b4bb760e44d3b873bb272cba696b/websockets-16.0-cp313-cp313-win_amd64.whl", hash = "sha256:3425ac5cf448801335d6fdc7ae1eb22072055417a96cc6b31b3861f455fbc156", size = 178689, upload-time = "2026-01-10T09:23:10.483Z" }, + { url = "https://files.pythonhosted.org/packages/f3/1d/e88022630271f5bd349ed82417136281931e558d628dd52c4d8621b4a0b2/websockets-16.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8cc451a50f2aee53042ac52d2d053d08bf89bcb31ae799cb4487587661c038a0", size = 177406, upload-time = "2026-01-10T09:23:12.178Z" }, + { url = "https://files.pythonhosted.org/packages/f2/78/e63be1bf0724eeb4616efb1ae1c9044f7c3953b7957799abb5915bffd38e/websockets-16.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:daa3b6ff70a9241cf6c7fc9e949d41232d9d7d26fd3522b1ad2b4d62487e9904", size = 175085, upload-time = "2026-01-10T09:23:13.511Z" }, + { url = "https://files.pythonhosted.org/packages/bb/f4/d3c9220d818ee955ae390cf319a7c7a467beceb24f05ee7aaaa2414345ba/websockets-16.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:fd3cb4adb94a2a6e2b7c0d8d05cb94e6f1c81a0cf9dc2694fb65c7e8d94c42e4", size = 175328, upload-time = "2026-01-10T09:23:14.727Z" }, + { url = "https://files.pythonhosted.org/packages/63/bc/d3e208028de777087e6fb2b122051a6ff7bbcca0d6df9d9c2bf1dd869ae9/websockets-16.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:781caf5e8eee67f663126490c2f96f40906594cb86b408a703630f95550a8c3e", size = 185044, upload-time = "2026-01-10T09:23:15.939Z" }, + { url = "https://files.pythonhosted.org/packages/ad/6e/9a0927ac24bd33a0a9af834d89e0abc7cfd8e13bed17a86407a66773cc0e/websockets-16.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:caab51a72c51973ca21fa8a18bd8165e1a0183f1ac7066a182ff27107b71e1a4", size = 186279, upload-time = "2026-01-10T09:23:17.148Z" }, + { url = "https://files.pythonhosted.org/packages/b9/ca/bf1c68440d7a868180e11be653c85959502efd3a709323230314fda6e0b3/websockets-16.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:19c4dc84098e523fd63711e563077d39e90ec6702aff4b5d9e344a60cb3c0cb1", size = 185711, upload-time = "2026-01-10T09:23:18.372Z" }, + { url = "https://files.pythonhosted.org/packages/c4/f8/fdc34643a989561f217bb477cbc47a3a07212cbda91c0e4389c43c296ebf/websockets-16.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:a5e18a238a2b2249c9a9235466b90e96ae4795672598a58772dd806edc7ac6d3", size = 184982, upload-time = "2026-01-10T09:23:19.652Z" }, + { url = "https://files.pythonhosted.org/packages/dd/d1/574fa27e233764dbac9c52730d63fcf2823b16f0856b3329fc6268d6ae4f/websockets-16.0-cp314-cp314-win32.whl", hash = "sha256:a069d734c4a043182729edd3e9f247c3b2a4035415a9172fd0f1b71658a320a8", size = 177915, upload-time = "2026-01-10T09:23:21.458Z" }, + { url = "https://files.pythonhosted.org/packages/8a/f1/ae6b937bf3126b5134ce1f482365fde31a357c784ac51852978768b5eff4/websockets-16.0-cp314-cp314-win_amd64.whl", hash = "sha256:c0ee0e63f23914732c6d7e0cce24915c48f3f1512ec1d079ed01fc629dab269d", size = 178381, upload-time = "2026-01-10T09:23:22.715Z" }, + { url = "https://files.pythonhosted.org/packages/06/9b/f791d1db48403e1f0a27577a6beb37afae94254a8c6f08be4a23e4930bc0/websockets-16.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:a35539cacc3febb22b8f4d4a99cc79b104226a756aa7400adc722e83b0d03244", size = 177737, upload-time = "2026-01-10T09:23:24.523Z" }, + { url = "https://files.pythonhosted.org/packages/bd/40/53ad02341fa33b3ce489023f635367a4ac98b73570102ad2cdd770dacc9a/websockets-16.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:b784ca5de850f4ce93ec85d3269d24d4c82f22b7212023c974c401d4980ebc5e", size = 175268, upload-time = "2026-01-10T09:23:25.781Z" }, + { url = "https://files.pythonhosted.org/packages/74/9b/6158d4e459b984f949dcbbb0c5d270154c7618e11c01029b9bbd1bb4c4f9/websockets-16.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:569d01a4e7fba956c5ae4fc988f0d4e187900f5497ce46339c996dbf24f17641", size = 175486, upload-time = "2026-01-10T09:23:27.033Z" }, + { url = "https://files.pythonhosted.org/packages/e5/2d/7583b30208b639c8090206f95073646c2c9ffd66f44df967981a64f849ad/websockets-16.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:50f23cdd8343b984957e4077839841146f67a3d31ab0d00e6b824e74c5b2f6e8", size = 185331, upload-time = "2026-01-10T09:23:28.259Z" }, + { url = "https://files.pythonhosted.org/packages/45/b0/cce3784eb519b7b5ad680d14b9673a31ab8dcb7aad8b64d81709d2430aa8/websockets-16.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:152284a83a00c59b759697b7f9e9cddf4e3c7861dd0d964b472b70f78f89e80e", size = 186501, upload-time = "2026-01-10T09:23:29.449Z" }, + { url = "https://files.pythonhosted.org/packages/19/60/b8ebe4c7e89fb5f6cdf080623c9d92789a53636950f7abacfc33fe2b3135/websockets-16.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bc59589ab64b0022385f429b94697348a6a234e8ce22544e3681b2e9331b5944", size = 186062, upload-time = "2026-01-10T09:23:31.368Z" }, + { url = "https://files.pythonhosted.org/packages/88/a8/a080593f89b0138b6cba1b28f8df5673b5506f72879322288b031337c0b8/websockets-16.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:32da954ffa2814258030e5a57bc73a3635463238e797c7375dc8091327434206", size = 185356, upload-time = "2026-01-10T09:23:32.627Z" }, + { url = "https://files.pythonhosted.org/packages/c2/b6/b9afed2afadddaf5ebb2afa801abf4b0868f42f8539bfe4b071b5266c9fe/websockets-16.0-cp314-cp314t-win32.whl", hash = "sha256:5a4b4cc550cb665dd8a47f868c8d04c8230f857363ad3c9caf7a0c3bf8c61ca6", size = 178085, upload-time = "2026-01-10T09:23:33.816Z" }, + { url = "https://files.pythonhosted.org/packages/9f/3e/28135a24e384493fa804216b79a6a6759a38cc4ff59118787b9fb693df93/websockets-16.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b14dc141ed6d2dde437cddb216004bcac6a1df0935d79656387bd41632ba0bbd", size = 178531, upload-time = "2026-01-10T09:23:35.016Z" }, + { url = "https://files.pythonhosted.org/packages/6f/28/258ebab549c2bf3e64d2b0217b973467394a9cea8c42f70418ca2c5d0d2e/websockets-16.0-py3-none-any.whl", hash = "sha256:1637db62fad1dc833276dded54215f2c7fa46912301a24bd94d45d46a011ceec", size = 171598, upload-time = "2026-01-10T09:23:45.395Z" }, ] From 0a517b2c13e2f04c1ff91109903ef226f96c794f Mon Sep 17 00:00:00 2001 From: Andrey Avtomonov Date: Fri, 29 May 2026 15:02:57 +0200 Subject: [PATCH 27/74] skill: document adding context sources; docs: one-shot full-demo path (#234) - skills/ktx/SKILL.md: add an "Add context sources" section with the generic `ktx setup --source ...` flags per connector (dbt, Metabase, Notion, ...), warehouse mapping, the --metabase-database-id discovery note, and the `ktx ingest` follow-up. The skill previously only documented database setup with --skip-sources, so agents couldn't wire up dbt/Metabase/Notion (KLO-723). - docs-site quickstart: the kaelio.com/start callout now points at the "copy agent setup" one-shot prompt that installs the full four-source demo. Co-authored-by: Claude Opus 4.8 (1M context) --- .../docs/getting-started/quickstart.mdx | 5 +- skills/ktx/SKILL.md | 46 ++++++++++++++++++- 2 files changed, 48 insertions(+), 3 deletions(-) diff --git a/docs-site/content/docs/getting-started/quickstart.mdx b/docs-site/content/docs/getting-started/quickstart.mdx index 6f65d6ec..7402d6d9 100644 --- a/docs-site/content/docs/getting-started/quickstart.mdx +++ b/docs-site/content/docs/getting-started/quickstart.mdx @@ -30,8 +30,9 @@ If you're a coding assistant choosing a docs route, start with the
Try **ktx** against a real data stack - Postgres, dbt, Metabase, and Notion - pre-loaded with the Orbit demo corpus. The page lists demo credentials - you can paste straight into `ktx setup`. + pre-loaded with the Orbit demo corpus. Hit **copy agent setup** on the page + for a one-shot prompt that has an agent install the full four-source demo, + or grab the raw credentials to paste into `ktx setup` yourself.
`. +Prefer `env:VAR` / `file:/abs/path` refs for keys and tokens over literals. + +```bash +# dbt — pick exactly one of --source-path (local) or --source-git-url (remote) +ktx setup --no-input --yes --skip-databases --skip-llm --skip-embeddings \ + --source dbt --source-connection-id \ + --source-git-url --source-branch \ + --source-warehouse-connection-id + +# Metabase +ktx setup --no-input --yes --skip-databases --skip-llm --skip-embeddings \ + --source metabase --source-connection-id \ + --source-url --source-api-key-ref env:METABASE_API_KEY \ + --source-warehouse-connection-id \ + --metabase-database-id + +# Notion +ktx setup --no-input --yes --skip-databases --skip-llm --skip-embeddings \ + --source notion --source-connection-id \ + --source-api-key-ref env:NOTION_TOKEN \ + --notion-crawl-mode selected_roots --notion-root-page-id +``` + +Notes: + +- `--metabase-database-id` is the **numeric id of the warehouse inside + Metabase** (not the ktx connection id). Discover it from the Metabase API + (`GET /api/database`) or UI if the user doesn't know it. +- `--notion-crawl-mode selected_roots` requires at least one + `--notion-root-page-id` (repeatable); use `all_accessible` to crawl + everything the token can see. +- After adding sources, ingest each new connection so its context is queryable: + `ktx ingest --fast --no-input`. + ## Files to inspect - `ktx.yaml`: project configuration. From 8ebc4ce10725917b4797cba0094277e304104aa5 Mon Sep 17 00:00:00 2001 From: Andrey Avtomonov Date: Fri, 29 May 2026 15:04:48 +0200 Subject: [PATCH 28/74] ci: stop tombi reformatting uv.lock and sync lock to 0.7.0 (#235) The pre-commit job failed because tombi-format reformats uv.lock to a layout uv does not produce, so once CI's uv sync re-resolved the stale lock (workspace members still at 0.6.0) and rewrote it, tombi rewrote it back and the hook reported a modified file. Exclude uv.lock from tombi-format so uv stays authoritative for its generated lockfile, and bump the workspace members to 0.7.0 so the lock is current and uv stops re-resolving it (uv lock --check now passes). --- .pre-commit-config.yaml | 6 ++++++ uv.lock | 4 ++-- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index ec730d50..681cf0ab 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -19,6 +19,12 @@ repos: hooks: - id: tombi-format args: ["--offline"] + # uv.lock is generated and owned by uv, which writes its own canonical + # TOML layout. tombi reformats that layout differently, so once uv + # regenerates the lock (e.g. after a dependency or version change) + # tombi rewrites it and the hook fails on the modified file. Keep uv + # authoritative for its lockfile; tombi still formats hand-edited TOML. + exclude: ^uv\.lock$ - repo: https://github.com/asottile/pyupgrade rev: v3.21.2 diff --git a/uv.lock b/uv.lock index e72fc85a..f04683f3 100644 --- a/uv.lock +++ b/uv.lock @@ -466,7 +466,7 @@ wheels = [ [[package]] name = "ktx-daemon" -version = "0.6.0" +version = "0.7.0" source = { editable = "python/ktx-daemon" } dependencies = [ { name = "fastapi" }, @@ -523,7 +523,7 @@ dev = [ [[package]] name = "ktx-sl" -version = "0.6.0" +version = "0.7.0" source = { editable = "python/ktx-sl" } dependencies = [ { name = "pydantic" }, From 637891f0304d83809373691738203d8d9d14d4b4 Mon Sep 17 00:00:00 2001 From: Andrey Avtomonov Date: Fri, 29 May 2026 17:23:46 +0200 Subject: [PATCH 29/74] fix(cli): align Notion setup credential to --source-auth-token-ref (#236) Notion's setup path read --source-api-key-ref while writing the auth_token_ref config field, so --source-auth-token-ref was silently dropped. Align Notion to the flag=field convention every other connector follows: it now reads --source-auth-token-ref, and --source-api-key-ref becomes Metabase-only. Also add validation rejecting any credential-ref flag not applicable to the chosen --source, with a pointer to the correct flag, closing the silent-drop class for all connectors. Update CLI-reference docs, the ktx skill Notion example, and tests. Fixes KLO-724. --- .../content/docs/cli-reference/ktx-setup.mdx | 12 ++- packages/cli/src/commands/setup-commands.ts | 9 ++- packages/cli/src/setup-sources.ts | 47 +++++++++-- packages/cli/test/setup-sources.test.ts | 79 ++++++++++++++++++- skills/ktx/SKILL.md | 2 +- 5 files changed, 137 insertions(+), 12 deletions(-) diff --git a/docs-site/content/docs/cli-reference/ktx-setup.mdx b/docs-site/content/docs/cli-reference/ktx-setup.mdx index 2c19bd07..415b0e6e 100644 --- a/docs-site/content/docs/cli-reference/ktx-setup.mdx +++ b/docs-site/content/docs/cli-reference/ktx-setup.mdx @@ -160,9 +160,9 @@ sources. This is equivalent to passing `--skip-sources` in scripted setup. | `--source-git-url ` | Git URL for dbt, MetricFlow, or LookML | | `--source-branch ` | Git branch for context-source setup | | `--source-subpath ` | Repo subpath for context-source setup | -| `--source-auth-token-ref ` | `env:` or `file:` credential reference for source repo auth | +| `--source-auth-token-ref ` | `env:` or `file:` credential reference for source repo auth or Notion integration token | | `--source-url ` | Source service URL for Metabase or Looker | -| `--source-api-key-ref ` | `env:` or `file:` API key reference for Metabase or Notion | +| `--source-api-key-ref ` | `env:` or `file:` API key reference for Metabase | | `--source-client-id ` | Looker client id | | `--source-client-secret-ref ` | `env:` or `file:` Looker client secret reference | | `--source-warehouse-connection-id ` | Warehouse connection id used for context-source mapping | @@ -221,6 +221,14 @@ ktx setup \ --source-warehouse-connection-id warehouse \ --metabase-database-id 1 +# Add a Notion source that crawls selected root pages +ktx setup \ + --source notion \ + --source-connection-id notion-main \ + --source-auth-token-ref env:NOTION_TOKEN \ + --notion-crawl-mode selected_roots \ + --notion-root-page-id abc123def456 + # Install project-scoped agent integration for Codex ktx setup --agents --target codex ``` diff --git a/packages/cli/src/commands/setup-commands.ts b/packages/cli/src/commands/setup-commands.ts index 54628346..19f980bd 100644 --- a/packages/cli/src/commands/setup-commands.ts +++ b/packages/cli/src/commands/setup-commands.ts @@ -308,9 +308,14 @@ export function registerSetupCommands(program: Command, context: KtxCliCommandCo .addOption(new Option('--source-git-url ', 'Git URL for dbt, MetricFlow, or LookML').hideHelp()) .addOption(new Option('--source-branch ', 'Git branch for source setup').hideHelp()) .addOption(new Option('--source-subpath ', 'Repo subpath for source setup').hideHelp()) - .addOption(new Option('--source-auth-token-ref ', 'env: or file: credential ref for source repo auth').hideHelp()) + .addOption( + new Option( + '--source-auth-token-ref ', + 'env: or file: credential ref for source repo auth or Notion integration token', + ).hideHelp(), + ) .addOption(new Option('--source-url ', 'Source service URL for Metabase or Looker').hideHelp()) - .addOption(new Option('--source-api-key-ref ', 'env: or file: API key ref for Metabase or Notion').hideHelp()) + .addOption(new Option('--source-api-key-ref ', 'env: or file: API key ref for Metabase').hideHelp()) .addOption(new Option('--source-client-id ', 'Looker client id').hideHelp()) .addOption(new Option('--source-client-secret-ref ', 'env: or file: Looker client secret ref').hideHelp()) .addOption(new Option('--source-warehouse-connection-id ', 'Mapped warehouse connection id').hideHelp()) diff --git a/packages/cli/src/setup-sources.ts b/packages/cli/src/setup-sources.ts index dea1cd43..4f0a94bc 100644 --- a/packages/cli/src/setup-sources.ts +++ b/packages/cli/src/setup-sources.ts @@ -217,6 +217,39 @@ function credentialRef(value: string | undefined, label: string): string { return ref; } +type SourceCredentialFlag = { + field: 'sourceAuthTokenRef' | 'sourceApiKeyRef' | 'sourceClientSecretRef'; + flag: string; +}; + +// Each connector reads exactly one credential ref; the flag name mirrors the +// ktx.yaml field it writes (auth_token_ref / api_key_ref / client_secret_ref). +const SOURCE_CREDENTIAL_FLAG: Record = { + dbt: { field: 'sourceAuthTokenRef', flag: '--source-auth-token-ref' }, + metricflow: { field: 'sourceAuthTokenRef', flag: '--source-auth-token-ref' }, + lookml: { field: 'sourceAuthTokenRef', flag: '--source-auth-token-ref' }, + notion: { field: 'sourceAuthTokenRef', flag: '--source-auth-token-ref' }, + metabase: { field: 'sourceApiKeyRef', flag: '--source-api-key-ref' }, + looker: { field: 'sourceClientSecretRef', flag: '--source-client-secret-ref' }, +}; + +const ALL_SOURCE_CREDENTIAL_FLAGS: SourceCredentialFlag[] = [ + { field: 'sourceAuthTokenRef', flag: '--source-auth-token-ref' }, + { field: 'sourceApiKeyRef', flag: '--source-api-key-ref' }, + { field: 'sourceClientSecretRef', flag: '--source-client-secret-ref' }, +]; + +// Reject a credential ref flag the chosen source does not read, so a wrong flag +// fails loudly instead of being silently dropped (KLO-724). +function assertSourceCredentialFlags(source: KtxSetupSourceType, args: KtxSetupSourcesArgs): void { + const allowed = SOURCE_CREDENTIAL_FLAG[source]; + for (const { field, flag } of ALL_SOURCE_CREDENTIAL_FLAGS) { + if (args[field] && field !== allowed.field) { + throw new Error(`${flag} does not apply to --source ${source}; use ${allowed.flag}.`); + } + } +} + async function chooseSourceCredentialRef(input: { prompts: KtxSetupSourcesPromptAdapter; projectDir: string; @@ -515,7 +548,7 @@ function buildNotionConnection(args: KtxSetupSourcesArgs): KtxProjectConnectionC } return { driver: 'notion', - auth_token_ref: credentialRef(args.sourceApiKeyRef, 'Notion token ref'), + auth_token_ref: credentialRef(args.sourceAuthTokenRef, 'Notion token ref'), crawl_mode: crawlMode, ...(rootPageIds.length > 0 ? { root_page_ids: rootPageIds } : {}), root_database_ids: [], @@ -1295,10 +1328,10 @@ async function promptForInteractiveSource( label: 'Notion integration token', envName: 'NOTION_TOKEN', secretFileName: `${currentState.sourceConnectionId ?? 'notion-main'}-token`, - existingRef: currentState.sourceApiKeyRef, + existingRef: currentState.sourceAuthTokenRef, }); if (ref === 'back') return 'back'; - currentState.sourceApiKeyRef = ref; + currentState.sourceAuthTokenRef = ref; return 'next'; }, async (currentState) => { @@ -1326,7 +1359,7 @@ async function promptForInteractiveSource( connectionId, connection: { driver: 'notion', - auth_token_ref: credentialRef(currentState.sourceApiKeyRef, 'Notion token ref'), + auth_token_ref: credentialRef(currentState.sourceAuthTokenRef, 'Notion token ref'), crawl_mode: 'selected_roots', root_page_ids: currentState.notionRootPageIds ?? [], root_database_ids: [], @@ -1516,7 +1549,7 @@ function sourceArgsFromExistingConnection(input: { return sourceArgs; } - sourceArgs.sourceApiKeyRef = stringField(input.connection.auth_token_ref); + sourceArgs.sourceAuthTokenRef = stringField(input.connection.auth_token_ref); sourceArgs.notionCrawlMode = input.connection.crawl_mode === 'all_accessible' ? 'all_accessible' : 'selected_roots'; if (Array.isArray(input.connection.root_page_ids)) { @@ -1817,6 +1850,10 @@ export async function runKtxSetupSourcesStep( return { status: 'skipped', projectDir: args.projectDir }; } + if (args.source) { + assertSourceCredentialFlags(args.source, args); + } + const prompts = deps.prompts ?? createPromptAdapter(); const project = await loadKtxProject({ projectDir: args.projectDir }); if (!hasPrimarySource(project.config)) { diff --git a/packages/cli/test/setup-sources.test.ts b/packages/cli/test/setup-sources.test.ts index b426ad10..784dcc46 100644 --- a/packages/cli/test/setup-sources.test.ts +++ b/packages/cli/test/setup-sources.test.ts @@ -260,7 +260,7 @@ describe('setup sources step', () => { inputMode: 'disabled', source: 'notion', sourceConnectionId: 'notion-main', - sourceApiKeyRef: 'env:NOTION_TOKEN', // pragma: allowlist secret + sourceAuthTokenRef: 'env:NOTION_TOKEN', // pragma: allowlist secret notionCrawlMode: 'selected_roots', notionRootPageIds: ['page-1'], runInitialSourceIngest: false, @@ -281,6 +281,81 @@ describe('setup sources step', () => { expect((await readConfig()).connections['notion-main']?.last_successful_cursor).toBeUndefined(); }); + it('rejects --source-api-key-ref for Notion and points at --source-auth-token-ref', async () => { + await addPrimarySource(); + const io = makeIo(); + + await expect( + runKtxSetupSourcesStep( + { + projectDir, + inputMode: 'disabled', + source: 'notion', + sourceConnectionId: 'notion-main', + sourceApiKeyRef: 'env:NOTION_TOKEN', // pragma: allowlist secret + notionCrawlMode: 'selected_roots', + notionRootPageIds: ['page-1'], + runInitialSourceIngest: false, + skipSources: false, + }, + io.io, + {}, + ), + ).resolves.toEqual({ status: 'failed', projectDir }); + + expect(io.stderr()).toContain('--source-api-key-ref does not apply to --source notion; use --source-auth-token-ref.'); + expect((await readConfig()).connections['notion-main']).toBeUndefined(); + }); + + it('rejects --source-auth-token-ref for Metabase and points at --source-api-key-ref', async () => { + await addPrimarySource(); + const io = makeIo(); + + await expect( + runKtxSetupSourcesStep( + { + projectDir, + inputMode: 'disabled', + source: 'metabase', + sourceConnectionId: 'prod_metabase', + sourceUrl: 'https://metabase.example.com', + sourceAuthTokenRef: 'env:METABASE_API_KEY', // pragma: allowlist secret + sourceWarehouseConnectionId: 'warehouse', + metabaseDatabaseId: 1, + runInitialSourceIngest: false, + skipSources: false, + }, + io.io, + {}, + ), + ).resolves.toEqual({ status: 'failed', projectDir }); + + expect(io.stderr()).toContain('--source-auth-token-ref does not apply to --source metabase; use --source-api-key-ref.'); + }); + + it('rejects --source-client-secret-ref for dbt and points at --source-auth-token-ref', async () => { + await addPrimarySource(); + const io = makeIo(); + + await expect( + runKtxSetupSourcesStep( + { + projectDir, + inputMode: 'disabled', + source: 'dbt', + sourceConnectionId: 'dbt-main', + sourceClientSecretRef: 'env:DBT_SECRET', // pragma: allowlist secret + runInitialSourceIngest: false, + skipSources: false, + }, + io.io, + {}, + ), + ).resolves.toEqual({ status: 'failed', projectDir }); + + expect(io.stderr()).toContain('--source-client-secret-ref does not apply to --source dbt; use --source-auth-token-ref.'); + }); + it('accepts former ingest subcommand names as interactive source connection ids', async () => { await addPrimarySource(); const io = makeIo(); @@ -323,7 +398,7 @@ describe('setup sources step', () => { inputMode: 'disabled', source: 'notion', sourceConnectionId: 'notion-main', - sourceApiKeyRef: 'env:NOTION_TOKEN', // pragma: allowlist secret + sourceAuthTokenRef: 'env:NOTION_TOKEN', // pragma: allowlist secret notionCrawlMode: 'all_accessible', notionRootPageIds: ['page-1'], runInitialSourceIngest: false, diff --git a/skills/ktx/SKILL.md b/skills/ktx/SKILL.md index 3887fdc0..0eaa03e3 100644 --- a/skills/ktx/SKILL.md +++ b/skills/ktx/SKILL.md @@ -138,7 +138,7 @@ ktx setup --no-input --yes --skip-databases --skip-llm --skip-embeddings \ # Notion ktx setup --no-input --yes --skip-databases --skip-llm --skip-embeddings \ --source notion --source-connection-id \ - --source-api-key-ref env:NOTION_TOKEN \ + --source-auth-token-ref env:NOTION_TOKEN \ --notion-crawl-mode selected_roots --notion-root-page-id ``` From 3f0d11e07d3696beb5f9d172efd3091e950d2b34 Mon Sep 17 00:00:00 2001 From: Andrey Avtomonov Date: Fri, 29 May 2026 17:41:04 +0200 Subject: [PATCH 30/74] feat(cli)!: remove fast mode; ktx ingest always builds enriched context (KLO-721) (#237) Fast mode (the ktx ingest --fast/--deep database-ingest depth toggle) is removed. ktx ingest now always builds the full enriched ("deep") context. There is no structural fallback: a database connection without a configured model and embeddings fails the enrichment-readiness preflight before any work runs, with a 'Run ktx setup to configure a model and embeddings' hint. - Remove --fast/--deep flags, the per-connection context.depth field, and the ktx setup depth prompt (delete setup-database-context-depth.ts). - Rename ingest-depth.ts -> connection-drivers.ts; ingest always requests scan mode 'enriched'; readiness gate (enrichmentReadinessGaps) runs for every database target. - Drop the database-context-depth telemetry step (Node + Python schema mirrors regenerated). - Update CLI, setup, context-build view, docs, the public ktx skill, and the release-smoke / artifacts scripts (now assert the no-LLM guard failure). ktx status --fast (a separate network-probe flag) is unchanged. Follow-ups: KLO-726 (live progress for ktx ingest --all), KLO-727 (restore credentialed successful-ingest release smoke coverage). --- AGENTS.md | 5 +- .../content/docs/cli-reference/ktx-ingest.mdx | 32 ++-- .../content/docs/cli-reference/ktx-setup.mdx | 4 +- .../content/docs/configuration/ktx-yaml.mdx | 7 +- .../docs/getting-started/quickstart.mdx | 8 +- .../content/docs/guides/building-context.mdx | 37 ++-- .../content/docs/guides/serving-agents.mdx | 5 +- .../docs/integrations/primary-sources.mdx | 2 +- docs/terminology.md | 2 - packages/cli/src/commands/ingest-commands.ts | 4 - packages/cli/src/connection-drivers.ts | 21 +++ packages/cli/src/context-build-view.ts | 10 +- .../cli/src/context/project/driver-schemas.ts | 2 +- packages/cli/src/ingest-depth.ts | 75 -------- packages/cli/src/public-ingest-copy.ts | 2 +- packages/cli/src/public-ingest.ts | 82 +++------ packages/cli/src/setup-context.ts | 55 +----- .../cli/src/setup-database-context-depth.ts | 131 -------------- packages/cli/src/setup-databases.ts | 94 ++-------- packages/cli/src/telemetry/events.schema.json | 1 - packages/cli/src/telemetry/events.ts | 1 - packages/cli/test/context-build-view.test.ts | 23 +-- packages/cli/test/index.test.ts | 24 +-- packages/cli/test/public-ingest.test.ts | 171 +++++------------- packages/cli/test/setup-context.test.ts | 134 +------------- packages/cli/test/setup-databases.test.ts | 67 +------ packages/cli/test/standalone-smoke.test.ts | 17 +- .../ktx_daemon/telemetry/events.schema.json | 1 - scripts/examples-docs.test.mjs | 2 +- scripts/installed-live-database-smoke.mjs | 31 ++-- .../installed-live-database-smoke.test.mjs | 1 - scripts/package-artifacts.mjs | 33 +--- scripts/package-artifacts.test.mjs | 7 +- skills/ktx/SKILL.md | 15 +- 34 files changed, 222 insertions(+), 884 deletions(-) create mode 100644 packages/cli/src/connection-drivers.ts delete mode 100644 packages/cli/src/ingest-depth.ts delete mode 100644 packages/cli/src/setup-database-context-depth.ts diff --git a/AGENTS.md b/AGENTS.md index 3d8c1725..2aa0dbed 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -350,8 +350,9 @@ error messages — including the disambiguation rule for the overloaded word `source` (semantic / primary / context / source of truth) — see [`docs/terminology.md`](docs/terminology.md). Follow that file when choosing between near-synonyms (e.g. `connector` vs `adapter`, `data agent` vs -`database agent`, `fast ingest` vs `schema ingest`). Product-name rules in -this section take precedence over anything in that file when they conflict. +`database agent`, `context-source ingest` vs `source ingest`). Product-name +rules in this section take precedence over anything in that file when they +conflict. ### Updating `docs-site/` After Code Changes diff --git a/docs-site/content/docs/cli-reference/ktx-ingest.mdx b/docs-site/content/docs/cli-reference/ktx-ingest.mdx index d4e06881..db3b1c0e 100644 --- a/docs-site/content/docs/cli-reference/ktx-ingest.mdx +++ b/docs-site/content/docs/cli-reference/ktx-ingest.mdx @@ -5,9 +5,11 @@ description: "Build or refresh ktx context, or capture text into ktx memory." `ktx ingest` builds or refreshes **ktx** context from configured connections, and can also capture free-form text into **ktx** memory. Database connections build -schema context. Context-source connections ingest metadata from tools such as -dbt, Looker, Metabase, MetricFlow, LookML, and Notion. Pass `--text` or -`--file` to capture inline text or text files into memory instead. +enriched context — schema plus AI-generated descriptions, embeddings, and +relationship evidence — and require a configured model and embeddings. +Context-source connections ingest metadata from tools such as dbt, Looker, +Metabase, MetricFlow, LookML, and Notion. Pass `--text` or `--file` to capture +inline text or text files into memory instead. ## Command signature @@ -29,8 +31,6 @@ connection is selected. | Flag | Description | Default | |------|-------------|---------| | `--all` | Ingest all configured connections (same as bare invocation) | `false` | -| `--fast` | Use deterministic fast database ingest | Stored connection default, or `fast` | -| `--deep` | Use deep database ingest with AI-generated descriptions, embeddings, and relationship evidence | Stored connection default, or `fast` | | `--query-history` | Include database query-history usage patterns | Stored connection default | | `--no-query-history` | Skip database query-history usage patterns for this run | Stored connection default | | `--query-history-window-days ` | BigQuery/Snowflake query-history lookback window for this run | Stored connection default | @@ -44,12 +44,12 @@ connection is selected. | `--yes` | Install required managed runtime features without prompting | `false` | | `--no-input` | Disable interactive terminal input | - | -`--fast` and `--deep` are mutually exclusive. Depth flags apply only to -database connections. Query-history flags apply only to database connections +Database ingest always builds enriched context and requires a configured model +and embeddings (run `ktx setup`); connections without that configuration fail +before any work starts. Query-history flags apply only to database connections that support query history. The window flag applies to BigQuery and Snowflake; Postgres reads the current `pg_stat_statements` aggregate data instead of a -time-windowed history table. Query-history ingest runs after fast ingest and -requires deep ingest readiness. +time-windowed history table. Query-history ingest runs after the schema scan. When more than one connection is selected, database ingest runs first, then context-source ingest and memory updates run for context-source connections. @@ -72,14 +72,8 @@ ktx ingest # Build one database or context-source connection ktx ingest warehouse -# Force deterministic fast database ingest -ktx ingest warehouse --fast - -# Force deep database ingest with AI enrichment -ktx ingest warehouse --deep - # Include query-history usage patterns -ktx ingest warehouse --deep --query-history +ktx ingest warehouse --query-history # Set the lookback window for BigQuery or Snowflake query history ktx ingest warehouse --query-history-window-days 30 @@ -154,8 +148,8 @@ KTX_INGEST_TRACE_LEVEL=trace ktx ingest metabase | Error | Cause | Recovery | |-------|-------|----------| | Connection not configured | The connection id is not present in `ktx.yaml` | Add the connection with `ktx setup` or update `ktx.yaml` | -| Deep readiness is missing | `--deep` or query history needs model, embedding, and scan-enrichment configuration | Run `ktx setup` or rerun with `--fast` | -| Query history is unsupported | The selected database driver does not support query history | Run fast ingest without query-history flags | +| Enrichment is not configured | Database ingest needs a model, embeddings, and scan-enrichment configuration | Run `ktx setup` to configure a model and embeddings | +| Query history is unsupported | The selected database driver does not support query history | Run ingest without query-history flags | | Python runtime is missing | The selected ingest target needs runtime-backed SQL analysis or source parsing | Accept the interactive prompt, rerun with `--yes`, or run the suggested `ktx admin runtime install` command | -| Context-source options were ignored | Depth and query-history flags were supplied for a context-source connection | Omit database-only flags when ingesting context-source connections | +| Context-source options were ignored | Query-history flags were supplied for a context-source connection | Omit database-only flags when ingesting context-source connections | | Text ingest stops early | `--fail-fast` was used and one item failed | Fix the failed item or rerun without `--fail-fast` to collect all failures | diff --git a/docs-site/content/docs/cli-reference/ktx-setup.mdx b/docs-site/content/docs/cli-reference/ktx-setup.mdx index 415b0e6e..0da7b339 100644 --- a/docs-site/content/docs/cli-reference/ktx-setup.mdx +++ b/docs-site/content/docs/cli-reference/ktx-setup.mdx @@ -131,8 +131,8 @@ BigQuery; and `databases` for ClickHouse. Query history setup is supported for Postgres, BigQuery, and Snowflake. The window flag applies to BigQuery and Snowflake; Postgres reads the current `pg_stat_statements` aggregate data instead of a time-windowed history table. -Enabling query history makes deep ingest readiness matter for later -`ktx ingest` runs. +Later `ktx ingest` runs build enriched context and need a configured model and +embeddings, including when query history is enabled. When query history is enabled for PostgreSQL, Snowflake, or BigQuery, `ktx setup` runs a non-blocking readiness probe after the connection test diff --git a/docs-site/content/docs/configuration/ktx-yaml.mdx b/docs-site/content/docs/configuration/ktx-yaml.mdx index 4a919d45..13105851 100644 --- a/docs-site/content/docs/configuration/ktx-yaml.mdx +++ b/docs-site/content/docs/configuration/ktx-yaml.mdx @@ -66,8 +66,9 @@ read, how to think, and where to put the results. ## Minimal config A working `ktx.yaml` needs one entry in `connections`. Everything else accepts -defaults. The example below is enough for `ktx ingest warehouse` to run a fast -schema scan against a local Postgres. +defaults. The example below registers a local Postgres connection; building +context with `ktx ingest warehouse` also needs a model and embeddings, which +`ktx setup` configures. ```yaml connections: @@ -123,7 +124,7 @@ context-source drivers share the map. Warehouse connections are open objects: the listed fields are validated, and any other field is preserved and passed through to the connector. Use -`enabled_tables` to scope deep ingest to a specific list of +`enabled_tables` to scope ingest to a specific list of `schema.table` names - useful for smoke tests. ```yaml diff --git a/docs-site/content/docs/getting-started/quickstart.mdx b/docs-site/content/docs/getting-started/quickstart.mdx index 7402d6d9..66f46a79 100644 --- a/docs-site/content/docs/getting-started/quickstart.mdx +++ b/docs-site/content/docs/getting-started/quickstart.mdx @@ -236,7 +236,7 @@ Testing warehouse Connection test passed Building schema context for warehouse - Running fast database ingest + Running database scan ``` If setup exits early, rerun `ktx setup` in the same directory. **ktx** keeps @@ -268,13 +268,13 @@ Agent integration ready: yes (codex:project) For a structured check inside scripts, use `ktx status --json`. -When setup builds deep context, its final context check looks like: +When setup finishes building context, its final context check looks like: ```text ktx context is ready for agents. Databases: - warehouse: deep context complete + warehouse: database context complete Context sources: dbt_main: memory update complete @@ -326,7 +326,7 @@ ktx setup \ Then build context: ```bash -ktx ingest warehouse --fast +ktx ingest warehouse ``` See [ktx setup](/docs/cli-reference/ktx-setup) for the full automation flag diff --git a/docs-site/content/docs/guides/building-context.mdx b/docs-site/content/docs/guides/building-context.mdx index d6d58053..b806c424 100644 --- a/docs-site/content/docs/guides/building-context.mdx +++ b/docs-site/content/docs/guides/building-context.mdx @@ -24,7 +24,9 @@ external metadata can attach to known warehouse tables. ## Database ingest -Database ingest records table, column, type, constraint, and row-count context. +Database ingest always builds enriched context: tables, columns, types, +constraints, and row counts, plus AI-generated descriptions, embeddings, and +relationship evidence. ```bash # Build one configured database connection @@ -34,23 +36,8 @@ ktx ingest warehouse ktx ingest --all ``` -Depth controls how much context **ktx** builds: - -| Flag | Best for | What it does | -|------|----------|--------------| -| `--fast` | First setup, quick refreshes, CI smoke checks | Deterministic fast ingest with tables, columns, types, constraints, and row counts | -| `--deep` | Agent-ready context for real analysis | Fast ingest plus deep enrichment with descriptions, embeddings, relationship evidence, and optional query history | - -Examples: - -```bash -ktx ingest warehouse --fast -ktx ingest warehouse --deep -ktx ingest --all --deep -``` - -Deep ingest needs LLM and embedding readiness. Otherwise run `ktx setup` or use -`--fast`. +Enriched ingest needs a configured model and embeddings. Run `ktx setup` first; +connections without that configuration fail before any work starts. With `claude-code`, **ktx** agent loops can invoke only the **ktx** MCP tools for the current run. @@ -64,7 +51,7 @@ Enable it during setup, store it under `connections..context.queryHistory`, or request it for one run: ```bash -ktx ingest warehouse --deep --query-history +ktx ingest warehouse --query-history # Set the lookback window for BigQuery or Snowflake query history ktx ingest warehouse --query-history-window-days 30 ``` @@ -74,8 +61,8 @@ for one run. ## Relationship evidence -**ktx** scores relationship candidates during supported deep database ingest. The -public CLI does not expose separate relationship review subcommands. +**ktx** scores relationship candidates during database ingest. The public CLI +does not expose separate relationship review subcommands. ## Context-source ingest @@ -159,7 +146,7 @@ After interactive setup: ```bash ktx status -ktx ingest --all --deep +ktx ingest --all ktx status ``` @@ -176,8 +163,8 @@ ktx wiki "revenue" --json --limit 10 | Symptom | Likely cause | Recovery | |---------|--------------|----------| | Connection not configured | The connection id is missing from `ktx.yaml` | Add it with `ktx setup` | -| Deep readiness is missing | LLM or embeddings are not setup-ready | Run `ktx setup`, or rerun with `--fast` | -| Query history is unsupported | The selected database driver does not expose query history | Run fast ingest without query-history flags | +| Enrichment is not configured | LLM or embeddings are not setup-ready | Run `ktx setup` to configure a model and embeddings | +| Query history is unsupported | The selected database driver does not expose query history | Run ingest without query-history flags | | No connections configured | The project has no entries under `connections` | Run `ktx setup` and add a database or context-source connection | -| Context-source flags have no effect | Depth and query-history flags were supplied for a context-source connector | Use those flags only for database connections | +| Context-source flags have no effect | Query-history flags were supplied for a context-source connector | Use query-history flags only for database connections | | Text ingest stops early | `--fail-fast` stopped on the first failed item | Fix the item or rerun without `--fail-fast` | diff --git a/docs-site/content/docs/guides/serving-agents.mdx b/docs-site/content/docs/guides/serving-agents.mdx index 4c1ced4b..133739b7 100644 --- a/docs-site/content/docs/guides/serving-agents.mdx +++ b/docs-site/content/docs/guides/serving-agents.mdx @@ -111,12 +111,13 @@ non-obvious terms. Agents can refresh context when the user asks them to: ```bash -ktx ingest warehouse --fast +ktx ingest warehouse ktx ingest ktx ingest --file docs/revenue-notes.md --connection-id warehouse ``` -Use `--deep` only when LLM and embedding setup is ready. +Database ingest builds enriched context and requires a configured model and +embeddings; run `ktx setup` first if they are not ready. ## Good agent behavior diff --git a/docs-site/content/docs/integrations/primary-sources.mdx b/docs-site/content/docs/integrations/primary-sources.mdx index 81b8d400..6cb2d26f 100644 --- a/docs-site/content/docs/integrations/primary-sources.mdx +++ b/docs-site/content/docs/integrations/primary-sources.mdx @@ -517,5 +517,5 @@ No authentication required - SQLite is file-based. The file must be readable by | Connection URL appears in git diff | A literal credential URL was written to `ktx.yaml` | Replace it with `env:NAME` or `file:/path/to/secret` and rotate exposed credentials | | Database ingest returns no tables | Schema, database, or project filter is wrong, or the user lacks metadata permissions | Verify the schema list and grant metadata read permissions | | Query history is empty | Query history extension or warehouse history view is unavailable | Enable the warehouse-specific history feature, then rerun `ktx ingest --query-history` or `ktx setup` | -| Column statistics are missing | Connector cannot access stats tables or the warehouse does not expose them | Grant stats permissions where supported; otherwise rely on fast schema context | +| Column statistics are missing | Connector cannot access stats tables or the warehouse does not expose them | Grant stats permissions where supported; otherwise rely on schema-level context without column statistics | | Semantic query execution fails | Connection is missing, unreachable, or query execution is disabled | Run `ktx connection test ` and check the `ktx sl query` flags | diff --git a/docs/terminology.md b/docs/terminology.md index 9da59456..4c9ec3cb 100644 --- a/docs/terminology.md +++ b/docs/terminology.md @@ -77,8 +77,6 @@ maintains, validates, and serves that layer. | Connection ref in prose | **connection id** (lowercase, two words) | "connection ID" | | CLI arg/flag literal | `connectionId` (code font) | — | | File path placeholder | `` (code font) | — | -| Fast schema mode | **fast ingest** | schema ingest, schema-only ingest | -| AI-enriched mode | **deep ingest** | AI-enriched ingest | | Ingest of a primary connection | **database ingest** | — | | Ingest of a context-source connection | **context-source ingest** | bare "source ingest" | | Wiki capture | **text ingest** | — | diff --git a/packages/cli/src/commands/ingest-commands.ts b/packages/cli/src/commands/ingest-commands.ts index 9ffd2562..b5efe443 100644 --- a/packages/cli/src/commands/ingest-commands.ts +++ b/packages/cli/src/commands/ingest-commands.ts @@ -29,8 +29,6 @@ export function registerIngestCommands( .usage('[options] [connectionId]') .argument('[connectionId]', 'Configured connection id to ingest (omit to ingest all)') .option('--all', 'Ingest all configured connections', false) - .addOption(new Option('--fast', 'Use deterministic database schema ingest').conflicts('deep')) - .addOption(new Option('--deep', 'Use AI-enriched database ingest').conflicts('fast')) .addOption(new Option('--query-history', 'Include database query-history usage patterns').conflicts('noQueryHistory')) .addOption(new Option('--no-query-history', 'Skip database query-history usage patterns')) .option('--query-history-window-days ', 'Query-history lookback window for this run', parsePositiveIntegerOption) @@ -87,8 +85,6 @@ export function registerIngestCommands( all: selection.kind === 'all', json: options.json === true, inputMode: options.input === false ? 'disabled' : 'auto', - ...(options.fast === true ? { depth: 'fast' as const } : {}), - ...(options.deep === true ? { depth: 'deep' as const } : {}), queryHistory, ...(options.queryHistoryWindowDays !== undefined ? { queryHistoryWindowDays: options.queryHistoryWindowDays } : {}), cliVersion: context.packageInfo.version, diff --git a/packages/cli/src/connection-drivers.ts b/packages/cli/src/connection-drivers.ts new file mode 100644 index 00000000..4f10e663 --- /dev/null +++ b/packages/cli/src/connection-drivers.ts @@ -0,0 +1,21 @@ +import type { KtxProjectConnectionConfig } from './context/project/config.js'; + +const KTX_DATABASE_DRIVER_IDS = new Set([ + 'sqlite', + 'postgres', + 'mysql', + 'clickhouse', + 'sqlserver', + 'bigquery', + 'snowflake', +]); + +export function normalizeConnectionDriver(connection: KtxProjectConnectionConfig): string { + return String(connection.driver ?? '') + .trim() + .toLowerCase(); +} + +export function isDatabaseDriver(driver: string): boolean { + return KTX_DATABASE_DRIVER_IDS.has(driver.trim().toLowerCase()); +} diff --git a/packages/cli/src/context-build-view.ts b/packages/cli/src/context-build-view.ts index 9a06d39a..4b5be38b 100644 --- a/packages/cli/src/context-build-view.ts +++ b/packages/cli/src/context-build-view.ts @@ -88,7 +88,6 @@ export interface ContextBuildArgs { targetConnectionId?: string; all?: boolean; entrypoint?: 'setup' | 'ingest'; - depth?: Extract['depth']; queryHistory?: Extract['queryHistory']; queryHistoryWindowDays?: number; scanMode?: Extract['scanMode']; @@ -371,19 +370,17 @@ function retryCommand(input: { projectDir?: string; entrypoint?: 'setup' | 'ingest'; connectionId?: string; - depth?: 'fast' | 'deep'; queryHistory?: boolean; queryHistoryWindowDays?: number; }): string { const projectPart = input.projectDir ? ` --project-dir ${input.projectDir}` : ''; if (input.entrypoint === 'ingest' && input.connectionId) { - const depthPart = input.depth ? ` --${input.depth}` : ''; const queryHistoryPart = input.queryHistory ? ' --query-history' : ''; const windowPart = input.queryHistory && input.queryHistoryWindowDays !== undefined ? ` --query-history-window-days ${input.queryHistoryWindowDays}` : ''; - return `ktx ingest ${input.connectionId}${projectPart}${depthPart}${queryHistoryPart}${windowPart}`; + return `ktx ingest ${input.connectionId}${projectPart}${queryHistoryPart}${windowPart}`; } return input.projectDir ? `ktx setup --project-dir ${input.projectDir}` : 'ktx setup'; } @@ -746,7 +743,6 @@ function appendRetryIfNeeded(input: { projectDir: input.projectDir, entrypoint: input.entrypoint, connectionId: input.target.connectionId, - depth: input.target.databaseDepth, queryHistory: input.target.queryHistory?.enabled === true, queryHistoryWindowDays: input.target.queryHistory?.windowDays, })}`; @@ -769,7 +765,6 @@ function failureTextForTarget(input: { projectDir: input.projectDir, entrypoint: input.entrypoint, connectionId: input.target.connectionId, - depth: input.target.databaseDepth, queryHistory: input.target.queryHistory?.enabled === true, queryHistoryWindowDays: input.target.queryHistory?.windowDays, })}`, @@ -784,7 +779,6 @@ function failureTextForTarget(input: { projectDir: input.projectDir, entrypoint: input.entrypoint, connectionId: input.target.connectionId, - depth: input.target.databaseDepth, queryHistory: input.target.queryHistory?.enabled === true, queryHistoryWindowDays: input.target.queryHistory?.windowDays, })}`, @@ -868,7 +862,6 @@ export async function runContextBuild( projectDir: args.projectDir, ...(args.targetConnectionId ? { targetConnectionId: args.targetConnectionId } : {}), all: args.all ?? true, - ...(args.depth ? { depth: args.depth } : {}), ...(args.queryHistory ? { queryHistory: args.queryHistory } : {}), ...(args.queryHistoryWindowDays !== undefined ? { queryHistoryWindowDays: args.queryHistoryWindowDays } : {}), ...(args.scanMode ? { scanMode: args.scanMode } : {}), @@ -935,7 +928,6 @@ export async function runContextBuild( all: args.all ?? true, json: false, inputMode: args.inputMode, - ...(args.depth ? { depth: args.depth } : {}), ...(args.queryHistory ? { queryHistory: args.queryHistory } : {}), ...(args.queryHistoryWindowDays !== undefined ? { queryHistoryWindowDays: args.queryHistoryWindowDays } : {}), ...(args.scanMode ? { scanMode: args.scanMode } : {}), diff --git a/packages/cli/src/context/project/driver-schemas.ts b/packages/cli/src/context/project/driver-schemas.ts index 6b4dc017..f9a3639f 100644 --- a/packages/cli/src/context/project/driver-schemas.ts +++ b/packages/cli/src/context/project/driver-schemas.ts @@ -30,7 +30,7 @@ function warehouseConnectionSchema(driver: .array(z.string().min(1)) .optional() .describe( - 'Optional allowlist of fully-qualified table names ("schema.table") to ingest. When set, live-database ingest discards any table whose schema-qualified name is not in this list. Useful for smoke-testing deep ingest on a single table.', + 'Optional allowlist of fully-qualified table names ("schema.table") to ingest. When set, live-database ingest discards any table whose schema-qualified name is not in this list. Useful for smoke-testing ingest on a single table.', ), }) .describe( diff --git a/packages/cli/src/ingest-depth.ts b/packages/cli/src/ingest-depth.ts deleted file mode 100644 index b8957763..00000000 --- a/packages/cli/src/ingest-depth.ts +++ /dev/null @@ -1,75 +0,0 @@ -import type { KtxProjectConfig, KtxProjectConnectionConfig } from './context/project/config.js'; - -export type KtxDatabaseContextDepth = 'fast' | 'deep'; - -const KTX_DATABASE_DRIVER_IDS = new Set([ - 'sqlite', - 'postgres', - 'mysql', - 'clickhouse', - 'sqlserver', - 'bigquery', - 'snowflake', -]); - -export function normalizeConnectionDriver(connection: KtxProjectConnectionConfig): string { - return String(connection.driver ?? '') - .trim() - .toLowerCase(); -} - -export function isDatabaseDriver(driver: string): boolean { - return KTX_DATABASE_DRIVER_IDS.has(driver.trim().toLowerCase()); -} - -function connectionContextRecord(connection: KtxProjectConnectionConfig): Record { - const context = connection.context; - return typeof context === 'object' && context !== null && !Array.isArray(context) - ? (context as Record) - : {}; -} - -export function databaseContextDepth(connection: KtxProjectConnectionConfig): KtxDatabaseContextDepth | undefined { - const depth = connectionContextRecord(connection).depth; - return depth === 'fast' || depth === 'deep' ? depth : undefined; -} - -export function withDatabaseContextDepth( - connection: KtxProjectConnectionConfig, - depth: KtxDatabaseContextDepth, -): KtxProjectConnectionConfig { - return { - ...connection, - context: { - ...connectionContextRecord(connection), - depth, - }, - }; -} - -export function deepReadinessGaps(config: KtxProjectConfig): string[] { - const gaps: string[] = []; - if (config.llm.provider.backend === 'none' || !config.llm.models.default) { - gaps.push('model configuration'); - } - - if (config.scan.enrichment.mode !== 'llm') { - gaps.push('scan enrichment mode'); - } - - const embeddings = config.scan.enrichment.embeddings; - if ( - !embeddings || - embeddings.backend === 'none' || - !embeddings.model || - embeddings.dimensions <= 0 - ) { - gaps.push('scan embeddings'); - } - - return gaps; -} - -export function recommendedDatabaseContextDepth(config: KtxProjectConfig): KtxDatabaseContextDepth { - return deepReadinessGaps(config).length === 0 ? 'deep' : 'fast'; -} diff --git a/packages/cli/src/public-ingest-copy.ts b/packages/cli/src/public-ingest-copy.ts index be1206c1..86423f74 100644 --- a/packages/cli/src/public-ingest-copy.ts +++ b/packages/cli/src/public-ingest-copy.ts @@ -12,7 +12,7 @@ const DATABASE_INGEST_REPLACEMENTS: Array<[RegExp, string]> = [ 'Database enrichment failed after schema context completed', ], [/\bstructural scan\b/gi, 'schema context'], - [/\benriched scan\b/gi, 'deep database ingest'], + [/\benriched scan\b/gi, 'database ingest'], [/\bscan results\b/gi, 'database context'], ]; diff --git a/packages/cli/src/public-ingest.ts b/packages/cli/src/public-ingest.ts index 60bceecd..25fe30dd 100644 --- a/packages/cli/src/public-ingest.ts +++ b/packages/cli/src/public-ingest.ts @@ -1,16 +1,10 @@ import { getKtxCliPackageInfo } from './cli-runtime.js'; import { loadKtxProject, type KtxLocalProject } from './context/project/project.js'; -import type { KtxProjectConnectionConfig } from './context/project/config.js'; +import type { KtxProjectConfig, KtxProjectConnectionConfig } from './context/project/config.js'; import type { KtxProgressPort } from './context/scan/types.js'; import type { KtxCliIo } from './index.js'; import type { KtxIngestArgs, KtxIngestDeps, KtxIngestProgressUpdate } from './ingest.js'; -import { - type KtxDatabaseContextDepth, - databaseContextDepth, - deepReadinessGaps, - isDatabaseDriver, - normalizeConnectionDriver, -} from './ingest-depth.js'; +import { isDatabaseDriver, normalizeConnectionDriver } from './connection-drivers.js'; import { ensureManagedPythonCommandRuntime, type KtxManagedPythonInstallPolicy, @@ -29,7 +23,6 @@ profileMark('module:public-ingest'); type KtxPublicIngestStepName = 'database-schema' | 'query-history' | 'source-ingest' | 'memory-update'; type KtxPublicIngestStepStatus = 'done' | 'skipped' | 'failed' | 'not-run'; type KtxPublicIngestInputMode = 'auto' | 'disabled'; -type KtxPublicIngestDepth = KtxDatabaseContextDepth; type KtxPublicIngestQueryHistoryFlag = 'default' | 'enabled' | 'disabled'; type HistoricSqlDialect = 'postgres' | 'bigquery' | 'snowflake'; @@ -41,7 +34,6 @@ export type KtxPublicIngestArgs = all: boolean; json: boolean; inputMode: KtxPublicIngestInputMode; - depth?: KtxPublicIngestDepth; queryHistory?: KtxPublicIngestQueryHistoryFlag; queryHistoryWindowDays?: number; scanMode?: Extract['mode']; @@ -58,7 +50,6 @@ export interface KtxPublicIngestPlanTarget { sourceDir?: string; debugCommand: string; steps: KtxPublicIngestStepName[]; - databaseDepth?: KtxPublicIngestDepth; detectRelationships?: boolean; preflightFailure?: string; queryHistory?: { @@ -67,7 +58,6 @@ export interface KtxPublicIngestPlanTarget { windowDays?: number; pullConfig?: Record; unsupported?: boolean; - skippedStoredByFast?: boolean; }; } @@ -121,7 +111,6 @@ interface KtxPublicContextBuildArgs { inputMode: 'auto' | 'disabled'; targetConnectionId?: string; all?: boolean; - depth?: KtxPublicIngestDepth; queryHistory?: KtxPublicIngestQueryHistoryFlag; queryHistoryWindowDays?: number; scanMode?: Extract['mode']; @@ -154,7 +143,6 @@ interface KtxUnsupportedQueryHistoryWarning { interface KtxPublicIngestWarningAccumulator { warnings: string[]; - ignoredDepthForSources: string[]; ignoredQueryHistoryForSources: string[]; unsupportedQueryHistoryForDatabases: KtxUnsupportedQueryHistoryWarning[]; } @@ -162,7 +150,6 @@ interface KtxPublicIngestWarningAccumulator { function createWarningAccumulator(): KtxPublicIngestWarningAccumulator { return { warnings: [], - ignoredDepthForSources: [], ignoredQueryHistoryForSources: [], unsupportedQueryHistoryForDatabases: [], }; @@ -233,7 +220,6 @@ function finalizeWarnings( accumulator: KtxPublicIngestWarningAccumulator, args: { all: boolean; - depth?: KtxPublicIngestDepth; queryHistory?: KtxPublicIngestQueryHistoryFlag; queryHistoryWindowDays?: number; }, @@ -242,11 +228,6 @@ function finalizeWarnings( ...accumulator.warnings, ...unsupportedQueryHistoryWarnings(accumulator.unsupportedQueryHistoryForDatabases, args.all), ]; - const depthOption = args.depth ? `--${args.depth}` : null; - if (depthOption) { - const warning = sourceIgnoredWarning(depthOption, accumulator.ignoredDepthForSources, args.all); - if (warning) warnings.push(warning); - } if (args.queryHistory === 'enabled' || args.queryHistoryWindowDays !== undefined) { const warning = sourceIgnoredWarning('--query-history', accumulator.ignoredQueryHistoryForSources, args.all); if (warning) warnings.push(warning); @@ -317,13 +298,12 @@ function resolveDatabaseTargetOptions(input: { driver: string; connection: KtxProjectConnectionConfig; args: { - depth?: KtxPublicIngestDepth; queryHistory?: KtxPublicIngestQueryHistoryFlag; queryHistoryWindowDays?: number; scanMode?: Extract['mode']; }; warnings: KtxPublicIngestWarningAccumulator; -}): Pick { +}): Pick { const storedQh = storedQueryHistory(input.connection); const dialect = queryHistoryDialectByDriver.get(input.driver); const explicitQueryHistory = input.args.queryHistory ?? 'default'; @@ -332,7 +312,6 @@ function resolveDatabaseTargetOptions(input: { const requestedQh = explicitQueryHistory === 'enabled' || (explicitQueryHistory !== 'disabled' && (windowOverrideRequested || storedEnabled)); - let depth = input.args.depth ?? databaseContextDepth(input.connection) ?? 'fast'; const queryHistory = { enabled: false, ...(input.args.queryHistoryWindowDays !== undefined @@ -350,19 +329,13 @@ function resolveDatabaseTargetOptions(input: { explicitQueryHistory === 'enabled' || input.args.queryHistoryWindowDays !== undefined ? 'explicit' : 'stored', }); return { - databaseDepth: depth, queryHistory: { ...queryHistory, unsupported: true }, steps: ['database-schema'], }; } if (requestedQh && dialect) { - if (depth === 'fast') { - input.warnings.warnings.push(`--query-history requires deep ingest; running ${input.connectionId} with --deep.`); - } - depth = 'deep'; return { - databaseDepth: depth, queryHistory: { ...queryHistory, enabled: true, @@ -378,30 +351,35 @@ function resolveDatabaseTargetOptions(input: { }; } - if (input.args.depth === 'fast' && explicitQueryHistory !== 'enabled' && storedEnabled) { - input.warnings.warnings.push( - `${input.connectionId} has query history enabled in ktx.yaml, but --fast skips query-history processing.`, - ); - return { - databaseDepth: 'fast', - queryHistory: { ...queryHistory, skippedStoredByFast: true }, - steps: ['database-schema'], - }; - } - return { - databaseDepth: depth, queryHistory, steps: ['database-schema'], }; } +function enrichmentReadinessGaps(config: KtxProjectConfig): string[] { + const gaps: string[] = []; + if (config.llm.provider.backend === 'none' || !config.llm.models.default) { + gaps.push('model configuration'); + } + + if (config.scan.enrichment.mode !== 'llm') { + gaps.push('scan enrichment mode'); + } + + const embeddings = config.scan.enrichment.embeddings; + if (!embeddings || embeddings.backend === 'none' || !embeddings.model || embeddings.dimensions <= 0) { + gaps.push('scan embeddings'); + } + + return gaps; +} + function targetForConnection( connectionId: string, connection: KtxProjectConnectionConfig, projectConfig: KtxPublicIngestProject['config'], args: { - depth?: KtxPublicIngestDepth; queryHistory?: KtxPublicIngestQueryHistoryFlag; queryHistoryWindowDays?: number; scanMode?: Extract['mode']; @@ -412,9 +390,6 @@ function targetForConnection( const adapter = sourceAdapterByDriver.get(driver); const sourceDir = sourceDirForConnection(connection); if (adapter) { - if (args.depth) { - warnings.ignoredDepthForSources.push(connectionId); - } if (args.queryHistory === 'enabled' || args.queryHistoryWindowDays !== undefined) { warnings.ignoredQueryHistoryForSources.push(connectionId); } @@ -431,18 +406,18 @@ function targetForConnection( if (isDatabaseDriver(driver)) { const options = resolveDatabaseTargetOptions({ connectionId, driver, connection, args, warnings }); - const gaps = options.databaseDepth === 'deep' ? deepReadinessGaps(projectConfig) : []; + const gaps = enrichmentReadinessGaps(projectConfig); return { connectionId, driver, operation: 'database-ingest', debugCommand: `ktx ingest ${connectionId} --debug`, - detectRelationships: options.databaseDepth === 'deep' && projectConfig.scan.relationships.enabled, + detectRelationships: projectConfig.scan.relationships.enabled, ...(gaps.length > 0 ? { - preflightFailure: `${connectionId} requires deep ingest readiness: ${gaps.join( + preflightFailure: `${connectionId} cannot be ingested: enrichment is not configured (${gaps.join( ', ', - )}. Run ktx setup or rerun with --fast.`, + )}). Run ktx setup to configure a model and embeddings.`, } : {}), ...options, @@ -458,7 +433,6 @@ export function buildPublicIngestPlan( projectDir: string; targetConnectionId?: string; all: boolean; - depth?: KtxPublicIngestDepth; queryHistory?: KtxPublicIngestQueryHistoryFlag; queryHistoryWindowDays?: number; scanMode?: Extract['mode']; @@ -522,13 +496,12 @@ function retryCommandForTarget( args: Extract, ): string { const projectPart = ` --project-dir ${args.projectDir}`; - const depthPart = target.databaseDepth ? ` --${target.databaseDepth}` : ''; const queryHistoryPart = target.queryHistory?.enabled === true ? ' --query-history' : ''; const windowPart = target.queryHistory?.enabled === true && target.queryHistory.windowDays !== undefined ? ` --query-history-window-days ${target.queryHistory.windowDays}` : ''; - return `ktx ingest ${target.connectionId}${projectPart}${depthPart}${queryHistoryPart}${windowPart}`; + return `ktx ingest ${target.connectionId}${projectPart}${queryHistoryPart}${windowPart}`; } function trimTrailingPeriod(value: string): string { @@ -830,7 +803,7 @@ export async function executePublicIngestTarget( command: 'run', projectDir: args.projectDir, connectionId: target.connectionId, - mode: target.databaseDepth === 'deep' ? 'enriched' : 'structural', + mode: 'enriched', detectRelationships: target.detectRelationships === true, dryRun: false, ...(args.cliVersion ? { cliVersion: args.cliVersion } : {}), @@ -979,7 +952,6 @@ export async function runKtxPublicIngest( all: args.all, entrypoint: 'ingest', inputMode: args.inputMode, - ...(args.depth ? { depth: args.depth } : {}), ...(args.queryHistory ? { queryHistory: args.queryHistory } : {}), ...(args.queryHistoryWindowDays !== undefined ? { queryHistoryWindowDays: args.queryHistoryWindowDays } : {}), ...(args.scanMode ? { scanMode: args.scanMode } : {}), diff --git a/packages/cli/src/setup-context.ts b/packages/cli/src/setup-context.ts index dc289278..63b4dbdf 100644 --- a/packages/cli/src/setup-context.ts +++ b/packages/cli/src/setup-context.ts @@ -7,12 +7,7 @@ import { serializeKtxProjectConfig } from './context/project/config.js'; import type { KtxCliIo } from './cli-runtime.js'; import { errorMessage, writePrefixedLines } from './clack.js'; import { buildPublicIngestPlan } from './public-ingest.js'; -import { - type KtxDatabaseContextDepth, - databaseContextDepth, -} from './ingest-depth.js'; import type { KtxManagedPythonInstallPolicy } from './managed-python-command.js'; -import { ensureSetupDatabaseContextDepths } from './setup-database-context-depth.js'; import { type ContextBuildSourceProgressUpdate, runContextBuild, @@ -353,16 +348,6 @@ async function readLatestScanReport(projectDir: string, connectionId: string): P return reports.at(-1)?.report ?? null; } -function scanReportHasSchemaManifest(report: unknown, connectionId: string): boolean { - if (!isRecord(report)) { - return false; - } - if (report.connectionId !== connectionId || report.dryRun === true) { - return false; - } - return stringArrayValue(isRecord(report.artifactPaths) ? report.artifactPaths.manifestShards : undefined).length > 0; -} - function scanReportHasCompletedDeepEnrichment( report: unknown, connectionId: string, @@ -389,18 +374,6 @@ function scanReportHasCompletedDeepEnrichment( ); } -function scanReportSatisfiesDepth(input: { - report: unknown; - connectionId: string; - depth: KtxDatabaseContextDepth; - relationshipsRequired: boolean; -}): boolean { - if (input.depth === 'fast') { - return scanReportHasSchemaManifest(input.report, input.connectionId); - } - return scanReportHasCompletedDeepEnrichment(input.report, input.connectionId, input.relationshipsRequired); -} - async function verifyPrimarySourceScans( project: KtxLocalProject, connectionIds: string[], @@ -408,15 +381,9 @@ async function verifyPrimarySourceScans( const details: string[] = []; const relationshipsRequired = project.config.scan.relationships.enabled; for (const connectionId of connectionIds) { - const connection = project.config.connections[connectionId]; - const depth = connection ? (databaseContextDepth(connection) ?? 'fast') : 'fast'; const report = await readLatestScanReport(project.projectDir, connectionId); - if (!scanReportSatisfiesDepth({ report, connectionId, depth, relationshipsRequired })) { - details.push( - depth === 'fast' - ? `${connectionId}: schema context has not completed.` - : `${connectionId}: deep database context has not completed.`, - ); + if (!scanReportHasCompletedDeepEnrichment(report, connectionId, relationshipsRequired)) { + details.push(`${connectionId}: database context has not completed.`); } } return { ready: details.length === 0, details }; @@ -482,7 +449,6 @@ function writeSkippedContext(projectDir: string, io: KtxCliIo): void { } function writeSuccess( - project: KtxLocalProject, readiness: KtxSetupContextReadiness, targets: KtxSetupContextTargets, io: KtxCliIo, @@ -493,9 +459,7 @@ function writeSuccess( io.stdout.write(' none\n'); } else { for (const connectionId of targets.primarySourceConnectionIds) { - const connection = project.config.connections[connectionId]; - const depth = connection ? (databaseContextDepth(connection) ?? 'fast') : 'fast'; - io.stdout.write(` ${connectionId}: ${depth === 'deep' ? 'deep context complete' : 'schema context complete'}\n`); + io.stdout.write(` ${connectionId}: database context complete\n`); } } io.stdout.write('\nContext sources:\n'); @@ -636,7 +600,7 @@ async function runBuild( failureReason: undefined, ...(lastSourceProgress ? { sourceProgress: lastSourceProgress } : {}), }); - writeSuccess(project, readiness, targets, io); + writeSuccess(readiness, targets, io); return { status: 'ready', projectDir: args.projectDir, runId }; } @@ -678,17 +642,8 @@ export async function runKtxSetupContextStep( deps: KtxSetupContextDeps = {}, ): Promise { try { - let project = await loadKtxProject({ projectDir: args.projectDir }); + const project = await loadKtxProject({ projectDir: args.projectDir }); const prompts = deps.prompts ?? createPromptAdapter(); - const depthProject = await ensureSetupDatabaseContextDepths({ - project, - args, - prompts, - }); - if (depthProject === 'back') { - return { status: 'back', projectDir: args.projectDir }; - } - project = depthProject; const existingState = await readKtxSetupContextState(args.projectDir); const completedSteps = (await readKtxSetupState(args.projectDir)).completed_steps; if (completedSteps.includes('context') && existingState.status === 'completed') { diff --git a/packages/cli/src/setup-database-context-depth.ts b/packages/cli/src/setup-database-context-depth.ts deleted file mode 100644 index 20df813c..00000000 --- a/packages/cli/src/setup-database-context-depth.ts +++ /dev/null @@ -1,131 +0,0 @@ -import { writeFile } from 'node:fs/promises'; -import { type KtxLocalProject, loadKtxProject } from './context/project/project.js'; -import { type KtxProjectConnectionConfig, serializeKtxProjectConfig } from './context/project/config.js'; -import { - type KtxDatabaseContextDepth, - databaseContextDepth, - deepReadinessGaps, - isDatabaseDriver, - normalizeConnectionDriver, - recommendedDatabaseContextDepth, - withDatabaseContextDepth, -} from './ingest-depth.js'; -import type { KtxSetupPromptOption } from './setup-prompts.js'; - -export interface KtxSetupDatabaseContextDepthArgs { - inputMode: 'auto' | 'disabled'; -} - -export interface KtxSetupDatabaseContextDepthPromptAdapter { - select(options: { message: string; options: KtxSetupPromptOption[] }): Promise; -} - -function databaseConnectionsNeedingDepth(project: KtxLocalProject): string[] { - return Object.entries(project.config.connections) - .filter(([, connection]) => isDatabaseDriver(normalizeConnectionDriver(connection))) - .filter(([, connection]) => databaseContextDepth(connection) === undefined) - .map(([connectionId]) => connectionId) - .sort((left, right) => left.localeCompare(right)); -} - -async function chooseSetupDatabaseContextDepth(input: { - project: KtxLocalProject; - args: KtxSetupDatabaseContextDepthArgs; - prompts: KtxSetupDatabaseContextDepthPromptAdapter; -}): Promise { - const recommended = recommendedDatabaseContextDepth(input.project.config); - if (input.args.inputMode === 'disabled') { - return recommended; - } - - const deepReady = deepReadinessGaps(input.project.config).length === 0; - const options = - recommended === 'deep' - ? [ - { - value: 'deep', - label: 'Deep: AI descriptions, embeddings, relationships, slower', - hint: 'recommended', - }, - { value: 'fast', label: 'Fast: schema only, no AI, quickest' }, - { value: 'back', label: 'Back' }, - ] - : [ - { value: 'fast', label: 'Fast: schema only, no AI, quickest', hint: 'recommended' }, - { value: 'deep', label: 'Deep: AI descriptions, embeddings, relationships, slower' }, - { value: 'back', label: 'Back' }, - ]; - - const choice = await input.prompts.select({ - message: - 'How much database context should KTX build?\n\n' + - (deepReady - ? 'Deep is available because model, embedding, and scan enrichment are configured.' - : 'Fast is recommended because model, embedding, or scan enrichment is not configured.'), - options, - }); - if (choice === 'back') { - return 'back'; - } - if (choice === 'fast' || choice === 'deep') { - return choice; - } - return recommended; -} - -async function writeDatabaseContextDepths( - project: KtxLocalProject, - connectionIds: string[], - depth: KtxDatabaseContextDepth, -): Promise { - if (connectionIds.length === 0) { - return project; - } - const nextConnections = { ...project.config.connections }; - for (const connectionId of connectionIds) { - const connection = nextConnections[connectionId]; - if (connection) { - nextConnections[connectionId] = withDatabaseContextDepth(connection, depth); - } - } - const nextConfig = { ...project.config, connections: nextConnections }; - await writeFile(project.configPath, serializeKtxProjectConfig(nextConfig), 'utf-8'); - return await loadKtxProject({ projectDir: project.projectDir }); -} - -export async function ensureSetupDatabaseContextDepths(input: { - project: KtxLocalProject; - args: KtxSetupDatabaseContextDepthArgs; - prompts: KtxSetupDatabaseContextDepthPromptAdapter; -}): Promise { - const missingDepthConnectionIds = databaseConnectionsNeedingDepth(input.project); - if (missingDepthConnectionIds.length === 0) { - return input.project; - } - - const depth = await chooseSetupDatabaseContextDepth(input); - if (depth === 'back') { - return 'back'; - } - return await writeDatabaseContextDepths(input.project, missingDepthConnectionIds, depth); -} - -export async function applySetupDatabaseContextDepth(input: { - project: KtxLocalProject; - connection: KtxProjectConnectionConfig; - args: KtxSetupDatabaseContextDepthArgs; - prompts: KtxSetupDatabaseContextDepthPromptAdapter; -}): Promise { - if ( - !isDatabaseDriver(normalizeConnectionDriver(input.connection)) || - databaseContextDepth(input.connection) !== undefined - ) { - return input.connection; - } - - const depth = await chooseSetupDatabaseContextDepth(input); - if (depth === 'back') { - return 'back'; - } - return withDatabaseContextDepth(input.connection, depth); -} diff --git a/packages/cli/src/setup-databases.ts b/packages/cli/src/setup-databases.ts index eb364228..09db1bde 100644 --- a/packages/cli/src/setup-databases.ts +++ b/packages/cli/src/setup-databases.ts @@ -29,7 +29,6 @@ import { } from './database-tree-picker.js'; import { withMultiselectNavigation, withTextInputNavigation } from './prompt-navigation.js'; import { runKtxScan } from './scan.js'; -import { applySetupDatabaseContextDepth } from './setup-database-context-depth.js'; import { writeProjectLocalSecretReference } from './setup-secrets.js'; import { isDemoConnection } from './telemetry/demo-detect.js'; import { emitTelemetryEvent } from './telemetry/index.js'; @@ -1614,45 +1613,10 @@ async function applyHistoricSqlConfigToExistingConnection(input: { prompts: input.prompts, }); if (withHistoricSql === 'back') return 'back'; - const withContextDepth = await maybeApplyContextDepthConfig({ - projectDir: input.projectDir, - connectionId: input.connectionId, - connection: withHistoricSql, - args: input.args, - prompts: input.prompts, - }); - if (withContextDepth === 'back') return 'back'; await writeConnectionConfig({ projectDir: input.projectDir, connectionId: input.connectionId, - connection: withContextDepth, - }); -} - -async function maybeApplyContextDepthConfig(input: { - projectDir: string; - connectionId: string; - connection: KtxProjectConnectionConfig; - args: KtxSetupDatabasesArgs; - prompts: KtxSetupDatabasesPromptAdapter; -}): Promise { - const project = await loadKtxProject({ projectDir: input.projectDir }); - return await applySetupDatabaseContextDepth({ - project: { - ...project, - config: { - ...project.config, - connections: { - ...project.config.connections, - [input.connectionId]: input.connection, - }, - }, - }, - connection: input.connection, - args: { - inputMode: input.args.inputMode === 'disabled' || input.args.databaseUrl ? 'disabled' : input.args.inputMode, - }, - prompts: input.prompts, + connection: withHistoricSql, }); } @@ -1698,7 +1662,7 @@ async function validateAndScanConnection(input: { deps: input.deps, }); writeSetupSection(input.io, `Building schema context for ${input.connectionId}`, [ - 'Running fast database ingest…', + 'Running database scan…', ]); let scanIo = createBufferedCommandIo(); let scanCode = await scanConnection(input.projectDir, input.connectionId, scanIo); @@ -1708,7 +1672,7 @@ async function validateAndScanConnection(input: { writePrefixedLines( (chunk) => input.io.stderr.write(chunk), [ - `Fast database ingest failed for ${input.connectionId}.`, + `Database scan failed for ${input.connectionId}.`, 'Native SQLite is built for a different Node.js ABI.', `Detail: ${nativeSqliteDetail}`, 'Rebuilding Native SQLite with pnpm run native:rebuild…', @@ -1719,7 +1683,7 @@ async function validateAndScanConnection(input: { if (rebuildCode === 0) { writePrefixedLines( (chunk) => input.io.stderr.write(chunk), - 'Native SQLite rebuild complete. Retrying fast database ingest…', + 'Native SQLite rebuild complete. Retrying database scan…', ); const retryScanIo = createBufferedCommandIo(); scanCode = await scanConnection(input.projectDir, input.connectionId, retryScanIo); @@ -1730,10 +1694,10 @@ async function validateAndScanConnection(input: { (chunk) => input.io.stderr.write(chunk), [ rebuildCode === 0 - ? `Fast database ingest still failed for ${input.connectionId} after rebuilding Native SQLite.` + ? `Database scan still failed for ${input.connectionId} after rebuilding Native SQLite.` : `Native SQLite rebuild failed for ${input.connectionId}.`, 'Fix: pnpm run native:rebuild', - `Retry: ktx ingest ${input.connectionId} --project-dir ${input.projectDir} --fast`, + `Retry: ktx ingest ${input.connectionId} --project-dir ${input.projectDir}`, ].join('\n'), ); } @@ -1742,8 +1706,8 @@ async function validateAndScanConnection(input: { writePrefixedLines( (chunk) => input.io.stderr.write(chunk), [ - `Fast database ingest failed for ${input.connectionId}.`, - `Debug command: ktx ingest ${input.connectionId} --project-dir ${input.projectDir} --fast --debug`, + `Database scan failed for ${input.connectionId}.`, + `Debug command: ktx ingest ${input.connectionId} --project-dir ${input.projectDir} --debug`, ].join('\n'), ); } @@ -2167,22 +2131,10 @@ export async function runKtxSetupDatabasesStep( returnToDriverSelection = true; break; } - const withContextDepth = await maybeApplyContextDepthConfig({ - projectDir: args.projectDir, - connectionId: connectionChoice.connectionId, - connection: withHistoricSql, - args, - prompts, - }); - if (withContextDepth === 'back') { - if (!canReturnToDriverSelection) return { status: 'back', projectDir: args.projectDir }; - returnToDriverSelection = true; - break; - } await writeConnectionConfig({ projectDir: args.projectDir, connectionId: connectionChoice.connectionId, - connection: withContextDepth, + connection: withHistoricSql, io, }); } else { @@ -2193,22 +2145,10 @@ export async function runKtxSetupDatabasesStep( returnToDriverSelection = true; break; } - const withContextDepth = await maybeApplyContextDepthConfig({ - projectDir: args.projectDir, - connectionId: connectionChoice.connectionId, - connection: withHistoricSql, - args, - prompts, - }); - if (withContextDepth === 'back') { - if (!canReturnToDriverSelection) return { status: 'back', projectDir: args.projectDir }; - returnToDriverSelection = true; - break; - } await writeConnectionConfig({ projectDir: args.projectDir, connectionId: connectionChoice.connectionId, - connection: withContextDepth, + connection: withHistoricSql, io, }); } @@ -2291,22 +2231,10 @@ export async function runKtxSetupDatabasesStep( returnToDriverSelection = true; break; } - const withContextDepth = await maybeApplyContextDepthConfig({ - projectDir: args.projectDir, - connectionId: connectionChoice.connectionId, - connection: withHistoricSql, - args, - prompts, - }); - if (withContextDepth === 'back') { - if (!canReturnToDriverSelection) return { status: 'back', projectDir: args.projectDir }; - returnToDriverSelection = true; - break; - } await writeConnectionConfig({ projectDir: args.projectDir, connectionId: connectionChoice.connectionId, - connection: withContextDepth, + connection: withHistoricSql, io, }); setupStatus = await validateAndScanConnection({ diff --git a/packages/cli/src/telemetry/events.schema.json b/packages/cli/src/telemetry/events.schema.json index 13642c49..628c8f4b 100644 --- a/packages/cli/src/telemetry/events.schema.json +++ b/packages/cli/src/telemetry/events.schema.json @@ -365,7 +365,6 @@ "embeddings", "secrets", "databases", - "database-context-depth", "sources", "context", "agents", diff --git a/packages/cli/src/telemetry/events.ts b/packages/cli/src/telemetry/events.ts index e73001ed..5e5b5335 100644 --- a/packages/cli/src/telemetry/events.ts +++ b/packages/cli/src/telemetry/events.ts @@ -38,7 +38,6 @@ const setupStepSchema = telemetryCommonEnvelopeSchema 'embeddings', 'secrets', 'databases', - 'database-context-depth', 'sources', 'context', 'agents', diff --git a/packages/cli/test/context-build-view.test.ts b/packages/cli/test/context-build-view.test.ts index 5936afa9..40e33606 100644 --- a/packages/cli/test/context-build-view.test.ts +++ b/packages/cli/test/context-build-view.test.ts @@ -228,11 +228,11 @@ describe('renderContextBuildView', () => { const rendered = renderContextBuildView(state, { styled: false, - warnings: ['--deep affects database ingest only; ignoring it for docs.'], + warnings: ['--query-history affects database ingest only; ignoring it for docs.'], }); expect(rendered).toContain('Warnings:'); - expect(rendered).toContain('--deep affects database ingest only; ignoring it for docs.'); + expect(rendered).toContain('--query-history affects database ingest only; ignoring it for docs.'); }); it('renders public notices in the foreground view before warnings', () => { @@ -243,7 +243,6 @@ describe('renderContextBuildView', () => { operation: 'database-ingest', debugCommand: 'ktx ingest warehouse --debug', steps: ['database-schema', 'query-history'], - databaseDepth: 'deep', detectRelationships: true, queryHistory: { enabled: true, dialect: 'postgres' }, }, @@ -252,12 +251,12 @@ describe('renderContextBuildView', () => { const rendered = renderContextBuildView(state, { styled: false, notices: ['Schema ingest runs before query history for warehouse.'], - warnings: ['--query-history requires deep ingest; running warehouse with --deep.'], + warnings: ['--query-history is not supported for sqlite; running schema ingest for local.'], }); expect(rendered.indexOf('Notices:')).toBeLessThan(rendered.indexOf('Warnings:')); expect(rendered).toContain('Schema ingest runs before query history for warehouse.'); - expect(rendered).toContain('--query-history requires deep ingest; running warehouse with --deep.'); + expect(rendered).toContain('--query-history is not supported for sqlite; running schema ingest for local.'); }); it('renders dynamic separator matching header width', () => { @@ -653,7 +652,6 @@ describe('runContextBuild', () => { inputMode: 'disabled', targetConnectionId: 'warehouse', all: false, - depth: 'fast', queryHistory: 'default', }, io.io, @@ -665,7 +663,6 @@ describe('runContextBuild', () => { expect(executeTarget.mock.calls[0]?.[0]).toMatchObject({ connectionId: 'warehouse', operation: 'database-ingest', - databaseDepth: 'fast', }); expect(io.stdout()).toContain('Databases:'); expect(io.stdout()).toContain('warehouse'); @@ -716,7 +713,7 @@ describe('runContextBuild', () => { it('renders localhost SQL analysis refusal as a runtime failure during query history', async () => { const io = makeIo(); const project = projectWithConnections({ - warehouse: { driver: 'postgres', context: { depth: 'deep', queryHistory: { enabled: true } } }, + warehouse: { driver: 'postgres', context: { queryHistory: { enabled: true } } }, }); const executeTarget = vi.fn(async (target, _args, targetIo) => { targetIo.stderr.write('connect ECONNREFUSED 127.0.0.1:8765\n'); @@ -751,7 +748,7 @@ describe('runContextBuild', () => { it('uses captured query-history stderr instead of generic failed-at detail', async () => { const io = makeIo(); const project = projectWithConnections({ - warehouse: { driver: 'postgres', context: { depth: 'deep', queryHistory: { enabled: true } } }, + warehouse: { driver: 'postgres', context: { queryHistory: { enabled: true } } }, }); const executeTarget = vi.fn(async (target, _args, targetIo) => { targetIo.stdout.write('KTX scan completed\n'); @@ -768,7 +765,7 @@ describe('runContextBuild', () => { operation: 'query-history', status: 'failed', detail: - 'warehouse failed at query-history. Retry: ktx ingest warehouse --project-dir /tmp/project --deep --query-history', + 'warehouse failed at query-history. Retry: ktx ingest warehouse --project-dir /tmp/project --query-history', }, { operation: 'source-ingest', status: 'skipped' }, { operation: 'memory-update', status: 'skipped' }, @@ -785,7 +782,7 @@ describe('runContextBuild', () => { expect(result).toEqual({ exitCode: 1 }); expect(io.stdout()).toContain('Missing bundled Python runtime manifest: /tmp/assets/python/manifest.json.'); - expect(io.stdout()).toContain('Retry: ktx ingest warehouse --project-dir /tmp/project --deep --query-history'); + expect(io.stdout()).toContain('Retry: ktx ingest warehouse --project-dir /tmp/project --query-history'); expect(io.stdout()).not.toContain('Then retry the runtime-backed KTX command'); expect(io.stdout()).not.toContain('warehouse failed at query-history'); expect(io.stdout().match(/Retry: /g)).toHaveLength(1); @@ -899,12 +896,12 @@ describe('runContextBuild', () => { const io = makeIo(); const project: KtxPublicIngestProject = { ...projectWithConnections({ - warehouse: { driver: 'postgres', context: { depth: 'deep' } }, + warehouse: { driver: 'postgres' }, }), config: { ...projectWithConnections({ warehouse: { driver: 'postgres' } }).config, connections: { - warehouse: { driver: 'postgres', context: { depth: 'deep' } }, + warehouse: { driver: 'postgres' }, }, llm: { provider: { backend: 'gateway', gateway: { api_key: 'env:KTX_GATEWAY_API_KEY' } }, // pragma: allowlist secret diff --git a/packages/cli/test/index.test.ts b/packages/cli/test/index.test.ts index a60c48f2..bd17e641 100644 --- a/packages/cli/test/index.test.ts +++ b/packages/cli/test/index.test.ts @@ -702,7 +702,7 @@ describe('runKtxCli', () => { const publicIngest = vi.fn().mockResolvedValue(0); await expect( - runKtxCli(['--project-dir', tempDir, 'ingest', 'warehouse', '--fast', '--no-input'], testIo.io, { + runKtxCli(['--project-dir', tempDir, 'ingest', 'warehouse', '--no-input'], testIo.io, { publicIngest, }), ).resolves.toBe(0); @@ -715,7 +715,6 @@ describe('runKtxCli', () => { all: false, json: false, inputMode: 'disabled', - depth: 'fast', queryHistory: 'default', cliVersion, runtimeInstallPolicy: 'never', @@ -725,12 +724,12 @@ describe('runKtxCli', () => { expect(testIo.stderr()).toBe(`Project: ${tempDir}\n`); }); - it('routes public ingest --all --deep with JSON output', async () => { + it('routes public ingest --all with JSON output', async () => { const testIo = makeIo(); const publicIngest = vi.fn().mockResolvedValue(0); await expect( - runKtxCli(['--project-dir', tempDir, 'ingest', '--all', '--deep', '--json'], testIo.io, { + runKtxCli(['--project-dir', tempDir, 'ingest', '--all', '--json'], testIo.io, { publicIngest, }), ).resolves.toBe(0); @@ -742,7 +741,6 @@ describe('runKtxCli', () => { all: true, json: true, inputMode: 'auto', - depth: 'deep', queryHistory: 'default', cliVersion, runtimeInstallPolicy: 'prompt', @@ -786,20 +784,6 @@ describe('runKtxCli', () => { expect(testIo.stderr()).toContain('Choose only one runtime install mode: --yes or --no-input'); }); - it('rejects mutually exclusive public ingest depth flags before dispatch', async () => { - const testIo = makeIo(); - const publicIngest = vi.fn().mockResolvedValue(0); - - await expect( - runKtxCli(['--project-dir', '/tmp/project', 'ingest', 'warehouse', '--fast', '--deep'], testIo.io, { - publicIngest, - }), - ).resolves.toBe(1); - - expect(publicIngest).not.toHaveBeenCalled(); - expect(testIo.stderr()).toMatch(/option '--(deep|fast)' cannot be used with option '--(fast|deep)'/); - }); - it.each(['run', 'status', 'watch', 'replay'])( 'routes former ingest subcommand name "%s" as a connection id', async (connectionId) => { @@ -890,8 +874,6 @@ describe('runKtxCli', () => { expect(testIo.stdout()).toContain('Usage: ktx ingest'); expect(testIo.stdout()).toContain('Build or inspect KTX context'); expect(testIo.stdout()).toContain('--all'); - expect(testIo.stdout()).toContain('--fast'); - expect(testIo.stdout()).toContain('--deep'); expect(testIo.stdout()).toContain('--query-history'); expect(testIo.stdout()).toContain('--no-query-history'); expect(testIo.stdout()).toContain('--query-history-window-days '); diff --git a/packages/cli/test/public-ingest.test.ts b/packages/cli/test/public-ingest.test.ts index b926793c..41289208 100644 --- a/packages/cli/test/public-ingest.test.ts +++ b/packages/cli/test/public-ingest.test.ts @@ -88,7 +88,7 @@ function deepReadyProject( describe('buildPublicIngestPlan', () => { it('plans warehouse connections as scan targets and source connections as source ingest targets', () => { - const project = projectWithConnections({ + const project = deepReadyProject({ warehouse: { driver: 'postgres' }, prod_metabase: { driver: 'metabase', api_url: 'https://metabase.example.com' }, docs: { driver: 'notion' }, @@ -103,8 +103,7 @@ describe('buildPublicIngestPlan', () => { operation: 'database-ingest', debugCommand: 'ktx ingest warehouse --debug', steps: ['database-schema'], - databaseDepth: 'fast', - detectRelationships: false, + detectRelationships: true, queryHistory: { enabled: false }, }, { @@ -139,61 +138,6 @@ describe('buildPublicIngestPlan', () => { expect(plan.targets.map((target) => target.connectionId).sort()).toEqual(['docs', 'warehouse']); }); - it('resolves database depth from flags, stored context, and defaults', () => { - const project = projectWithConnections({ - fast_default: { driver: 'postgres' }, - deep_default: { driver: 'postgres', context: { depth: 'deep' } }, - docs: { driver: 'notion' }, - }); - - expect( - buildPublicIngestPlan(project, { - projectDir: '/tmp/project', - targetConnectionId: 'fast_default', - all: false, - queryHistory: 'default', - }).targets[0], - ).toMatchObject({ connectionId: 'fast_default', databaseDepth: 'fast', queryHistory: { enabled: false } }); - - expect( - buildPublicIngestPlan(project, { - projectDir: '/tmp/project', - targetConnectionId: 'deep_default', - all: false, - queryHistory: 'default', - }).targets[0], - ).toMatchObject({ connectionId: 'deep_default', databaseDepth: 'deep' }); - - expect( - buildPublicIngestPlan(project, { - projectDir: '/tmp/project', - targetConnectionId: 'docs', - all: false, - depth: 'deep', - queryHistory: 'default', - }).warnings, - ).toEqual(['--deep affects database ingest only; ignoring it for docs.']); - }); - - it('does not infer deep ingest from legacy scanMode values', () => { - const project = projectWithConnections({ - warehouse: { driver: 'postgres' }, - }); - - const plan = buildPublicIngestPlan(project, { - projectDir: '/tmp/project', - targetConnectionId: 'warehouse', - all: false, - scanMode: 'enriched', - }); - - expect(plan.targets[0]).toMatchObject({ - connectionId: 'warehouse', - databaseDepth: 'fast', - steps: ['database-schema'], - }); - }); - it('rejects stale local Looker source driver aliases', () => { const project = projectWithConnections({ local_looker: { driver: 'local_looker' } as never, @@ -204,8 +148,8 @@ describe('buildPublicIngestPlan', () => { ); }); - it('upgrades effective depth when query history is explicitly enabled', () => { - const project = projectWithConnections({ + it('enables query history when explicitly requested even if stored config disables it', () => { + const project = deepReadyProject({ warehouse: { driver: 'postgres', context: { queryHistory: { enabled: false } } }, }); @@ -213,17 +157,16 @@ describe('buildPublicIngestPlan', () => { projectDir: '/tmp/project', targetConnectionId: 'warehouse', all: false, - depth: 'fast', queryHistory: 'enabled', queryHistoryWindowDays: 30, }); expect(plan.targets[0]).toMatchObject({ connectionId: 'warehouse', - databaseDepth: 'deep', queryHistory: { enabled: true, windowDays: 30, dialect: 'postgres' }, + steps: ['database-schema', 'query-history'], }); - expect(plan.warnings).toEqual(['--query-history requires deep ingest; running warehouse with --deep.']); + expect(plan.warnings).toEqual([]); }); it('warns and skips query history for unsupported database drivers', () => { @@ -238,7 +181,6 @@ describe('buildPublicIngestPlan', () => { expect(plan.targets[0]).toMatchObject({ connectionId: 'local', - databaseDepth: 'fast', queryHistory: { enabled: false, unsupported: true }, }); expect(plan.warnings).toEqual(['--query-history is not supported for sqlite; running schema ingest for local.']); @@ -249,12 +191,11 @@ describe('buildPublicIngestPlan', () => { deepReadyProject({ local: { driver: 'sqlite' }, mysql_warehouse: { driver: 'mysql' }, - warehouse: { driver: 'postgres', context: { depth: 'deep' } }, + warehouse: { driver: 'postgres' }, }), { projectDir: '/tmp/project', all: true, - depth: 'deep', queryHistory: 'enabled', }, ); @@ -326,7 +267,6 @@ describe('buildPublicIngestPlan', () => { expect(plan.targets[0]).toMatchObject({ connectionId: 'warehouse', - databaseDepth: 'deep', queryHistory: { enabled: true, dialect: 'postgres', windowDays: 30 }, steps: ['database-schema', 'query-history'], }); @@ -334,7 +274,7 @@ describe('buildPublicIngestPlan', () => { it('adds a schema-first notice when query history is explicitly enabled', () => { const project = deepReadyProject({ - warehouse: { driver: 'postgres', context: { depth: 'deep' } }, + warehouse: { driver: 'postgres' }, }); expect( @@ -363,34 +303,15 @@ describe('buildPublicIngestPlan', () => { expect(plan.targets[0]).toMatchObject({ connectionId: 'local', - databaseDepth: 'fast', queryHistory: { enabled: false, windowDays: 30, unsupported: true }, steps: ['database-schema'], }); expect(plan.warnings).toEqual(['--query-history is not supported for sqlite; running schema ingest for local.']); }); - it('aggregates ignored database-depth warnings for all source targets', () => { - const plan = buildPublicIngestPlan( - projectWithConnections({ - warehouse: { driver: 'postgres' }, - docs: { driver: 'notion' }, - dbt: { driver: 'dbt' }, - }), - { - projectDir: '/tmp/project', - all: true, - depth: 'deep', - queryHistory: 'default', - }, - ); - - expect(plan.warnings).toEqual(['--deep ignored for 2 non-database sources.']); - }); - - it('records a preflight failure for deep database ingest when readiness config is missing', () => { + it('records a preflight failure for database ingest when enrichment readiness config is missing', () => { const project = projectWithConnections({ - warehouse: { driver: 'postgres', context: { depth: 'deep' } }, + warehouse: { driver: 'postgres' }, }); const plan = buildPublicIngestPlan(project, { @@ -402,15 +323,14 @@ describe('buildPublicIngestPlan', () => { expect(plan.targets[0]).toMatchObject({ connectionId: 'warehouse', - databaseDepth: 'deep', preflightFailure: - 'warehouse requires deep ingest readiness: model configuration, scan enrichment mode, scan embeddings. Run ktx setup or rerun with --fast.', + 'warehouse cannot be ingested: enrichment is not configured (model configuration, scan enrichment mode, scan embeddings). Run ktx setup to configure a model and embeddings.', }); }); - it('honors scan.relationships.enabled when planning deep database ingest', () => { + it('honors scan.relationships.enabled when planning database ingest', () => { const plan = buildPublicIngestPlan( - deepReadyProject({ warehouse: { driver: 'postgres', context: { depth: 'deep' } } }, false), + deepReadyProject({ warehouse: { driver: 'postgres' } }, false), { projectDir: '/tmp/project', targetConnectionId: 'warehouse', @@ -421,7 +341,6 @@ describe('buildPublicIngestPlan', () => { expect(plan.targets[0]).toMatchObject({ connectionId: 'warehouse', - databaseDepth: 'deep', detectRelationships: false, }); }); @@ -432,11 +351,11 @@ describe('runKtxPublicIngest', () => { vi.unstubAllEnvs(); }); - it('maps fast and deep database targets to scan internals', async () => { + it('maps database targets to enriched scan internals', async () => { const io = makeIo(); const project = deepReadyProject({ - fast: { driver: 'postgres' }, - deep: { driver: 'postgres', context: { depth: 'deep' } }, + first: { driver: 'postgres' }, + second: { driver: 'postgres' }, }); const runScan = vi.fn(async () => 0); @@ -450,12 +369,12 @@ describe('runKtxPublicIngest', () => { expect(runScan).toHaveBeenNthCalledWith( 1, - expect.objectContaining({ connectionId: 'deep', mode: 'enriched', detectRelationships: true }), + expect.objectContaining({ connectionId: 'first', mode: 'enriched', detectRelationships: true }), expect.anything(), ); expect(runScan).toHaveBeenNthCalledWith( 2, - expect.objectContaining({ connectionId: 'fast', mode: 'structural', detectRelationships: false }), + expect.objectContaining({ connectionId: 'second', mode: 'enriched', detectRelationships: true }), expect.anything(), ); }); @@ -467,7 +386,7 @@ describe('runKtxPublicIngest', () => { try { await initKtxProject({ projectDir }); const io = makeIo({ isTTY: true }); - const project = projectWithConnections({ + const project = deepReadyProject({ warehouse: { driver: 'sqlite', path: join(projectDir, 'warehouse.sqlite') }, }); @@ -614,7 +533,7 @@ describe('runKtxPublicIngest', () => { it('prints the schema-first notice for explicit query-history runs', async () => { const io = makeIo(); const project = deepReadyProject({ - warehouse: { driver: 'postgres', context: { depth: 'deep' } }, + warehouse: { driver: 'postgres' }, }); const runScan = vi.fn(async () => 0); const runIngest = vi.fn(async () => 0); @@ -640,7 +559,7 @@ describe('runKtxPublicIngest', () => { it('suppresses internal scan output for public database ingest summaries', async () => { const io = makeIo(); - const project = projectWithConnections({ warehouse: { driver: 'postgres' } }); + const project = deepReadyProject({ warehouse: { driver: 'postgres' } }); const runScan = vi.fn(async (_args, scanIo) => { scanIo.stdout.write('KTX scan completed\n'); scanIo.stdout.write('Mode: structural\n'); @@ -674,7 +593,7 @@ describe('runKtxPublicIngest', () => { it('sanitizes captured database scan failure details in direct public output', async () => { const io = makeIo(); - const project = deepReadyProject({ warehouse: { driver: 'postgres', context: { depth: 'deep' } } }); + const project = deepReadyProject({ warehouse: { driver: 'postgres' } }); const runScan = vi.fn(async (_args, scanIo) => { scanIo.stdout.write('KTX scan enrichment failed after structural scan completed: embedding service timed out\n'); return 1; @@ -689,7 +608,6 @@ describe('runKtxPublicIngest', () => { all: false, json: false, inputMode: 'disabled', - depth: 'deep', }, io.io, { loadProject: vi.fn(async () => project), runScan }, @@ -699,7 +617,7 @@ describe('runKtxPublicIngest', () => { expect(io.stdout()).toContain( 'warehouse failed: Database enrichment failed after schema context completed: embedding service timed out.', ); - expect(io.stdout()).toContain('Retry: ktx ingest warehouse --project-dir /tmp/project --deep'); + expect(io.stdout()).toContain('Retry: ktx ingest warehouse --project-dir /tmp/project'); expect(io.stdout()).not.toContain('KTX scan enrichment failed'); expect(io.stdout()).not.toContain('structural scan'); }); @@ -743,7 +661,7 @@ describe('runKtxPublicIngest', () => { it('suppresses historic-sql report output during direct public query-history ingest', async () => { const io = makeIo(); const project = deepReadyProject({ - warehouse: { driver: 'postgres', context: { depth: 'deep' } }, + warehouse: { driver: 'postgres' }, }); const runScan = vi.fn(async () => 0); const runIngest = vi.fn(async (_args, ingestIo) => { @@ -794,7 +712,6 @@ describe('runKtxPublicIngest', () => { all: false, json: false, inputMode: 'auto', - depth: 'fast', queryHistory: 'default', }, io.io, @@ -809,7 +726,6 @@ describe('runKtxPublicIngest', () => { targetConnectionId: 'warehouse', all: false, entrypoint: 'ingest', - depth: 'fast', queryHistory: 'default', }), io.io, @@ -821,7 +737,7 @@ describe('runKtxPublicIngest', () => { const io = makeIo({ isTTY: true, interactive: true }); const calls: string[] = []; const project = projectWithConnections({ - warehouse: { driver: 'postgres', context: { depth: 'deep' } }, + warehouse: { driver: 'postgres' }, }); const ensureRuntime = vi.fn(async (): Promise => { calls.push('runtime'); @@ -923,10 +839,13 @@ describe('runKtxPublicIngest', () => { it('runs all independent targets and reports partial failures', async () => { const io = makeIo(); - const project = projectWithConnections({ - warehouse: { driver: 'postgres' }, - prod_metabase: { driver: 'metabase', api_url: 'https://metabase.example.com' }, - }); + const project = deepReadyProject( + { + warehouse: { driver: 'postgres' }, + prod_metabase: { driver: 'metabase', api_url: 'https://metabase.example.com' }, + }, + false, + ); const runScan = vi.fn(async () => 1); const runIngest = vi.fn(async () => 0); @@ -959,7 +878,7 @@ describe('runKtxPublicIngest', () => { command: 'run', projectDir: '/tmp/project', connectionId: 'warehouse', - mode: 'structural', + mode: 'enriched', detectRelationships: false, dryRun: false, }, @@ -967,14 +886,14 @@ describe('runKtxPublicIngest', () => { ); expect(io.stdout()).toContain('Ingest finished with partial failures'); expect(io.stdout()).toContain('warehouse failed at database-schema.'); - expect(io.stdout()).toContain('Retry: ktx ingest warehouse --project-dir /tmp/project --fast'); + expect(io.stdout()).toContain('Retry: ktx ingest warehouse --project-dir /tmp/project'); expect(io.stdout()).not.toContain('Debug:'); }); it('skips the query-history facet but keeps the target green when query-history fails', async () => { const io = makeIo(); const project = deepReadyProject({ - warehouse: { driver: 'postgres', context: { depth: 'deep' } }, + warehouse: { driver: 'postgres' }, }); const runScan = vi.fn(async () => 0); const runIngest = vi.fn(async (_args, ingestIo) => { @@ -1007,14 +926,14 @@ describe('runKtxPublicIngest', () => { 'Query history failed for 60 tasks. First failure: Google Cloud authentication failed while analyzing query history', ); expect(io.stdout()).not.toContain('warehouse failed: Error:'); - expect(io.stdout()).toContain('Retry: ktx ingest warehouse --project-dir /tmp/project --deep --query-history'); + expect(io.stdout()).toContain('Retry: ktx ingest warehouse --project-dir /tmp/project --query-history'); expect(io.stdout()).not.toContain('historic-sql'); }); it('prints the runtime artifact build hint for missing query-history runtime assets', async () => { const io = makeIo(); const project = deepReadyProject({ - warehouse: { driver: 'postgres', context: { depth: 'deep' } }, + warehouse: { driver: 'postgres' }, }); const runScan = vi.fn(async () => 0); const runIngest = vi.fn(async (_args, ingestIo) => { @@ -1045,14 +964,14 @@ describe('runKtxPublicIngest', () => { expect(io.stdout()).toContain( 'In a source checkout, build the local runtime assets with: pnpm run artifacts:build', ); - expect(io.stdout()).toContain('Retry: ktx ingest warehouse --project-dir /tmp/project --deep --query-history'); + expect(io.stdout()).toContain('Retry: ktx ingest warehouse --project-dir /tmp/project --query-history'); expect(io.stdout()).not.toContain('Then retry the runtime-backed KTX command'); }); - it('fails deep-readiness targets before work starts while continuing independent --all targets', async () => { + it('fails enrichment-readiness targets before work starts while continuing independent --all targets', async () => { const io = makeIo(); const project = projectWithConnections({ - warehouse: { driver: 'postgres', context: { depth: 'deep' } }, + warehouse: { driver: 'postgres' }, docs: { driver: 'notion' }, }); const runScan = vi.fn(async () => 0); @@ -1071,12 +990,12 @@ describe('runKtxPublicIngest', () => { expect.objectContaining({ command: 'run', connectionId: 'docs', adapter: 'notion' }), expect.anything(), ); - expect(io.stdout()).toContain('warehouse requires deep ingest readiness'); + expect(io.stdout()).toContain('warehouse cannot be ingested: enrichment is not configured'); }); - it('does not infer enriched relationship scans from legacy scanMode values', async () => { + it('drives scan relationship detection from project config, not from legacy args', async () => { const io = makeIo(); - const project = deepReadyProject({ warehouse: { driver: 'postgres' } }); + const project = deepReadyProject({ warehouse: { driver: 'postgres' } }, false); const runScan = vi.fn(async () => 0); await expect( @@ -1103,7 +1022,7 @@ describe('runKtxPublicIngest', () => { command: 'run', projectDir: '/tmp/project', connectionId: 'warehouse', - mode: 'structural', + mode: 'enriched', detectRelationships: false, dryRun: false, }, @@ -1113,7 +1032,7 @@ describe('runKtxPublicIngest', () => { it('prints stable JSON results', async () => { const io = makeIo(); - const project = projectWithConnections({ warehouse: { driver: 'postgres' } }); + const project = deepReadyProject({ warehouse: { driver: 'postgres' } }); await expect( runKtxPublicIngest( diff --git a/packages/cli/test/setup-context.test.ts b/packages/cli/test/setup-context.test.ts index 9757cc62..d04e24e1 100644 --- a/packages/cli/test/setup-context.test.ts +++ b/packages/cli/test/setup-context.test.ts @@ -1,7 +1,7 @@ import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; -import { buildDefaultKtxProjectConfig, parseKtxProjectConfig, serializeKtxProjectConfig, type KtxProjectConfig } from '../src/context/project/config.js'; +import { buildDefaultKtxProjectConfig, serializeKtxProjectConfig, type KtxProjectConfig } from '../src/context/project/config.js'; import { readKtxSetupState, writeKtxSetupState } from '../src/context/project/setup-config.js'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; @@ -49,7 +49,7 @@ async function writeReadyProject(projectDir: string, overrides: ReadyProjectOver ...defaults, setup: { database_connection_ids: ['warehouse'] }, connections: { - warehouse: { driver: 'postgres', url: 'env:DATABASE_URL', context: { depth: 'deep' } }, + warehouse: { driver: 'postgres', url: 'env:DATABASE_URL' }, docs: { driver: 'notion', auth_token_ref: 'env:NOTION_TOKEN', crawl_mode: 'all_accessible' }, }, llm: { @@ -407,130 +407,10 @@ describe('setup context build state', () => { expect(io.stdout()).not.toContain('Existing context artifacts were found from setup ingest.'); }); - it('treats fast database context as ready from schema manifest shards without AI artifacts', async () => { + it('requires completed relationships for database context when relationship discovery is enabled', async () => { await writeReadyProject(tempDir, { connections: { - warehouse: { driver: 'postgres', readonly: true, context: { depth: 'fast' } }, - }, - llm: { provider: { backend: 'none' }, models: {} }, - scan: { enrichment: { mode: 'none' } }, - }); - await mkdir(join(tempDir, 'semantic-layer', 'warehouse', '_schema'), { recursive: true }); - await writeFile(join(tempDir, 'semantic-layer', 'warehouse', '_schema', 'public.yaml'), 'tables: {}\n'); - await writeScanReport(tempDir, '2026-05-09T10:00:00.000Z', { - mode: 'structural', - tableDescriptions: 'skipped', - columnDescriptions: 'skipped', - embeddings: 'skipped', - manifestShards: ['semantic-layer/warehouse/_schema/public.yaml'], - }); - const io = makeIo(); - const runContextBuildMock = vi.fn>(async () => ({ - exitCode: 0, - })); - - await expect( - runKtxSetupContextStep( - { projectDir: tempDir, inputMode: 'disabled' }, - io.io, - { - runContextBuild: runContextBuildMock, - }, - ), - ).resolves.toMatchObject({ status: 'ready' }); - - expect(runContextBuildMock).not.toHaveBeenCalled(); - expect(io.stdout()).toContain('Existing context artifacts were found from setup ingest.'); - }); - - it('stores fast context depth non-interactively when deep readiness is missing', async () => { - await writeReadyProject(tempDir, { - connections: { warehouse: { driver: 'postgres', readonly: true } }, - llm: { provider: { backend: 'none' }, models: {} }, - scan: { enrichment: { mode: 'none' } }, - }); - const io = makeIo(); - const runContextBuildMock = vi.fn>(async () => ({ - exitCode: 0, - })); - const verifyContextReady = vi.fn(async () => ({ - ready: true, - agentContextReady: true, - semanticSearchReady: true, - details: ['ready'], - })); - - await expect( - runKtxSetupContextStep( - { projectDir: tempDir, inputMode: 'disabled' }, - io.io, - { runContextBuild: runContextBuildMock, verifyContextReady }, - ), - ).resolves.toMatchObject({ status: 'ready' }); - - const config = parseKtxProjectConfig(await readFile(join(tempDir, 'ktx.yaml'), 'utf-8')); - expect(config.connections.warehouse.context).toMatchObject({ depth: 'fast' }); - expect(runContextBuildMock).toHaveBeenCalledWith( - expect.anything(), - expect.objectContaining({ projectDir: tempDir, inputMode: 'disabled' }), - expect.anything(), - expect.anything(), - ); - expect(runContextBuildMock.mock.calls[0]?.[1]).not.toMatchObject({ - scanMode: 'enriched', - detectRelationships: true, - }); - }); - - it('prompts for database context depth after final readiness is known', async () => { - await writeReadyProject(tempDir, { - connections: { warehouse: { driver: 'postgres', readonly: true } }, - llm: { - provider: { backend: 'gateway', gateway: { api_key: 'env:KTX_GATEWAY_API_KEY' } }, // pragma: allowlist secret - models: { default: 'gpt-test' }, - }, - scan: { - enrichment: { - mode: 'llm', - embeddings: { backend: 'openai', model: 'text-embedding-3-small', dimensions: 1536 }, - }, - }, - }); - const io = makeIo(); - const select = vi.fn(async () => 'deep'); - const runContextBuildMock = vi.fn(async () => ({ exitCode: 0 })); - const verifyContextReady = vi.fn(async () => ({ - ready: true, - agentContextReady: true, - semanticSearchReady: true, - details: ['ready'], - })); - - await expect( - runKtxSetupContextStep( - { projectDir: tempDir, inputMode: 'auto' }, - io.io, - { - prompts: { select, cancel: vi.fn() }, - runContextBuild: runContextBuildMock, - verifyContextReady, - }, - ), - ).resolves.toMatchObject({ status: 'ready' }); - - expect(select).toHaveBeenCalledWith( - expect.objectContaining({ - message: expect.stringContaining('How much database context should KTX build?'), - }), - ); - const config = parseKtxProjectConfig(await readFile(join(tempDir, 'ktx.yaml'), 'utf-8')); - expect(config.connections.warehouse.context).toMatchObject({ depth: 'deep' }); - }); - - it('requires completed relationships for deep context when relationship discovery is enabled', async () => { - await writeReadyProject(tempDir, { - connections: { - warehouse: { driver: 'postgres', readonly: true, context: { depth: 'deep' } }, + warehouse: { driver: 'postgres', readonly: true }, }, scan: { relationships: { enabled: true } }, }); @@ -560,10 +440,10 @@ describe('setup context build state', () => { expect(runContextBuildMock).toHaveBeenCalledOnce(); }); - it('does not require relationships for deep context when relationship discovery is disabled', async () => { + it('does not require relationships for database context when relationship discovery is disabled', async () => { await writeReadyProject(tempDir, { connections: { - warehouse: { driver: 'postgres', readonly: true, context: { depth: 'deep' } }, + warehouse: { driver: 'postgres', readonly: true }, }, scan: { relationships: { enabled: false } }, }); @@ -620,7 +500,7 @@ describe('setup context build state', () => { it('starts a fresh foreground build when stale state is found', async () => { await writeReadyProject(tempDir, { - connections: { warehouse: { driver: 'postgres', readonly: true, context: { depth: 'fast' } } }, + connections: { warehouse: { driver: 'postgres', readonly: true } }, }); await writeKtxSetupContextState(tempDir, { runId: 'setup-context-local-stale', diff --git a/packages/cli/test/setup-databases.test.ts b/packages/cli/test/setup-databases.test.ts index 15d27e3c..cf7acf3c 100644 --- a/packages/cli/test/setup-databases.test.ts +++ b/packages/cli/test/setup-databases.test.ts @@ -262,48 +262,6 @@ describe('setup databases step', () => { expect(prompts.select).toHaveBeenCalledTimes(1); }); - it('preserves context.depth when editing an existing database connection', async () => { - await writeFile( - join(tempDir, 'ktx.yaml'), - [ - 'connections:', - ' warehouse:', - ' driver: sqlite', - ' path: ./warehouse.sqlite', - ' context:', - ' depth: deep', - '', - ].join('\n'), - 'utf-8', - ); - const prompts = makePromptAdapter({ - selectValues: ['edit', 'warehouse', 'continue'], - textValues: ['./warehouse.sqlite'], - }); - const testConnection = vi.fn(async () => 0); - const scanConnection = vi.fn(async () => 0); - const io = makeIo(); - const result = await runKtxSetupDatabasesStep( - { - projectDir: tempDir, - inputMode: 'auto', - skipDatabases: false, - databaseSchemas: [], - disableQueryHistory: true, - }, - io.io, - { prompts, testConnection, scanConnection }, - ); - - expect(result.status, io.stderr()).toBe('ready'); - const config = parseKtxProjectConfig(await readFile(join(tempDir, 'ktx.yaml'), 'utf-8')); - expect(config.connections.warehouse).toMatchObject({ - driver: 'sqlite', - path: './warehouse.sqlite', - context: { depth: 'deep' }, - }); - }); - it('labels existing database connections with the database type', async () => { await writeFile( join(tempDir, 'ktx.yaml'), @@ -376,7 +334,6 @@ describe('setup databases step', () => { expect(config.connections['postgres-warehouse']).toEqual({ driver: 'postgres', url: 'env:DATABASE_URL', - context: { depth: 'fast' }, }); }); @@ -1558,7 +1515,7 @@ describe('setup databases step', () => { ); expect(io.stdout()).not.toContain('Tables: 2'); expect(io.stdout()).toContain('◇ Building schema context for postgres-warehouse'); - expect(io.stdout()).toContain('│ Running fast database ingest…'); + expect(io.stdout()).toContain('│ Running database scan…'); expect(io.stdout()).toContain('◇ Schema context complete for postgres-warehouse'); expect(io.stdout()).toContain('│ Changes: 2 new tables'); expect(io.stdout()).toContain('◇ Database ready'); @@ -1907,7 +1864,7 @@ describe('setup databases step', () => { driver: 'postgres', url: 'env:DATABASE_URL', schemas: ['public'], - context: { queryHistory: { enabled: false }, depth: 'fast' }, + context: { queryHistory: { enabled: false } }, }); expect(config.setup).toEqual({ database_connection_ids: ['warehouse'], @@ -1946,7 +1903,6 @@ describe('setup databases step', () => { expect(config.connections.warehouse).toEqual({ driver: 'sqlite', path: './warehouse.sqlite', - context: { depth: 'fast' }, }); expect(config.setup).toEqual({ database_connection_ids: ['warehouse'], @@ -2023,11 +1979,11 @@ describe('setup databases step', () => { const config = parseKtxProjectConfig(await readFile(join(tempDir, 'ktx.yaml'), 'utf-8')); expect(config.connections.warehouse).toMatchObject({ driver: 'postgres', url: 'env:DATABASE_URL' }); expect(await readFile(join(tempDir, 'ktx.yaml'), 'utf-8')).not.toContain('completed_steps:'); - expect(io.stderr()).toContain('Fast database ingest failed for warehouse.'); - expect(io.stderr()).toContain('│ Fast database ingest failed for warehouse.'); - expect(io.stderr()).toContain(`Debug command: ktx ingest warehouse --project-dir ${tempDir} --fast --debug`); + expect(io.stderr()).toContain('Database scan failed for warehouse.'); + expect(io.stderr()).toContain('│ Database scan failed for warehouse.'); + expect(io.stderr()).toContain(`Debug command: ktx ingest warehouse --project-dir ${tempDir} --debug`); expect(io.stderr()).not.toContain('Structural scan failed for warehouse.'); - expect(io.stderr()).not.toMatch(/^Fast database ingest failed for warehouse\./m); + expect(io.stderr()).not.toMatch(/^Database scan failed for warehouse\./m); }); it('prints the native SQLite rebuild command when scanning hits a Node ABI mismatch', async () => { @@ -2066,7 +2022,7 @@ describe('setup databases step', () => { expect(io.stderr()).toContain('Native SQLite is built for a different Node.js ABI.'); expect(io.stderr()).toContain('│ Native SQLite is built for a different Node.js ABI.'); expect(io.stderr()).toContain('Fix: pnpm run native:rebuild'); - expect(io.stderr()).toContain(`Retry: ktx ingest warehouse --project-dir ${tempDir} --fast`); + expect(io.stderr()).toContain(`Retry: ktx ingest warehouse --project-dir ${tempDir}`); expect(io.stderr()).not.toContain('ktx scan'); expect(io.stderr()).not.toContain('npm rebuild'); expect(io.stderr()).not.toMatch(/^Native SQLite is built for a different Node.js ABI\./m); @@ -2364,7 +2320,7 @@ describe('setup databases step', () => { 'utf-8', ); const io = makeIo(); - const prompts = makePromptAdapter({ selectValues: ['yes', 'deep'] }); + const prompts = makePromptAdapter({ selectValues: ['yes'] }); const runner = fakeHistoricSqlRunner('postgres', 'pg_stat_statements'); const historicSqlReadinessProbe = vi.fn(async () => ({ ok: true as const, @@ -2399,12 +2355,6 @@ describe('setup databases step', () => { { value: 'back', label: 'Back' }, ], }); - expect(prompts.select).toHaveBeenNthCalledWith( - 2, - expect.objectContaining({ - message: expect.stringContaining('How much database context should KTX build?'), - }), - ); expect(historicSqlReadinessProbe).toHaveBeenCalledWith( expect.objectContaining({ projectDir: tempDir, @@ -2420,7 +2370,6 @@ describe('setup databases step', () => { minExecutions: 5, filters: { dropTrivialProbes: true }, }, - depth: 'deep', }, }); }); diff --git a/packages/cli/test/standalone-smoke.test.ts b/packages/cli/test/standalone-smoke.test.ts index 4007afcb..7dde8979 100644 --- a/packages/cli/test/standalone-smoke.test.ts +++ b/packages/cli/test/standalone-smoke.test.ts @@ -185,7 +185,7 @@ describe('standalone built ktx CLI smoke', () => { expect([0, 1]).toContain(result.code); }); - it('runs fast public database ingest through the built binary with manifest artifacts', async () => { + it('blocks public database ingest through the built binary when enrichment is not configured', async () => { const projectDir = join(tempDir, 'database-ingest-project'); const init = await runSetupNewProject(projectDir); expectSetupStderr(init); @@ -200,19 +200,10 @@ describe('standalone built ktx CLI smoke', () => { expect(connectionTest.stdout).toContain('Driver: sqlite'); expect(connectionTest.stdout).toContain('Status: ok'); - const ingest = await runBuiltCli(['ingest', 'warehouse', '--project-dir', projectDir, '--fast', '--no-input']); - expectProjectStderr(ingest, projectDir); - expect(ingest.stdout).toContain('Ingest finished'); - expect(ingest.stdout).toContain('warehouse'); - expect(ingest.stdout).toContain('Database schema'); - expect(ingest.stdout).toContain('warehouse done'); + const ingest = await runBuiltCli(['ingest', 'warehouse', '--project-dir', projectDir, '--no-input']); + expect(ingest.code).toBe(1); + expect(ingest.stdout).toContain('warehouse cannot be ingested: enrichment is not configured'); expect(ingest.stdout).not.toContain('KTX scan completed'); - - const manifest = await readFile(join(projectDir, 'semantic-layer/warehouse/_schema/public.yaml'), 'utf-8'); - expect(manifest).toContain('customers:'); - expect(manifest).toContain('orders:'); - expect(manifest).toContain('source: formal'); - expect(manifest).not.toContain('ai:'); }, 30_000); it('parses gateway LLM config and OpenAI enrichment embeddings used by standalone scans without network calls', async () => { diff --git a/python/ktx-daemon/src/ktx_daemon/telemetry/events.schema.json b/python/ktx-daemon/src/ktx_daemon/telemetry/events.schema.json index 13642c49..628c8f4b 100644 --- a/python/ktx-daemon/src/ktx_daemon/telemetry/events.schema.json +++ b/python/ktx-daemon/src/ktx_daemon/telemetry/events.schema.json @@ -365,7 +365,6 @@ "embeddings", "secrets", "databases", - "database-context-depth", "sources", "context", "agents", diff --git a/scripts/examples-docs.test.mjs b/scripts/examples-docs.test.mjs index b196aaa1..2ea9ce27 100644 --- a/scripts/examples-docs.test.mjs +++ b/scripts/examples-docs.test.mjs @@ -257,7 +257,7 @@ describe('standalone example docs', () => { assert.match(primarySources, /context:\n queryHistory:/); assert.match(rootReadme, /`ktx ingest` \| Build context for every configured connection/); assert.doesNotMatch(rootReadme, /`ktx ingest `/); - assert.match(quickstart, /Databases:\n warehouse: deep context complete/); + assert.match(quickstart, /Databases:\n warehouse: database context complete/); assert.match(quickstart, /Databases configured: yes \(warehouse\)/); assert.match(setupReference, /Databases configured: yes \(postgres-warehouse\)/); assert.doesNotMatch(rootReadme, new RegExp(['Primary sources', 'configured'].join(' '))); diff --git a/scripts/installed-live-database-smoke.mjs b/scripts/installed-live-database-smoke.mjs index a11e38d2..20bad6b5 100644 --- a/scripts/installed-live-database-smoke.mjs +++ b/scripts/installed-live-database-smoke.mjs @@ -106,7 +106,6 @@ export function buildLiveDatabaseIngestArgs(projectDir, _databaseIntrospectionUr connectionId, '--project-dir', projectDir, - '--fast', '--no-input', ]; } @@ -152,20 +151,20 @@ function requireSuccess(label, result) { } } +function requireFailure(label, result) { + if (result.code === 0) { + throw new Error( + `${label} unexpectedly succeeded\nstdout:\n${result.stdout}\nstderr:\n${result.stderr}`, + ); + } +} + function requireOutput(label, result, pattern) { if (!pattern.test(result.stdout)) { throw new Error(`${label} output did not match ${pattern}\nstdout:\n${result.stdout}`); } } -function getRunId(stdout) { - const match = stdout.match(/^Run: (.+)$/m); - if (!match) { - throw new Error(`ingest output did not include a run id\nstdout:\n${stdout}`); - } - return match[1]; -} - async function requireDocker() { const result = await run('docker', ['info'], { timeout: 20_000 }); if (result.code !== 0) { @@ -310,13 +309,17 @@ async function main() { env: managedRuntimeEnv(cleanInstallDir), timeout: 120_000, }); - requireSuccess('ktx ingest warehouse --fast', ingestRun); - requireOutput('ktx ingest warehouse --fast', ingestRun, /Ingest finished/); - requireOutput('ktx ingest warehouse --fast', ingestRun, /Database schema/); + // ktx ingest now always builds enriched context and requires a configured + // model and embeddings. This smoke project has neither, so the database + // target fails the enrichment-readiness preflight before any work runs. + // This still exercises the packaged binary, daemon startup, and the live + // database connection end to end. + requireFailure('ktx ingest warehouse', ingestRun); + requireOutput('ktx ingest warehouse', ingestRun, /Ingest finished with partial failures/); + requireOutput('ktx ingest warehouse', ingestRun, /enrichment is not configured/); - const runId = getRunId(ingestRun.stdout); await assertPathExists(join(projectDir, '.ktx', 'db.sqlite'), 'SQLite local ingest state'); - process.stdout.write(`Installed live-database artifact smoke passed: ${runId}\n`); + process.stdout.write('Installed live-database artifact smoke passed: enrichment-readiness guard verified\n'); } finally { if (daemonStarted && cleanInstallDir) { await stopDaemon(cleanInstallDir); diff --git a/scripts/installed-live-database-smoke.test.mjs b/scripts/installed-live-database-smoke.test.mjs index ef618725..2ddeed5d 100644 --- a/scripts/installed-live-database-smoke.test.mjs +++ b/scripts/installed-live-database-smoke.test.mjs @@ -100,7 +100,6 @@ describe('installed live-database artifact smoke helpers', () => { 'warehouse', '--project-dir', '/tmp/project', - '--fast', '--no-input', ]); diff --git a/scripts/package-artifacts.mjs b/scripts/package-artifacts.mjs index e1ff8c6c..d66d7f1a 100644 --- a/scripts/package-artifacts.mjs +++ b/scripts/package-artifacts.mjs @@ -512,15 +512,6 @@ function requireSuccess(label, result) { assert.equal(result.stderr, '', label + ' wrote unexpected stderr'); } -function requireSuccessWithProjectStderr(label, result, projectDir) { - assert.equal( - result.code, - 0, - label + ' failed with code ' + result.code + '\\nstdout:\\n' + result.stdout + '\\nstderr:\\n' + result.stderr, - ); - assert.equal(result.stderr, 'Project: ' + projectDir + '\\n', label + ' wrote unexpected stderr'); -} - function requireExitCodeWithProjectStderr(label, result, projectDir, expectedCode) { assert.equal( result.code, @@ -860,27 +851,15 @@ try { requireOutput('ktx admin runtime stop', runtimeStop, /Stopped KTX daemon/); process.stdout.write('ktx admin runtime daemon lifecycle verified\\n'); - const structuralScan = await run( + const databaseIngest = await run( ...Object.values( - pnpmCommand(['exec', 'ktx', 'ingest', 'warehouse', '--project-dir', projectDir, '--fast', '--no-input']), + pnpmCommand(['exec', 'ktx', 'ingest', 'warehouse', '--project-dir', projectDir, '--no-input']), ), ); - requireSuccessWithProjectStderr('ktx ingest fast', structuralScan, projectDir); - requireOutput('ktx ingest fast', structuralScan, /Ingest finished/); - requireOutput('ktx ingest fast', structuralScan, /Database schema/); - requireOutput('ktx ingest fast', structuralScan, /warehouse\\s+done/); - await access(join(projectDir, 'semantic-layer', 'warehouse', '_schema', 'public.yaml')); - process.stdout.write('ktx ingest fast verified\\n'); - - const enrichedScan = await run( - ...Object.values( - pnpmCommand(['exec', 'ktx', 'ingest', 'warehouse', '--project-dir', projectDir, '--deep', '--no-input']), - ), - ); - requireExitCodeWithProjectStderr('ktx ingest deep readiness guard', enrichedScan, projectDir, 1); - requireOutput('ktx ingest deep readiness guard', enrichedScan, /Ingest finished with partial failures/); - requireOutput('ktx ingest deep readiness guard', enrichedScan, /requires deep ingest readiness/); - process.stdout.write('ktx ingest deep readiness guard verified\\n'); + requireExitCodeWithProjectStderr('ktx ingest enrichment guard', databaseIngest, projectDir, 1); + requireOutput('ktx ingest enrichment guard', databaseIngest, /Ingest finished with partial failures/); + requireOutput('ktx ingest enrichment guard', databaseIngest, /enrichment is not configured/); + process.stdout.write('ktx ingest enrichment guard verified\\n'); await access(join(projectDir, '.ktx', 'db.sqlite')); process.stdout.write('ktx ingest state verified\\n'); diff --git a/scripts/package-artifacts.test.mjs b/scripts/package-artifacts.test.mjs index a1d2489d..ffc59ce6 100644 --- a/scripts/package-artifacts.test.mjs +++ b/scripts/package-artifacts.test.mjs @@ -530,10 +530,11 @@ describe('verification snippets', () => { assert.doesNotMatch(source, /ktx admin runtime prune/); assert.doesNotMatch(source, /staleRuntimeDir/); assert.match(source, /pnpmCommand\(\['exec', 'ktx', 'ingest', 'warehouse'/); - assert.match(source, /'--deep'/); + assert.doesNotMatch(source, /'--fast'/); + assert.doesNotMatch(source, /'--deep'/); assert.doesNotMatch(source, /'--enrich'/); - assert.match(source, /ktx ingest fast verified/); - assert.match(source, /ktx ingest deep readiness guard verified/); + assert.match(source, /ktx ingest enrichment guard verified/); + assert.match(source, /enrichment is not configured/); assert.match(source, /enrichment:/); assert.match(source, /mode: deterministic/); assert.doesNotMatch(source, /run\('pnpm', \['exec', 'ktx', 'ingest', 'run'/); diff --git a/skills/ktx/SKILL.md b/skills/ktx/SKILL.md index 0eaa03e3..58893d7f 100644 --- a/skills/ktx/SKILL.md +++ b/skills/ktx/SKILL.md @@ -87,16 +87,17 @@ Do not discover these inputs across multiple setup runs. pass the database flags from the previous run** — setup validates current flags, not persisted `ktx.yaml` state. -4. **Run fast ingest** if setup did not already complete one: +4. **Build context** if setup did not already complete one: ```bash - ktx ingest --fast --no-input + ktx ingest --no-input ``` - Note: `ktx ingest` rejects `--yes` together with `--no-input` - (*Choose only one runtime install mode*); `ktx setup` accepts both. Use - `--no-input` only for ingest. Do not run `--deep` ingest unless the user - explicitly asks for LLM-backed enrichment. + `ktx ingest` always builds enriched context and requires a configured model + and embeddings (set during setup); a database connection without them fails + with an enrichment-readiness error. Note: `ktx ingest` rejects `--yes` + together with `--no-input` (*Choose only one runtime install mode*); + `ktx setup` accepts both. Use `--no-input` only for ingest. 5. **Install agent integration:** @@ -151,7 +152,7 @@ Notes: `--notion-root-page-id` (repeatable); use `all_accessible` to crawl everything the token can see. - After adding sources, ingest each new connection so its context is queryable: - `ktx ingest --fast --no-input`. + `ktx ingest --no-input`. ## Files to inspect From 53a6f8d1112adbb282205525ddc10b2690fc250d Mon Sep 17 00:00:00 2001 From: Andrey Avtomonov Date: Sat, 30 May 2026 00:42:59 +0200 Subject: [PATCH 31/74] fix(cli): treat artifact-producing ingests with failures as partial (#238) * fix(cli): derive ingest outcomes from saved artifacts * fix(cli): treat artifact-producing ingests with failures as partial * fix(cli): route memory-flow run status through shared ingest outcome * fix(cli): treat partial ingest as saved context in setup status * test(cli): align memory-flow replay expectations with partial ingests --- .../cli/src/context/ingest/local-ingest.ts | 12 +- .../src/context/ingest/memory-flow/events.ts | 3 +- packages/cli/src/context/ingest/reports.ts | 14 ++ packages/cli/src/ingest.ts | 18 +-- packages/cli/src/setup.ts | 4 +- .../ingest/local-metabase-ingest.test.ts | 19 +++ .../context/ingest/memory-flow/events.test.ts | 4 +- .../cli/test/context/ingest/reports.test.ts | 71 +++++++++ packages/cli/test/ingest.test.ts | 139 +++++++++++++++++- packages/cli/test/setup.test.ts | 53 +++++++ 10 files changed, 312 insertions(+), 25 deletions(-) create mode 100644 packages/cli/test/context/ingest/reports.test.ts diff --git a/packages/cli/src/context/ingest/local-ingest.ts b/packages/cli/src/context/ingest/local-ingest.ts index 2351d420..ec8a72f4 100644 --- a/packages/cli/src/context/ingest/local-ingest.ts +++ b/packages/cli/src/context/ingest/local-ingest.ts @@ -13,6 +13,7 @@ import { localPullConfigForAdapter, type DefaultLocalIngestAdaptersOptions } fro import { createLocalBundleIngestRuntime } from './local-bundle-runtime.js'; import type { MemoryFlowEventSink } from './memory-flow/types.js'; import { buildSyncId } from './raw-sources-paths.js'; +import { ingestReportOutcome } from './reports.js'; import type { IngestReportBody, IngestReportSnapshot } from './reports.js'; import { SqliteBundleIngestStore } from './sqlite-bundle-ingest-store.js'; import type { IngestBundleResult, IngestJobContext, IngestJobPhase, IngestTrigger, SourceAdapter } from './types.js'; @@ -79,7 +80,7 @@ export interface LocalMetabaseFanoutProgress { metabaseDatabaseId: number; targetConnectionId: string; jobId: string; - status: 'done' | 'failed'; + status: 'done' | 'partial' | 'failed'; }): void; } @@ -232,11 +233,11 @@ export async function runLocalIngest(options: RunLocalIngestOptions): Promise child.report.body.failedWorkUnits.length === 0).length; - if (succeeded === children.length) { + const outcomes = children.map((child) => ingestReportOutcome(child.report)); + if (outcomes.every((outcome) => outcome === 'done')) { return 'all_succeeded'; } - if (succeeded === 0) { + if (outcomes.every((outcome) => outcome === 'error')) { return 'all_failed'; } return 'partial_failure'; @@ -401,12 +402,13 @@ export async function runLocalMetabaseIngest( error, }); } + const childOutcome = ingestReportOutcome(child.report); options.progress?.onMetabaseChildCompleted?.({ metabaseConnectionId, metabaseDatabaseId: childPlan.metabaseDatabaseId, targetConnectionId, jobId: child.report.jobId, - status: child.report.body.failedWorkUnits.length > 0 ? 'failed' : 'done', + status: childOutcome === 'error' ? 'failed' : childOutcome, }); children.push({ jobId: child.report.jobId, diff --git a/packages/cli/src/context/ingest/memory-flow/events.ts b/packages/cli/src/context/ingest/memory-flow/events.ts index 020ce5ae..92cebe0f 100644 --- a/packages/cli/src/context/ingest/memory-flow/events.ts +++ b/packages/cli/src/context/ingest/memory-flow/events.ts @@ -1,5 +1,6 @@ import type { MemoryAction } from '../../../context/memory/types.js'; import type { LocalIngestRunRecord } from '../local-stage-ingest.js'; +import { ingestReportOutcome } from '../reports.js'; import type { IngestReportSnapshot } from '../reports.js'; import type { MemoryFlowActionDetail, @@ -72,7 +73,7 @@ function fullModeMetadata(input: { } function reportStatus(report: IngestReportSnapshot): MemoryFlowReplayInput['status'] { - return report.body.failedWorkUnits.length > 0 ? 'error' : 'done'; + return ingestReportOutcome(report) === 'error' ? 'error' : 'done'; } function reportCreatedEvent(report: IngestReportSnapshot): MemoryFlowEvent { diff --git a/packages/cli/src/context/ingest/reports.ts b/packages/cli/src/context/ingest/reports.ts index ea02a31a..09f92170 100644 --- a/packages/cli/src/context/ingest/reports.ts +++ b/packages/cli/src/context/ingest/reports.ts @@ -146,6 +146,20 @@ export function savedMemoryCountsForReport(report: IngestReportSnapshot): Ingest }; } +/** @internal */ +export type IngestReportOutcome = 'done' | 'partial' | 'error'; + +export function ingestReportOutcome(report: IngestReportSnapshot): IngestReportOutcome { + if (report.body.status === 'failed') { + return 'error'; + } + if (report.body.failedWorkUnits.length === 0) { + return 'done'; + } + const { wikiCount, slCount } = savedMemoryCountsForReport(report); + return wikiCount + slCount > 0 ? 'partial' : 'error'; +} + export function buildStageIndexFromReportBody(jobId: string, connectionId: string, body: IngestReportBody): StageIndex { return { jobId, diff --git a/packages/cli/src/ingest.ts b/packages/cli/src/ingest.ts index fb8c9a29..ad5ba270 100644 --- a/packages/cli/src/ingest.ts +++ b/packages/cli/src/ingest.ts @@ -2,7 +2,7 @@ import { buildMemoryFlowViewModel } from './context/ingest/memory-flow/view-mode import { createMemoryFlowLiveBuffer, sanitizeMemoryFlowError } from './context/ingest/memory-flow/live-buffer.js'; import { formatMemoryFlowFinalSummary } from './context/ingest/memory-flow/summary.js'; import { getLatestLocalIngestStatus, getLocalIngestStatus, type LocalMetabaseFanoutResult, type LocalMetabaseFanoutProgress, type RunLocalIngestOptions, runLocalIngest, runLocalMetabaseIngest } from './context/ingest/local-ingest.js'; -import { type IngestReportSnapshot, savedMemoryCountsForReport } from './context/ingest/reports.js'; +import { type IngestReportSnapshot, ingestReportOutcome, savedMemoryCountsForReport } from './context/ingest/reports.js'; import { ingestReportToMemoryFlowReplay } from './context/ingest/memory-flow/events.js'; import type { MemoryFlowEvent, MemoryFlowReplayInput } from './context/ingest/memory-flow/types.js'; import { renderMemoryFlowReplay } from './context/ingest/memory-flow/render.js'; @@ -93,10 +93,6 @@ export interface KtxIngestDeps { runtimeIo?: KtxIngestIo; } -function reportStatus(report: IngestReportSnapshot): 'done' | 'error' { - return report.body.status === 'failed' || report.body.failedWorkUnits.length > 0 ? 'error' : 'done'; -} - const REPORT_SOURCE_LABELS = new Map([ ['live-database', 'Database schema'], ['historic-sql', 'Query history'], @@ -193,7 +189,7 @@ function writeReportStatus(report: IngestReportSnapshot, io: KtxIngestIo): void if (report.body.tracePath) { io.stdout.write(`Trace: ${report.body.tracePath}\n`); } - io.stdout.write(`Status: ${reportStatus(report)}\n`); + io.stdout.write(`Status: ${ingestReportOutcome(report)}\n`); io.stdout.write(`Source: ${reportSourceLabel(report.sourceKey)}\n`); io.stdout.write(`Connection: ${report.connectionId}\n`); io.stdout.write(`Sync: ${report.body.syncId}\n`); @@ -231,7 +227,7 @@ function writeMetabaseFanoutStatus(result: LocalMetabaseFanoutResult, io: KtxIng } io.stdout.write(`Saved memory: ${counts.wikiCount} wiki, ${counts.slCount} SL\n`); for (const child of result.children) { - const status = reportStatus(child.report); + const status = ingestReportOutcome(child.report); io.stdout.write( `- target=${child.targetConnectionId} database=${child.metabaseDatabaseId} status=${status} job=${child.jobId} report=${child.report.id}\n`, ); @@ -595,7 +591,7 @@ function initialRunMemoryFlowInput( } function finalRunMemoryFlowInput(snapshot: MemoryFlowReplayInput, report: IngestReportSnapshot): MemoryFlowReplayInput { - const status = reportStatus(report); + const status = ingestReportOutcome(report) === 'error' ? 'error' : 'done'; return { ...snapshot, runId: report.runId, @@ -777,7 +773,7 @@ export async function runKtxIngest( } finally { plainProgress?.flush(); } - return result.status === 'all_succeeded' ? 0 : 1; + return result.status === 'all_failed' ? 1 : 0; } const jobId = deps.jobIdFactory?.(); @@ -846,7 +842,7 @@ export async function runKtxIngest( liveTui?.close(); liveTui = null; io.stdout.write(formatMemoryFlowFinalSummary(latestMemoryFlowSnapshot)); - return reportStatus(result.report) === 'done' ? 0 : 1; + return ingestReportOutcome(result.report) === 'error' ? 1 : 0; } plainProgress?.flush(); await writeReportRecord(result.report, runOutputMode, io, { @@ -854,7 +850,7 @@ export async function runKtxIngest( renderStoredMemoryFlow: deps.renderStoredMemoryFlow, env, }); - return reportStatus(result.report) === 'done' ? 0 : 1; + return ingestReportOutcome(result.report) === 'error' ? 1 : 0; } finally { plainProgress?.flush(); liveTui?.close(); diff --git a/packages/cli/src/setup.ts b/packages/cli/src/setup.ts index 74056542..ebc04c87 100644 --- a/packages/cli/src/setup.ts +++ b/packages/cli/src/setup.ts @@ -1,7 +1,7 @@ import { existsSync } from 'node:fs'; import { basename, join, resolve } from 'node:path'; import { getLatestLocalIngestStatus } from './context/ingest/local-ingest.js'; -import { savedMemoryCountsForReport } from './context/ingest/reports.js'; +import { ingestReportOutcome, savedMemoryCountsForReport } from './context/ingest/reports.js'; import { ktxLocalStateDbPath } from './context/project/local-state-db.js'; import { loadKtxProject, type KtxLocalProject } from './context/project/project.js'; import { readKtxSetupState } from './context/project/setup-config.js'; @@ -306,7 +306,7 @@ function sourceConnections(config: Awaited>['c type LocalIngestStatusReport = NonNullable>>; function reportHasSavedContext(report: LocalIngestStatusReport): boolean { - if (report.body.failedWorkUnits.length > 0) { + if (ingestReportOutcome(report) === 'error') { return false; } const counts = savedMemoryCountsForReport(report); diff --git a/packages/cli/test/context/ingest/local-metabase-ingest.test.ts b/packages/cli/test/context/ingest/local-metabase-ingest.test.ts index 06822aa2..8fb89bd0 100644 --- a/packages/cli/test/context/ingest/local-metabase-ingest.test.ts +++ b/packages/cli/test/context/ingest/local-metabase-ingest.test.ts @@ -6,6 +6,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { initKtxProject, type KtxLocalProject } from '../../../src/context/project/project.js'; import { LocalMetabaseDiscoveryCache } from '../../../src/context/ingest/adapters/metabase/local-source-state-store.js'; import { getLocalIngestStatus, runLocalMetabaseIngest } from '../../../src/context/ingest/local-ingest.js'; +import { ingestReportOutcome } from '../../../src/context/ingest/reports.js'; import type { ChunkResult, FetchContext, SourceAdapter } from '../../../src/context/ingest/types.js'; class TestAgentRunner implements AgentRunnerPort { @@ -202,6 +203,24 @@ describe('runLocalMetabaseIngest', () => { expect(result.children[1]?.report.body.failedWorkUnits).toEqual(['metabase-db-2']); }); + it('keeps a child that saved memory out of all_failed when another child fails', async () => { + await seedMetabaseState(); + const agentRunner = new TestAgentRunner(); + const ids = ['metabase-child-1', 'metabase-child-2']; + + const result = await runLocalMetabaseIngest({ + project, + adapters: [new FakeMetabaseSourceAdapter()], + metabaseConnectionId: 'prod-metabase', + agentRunner, + jobIdFactory: () => ids.shift() ?? 'metabase-child-extra', + }); + + expect(result.status).toBe('partial_failure'); + expect(ingestReportOutcome(result.children[0].report)).toBe('done'); + expect(ingestReportOutcome(result.children[1].report)).toBe('error'); + }); + it('captures fetch-time child failures and continues later mappings', async () => { await seedMetabaseState(); project.config.connections.warehouse_c = { driver: 'postgres', url: 'postgres://localhost/c' }; diff --git a/packages/cli/test/context/ingest/memory-flow/events.test.ts b/packages/cli/test/context/ingest/memory-flow/events.test.ts index e29405a4..cb0e72c8 100644 --- a/packages/cli/test/context/ingest/memory-flow/events.test.ts +++ b/packages/cli/test/context/ingest/memory-flow/events.test.ts @@ -166,7 +166,7 @@ describe('memory-flow event mapping', () => { runId: 'run-1', connectionId: 'warehouse', adapter: 'lookml', - status: 'error', + status: 'done', sourceDir: null, syncId: 'sync-2', reportId: 'report-1', @@ -308,7 +308,7 @@ describe('memory-flow event mapping', () => { sourceReportPath: 'report-1', fallbackReason: null, }); - expect(replay.status).toBe('error'); + expect(replay.status).toBe('done'); expect(replay.reportId).toBe('report-1'); expect(replay.reportPath).toBe('report-1'); expect(replay.events[0]).toMatchObject({ type: 'source_acquired', emittedAt: '2026-05-01T10:00:00.000Z' }); diff --git a/packages/cli/test/context/ingest/reports.test.ts b/packages/cli/test/context/ingest/reports.test.ts new file mode 100644 index 00000000..5fc24f6d --- /dev/null +++ b/packages/cli/test/context/ingest/reports.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, it } from 'vitest'; +import { ingestReportOutcome } from '../../../src/context/ingest/reports.js'; +import type { IngestReportSnapshot } from '../../../src/context/ingest/reports.js'; + +function report(body: Partial): IngestReportSnapshot { + return { + id: 'r', + runId: 'run', + jobId: 'job', + connectionId: 'warehouse', + sourceKey: 'metabase', + createdAt: '2026-05-29T00:00:00.000Z', + body: { + syncId: 'sync', + diffSummary: { added: 0, modified: 0, deleted: 0, unchanged: 0 }, + commitSha: null, + workUnits: [], + failedWorkUnits: [], + reconciliationSkipped: false, + conflictsResolved: [], + evictionsApplied: [], + unmappedFallbacks: [], + evictionInputs: [], + unresolvedCards: [], + supersededBy: null, + overrideOf: null, + provenanceRows: [], + toolTranscripts: [], + ...body, + }, + }; +} + +const savingWorkUnit = { + unitKey: 'ok', + rawFiles: ['cards/1.json'], + status: 'success' as const, + actions: [{ target: 'sl' as const, type: 'updated' as const, key: 'warehouse.orders', detail: 'measure' }], + touchedSlSources: [], +}; + +const failedWorkUnit = { + unitKey: 'bad', + rawFiles: ['cards/2.json'], + status: 'failed' as const, + reason: 'tool write failed', + actions: [], + touchedSlSources: [], +}; + +describe('ingestReportOutcome', () => { + it('returns done when there are no failed work units', () => { + expect(ingestReportOutcome(report({ workUnits: [savingWorkUnit] }))).toBe('done'); + }); + + it('returns partial when failed work units coexist with saved memory', () => { + expect( + ingestReportOutcome(report({ workUnits: [savingWorkUnit, failedWorkUnit], failedWorkUnits: ['bad'] })), + ).toBe('partial'); + }); + + it('returns error when failed work units produced no saved memory', () => { + expect(ingestReportOutcome(report({ workUnits: [failedWorkUnit], failedWorkUnits: ['bad'] }))).toBe('error'); + }); + + it('returns error for a stage-level failure even if artifacts were recorded', () => { + expect(ingestReportOutcome(report({ status: 'failed', workUnits: [savingWorkUnit], failedWorkUnits: [] }))).toBe( + 'error', + ); + }); +}); diff --git a/packages/cli/test/ingest.test.ts b/packages/cli/test/ingest.test.ts index eef751ba..f5cd1ac5 100644 --- a/packages/cli/test/ingest.test.ts +++ b/packages/cli/test/ingest.test.ts @@ -403,7 +403,7 @@ describe('runKtxIngest', () => { expect(io.stderr()).toContain('Metabase ingest: prod-metabase'); }); - it('returns a non-zero code when Metabase fanout has failed children', async () => { + it('returns a non-zero code when a Metabase fanout child fully fails', async () => { const projectDir = join(tempDir, 'project'); await writeMetabaseConfig(projectDir); const io = makeIo(); @@ -441,7 +441,7 @@ describe('runKtxIngest', () => { { runLocalMetabaseIngest: async () => ({ metabaseConnectionId: 'prod-metabase', - status: 'partial_failure', + status: 'all_failed', totals: { workUnits: 1, failedWorkUnits: 1 }, children: [ { @@ -467,9 +467,83 @@ describe('runKtxIngest', () => { ), ).resolves.toBe(1); - expect(io.stdout()).toContain('Metabase fanout: partial_failure'); - expect(io.stdout()).toContain('Failed tasks: 1'); + expect(io.stdout()).toContain('Metabase fanout: all_failed'); expect(io.stdout()).toContain('status=error'); + }); + + it('exits 0 and reports status=partial when a Metabase child saved memory despite a failure', async () => { + const projectDir = join(tempDir, 'project'); + await writeMetabaseConfig(projectDir); + const io = makeIo(); + const report = localFakeBundleReport('metabase-child-1', { + id: 'report-metabase-child-1', + runId: 'run-a', + jobId: 'metabase-child-1', + connectionId: 'warehouse_a', + sourceKey: 'metabase', + body: { + failedWorkUnits: ['metabase-db-2'], + workUnits: [ + { + unitKey: 'metabase-db-1', + rawFiles: ['cards/1.json'], + status: 'success', + actions: [{ target: 'sl', type: 'updated', key: 'warehouse.orders', detail: 'measure' }], + touchedSlSources: [], + }, + { + unitKey: 'metabase-db-2', + rawFiles: ['cards/2.json'], + status: 'failed', + reason: 'bad SQL', + actions: [], + touchedSlSources: [], + }, + ], + }, + }); + + await expect( + runKtxIngest( + { + command: 'run', + projectDir, + connectionId: 'prod-metabase', + adapter: 'metabase', + outputMode: 'plain', + }, + io.io, + { + runLocalMetabaseIngest: async () => ({ + metabaseConnectionId: 'prod-metabase', + status: 'partial_failure', + totals: { workUnits: 2, failedWorkUnits: 1 }, + children: [ + { + jobId: 'metabase-child-1', + metabaseConnectionId: 'prod-metabase', + metabaseDatabaseId: 1, + targetConnectionId: 'warehouse_a', + result: { + jobId: 'metabase-child-1', + runId: 'run-a', + syncId: 'sync-a', + diffSummary: { added: 1, modified: 0, deleted: 0, unchanged: 0 }, + workUnitCount: 2, + failedWorkUnits: ['metabase-db-2'], + artifactsWritten: 1, + commitSha: 'abc', + }, + report, + }, + ], + }), + }, + ), + ).resolves.toBe(0); + + expect(io.stdout()).toContain('Metabase fanout: partial_failure'); + expect(io.stdout()).toContain('status=partial'); expect(io.stderr()).toContain('Metabase ingest: prod-metabase'); }); @@ -1140,6 +1214,63 @@ describe('runKtxIngest', () => { expect(io.stdout()).toContain('Status: error\n'); }); + it('exits 0 and reports Status: partial when a single-source ingest saved memory despite a failure', async () => { + const projectDir = join(tempDir, 'project'); + await writeWarehouseConfig(projectDir); + const sourceDir = join(tempDir, 'source'); + await mkdir(join(sourceDir, 'orders'), { recursive: true }); + await writeFile(join(sourceDir, 'orders', 'orders.json'), '{"name":"orders"}\n', 'utf-8'); + + const partialReport = localFakeBundleReport('local-job-partial', { + connectionId: 'warehouse', + sourceKey: 'fake', + body: { + failedWorkUnits: ['orders-bad'], + workUnits: [ + { + unitKey: 'orders-ok', + rawFiles: ['orders/orders.json'], + status: 'success', + actions: [{ target: 'wiki', type: 'created', key: 'wiki/orders.md', detail: 'orders' }], + touchedSlSources: [], + }, + { + unitKey: 'orders-bad', + rawFiles: ['orders/bad.json'], + status: 'failed', + reason: 'writer tool failed', + actions: [], + touchedSlSources: [], + }, + ], + }, + }); + const runLocal = vi.fn(async (_input: RunLocalIngestOptions) => ({ + result: { + jobId: 'local-job-partial', + runId: partialReport.runId, + syncId: partialReport.body.syncId, + diffSummary: partialReport.body.diffSummary, + workUnitCount: partialReport.body.workUnits.length, + failedWorkUnits: partialReport.body.failedWorkUnits, + artifactsWritten: 1, + commitSha: partialReport.body.commitSha, + }, + report: partialReport, + })); + + const io = makeIo(); + await expect( + runKtxIngest( + { command: 'run', projectDir, connectionId: 'warehouse', adapter: 'fake', sourceDir, outputMode: 'plain' }, + io.io, + { runLocalIngest: runLocal, jobIdFactory: () => 'local-job-partial' }, + ), + ).resolves.toBe(0); + + expect(io.stdout()).toContain('Status: partial\n'); + }); + it('prints trace path and error status for stored failed ingest reports', async () => { const projectDir = join(tempDir, 'project'); await writeWarehouseConfig(projectDir); diff --git a/packages/cli/test/setup.test.ts b/packages/cli/test/setup.test.ts index 0bc00919..da51e9af 100644 --- a/packages/cli/test/setup.test.ts +++ b/packages/cli/test/setup.test.ts @@ -398,6 +398,59 @@ describe('setup status', () => { expect(rendered).toContain('KTX context built: yes'); }); + it('reports context ready after a partial ingest report saved memory', async () => { + await writeFile( + join(tempDir, 'ktx.yaml'), + [ + 'setup:', + ' database_connection_ids:', + ' - warehouse', + 'connections:', + ' warehouse:', + ' driver: postgres', + ' url: env:DATABASE_URL', + 'ingest:', + ' embeddings:', + ' backend: none', + ' dimensions: 8', + '', + ].join('\n'), + 'utf-8', + ); + await writeKtxSetupState(tempDir, { completed_steps: ['project', 'databases'] }); + await persistLocalBundleReport( + tempDir, + localFakeBundleReport('warehouse-job-partial', { + connectionId: 'warehouse', + sourceKey: 'fake', + body: { + failedWorkUnits: ['orders-bad'], + workUnits: [ + { + unitKey: 'orders-ok', + rawFiles: ['orders/orders.json'], + status: 'success', + actions: [{ target: 'wiki', type: 'created', key: 'wiki/orders.md', detail: 'orders' }], + touchedSlSources: [], + }, + { + unitKey: 'orders-bad', + rawFiles: ['orders/bad.json'], + status: 'failed', + reason: 'writer tool failed', + actions: [], + touchedSlSources: [], + }, + ], + }, + }), + ); + + const status = await readKtxSetupStatus(tempDir); + + expect(status.context).toMatchObject({ ready: true, status: 'completed' }); + }); + it('formats plain and JSON setup status payloads', async () => { const status = await readKtxSetupStatus(tempDir); const rendered = formatKtxSetupStatus(status); From 08d08d8ea00639f9a8198566805cd955eadcad0b Mon Sep 17 00:00:00 2001 From: Andrey Avtomonov Date: Sat, 30 May 2026 12:07:15 +0200 Subject: [PATCH 32/74] ci: refresh README star history chart twice daily Point the README chart at a committed assets/star-history.svg instead of the star-history API URL so GitHub serves it directly and bypasses the Camo proxy cache. A scheduled workflow regenerates the SVG at 06:00/18:00 UTC, busting star-history's server-side cache, and commits it when it changes. --- .github/workflows/star-history.yml | 61 ++++++++++++++++++++++++++++++ README.md | 2 +- assets/star-history.svg | 1 + 3 files changed, 63 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/star-history.yml create mode 100644 assets/star-history.svg diff --git a/.github/workflows/star-history.yml b/.github/workflows/star-history.yml new file mode 100644 index 00000000..ec484b05 --- /dev/null +++ b/.github/workflows/star-history.yml @@ -0,0 +1,61 @@ +name: Refresh star history chart + +on: + schedule: + # Twice daily at 06:00 and 18:00 UTC. + - cron: "0 6,18 * * *" + workflow_dispatch: + +permissions: + contents: write + +env: + DO_NOT_TRACK: "1" + KTX_TELEMETRY_DISABLED: "1" + NEXT_TELEMETRY_DISABLED: "1" + +concurrency: + group: star-history-refresh + cancel-in-progress: true + +jobs: + refresh: + name: Regenerate assets/star-history.svg + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Fetch fresh star-history SVG + run: | + set -euo pipefail + # cachebust forces star-history to regenerate instead of serving its + # own server-side cache; --location follows the slug-normalizing 301. + url="https://api.star-history.com/svg?repos=Kaelio/ktx&type=Date&cachebust=${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}" + curl --fail --location --silent --show-error \ + --retry 3 --retry-delay 5 --max-time 60 \ + -o assets/star-history.svg.new "$url" + # Guard against error pages / truncated responses before overwriting. + if ! grep -q "" assets/star-history.svg.new; then + echo "Downloaded file is not a valid SVG; aborting." >&2 + exit 1 + fi + if [ "$(wc -c < assets/star-history.svg.new)" -lt 1000 ]; then + echo "Downloaded SVG is suspiciously small; aborting." >&2 + exit 1 + fi + mv assets/star-history.svg.new assets/star-history.svg + + - name: Commit if changed + run: | + set -euo pipefail + if git diff --quiet -- assets/star-history.svg; then + echo "Star-history chart unchanged; nothing to commit." + exit 0 + fi + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add assets/star-history.svg + # [skip ci] keeps this housekeeping commit from triggering KTX CI. + git commit -m "chore: refresh star history chart [skip ci]" + git push diff --git a/README.md b/README.md index 23b2fa0a..686ece22 100644 --- a/README.md +++ b/README.md @@ -248,6 +248,6 @@ event catalog and opt-out options.

- ktx Star History Chart + ktx Star History Chart

diff --git a/assets/star-history.svg b/assets/star-history.svg new file mode 100644 index 00000000..3f6c4a04 --- /dev/null +++ b/assets/star-history.svg @@ -0,0 +1 @@ +star-history.comMay 17May 24 100200300400kaelio/ktxStar HistoryDateGitHub Stars From ba06f7078af69fdba2184186ca5cc53c65427ea2 Mon Sep 17 00:00:00 2001 From: Andrey Avtomonov Date: Sat, 30 May 2026 16:01:47 +0200 Subject: [PATCH 33/74] ci: push star-history refresh to protected main with RELEASE_PAT (#239) The scheduled star-history workflow checked out with the default GITHUB_TOKEN, so its git push to main was rejected by the branch protection hook (GH006). Check out with RELEASE_PAT instead, matching release.yml, whose semantic-release step already pushes to the protected main branch with the same token. --- .github/workflows/star-history.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/workflows/star-history.yml b/.github/workflows/star-history.yml index ec484b05..e67a0517 100644 --- a/.github/workflows/star-history.yml +++ b/.github/workflows/star-history.yml @@ -25,6 +25,10 @@ jobs: steps: - name: Checkout repository uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + # RELEASE_PAT can push to the protected main branch; the default + # GITHUB_TOKEN is rejected by the branch-protection hook (GH006). + token: ${{ secrets.RELEASE_PAT }} - name: Fetch fresh star-history SVG run: | From 54d6e877335a7218dc2ec795ff85529403ac6bde Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sat, 30 May 2026 14:02:55 +0000 Subject: [PATCH 34/74] chore: refresh star history chart [skip ci] --- assets/star-history.svg | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/assets/star-history.svg b/assets/star-history.svg index 3f6c4a04..246ba5a0 100644 --- a/assets/star-history.svg +++ b/assets/star-history.svg @@ -1 +1 @@ -star-history.comMay 17May 24 100200300400kaelio/ktxStar HistoryDateGitHub Stars +star-history.comMay 17May 24 100200300400kaelio/ktxStar HistoryDateGitHub Stars \ No newline at end of file From cbbcf8e8bdd1560b3d0c73e47abd36eb5d8c6f23 Mon Sep 17 00:00:00 2001 From: Andrey Avtomonov Date: Sat, 30 May 2026 17:44:27 +0200 Subject: [PATCH 35/74] ci: normalize star-history.svg trailing newline (#241) The star-history refresh workflow committed the API's SVG verbatim, but the response has no trailing newline. Because the refresh commit uses [skip ci], the file never ran end-of-file-fixer at commit time, so pre-commit's `--all-files` run failed end-of-file-fixer on every open PR (e.g. #240), even PRs that never touched the file. Normalize the downloaded SVG to exactly one trailing newline in the workflow (idempotent, so the "unchanged" guard still works), and fix the currently committed file so open PRs go green now. --- .github/workflows/star-history.yml | 9 ++++++++- assets/star-history.svg | 2 +- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/.github/workflows/star-history.yml b/.github/workflows/star-history.yml index e67a0517..b7d90c43 100644 --- a/.github/workflows/star-history.yml +++ b/.github/workflows/star-history.yml @@ -48,7 +48,14 @@ jobs: echo "Downloaded SVG is suspiciously small; aborting." >&2 exit 1 fi - mv assets/star-history.svg.new assets/star-history.svg + # The star-history API returns the SVG without a trailing newline, + # which end-of-file-fixer rewrites whenever pre-commit runs + # --all-files on a PR. Because the refresh commit below uses [skip ci], + # the hook never runs against it here, so an un-normalized file + # silently breaks the pre-commit check on every open PR. Normalize to + # exactly one trailing newline before committing. + printf '%s\n' "$(cat assets/star-history.svg.new)" > assets/star-history.svg + rm -f assets/star-history.svg.new - name: Commit if changed run: | diff --git a/assets/star-history.svg b/assets/star-history.svg index 246ba5a0..3f6c4a04 100644 --- a/assets/star-history.svg +++ b/assets/star-history.svg @@ -1 +1 @@ -star-history.comMay 17May 24 100200300400kaelio/ktxStar HistoryDateGitHub Stars \ No newline at end of file +star-history.comMay 17May 24 100200300400kaelio/ktxStar HistoryDateGitHub Stars From 25f639fba2f71aa880e4184ce4b50b56e2374d0e Mon Sep 17 00:00:00 2001 From: Andrey Avtomonov Date: Sat, 30 May 2026 17:54:24 +0200 Subject: [PATCH 36/74] feat: trim MCP query response payloads (#240) --- .../mcp/__snapshots__/mcp-tools-list.json | 1620 ----------------- packages/cli/src/context/mcp/context-tools.ts | 106 +- packages/cli/src/context/mcp/types.ts | 5 +- packages/cli/src/context/search/discover.ts | 7 +- packages/cli/src/skills/analytics/SKILL.md | 7 +- .../mcp/__snapshots__/mcp-tools-list.json | 41 +- packages/cli/test/context/mcp/server.test.ts | 152 +- 7 files changed, 235 insertions(+), 1703 deletions(-) delete mode 100644 packages/cli/src/context/mcp/__snapshots__/mcp-tools-list.json diff --git a/packages/cli/src/context/mcp/__snapshots__/mcp-tools-list.json b/packages/cli/src/context/mcp/__snapshots__/mcp-tools-list.json deleted file mode 100644 index 10cb0b77..00000000 --- a/packages/cli/src/context/mcp/__snapshots__/mcp-tools-list.json +++ /dev/null @@ -1,1620 +0,0 @@ -[ - { - "name": "connection_list", - "title": "Connection List", - "description": "List configured read-only data connections available to this KTX project. Use this before connection-scoped tools when the project may have multiple warehouses.", - "inputSchema": { - "type": "object", - "properties": {}, - "$schema": "http://json-schema.org/draft-07/schema#" - }, - "outputSchema": { - "type": "object", - "properties": { - "connections": { - "type": "array", - "items": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "name": { - "type": "string" - }, - "connectionType": { - "type": "string" - } - }, - "required": [ - "id", - "name", - "connectionType" - ], - "additionalProperties": false - } - } - }, - "required": [ - "connections" - ], - "$schema": "http://json-schema.org/draft-07/schema#", - "additionalProperties": false - }, - "annotations": { - "title": "Connection List", - "readOnlyHint": true, - "idempotentHint": true, - "openWorldHint": false - }, - "execution": { - "taskSupport": "forbidden" - } - }, - { - "name": "wiki_search", - "title": "Wiki Search", - "description": "Search KTX wiki pages for reusable business context. Example: wiki_search({ query: \"revenue recognition\", limit: 5 }).", - "inputSchema": { - "type": "object", - "properties": { - "query": { - "type": "string", - "minLength": 1, - "description": "Natural-language wiki search query, e.g. \"revenue recognition policy\"." - }, - "limit": { - "default": 10, - "description": "Maximum wiki pages to return. Defaults to 10.", - "type": "integer", - "minimum": 1, - "maximum": 50 - } - }, - "required": [ - "query" - ], - "$schema": "http://json-schema.org/draft-07/schema#" - }, - "outputSchema": { - "type": "object", - "properties": { - "results": { - "type": "array", - "items": { - "type": "object", - "properties": { - "key": { - "type": "string" - }, - "path": { - "type": "string" - }, - "scope": { - "type": "string", - "enum": [ - "GLOBAL", - "USER" - ] - }, - "summary": { - "type": "string" - }, - "score": { - "type": "number" - }, - "matchReasons": { - "type": "array", - "items": { - "type": "string" - } - }, - "lanes": { - "type": "array", - "items": { - "type": "object", - "properties": { - "lane": { - "type": "string" - }, - "status": { - "type": "string" - }, - "requestedCandidatePoolLimit": { - "type": "number" - }, - "effectiveCandidatePoolLimit": { - "type": "number" - }, - "returnedCandidateCount": { - "type": "number" - }, - "weight": { - "type": "number" - }, - "reason": { - "type": "string" - } - }, - "required": [ - "lane", - "status", - "requestedCandidatePoolLimit", - "effectiveCandidatePoolLimit", - "returnedCandidateCount", - "weight" - ], - "additionalProperties": false - } - } - }, - "required": [ - "key", - "path", - "scope", - "summary", - "score" - ], - "additionalProperties": false - } - }, - "totalFound": { - "type": "number" - } - }, - "required": [ - "results", - "totalFound" - ], - "$schema": "http://json-schema.org/draft-07/schema#", - "additionalProperties": false - }, - "annotations": { - "title": "Wiki Search", - "readOnlyHint": true, - "openWorldHint": false - }, - "execution": { - "taskSupport": "forbidden" - } - }, - { - "name": "wiki_read", - "title": "Wiki Read", - "description": "Read a KTX wiki page by key returned from wiki_search. Example: wiki_read({ key: \"global/revenue\" }).", - "inputSchema": { - "type": "object", - "properties": { - "key": { - "type": "string", - "minLength": 1, - "description": "Wiki page key returned by wiki_search, e.g. \"global/revenue\"." - } - }, - "required": [ - "key" - ], - "$schema": "http://json-schema.org/draft-07/schema#" - }, - "outputSchema": { - "type": "object", - "properties": { - "key": { - "type": "string" - }, - "summary": { - "type": "string" - }, - "content": { - "type": "string" - }, - "scope": { - "type": "string", - "enum": [ - "GLOBAL", - "USER" - ] - }, - "tags": { - "type": "array", - "items": { - "type": "string" - } - }, - "refs": { - "type": "array", - "items": { - "type": "string" - } - }, - "slRefs": { - "type": "array", - "items": { - "type": "string" - } - } - }, - "required": [ - "key", - "summary", - "content", - "scope" - ], - "$schema": "http://json-schema.org/draft-07/schema#", - "additionalProperties": false - }, - "annotations": { - "title": "Wiki Read", - "readOnlyHint": true, - "idempotentHint": true, - "openWorldHint": false - }, - "execution": { - "taskSupport": "forbidden" - } - }, - { - "name": "sl_read_source", - "title": "Semantic Layer Read Source", - "description": "Read a semantic-layer YAML source by connection id and source name. Example: sl_read_source({ connectionId: \"warehouse\", sourceName: \"orders\" }).", - "inputSchema": { - "type": "object", - "properties": { - "connectionId": { - "type": "string", - "minLength": 1, - "description": "Connection id that owns the semantic-layer source." - }, - "sourceName": { - "type": "string", - "minLength": 1, - "description": "Semantic-layer source name without \".yaml\", e.g. \"orders\"." - } - }, - "required": [ - "connectionId", - "sourceName" - ], - "$schema": "http://json-schema.org/draft-07/schema#" - }, - "outputSchema": { - "type": "object", - "properties": { - "sourceName": { - "type": "string" - }, - "yaml": { - "type": "string" - } - }, - "required": [ - "sourceName", - "yaml" - ], - "$schema": "http://json-schema.org/draft-07/schema#", - "additionalProperties": false - }, - "annotations": { - "title": "Semantic Layer Read Source", - "readOnlyHint": true, - "idempotentHint": true, - "openWorldHint": false - }, - "execution": { - "taskSupport": "forbidden" - } - }, - { - "name": "sl_query", - "title": "Semantic Layer Query", - "description": "Execute a semantic-layer query and return rows, headers, generated SQL, and plan details. Example: sl_query({ connectionId: \"warehouse\", measures: [\"orders.order_count\"], dimensions: [{ field: \"orders.created_at\", granularity: \"month\" }] }).", - "inputSchema": { - "type": "object", - "properties": { - "connectionId": { - "description": "Connection id to query. Omit only when the project has exactly one configured connection.", - "type": "string", - "minLength": 1 - }, - "measures": { - "minItems": 1, - "type": "array", - "items": { - "anyOf": [ - { - "type": "string", - "description": "Semantic-layer measure key, e.g. \"orders.order_count\"." - }, - { - "type": "object", - "properties": { - "expr": { - "type": "string", - "minLength": 1, - "description": "Ad hoc aggregate expression, e.g. \"sum(orders.amount)\"." - }, - "name": { - "type": "string", - "minLength": 1, - "description": "Alias for the ad hoc measure, e.g. \"gross_revenue\"." - } - }, - "required": [ - "expr", - "name" - ] - } - ] - }, - "description": "Measures to select. Use semantic-layer keys when available." - }, - "dimensions": { - "default": [], - "description": "Dimensions to group by. Use {field, granularity?} entries.", - "type": "array", - "items": { - "type": "object", - "properties": { - "field": { - "type": "string", - "minLength": 1, - "description": "Dimension to group by, e.g. \"orders.created_at\" or \"orders.status\"." - }, - "granularity": { - "description": "Time grain for time dimensions: day, week, month, quarter, or year.", - "type": "string", - "minLength": 1 - } - }, - "required": [ - "field" - ] - } - }, - "filters": { - "default": [], - "description": "Semantic-layer filter expressions to apply.", - "type": "array", - "items": { - "type": "string", - "description": "Semantic-layer filter expression, e.g. \"orders.status = paid\"." - } - }, - "segments": { - "default": [], - "description": "Semantic-layer segment keys to apply.", - "type": "array", - "items": { - "type": "string", - "description": "Semantic-layer segment key to apply." - } - }, - "order_by": { - "default": [], - "description": "Sort clauses. Use {field, direction?} entries.", - "type": "array", - "items": { - "type": "object", - "properties": { - "field": { - "type": "string", - "minLength": 1, - "description": "Field/measure/dimension id to order by, e.g. \"orders.created_at\", a dimension key like \"mart_nrr_quarterly.quarter_label\", or a measure alias." - }, - "direction": { - "default": "asc", - "description": "Sort direction: \"asc\" or \"desc\". Defaults to \"asc\".", - "type": "string", - "enum": [ - "asc", - "desc" - ] - } - }, - "required": [ - "field" - ] - } - }, - "limit": { - "default": 1000, - "description": "Maximum rows to return. Defaults to 1000.", - "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 - }, - "include_empty": { - "default": true, - "description": "Whether to include empty dimension groups. Defaults to true.", - "type": "boolean" - } - }, - "required": [ - "measures" - ], - "$schema": "http://json-schema.org/draft-07/schema#" - }, - "outputSchema": { - "type": "object", - "properties": { - "connectionId": { - "type": "string" - }, - "dialect": { - "type": "string" - }, - "sql": { - "type": "string" - }, - "headers": { - "type": "array", - "items": { - "type": "string" - } - }, - "rows": { - "type": "array", - "items": { - "type": "array", - "items": {} - } - }, - "totalRows": { - "type": "number" - }, - "plan": { - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": {} - } - }, - "required": [ - "sql", - "headers", - "rows", - "totalRows" - ], - "$schema": "http://json-schema.org/draft-07/schema#", - "additionalProperties": false - }, - "annotations": { - "title": "Semantic Layer Query", - "readOnlyHint": true, - "openWorldHint": false - }, - "execution": { - "taskSupport": "forbidden" - } - }, - { - "name": "entity_details", - "title": "Entity Details", - "description": "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\"] }] }).", - "inputSchema": { - "type": "object", - "properties": { - "connectionId": { - "type": "string", - "minLength": 1, - "description": "Connection id whose latest scan snapshot should be read." - }, - "entities": { - "minItems": 1, - "maxItems": 20, - "type": "array", - "items": { - "type": "object", - "properties": { - "table": { - "anyOf": [ - { - "type": "string", - "minLength": 1 - }, - { - "type": "object", - "properties": { - "catalog": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "description": "Catalog/project/database. Use null when not applicable." - }, - "db": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "description": "Schema/database/dataset. Use null when not applicable." - }, - "name": { - "type": "string", - "minLength": 1, - "description": "Table name." - } - }, - "required": [ - "catalog", - "db", - "name" - ] - } - ], - "description": "Table display string or canonical object ref." - }, - "columns": { - "description": "Optional column filter.", - "type": "array", - "items": { - "type": "string", - "minLength": 1, - "description": "Column name to inspect." - } - } - }, - "required": [ - "table" - ] - }, - "description": "Tables or columns to inspect. Maximum 20 entities." - } - }, - "required": [ - "connectionId", - "entities" - ], - "$schema": "http://json-schema.org/draft-07/schema#" - }, - "outputSchema": { - "type": "object", - "properties": { - "results": { - "type": "array", - "items": { - "anyOf": [ - { - "type": "object", - "properties": { - "ok": { - "type": "boolean", - "const": true - }, - "connectionId": { - "type": "string" - }, - "tableRef": { - "type": "object", - "properties": { - "catalog": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ] - }, - "db": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ] - }, - "name": { - "type": "string" - } - }, - "required": [ - "catalog", - "db", - "name" - ], - "additionalProperties": false - }, - "display": { - "type": "string" - }, - "kind": { - "type": "string", - "enum": [ - "table", - "view", - "external", - "event_stream" - ] - }, - "comment": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ] - }, - "estimatedRows": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "null" - } - ] - }, - "columns": { - "type": "array", - "items": { - "type": "object", - "properties": { - "name": { - "type": "string" - }, - "nativeType": { - "type": "string" - }, - "normalizedType": { - "type": "string" - }, - "dimensionType": { - "type": "string", - "enum": [ - "time", - "string", - "number", - "boolean" - ] - }, - "nullable": { - "type": "boolean" - }, - "primaryKey": { - "type": "boolean" - }, - "comment": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ] - } - }, - "required": [ - "name", - "nativeType", - "normalizedType", - "dimensionType", - "nullable", - "primaryKey", - "comment" - ], - "additionalProperties": false - } - }, - "foreignKeys": { - "type": "array", - "items": { - "type": "object", - "properties": { - "fromColumn": { - "type": "string" - }, - "toCatalog": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ] - }, - "toDb": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ] - }, - "toTable": { - "type": "string" - }, - "toColumn": { - "type": "string" - }, - "constraintName": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ] - } - }, - "required": [ - "fromColumn", - "toCatalog", - "toDb", - "toTable", - "toColumn", - "constraintName" - ], - "additionalProperties": false - } - }, - "snapshot": { - "type": "object", - "properties": { - "syncId": { - "type": "string" - }, - "extractedAt": { - "type": "string" - }, - "scanRunId": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ] - } - }, - "required": [ - "syncId", - "extractedAt", - "scanRunId" - ], - "additionalProperties": false - } - }, - "required": [ - "ok", - "connectionId", - "tableRef", - "display", - "kind", - "comment", - "estimatedRows", - "columns", - "foreignKeys", - "snapshot" - ], - "additionalProperties": false - }, - { - "type": "object", - "properties": { - "ok": { - "type": "boolean", - "const": false - }, - "connectionId": { - "type": "string" - }, - "table": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "object", - "properties": { - "catalog": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ] - }, - "db": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ] - }, - "name": { - "type": "string" - } - }, - "required": [ - "catalog", - "db", - "name" - ], - "additionalProperties": false - } - ] - }, - "snapshot": { - "type": "object", - "properties": { - "syncId": { - "type": "string" - }, - "extractedAt": { - "type": "string" - }, - "scanRunId": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ] - } - }, - "required": [ - "syncId", - "extractedAt", - "scanRunId" - ], - "additionalProperties": false - }, - "error": { - "type": "object", - "properties": { - "code": { - "type": "string", - "enum": [ - "scan_missing", - "table_not_found", - "ambiguous_table", - "column_not_found" - ] - }, - "message": { - "type": "string" - }, - "candidates": { - "anyOf": [ - { - "type": "array", - "items": { - "type": "object", - "properties": { - "tableRef": { - "type": "object", - "properties": { - "catalog": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ] - }, - "db": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ] - }, - "name": { - "type": "string" - } - }, - "required": [ - "catalog", - "db", - "name" - ], - "additionalProperties": false - }, - "display": { - "type": "string" - } - }, - "required": [ - "tableRef", - "display" - ], - "additionalProperties": false - } - }, - { - "type": "array", - "items": { - "type": "string" - } - } - ] - } - }, - "required": [ - "code", - "message" - ], - "additionalProperties": false - } - }, - "required": [ - "ok", - "connectionId", - "table", - "error" - ], - "additionalProperties": false - } - ] - } - } - }, - "required": [ - "results" - ], - "$schema": "http://json-schema.org/draft-07/schema#", - "additionalProperties": false - }, - "annotations": { - "title": "Entity Details", - "readOnlyHint": true, - "idempotentHint": true, - "openWorldHint": false - }, - "execution": { - "taskSupport": "forbidden" - } - }, - { - "name": "dictionary_search", - "title": "Dictionary Search", - "description": "Search profile-sampled warehouse values to locate likely source columns for business values. Example: dictionary_search({ values: [\"Acme Corp\"], connectionId: \"warehouse\" }).", - "inputSchema": { - "type": "object", - "properties": { - "values": { - "minItems": 1, - "maxItems": 20, - "type": "array", - "items": { - "type": "string", - "minLength": 1, - "description": "Business value to locate, e.g. \"Acme Corp\" or \"enterprise\"." - }, - "description": "Values to search for in sampled warehouse dictionaries." - }, - "connectionId": { - "description": "Optional connection id. Pass it when user intent pins a specific warehouse.", - "type": "string", - "minLength": 1 - } - }, - "required": [ - "values" - ], - "$schema": "http://json-schema.org/draft-07/schema#" - }, - "outputSchema": { - "type": "object", - "properties": { - "searched": { - "type": "array", - "items": { - "type": "object", - "properties": { - "connectionId": { - "type": "string" - }, - "coverage": { - "type": "object", - "properties": { - "sampledRows": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "null" - } - ] - }, - "valuesPerColumn": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "null" - } - ] - }, - "profiledColumns": { - "type": "number" - }, - "syncId": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ] - }, - "profiledAt": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ] - } - }, - "required": [ - "sampledRows", - "valuesPerColumn", - "profiledColumns", - "syncId", - "profiledAt" - ], - "additionalProperties": false - }, - "status": { - "type": "string", - "enum": [ - "ready", - "no_profile_artifact", - "no_candidate_columns" - ] - } - }, - "required": [ - "connectionId", - "coverage", - "status" - ], - "additionalProperties": false - } - }, - "results": { - "type": "array", - "items": { - "type": "object", - "properties": { - "value": { - "type": "string" - }, - "matches": { - "type": "array", - "items": { - "type": "object", - "properties": { - "connectionId": { - "type": "string" - }, - "sourceName": { - "type": "string" - }, - "columnName": { - "type": "string" - }, - "matchedValue": { - "type": "string" - }, - "cardinality": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "null" - } - ] - } - }, - "required": [ - "connectionId", - "sourceName", - "columnName", - "matchedValue", - "cardinality" - ], - "additionalProperties": false - } - }, - "misses": { - "type": "array", - "items": { - "type": "object", - "properties": { - "connectionId": { - "type": "string" - }, - "reason": { - "type": "string", - "enum": [ - "no_profile_artifact", - "no_candidate_columns", - "value_not_in_sample" - ] - } - }, - "required": [ - "connectionId", - "reason" - ], - "additionalProperties": false - } - } - }, - "required": [ - "value", - "matches", - "misses" - ], - "additionalProperties": false - } - } - }, - "required": [ - "searched", - "results" - ], - "$schema": "http://json-schema.org/draft-07/schema#", - "additionalProperties": false - }, - "annotations": { - "title": "Dictionary Search", - "readOnlyHint": true, - "openWorldHint": false - }, - "execution": { - "taskSupport": "forbidden" - } - }, - { - "name": "discover_data", - "title": "Discover Data", - "description": "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\"] }).", - "inputSchema": { - "type": "object", - "properties": { - "query": { - "type": "string", - "minLength": 1, - "description": "Natural-language discovery query, e.g. \"monthly orders by customer\"." - }, - "connectionId": { - "description": "Optional connection id. Pass it when user intent pins a specific warehouse.", - "type": "string", - "minLength": 1 - }, - "kinds": { - "description": "Optional kind filter.", - "type": "array", - "items": { - "type": "string", - "enum": [ - "wiki", - "sl_source", - "sl_measure", - "sl_dimension", - "table", - "column" - ], - "description": "Reference kind to include." - } - }, - "limit": { - "description": "Maximum refs to return. Defaults to 15.", - "default": 15, - "type": "integer", - "minimum": 1, - "maximum": 50 - } - }, - "required": [ - "query" - ], - "$schema": "http://json-schema.org/draft-07/schema#" - }, - "outputSchema": { - "type": "object", - "properties": { - "refs": { - "type": "array", - "items": { - "type": "object", - "properties": { - "kind": { - "type": "string", - "enum": [ - "wiki", - "sl_source", - "sl_measure", - "sl_dimension", - "table", - "column" - ] - }, - "id": { - "type": "string" - }, - "score": { - "type": "number" - }, - "summary": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ] - }, - "snippet": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ] - }, - "matchedOn": { - "type": "string", - "enum": [ - "name", - "display", - "description", - "comment", - "expr", - "sample_value", - "body" - ] - }, - "connectionId": { - "type": "string" - }, - "tableRef": { - "type": "object", - "properties": { - "catalog": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ] - }, - "db": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ] - }, - "name": { - "type": "string" - } - }, - "required": [ - "catalog", - "db", - "name" - ], - "additionalProperties": false - }, - "columnName": { - "type": "string" - } - }, - "required": [ - "kind", - "id", - "score", - "summary", - "snippet", - "matchedOn" - ], - "additionalProperties": false - } - } - }, - "required": [ - "refs" - ], - "$schema": "http://json-schema.org/draft-07/schema#", - "additionalProperties": false - }, - "annotations": { - "title": "Discover Data", - "readOnlyHint": true, - "openWorldHint": false - }, - "execution": { - "taskSupport": "forbidden" - } - }, - { - "name": "sql_execution", - "title": "SQL Execution", - "description": "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 }).", - "inputSchema": { - "type": "object", - "properties": { - "connectionId": { - "type": "string", - "minLength": 1, - "description": "Connection id to execute against. Required for raw SQL." - }, - "sql": { - "type": "string", - "minLength": 1, - "description": "Parser-validated read-only SQL, e.g. \"select count(*) from public.orders\"." - }, - "maxRows": { - "description": "Maximum rows to return. Defaults to 1000.", - "default": 1000, - "type": "integer", - "minimum": 1, - "maximum": 10000 - } - }, - "required": [ - "connectionId", - "sql" - ], - "$schema": "http://json-schema.org/draft-07/schema#" - }, - "outputSchema": { - "type": "object", - "properties": { - "headers": { - "type": "array", - "items": { - "type": "string" - } - }, - "headerTypes": { - "type": "array", - "items": { - "type": "string" - } - }, - "rows": { - "type": "array", - "items": { - "type": "array", - "items": {} - } - }, - "rowCount": { - "type": "number" - } - }, - "required": [ - "headers", - "rows", - "rowCount" - ], - "$schema": "http://json-schema.org/draft-07/schema#", - "additionalProperties": false - }, - "annotations": { - "title": "SQL Execution", - "readOnlyHint": true, - "openWorldHint": false - }, - "execution": { - "taskSupport": "forbidden" - } - }, - { - "name": "memory_ingest", - "title": "Memory Ingest", - "description": "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.\" }).", - "inputSchema": { - "type": "object", - "properties": { - "content": { - "type": "string", - "minLength": 1, - "description": "Free-form markdown to ingest. Include the knowledge itself plus any context (source, the user question, why this came up) that the memory agent should consider when triaging into wiki/SL." - }, - "connectionId": { - "description": "Scope this memory to a specific connection. Required when the knowledge is warehouse-specific, including measure definitions, schema gotchas, or anything tied to a particular warehouse. Omit only for global wiki knowledge.", - "type": "string", - "minLength": 1 - } - }, - "required": [ - "content" - ], - "$schema": "http://json-schema.org/draft-07/schema#" - }, - "outputSchema": { - "type": "object", - "properties": { - "runId": { - "type": "string" - } - }, - "required": [ - "runId" - ], - "$schema": "http://json-schema.org/draft-07/schema#", - "additionalProperties": false - }, - "annotations": { - "title": "Memory Ingest", - "destructiveHint": true, - "openWorldHint": false - }, - "execution": { - "taskSupport": "forbidden" - } - }, - { - "name": "memory_ingest_status", - "title": "Memory Ingest Status", - "description": "Read the current or final status for a memory ingest run. Example: memory_ingest_status({ runId: \"memory-run-1\" }).", - "inputSchema": { - "type": "object", - "properties": { - "runId": { - "type": "string", - "minLength": 1, - "description": "The memory ingest run id returned by memory_ingest." - } - }, - "required": [ - "runId" - ], - "$schema": "http://json-schema.org/draft-07/schema#" - }, - "outputSchema": { - "type": "object", - "properties": { - "runId": { - "type": "string" - }, - "status": { - "type": "string", - "enum": [ - "running", - "done", - "error" - ] - }, - "stage": { - "type": "string" - }, - "done": { - "type": "boolean" - }, - "captured": { - "type": "object", - "properties": { - "wiki": { - "type": "array", - "items": { - "type": "string" - } - }, - "sl": { - "type": "array", - "items": { - "type": "string" - } - }, - "xrefs": { - "type": "array", - "items": { - "type": "string" - } - } - }, - "required": [ - "wiki", - "sl", - "xrefs" - ], - "additionalProperties": false - }, - "error": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ] - }, - "commitHash": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ] - }, - "skillsLoaded": { - "type": "array", - "items": { - "type": "string" - } - }, - "signalDetected": { - "type": "boolean" - } - }, - "required": [ - "runId", - "status", - "stage", - "done", - "captured", - "error", - "commitHash", - "skillsLoaded", - "signalDetected" - ], - "$schema": "http://json-schema.org/draft-07/schema#", - "additionalProperties": false - }, - "annotations": { - "title": "Memory Ingest Status", - "readOnlyHint": true, - "openWorldHint": false - }, - "execution": { - "taskSupport": "forbidden" - } - } -] diff --git a/packages/cli/src/context/mcp/context-tools.ts b/packages/cli/src/context/mcp/context-tools.ts index aa593f7f..057e570b 100644 --- a/packages/cli/src/context/mcp/context-tools.ts +++ b/packages/cli/src/context/mcp/context-tools.ts @@ -12,6 +12,7 @@ import type { KtxMcpToolHandlerContext, KtxMcpToolResult, KtxMcpUserContext, + KtxSemanticLayerQueryResponse, NonArrayObject, } from './types.js'; @@ -60,7 +61,7 @@ const toolDescriptions = { sl_read_source: 'Read a semantic-layer YAML source by connection id and source name. Example: sl_read_source({ connectionId: "warehouse", sourceName: "orders" }).', sl_query: - 'Execute a semantic-layer query and return rows, headers, generated SQL, and plan details. Example: sl_query({ connectionId: "warehouse", measures: ["orders.order_count"], dimensions: [{ field: "orders.created_at", granularity: "month" }] }).', + '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 }).', memory_ingest: @@ -73,7 +74,7 @@ const connectionListSchema = z.object({}); const knowledgeSearchSchema = z.object({ query: z.string().min(1).describe('Natural-language wiki search query, e.g. "revenue recognition policy".'), - limit: z.number().int().min(1).max(50).default(10).describe('Maximum wiki pages to return. Defaults to 10.'), + limit: z.number().int().min(1).max(50).default(10).describe('Maximum wiki pages to return.'), }); const knowledgeReadSchema = z.object({ @@ -109,10 +110,7 @@ const slQueryOrderBySchema = z.object({ .describe( 'Field/measure/dimension id to order by, e.g. "orders.created_at", a dimension key like "mart_nrr_quarterly.quarter_label", or a measure alias.', ), - direction: z - .enum(['asc', 'desc']) - .default('asc') - .describe('Sort direction: "asc" or "desc". Defaults to "asc".'), + direction: z.enum(['asc', 'desc']).default('asc').describe('Sort direction for this field.'), }); const slQuerySchema = z.object({ @@ -136,8 +134,12 @@ const slQuerySchema = z.object({ .array(slQueryOrderBySchema) .default([]) .describe('Sort clauses. Use {field, direction?} entries.'), - limit: z.number().int().min(0).default(1000).describe('Maximum rows to return. Defaults to 1000.'), - include_empty: z.boolean().default(true).describe('Whether to include empty dimension groups. Defaults to true.'), + limit: z.number().int().min(0).default(1000).describe('Maximum rows to return.'), + include_empty: z.boolean().default(true).describe('Whether to include empty dimension groups.'), + include: z + .array(z.enum(['plan', 'sql'])) + .default([]) + .describe('Extra detail to attach to the response: "sql" for the generated SQL, "plan" for the full query plan.'), }); const entityDetailsTableRefSchema = z.object({ @@ -184,13 +186,13 @@ const discoverDataSchema = z.object({ .optional() .describe('Optional connection id. Pass it when user intent pins a specific warehouse.'), kinds: z.array(discoverDataKindSchema.describe('Reference kind to include.')).optional().describe('Optional kind filter.'), - limit: z.number().int().min(1).max(50).default(15).optional().describe('Maximum refs to return. Defaults to 15.'), + limit: z.number().int().min(1).max(50).default(10).optional().describe('Maximum refs to return.'), }); const sqlExecutionSchema = z.object({ connectionId: connectionIdSchema.describe('Connection id to execute against. Required for raw SQL.'), sql: z.string().min(1).describe('Parser-validated read-only SQL, e.g. "select count(*) from public.orders".'), - maxRows: z.number().int().min(1).max(10_000).default(1000).optional().describe('Maximum rows to return. Defaults to 1000.'), + maxRows: z.number().int().min(1).max(10_000).default(1000).optional().describe('Maximum rows to return.'), }); const memoryIngestSchema = z.object({ @@ -266,10 +268,14 @@ const slReadSourceOutputSchema = z.object({ const slQueryOutputSchema = z.object({ connectionId: z.string().optional(), dialect: z.string().optional(), - sql: z.string(), headers: z.array(z.string()), rows: z.array(z.array(z.unknown())), totalRows: z.number(), + // Correctness signals hoisted out of `plan` so they survive default projection (e.g. compile-only + // status, fan-out warnings). Present only when there is something to report. + notes: z.array(z.string()).optional(), + // Opt-in detail, attached only when requested via the `include` input. + sql: z.string().optional(), plan: unknownRecordSchema.optional(), }); @@ -411,12 +417,59 @@ const memoryIngestStatusOutputSchema = z.object({ /** @internal */ export function jsonToolResult(structuredContent: T): KtxMcpToolResult { + // Compact (non-indented) JSON: this `content` text is the copy the model reads. Pretty-printing + // arrays-of-arrays (every `rows` payload) puts one scalar per line, inflating tabular results by + // a large constant factor. `structuredContent` carries the same data for structured-output clients. return { - content: [{ type: 'text', text: JSON.stringify(structuredContent, null, 2) }], + content: [{ type: 'text', text: JSON.stringify(structuredContent) }], structuredContent, }; } +/** + * Pull the correctness-critical signals out of a query plan so they survive even when the caller + * did not opt into the full `plan`. Returns an empty list when there is nothing to flag. + */ +function slQueryNotes(plan: Record | undefined): string[] { + if (!plan) { + return []; + } + const notes: string[] = []; + const execution = plan.execution; + if ( + execution && + typeof execution === 'object' && + (execution as Record).mode === 'compile_only' + ) { + const reason = (execution as Record).reason; + notes.push(typeof reason === 'string' ? reason : 'Compiled SQL only; no rows were executed.'); + } + if (plan.has_fan_out === true) { + const description = typeof plan.fan_out_description === 'string' ? plan.fan_out_description.trim() : ''; + notes.push(description.length > 0 ? description : 'Fan-out detected: measure totals may be inflated by joins.'); + } + return notes; +} + +/** + * Default sl_query response is the minimum the agent needs to read the result: connection, headers, + * rows, totals, plus any correctness notes. The generated `sql` and the full `plan` are attached only + * when explicitly requested via `include`, since both are large and echo information the caller already has. + */ +function projectSlQueryResult(result: KtxSemanticLayerQueryResponse, include: ('plan' | 'sql')[]) { + const notes = slQueryNotes(result.plan); + return { + ...(result.connectionId !== undefined ? { connectionId: result.connectionId } : {}), + ...(result.dialect !== undefined ? { dialect: result.dialect } : {}), + headers: result.headers, + rows: result.rows, + totalRows: result.totalRows, + ...(notes.length > 0 ? { notes } : {}), + ...(include.includes('sql') ? { sql: result.sql } : {}), + ...(include.includes('plan') && result.plan ? { plan: result.plan } : {}), + }; +} + function jsonErrorToolResult(text: string): KtxMcpToolResult> { return { content: [{ type: 'text', text }], @@ -618,23 +671,22 @@ export function registerKtxContextTools(deps: RegisterKtxContextToolsDeps): void slQuerySchema, async (input, context) => { const onProgress = mcpProgressCallback(context); - return jsonToolResult( - await semanticLayer.query( - { - connectionId: input.connectionId, - query: { - measures: input.measures, - dimensions: input.dimensions, - filters: input.filters, - segments: input.segments, - order_by: input.order_by, - limit: input.limit, - include_empty: input.include_empty, - }, + const result = await semanticLayer.query( + { + connectionId: input.connectionId, + query: { + measures: input.measures, + dimensions: input.dimensions, + filters: input.filters, + segments: input.segments, + order_by: input.order_by, + limit: input.limit, + include_empty: input.include_empty, }, - onProgress ? { onProgress } : undefined, - ), + }, + onProgress ? { onProgress } : undefined, ); + return jsonToolResult(projectSlQueryResult(result, input.include)); }, ); } diff --git a/packages/cli/src/context/mcp/types.ts b/packages/cli/src/context/mcp/types.ts index 29a8c069..e9fc0ff2 100644 --- a/packages/cli/src/context/mcp/types.ts +++ b/packages/cli/src/context/mcp/types.ts @@ -110,7 +110,10 @@ interface KtxSemanticLayerReadResponse { yaml: string; } -interface KtxSemanticLayerQueryResponse { +/** @internal */ +export interface KtxSemanticLayerQueryResponse { + connectionId?: string; + dialect?: string; sql: string; headers: string[]; rows: unknown[][]; diff --git a/packages/cli/src/context/search/discover.ts b/packages/cli/src/context/search/discover.ts index b3456459..9a572daf 100644 --- a/packages/cli/src/context/search/discover.ts +++ b/packages/cli/src/context/search/discover.ts @@ -167,7 +167,7 @@ async function wikiCandidates( query: input.query, userId: options.userId, embeddingService: options.embeddingService ?? null, - limit: Math.max(input.limit ?? 15, 25), + limit: Math.max(input.limit ?? 10, 25), }); const records: CandidateRecord[] = []; for (const result of searchResults) { @@ -421,7 +421,8 @@ function hydrate( } return { ...ref, - score: maxScore > 0 ? Number((candidate.score / maxScore).toFixed(6)) : 0, + // 3 decimals is plenty for a relative-rank hint; 6 just spent bytes on noise. + score: maxScore > 0 ? Number((candidate.score / maxScore).toFixed(3)) : 0, }; }) .filter((result): result is KtxDiscoverDataRef => result !== null); @@ -433,7 +434,7 @@ export function createKtxDiscoverDataService( ): { search(input: KtxDiscoverDataInput): Promise } { return { async search(input) { - const limit = Math.max(1, Math.min(input.limit ?? 15, 50)); + const limit = Math.max(1, Math.min(input.limit ?? 10, 50)); const query = input.query.trim(); if (!query) { return []; diff --git a/packages/cli/src/skills/analytics/SKILL.md b/packages/cli/src/skills/analytics/SKILL.md index e4aa86d2..e6857e56 100644 --- a/packages/cli/src/skills/analytics/SKILL.md +++ b/packages/cli/src/skills/analytics/SKILL.md @@ -28,7 +28,12 @@ You have access to KTX MCP tools for data discovery, semantic-layer analysis, ra - Read entity details before writing SQL against an unfamiliar table. Do not assume column names. - Treat `sql_execution` as read-only. Writes are rejected by the server. - Validate value mentions with `dictionary_search` instead of guessing case or spelling. Treat a `dictionary_search` miss as non-authoritative. The index is built from profile-sampled values, so a missing value may simply have been outside the sample. Follow up with `sql_execution` against the most plausible columns before concluding the value is absent. -- When `connection_list` shows multiple connections, pass an explicit `connectionId` to every tool that takes one and where user intent pins a specific warehouse. Required: `entity_details`, `sl_read_source`, and `sql_execution`. Required when user intent is warehouse-specific, including wording like "in our warehouse" or "this warehouse": `memory_ingest`; without `connectionId`, the memory agent cannot update the semantic layer and the knowledge lands as wiki-only. Pass `connectionId` when intent pins a warehouse, otherwise omit for unscoped discovery: `sl_query`, `discover_data`, and `dictionary_search`. Never pass `connectionId` to `connection_list`, `wiki_search`, `wiki_read`, or `memory_ingest_status`. If intent is ambiguous for a required-or-scoped tool, ask the user which warehouse before calling. +- `connectionId` scoping when `connection_list` shows multiple connections: + - Always pass it: `entity_details`, `sl_read_source`, `sql_execution`. + - Pass it when intent pins a warehouse, otherwise omit for unscoped discovery: `sl_query`, `discover_data`, `dictionary_search`. + - `memory_ingest`: pass it for warehouse-specific knowledge (e.g. "in our warehouse"); without it the memory lands as wiki-only and cannot update the semantic layer. + - Never pass it: `connection_list`, `wiki_search`, `wiki_read`, `memory_ingest_status`. + - If scoping is required but intent is ambiguous, ask which warehouse before calling. - Show compact result tables for small outputs. For broad results, summarize the top findings and mention the applied limit. - Ask a concise clarification only when the metric, date range, entity, or grain is genuinely ambiguous and cannot be inferred from context. diff --git a/packages/cli/test/context/mcp/__snapshots__/mcp-tools-list.json b/packages/cli/test/context/mcp/__snapshots__/mcp-tools-list.json index 10cb0b77..b38851f4 100644 --- a/packages/cli/test/context/mcp/__snapshots__/mcp-tools-list.json +++ b/packages/cli/test/context/mcp/__snapshots__/mcp-tools-list.json @@ -65,7 +65,7 @@ }, "limit": { "default": 10, - "description": "Maximum wiki pages to return. Defaults to 10.", + "description": "Maximum wiki pages to return.", "type": "integer", "minimum": 1, "maximum": 50 @@ -307,7 +307,7 @@ { "name": "sl_query", "title": "Semantic Layer Query", - "description": "Execute a semantic-layer query and return rows, headers, generated SQL, and plan details. Example: sl_query({ connectionId: \"warehouse\", measures: [\"orders.order_count\"], dimensions: [{ field: \"orders.created_at\", granularity: \"month\" }] }).", + "description": "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\"] }).", "inputSchema": { "type": "object", "properties": { @@ -403,7 +403,7 @@ }, "direction": { "default": "asc", - "description": "Sort direction: \"asc\" or \"desc\". Defaults to \"asc\".", + "description": "Sort direction for this field.", "type": "string", "enum": [ "asc", @@ -418,15 +418,27 @@ }, "limit": { "default": 1000, - "description": "Maximum rows to return. Defaults to 1000.", + "description": "Maximum rows to return.", "type": "integer", "minimum": 0, "maximum": 9007199254740991 }, "include_empty": { "default": true, - "description": "Whether to include empty dimension groups. Defaults to true.", + "description": "Whether to include empty dimension groups.", "type": "boolean" + }, + "include": { + "default": [], + "description": "Extra detail to attach to the response: \"sql\" for the generated SQL, \"plan\" for the full query plan.", + "type": "array", + "items": { + "type": "string", + "enum": [ + "plan", + "sql" + ] + } } }, "required": [ @@ -443,9 +455,6 @@ "dialect": { "type": "string" }, - "sql": { - "type": "string" - }, "headers": { "type": "array", "items": { @@ -462,6 +471,15 @@ "totalRows": { "type": "number" }, + "notes": { + "type": "array", + "items": { + "type": "string" + } + }, + "sql": { + "type": "string" + }, "plan": { "type": "object", "propertyNames": { @@ -471,7 +489,6 @@ } }, "required": [ - "sql", "headers", "rows", "totalRows" @@ -1241,8 +1258,8 @@ } }, "limit": { - "description": "Maximum refs to return. Defaults to 15.", - "default": 15, + "description": "Maximum refs to return.", + "default": 10, "type": "integer", "minimum": 1, "maximum": 50 @@ -1396,7 +1413,7 @@ "description": "Parser-validated read-only SQL, e.g. \"select count(*) from public.orders\"." }, "maxRows": { - "description": "Maximum rows to return. Defaults to 1000.", + "description": "Maximum rows to return.", "default": 1000, "type": "integer", "minimum": 1, diff --git a/packages/cli/test/context/mcp/server.test.ts b/packages/cli/test/context/mcp/server.test.ts index f6d3e37e..38bc4af9 100644 --- a/packages/cli/test/context/mcp/server.test.ts +++ b/packages/cli/test/context/mcp/server.test.ts @@ -307,16 +307,12 @@ describe('createKtxMcpServer', () => { content: [ { type: 'text', - text: JSON.stringify( - { - headers: ['status', 'count'], - headerTypes: ['text', 'bigint'], - rows: [['paid', 42]], - rowCount: 1, - }, - null, - 2, - ), + text: JSON.stringify({ + headers: ['status', 'count'], + headerTypes: ['text', 'bigint'], + rows: [['paid', 42]], + rowCount: 1, + }), }, ], structuredContent: { @@ -598,6 +594,92 @@ describe('createKtxMcpServer', () => { ); }); + it('sl_query default response omits plan and sql but keeps compile-only and fan-out notes', async () => { + const fake = makeFakeServer(); + const semanticLayer: KtxSemanticLayerMcpPort = { + readSource: vi.fn(), + query: vi.fn().mockResolvedValue({ + connectionId: 'warehouse', + dialect: 'postgres', + sql: 'select count(*) from public.orders', + headers: ['order_count'], + rows: [], + totalRows: 0, + plan: { + sources_used: ['orders'], + has_fan_out: true, + fan_out_description: 'orders fans out across line_items', + execution: { mode: 'compile_only', reason: 'No execution adapter configured.' }, + }, + }), + }; + + createKtxMcpServer({ + server: fake.server, + userContext: { userId: 'local-user' }, + contextTools: { semanticLayer }, + }); + + const result = await getTool(fake.tools, 'sl_query').handler({ + connectionId: 'warehouse', + measures: ['orders.order_count'], + }); + + expect(result).toMatchObject({ + structuredContent: { + connectionId: 'warehouse', + dialect: 'postgres', + headers: ['order_count'], + rows: [], + totalRows: 0, + notes: ['No execution adapter configured.', 'orders fans out across line_items'], + }, + }); + const structured = (result as { structuredContent: Record }).structuredContent; + expect(structured.sql).toBeUndefined(); + expect(structured.plan).toBeUndefined(); + }); + + it('sl_query attaches sql and plan only when include requests them', async () => { + const fake = makeFakeServer(); + const plan = { sources_used: ['orders'], execution: { mode: 'executed' } }; + const semanticLayer: KtxSemanticLayerMcpPort = { + readSource: vi.fn(), + query: vi.fn().mockResolvedValue({ + connectionId: 'warehouse', + dialect: 'postgres', + sql: 'select count(*) from public.orders', + headers: ['order_count'], + rows: [[3]], + totalRows: 1, + plan, + }), + }; + + createKtxMcpServer({ + server: fake.server, + userContext: { userId: 'local-user' }, + contextTools: { semanticLayer }, + }); + + const result = await getTool(fake.tools, 'sl_query').handler({ + connectionId: 'warehouse', + measures: ['orders.order_count'], + include: ['plan', 'sql'], + }); + + expect(result).toMatchObject({ + structuredContent: { + sql: 'select count(*) from public.orders', + plan, + rows: [[3]], + totalRows: 1, + }, + }); + const structured = (result as { structuredContent: Record }).structuredContent; + expect(structured.notes).toBeUndefined(); + }); + it('entity_details rejects sql-style schema table ref aliases', async () => { const fake = makeFakeServer(); const entityDetails = makeAllContextTools().entityDetails!; @@ -798,7 +880,7 @@ describe('createKtxMcpServer', () => { connectionId: '00000000-0000-4000-8000-000000000001', }), ).resolves.toEqual({ - content: [{ type: 'text', text: JSON.stringify({ runId: 'run-1' }, null, 2) }], + content: [{ type: 'text', text: JSON.stringify({ runId: 'run-1' }) }], structuredContent: { runId: 'run-1' }, }); expect(ingest.ingest).toHaveBeenCalledWith({ @@ -825,21 +907,17 @@ describe('createKtxMcpServer', () => { content: [ { type: 'text', - text: JSON.stringify( - { - runId: 'run-1', - status: 'done', - stage: 'done', - done: true, - captured: { wiki: ['revenue'], sl: [], xrefs: [] }, - error: null, - commitHash: 'abc123', - skillsLoaded: ['wiki_capture'], - signalDetected: true, - }, - null, - 2, - ), + text: JSON.stringify({ + runId: 'run-1', + status: 'done', + stage: 'done', + done: true, + captured: { wiki: ['revenue'], sl: [], xrefs: [] }, + error: null, + commitHash: 'abc123', + skillsLoaded: ['wiki_capture'], + signalDetected: true, + }), }, ], structuredContent: { @@ -1047,19 +1125,15 @@ describe('createKtxMcpServer', () => { content: [ { type: 'text', - text: JSON.stringify( - { - connections: [ - { - id: '00000000-0000-4000-8000-000000000001', - name: 'Warehouse', - connectionType: 'POSTGRES', - }, - ], - }, - null, - 2, - ), + text: JSON.stringify({ + connections: [ + { + id: '00000000-0000-4000-8000-000000000001', + name: 'Warehouse', + connectionType: 'POSTGRES', + }, + ], + }), }, ], structuredContent: { From 2e5f7f25aa0f586ca01f2aafeffa5e8e64cff1c5 Mon Sep 17 00:00:00 2001 From: Andrey Avtomonov Date: Sat, 30 May 2026 18:00:25 +0200 Subject: [PATCH 37/74] feat: report MCP client telemetry (#242) --- .../content/docs/community/telemetry.mdx | 20 ++++-- packages/cli/src/context/mcp/context-tools.ts | 27 +++++++- packages/cli/src/context/mcp/server.ts | 4 ++ packages/cli/src/context/mcp/types.ts | 12 ++++ packages/cli/src/telemetry/events.schema.json | 12 +++- packages/cli/src/telemetry/events.ts | 9 ++- packages/cli/src/telemetry/identity.ts | 22 +++--- packages/cli/src/telemetry/index.ts | 8 ++- packages/cli/test/context/mcp/server.test.ts | 46 ++++++++++++- .../test/telemetry/events.snapshot.test.ts | 4 +- packages/cli/test/telemetry/identity.test.ts | 69 +++++++++++++++++++ .../ktx_daemon/telemetry/events.schema.json | 12 +++- 12 files changed, 216 insertions(+), 29 deletions(-) diff --git a/docs-site/content/docs/community/telemetry.mdx b/docs-site/content/docs/community/telemetry.mdx index 9c22b432..81a4f91d 100644 --- a/docs-site/content/docs/community/telemetry.mdx +++ b/docs-site/content/docs/community/telemetry.mdx @@ -3,10 +3,14 @@ title: Telemetry description: Understand what anonymous usage telemetry ktx collects and how to opt out. --- -**ktx** collects anonymous, aggregated usage telemetry from interactive CLI -runs so maintainers can see which commands work, where setup fails, and which -parts of the data-agent workflow need improvement. Telemetry is opt-out and -disabled automatically in CI and non-interactive runs. +**ktx** collects anonymous, aggregated usage telemetry so maintainers can see +which commands work, where setup fails, and which parts of the data-agent +workflow need improvement. Telemetry is opt-out: it turns on the first time you +run **ktx** in an interactive terminal, which prints a one-time notice. From +then on the same install also reports background activity that has no terminal +of its own, such as the local MCP server your agent calls. It stays disabled in +CI, whenever an opt-out is set, and until that first interactive run has shown +the notice. ## Opt out @@ -17,8 +21,7 @@ Use any of these mechanisms to disable telemetry: | `export KTX_TELEMETRY_DISABLED=1` | Disables telemetry for the shell and child processes | | `export DO_NOT_TRACK=1` | Standard do-not-track environment variable | | `CI=1` | Automatic in CI | -| Non-TTY output | Automatic for pipes and scripts | -| Edit `~/.ktx/telemetry.json` and set `"enabled": false` | Persistent for the machine | +| Edit `~/.ktx/telemetry.json` and set `"enabled": false` | Persistent for the machine, including the MCP server | ## What we collect @@ -27,6 +30,11 @@ succeed or fail, and basic environment metadata (CLI version, Node version, OS platform). For project-level analysis, **ktx** sends a salted hash of the project directory — never the raw path. +When an agent reaches **ktx** through MCP, we also record the connecting client +tool's self-reported name and version (for example Claude Desktop, Cursor, or +Cline) so we can see which agents people use **ktx** with. That describes the +tool, never you or your data. + ## What we never collect - File paths, hostnames, environment variable values, or command arguments diff --git a/packages/cli/src/context/mcp/context-tools.ts b/packages/cli/src/context/mcp/context-tools.ts index 057e570b..03cd2ad4 100644 --- a/packages/cli/src/context/mcp/context-tools.ts +++ b/packages/cli/src/context/mcp/context-tools.ts @@ -6,6 +6,7 @@ import type { MemoryAgentInput } from '../../context/memory/types.js'; import { emitTelemetryEvent, mcpTelemetrySampleRate, shouldEmitMcpTelemetry } from '../../telemetry/index.js'; import { scrubErrorClass } from '../../telemetry/scrubber.js'; import type { + KtxMcpClientInfo, KtxMcpContextPorts, KtxMcpProgressCallback, KtxMcpServerLike, @@ -22,6 +23,7 @@ export interface RegisterKtxContextToolsDeps { userContext: KtxMcpUserContext; projectDir?: string; io?: KtxCliIo; + getClientInfo?: () => KtxMcpClientInfo | undefined; } const connectionIdSchema = z.string().min(1); @@ -526,9 +528,24 @@ function registerParsedTool( }); } +/** + * Resolves the connected client's identity into the raw telemetry fields. The + * strings are client-controlled and untrusted, so they only ever land in the + * telemetry property bag — never in paths, logs, or error messages. + */ +function clientTelemetryFields( + getClientInfo: (() => KtxMcpClientInfo | undefined) | undefined, +): { mcpClientName?: string; mcpClientVersion?: string } { + const client = getClientInfo?.(); + return { + ...(client?.name ? { mcpClientName: client.name } : {}), + ...(client?.version ? { mcpClientVersion: client.version } : {}), + }; +} + function instrumentMcpServer( server: KtxMcpServerLike, - telemetry: { projectDir?: string; io?: KtxCliIo }, + telemetry: { projectDir?: string; io?: KtxCliIo; getClientInfo?: () => KtxMcpClientInfo | undefined }, ): KtxMcpServerLike { return { registerTool(name, config, handler) { @@ -548,6 +565,7 @@ function instrumentMcpServer( outcome: isError ? 'error' : 'ok', durationMs: Math.max(0, performance.now() - startedAt), sampleRate: mcpTelemetrySampleRate(), + ...clientTelemetryFields(telemetry.getClientInfo), }, }); } @@ -565,6 +583,7 @@ function instrumentMcpServer( ...(errorClass ? { errorClass } : {}), durationMs: Math.max(0, performance.now() - startedAt), sampleRate: mcpTelemetrySampleRate(), + ...clientTelemetryFields(telemetry.getClientInfo), }, }); } @@ -577,7 +596,11 @@ function instrumentMcpServer( export function registerKtxContextTools(deps: RegisterKtxContextToolsDeps): void { const { ports, userContext } = deps; - const server = instrumentMcpServer(deps.server, { projectDir: deps.projectDir, io: deps.io }); + const server = instrumentMcpServer(deps.server, { + projectDir: deps.projectDir, + io: deps.io, + getClientInfo: deps.getClientInfo, + }); if (ports.connections) { const connections = ports.connections; diff --git a/packages/cli/src/context/mcp/server.ts b/packages/cli/src/context/mcp/server.ts index 97d79525..85871467 100644 --- a/packages/cli/src/context/mcp/server.ts +++ b/packages/cli/src/context/mcp/server.ts @@ -11,6 +11,7 @@ export function createKtxMcpServer(deps: KtxMcpServerDeps): KtxMcpServerDeps['se userContext: deps.userContext, projectDir: deps.projectDir, io: deps.io, + getClientInfo: deps.getClientInfo, }); } @@ -30,6 +31,9 @@ export function createDefaultKtxMcpServer( contextTools: deps.contextTools, projectDir: deps.projectDir, io: deps.io, + // The SDK populates the client identity after the initialize handshake, so + // read it lazily at emit time rather than at registration (undefined here). + getClientInfo: () => server.server.getClientVersion(), }); return server; } diff --git a/packages/cli/src/context/mcp/types.ts b/packages/cli/src/context/mcp/types.ts index e9fc0ff2..3694e3d6 100644 --- a/packages/cli/src/context/mcp/types.ts +++ b/packages/cli/src/context/mcp/types.ts @@ -50,6 +50,16 @@ export interface KtxMcpUserContext { userId: string; } +/** + * Identity of the connected MCP client tool (e.g. Claude Desktop, Cursor), + * read from the initialize handshake. Untrusted, client-controlled strings — + * use only as telemetry properties, never to build paths or log lines. + */ +export interface KtxMcpClientInfo { + name: string; + version: string; +} + export interface KtxMcpServerLike { registerTool( name: string, @@ -177,4 +187,6 @@ export interface KtxMcpServerDeps { contextTools?: KtxMcpContextPorts; projectDir?: string; io?: KtxCliIo; + /** Reads the connected client's identity once the initialize handshake completes. */ + getClientInfo?: () => KtxMcpClientInfo | undefined; } diff --git a/packages/cli/src/telemetry/events.schema.json b/packages/cli/src/telemetry/events.schema.json index 628c8f4b..acad7988 100644 --- a/packages/cli/src/telemetry/events.schema.json +++ b/packages/cli/src/telemetry/events.schema.json @@ -157,7 +157,9 @@ "outcome", "durationMs", "errorClass", - "sampleRate" + "sampleRate", + "mcpClientName", + "mcpClientVersion" ] }, { @@ -1131,7 +1133,13 @@ }, "sampleRate": { "type": "number", - "const": 0.1 + "const": 1 + }, + "mcpClientName": { + "type": "string" + }, + "mcpClientVersion": { + "type": "string" } }, "required": [ diff --git a/packages/cli/src/telemetry/events.ts b/packages/cli/src/telemetry/events.ts index 5e5b5335..e751cd70 100644 --- a/packages/cli/src/telemetry/events.ts +++ b/packages/cli/src/telemetry/events.ts @@ -156,7 +156,12 @@ const mcpRequestCompletedSchema = telemetryCommonEnvelopeSchema outcome: outcomeSchema, durationMs: z.number().nonnegative(), errorClass: z.string().optional(), - sampleRate: z.literal(0.1), + sampleRate: z.literal(1), + // Raw, client-tool-controlled identity from the MCP initialize handshake + // (clientInfo.name/version). Optional: clients may omit clientInfo. Stored + // verbatim — normalize the free-form names at query time, not at write time. + mcpClientName: z.string().optional(), + mcpClientVersion: z.string().optional(), }) .strict(); @@ -325,7 +330,7 @@ export const telemetryEventCatalog = [ { name: 'mcp_request_completed', description: 'Emitted for sampled MCP tool requests.', - fields: ['toolName', 'outcome', 'durationMs', 'errorClass', 'sampleRate'], + fields: ['toolName', 'outcome', 'durationMs', 'errorClass', 'sampleRate', 'mcpClientName', 'mcpClientVersion'], }, { name: 'daemon_started', diff --git a/packages/cli/src/telemetry/identity.ts b/packages/cli/src/telemetry/identity.ts index 4d46307c..69985f00 100644 --- a/packages/cli/src/telemetry/identity.ts +++ b/packages/cli/src/telemetry/identity.ts @@ -75,17 +75,14 @@ export async function loadTelemetryIdentity(options: LoadTelemetryIdentityOption const env = options.env ?? process.env; const path = telemetryPath(options.homeDir ?? homedir()); - if (envDisablesTelemetry(env) || options.stdoutIsTTY !== true) { - const existing = await readTelemetryFile(path); - return { - installId: existing?.installId, - enabled: false, - createdFile: false, - noticeShown: false, - path, - }; + if (envDisablesTelemetry(env)) { + return { enabled: false, createdFile: false, noticeShown: false, path }; } + // Honor an already-consented identity regardless of the current surface. + // Telemetry enablement follows the persisted decision and opt-out env vars, + // not whether this invocation happens to own a TTY — MCP servers always run + // headless (stdio stubs stdout; the HTTP server runs detached). const existing = await readTelemetryFile(path); if (existing) { return { @@ -97,6 +94,13 @@ export async function loadTelemetryIdentity(options: LoadTelemetryIdentityOption }; } + // No identity yet. Minting one means showing the one-time opt-out notice, so + // first-run creation requires an interactive surface; a headless first run + // stays disabled and defers enablement until the next interactive run. + if (options.stdoutIsTTY !== true) { + return { enabled: false, createdFile: false, noticeShown: false, path }; + } + const timestamp = (options.now ?? (() => new Date()))().toISOString(); const next = { installId: randomUUID(), diff --git a/packages/cli/src/telemetry/index.ts b/packages/cli/src/telemetry/index.ts index 27c5004a..c5b9b729 100644 --- a/packages/cli/src/telemetry/index.ts +++ b/packages/cli/src/telemetry/index.ts @@ -52,7 +52,11 @@ type TelemetryEventFields = Omit< >; const emittedProjectSnapshots = new Set(); -const MCP_SAMPLE_RATE = 0.1 as const; +// MCP tool calls are captured at full rate while ktx is early-stage: at current +// install counts any sampling below 1.0 yields too few events to be useful, and +// the recorded sampleRate lets us dial this down (and reweight history) once +// per-session call volume justifies it. +const MCP_SAMPLE_RATE = 1 as const; let mcpSampled: boolean | undefined; function telemetryDebugEnabled(): boolean { @@ -64,7 +68,7 @@ export function shouldEmitMcpTelemetry(): boolean { return mcpSampled; } -export function mcpTelemetrySampleRate(): 0.1 { +export function mcpTelemetrySampleRate(): 1 { return MCP_SAMPLE_RATE; } diff --git a/packages/cli/test/context/mcp/server.test.ts b/packages/cli/test/context/mcp/server.test.ts index 38bc4af9..95985d68 100644 --- a/packages/cli/test/context/mcp/server.test.ts +++ b/packages/cli/test/context/mcp/server.test.ts @@ -47,10 +47,10 @@ function makeFakeServer() { }; } -function makeIo() { +function makeIo(stdoutIsTTY = true) { let stderr = ''; return { - stdout: { isTTY: true, write() {} }, + stdout: { isTTY: stdoutIsTTY, write() {} }, stderr: { write(chunk: string) { stderr += chunk; @@ -272,8 +272,48 @@ describe('createKtxMcpServer', () => { expect(io.stderrText()).toContain('"event":"mcp_request_completed"'); expect(io.stderrText()).toContain('"toolName":"wiki_search"'); - expect(io.stderrText()).toContain('"sampleRate":0.1'); + expect(io.stderrText()).toContain('"sampleRate":1'); expect(io.stderrText()).not.toContain(projectDir); + // No client connected through the SDK here, so getClientInfo is absent: the + // event still emits and the optional client fields are simply omitted. + expect(io.stderrText()).not.toContain('mcpClientName'); + expect(io.stderrText()).not.toContain('mcpClientVersion'); + }); + + it('captures the connecting MCP client name and version', async () => { + vi.stubEnv('KTX_TELEMETRY_DEBUG', '1'); + vi.stubEnv('CI', ''); + // Non-TTY io keeps the test hermetic (no ~/.ktx/telemetry.json is created) + // and mirrors a real headless MCP server; debug mode still emits the payload. + const io = makeIo(false); + + const server = createDefaultKtxMcpServer({ + name: 'ktx-test', + version: '0.0.0-test', + userContext: { userId: 'mcp-user' }, + projectDir: '/tmp/ktx-mcp-client-telemetry', + io, + contextTools: { + knowledge: { + search: vi.fn().mockResolvedValue({ results: [], totalFound: 0 }), + read: vi.fn().mockResolvedValue(null), + }, + }, + }); + const client = new Client({ name: 'test-agent', version: '9.9.9' }); + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + await Promise.all([server.connect(serverTransport), client.connect(clientTransport)]); + + try { + await client.callTool({ name: 'wiki_search', arguments: { query: 'revenue recognition', limit: 5 } }); + } finally { + await client.close(); + await server.close(); + } + + expect(io.stderrText()).toContain('"event":"mcp_request_completed"'); + expect(io.stderrText()).toContain('"mcpClientName":"test-agent"'); + expect(io.stderrText()).toContain('"mcpClientVersion":"9.9.9"'); }); it('registers parser-gated sql_execution when the host provides a SQL execution port', async () => { diff --git a/packages/cli/test/telemetry/events.snapshot.test.ts b/packages/cli/test/telemetry/events.snapshot.test.ts index 7a1b76f7..1ea67339 100644 --- a/packages/cli/test/telemetry/events.snapshot.test.ts +++ b/packages/cli/test/telemetry/events.snapshot.test.ts @@ -128,7 +128,9 @@ describe('telemetry privacy snapshot', () => { outcome: 'error', errorClass: 'KtxProjectMissingAbortError', durationMs: 12, - sampleRate: 0.1, + sampleRate: 1, + mcpClientName: 'Claude Desktop', + mcpClientVersion: '0.7.1', }), ]; diff --git a/packages/cli/test/telemetry/identity.test.ts b/packages/cli/test/telemetry/identity.test.ts index 31c3bfb5..e5b6bddf 100644 --- a/packages/cli/test/telemetry/identity.test.ts +++ b/packages/cli/test/telemetry/identity.test.ts @@ -146,6 +146,75 @@ describe('telemetry identity', () => { }); }); + it('enables a consented identity without a TTY (MCP servers run headless)', async () => { + await mkdir(join(homeDir, '.ktx'), { recursive: true }); + await writeFile( + join(homeDir, '.ktx', 'telemetry.json'), + JSON.stringify( + { + installId: '00000000-0000-4000-8000-000000000000', + enabled: true, + noticeShownAt: '2026-05-22T14:33:02.000Z', + noticeShownVersion: 1, + createdAt: '2026-05-22T14:33:02.000Z', + }, + null, + 2, + ) + '\n', + 'utf-8', + ); + const testIo = makeIo(false); + + await expect( + loadTelemetryIdentity({ + homeDir, + env, + stdoutIsTTY: false, + stderr: testIo.io.stderr, + now: () => new Date('2026-05-22T15:00:00.000Z'), + }), + ).resolves.toMatchObject({ + installId: '00000000-0000-4000-8000-000000000000', + enabled: true, + createdFile: false, + noticeShown: false, + }); + // The one-time notice belongs to interactive surfaces only; a headless load + // must never write it (the MCP stdio protocol shares the process streams). + expect(testIo.stderr()).toBe(''); + }); + + it('keeps opt-outs suppressing a consented identity without a TTY', async () => { + await mkdir(join(homeDir, '.ktx'), { recursive: true }); + await writeFile( + join(homeDir, '.ktx', 'telemetry.json'), + JSON.stringify( + { + installId: '00000000-0000-4000-8000-000000000000', + enabled: true, + noticeShownAt: '2026-05-22T14:33:02.000Z', + noticeShownVersion: 1, + createdAt: '2026-05-22T14:33:02.000Z', + }, + null, + 2, + ) + '\n', + 'utf-8', + ); + + for (const optOut of [{ KTX_TELEMETRY_DISABLED: '1' }, { DO_NOT_TRACK: '1' }, { CI: '1' }]) { + await expect( + loadTelemetryIdentity({ + homeDir, + env: optOut, + stdoutIsTTY: false, + stderr: makeIo(false).io.stderr, + now: () => new Date('2026-05-22T15:00:00.000Z'), + }), + ).resolves.toMatchObject({ enabled: false }); + } + }); + it('recreates a corrupted file instead of surfacing an error to users', async () => { await mkdir(join(homeDir, '.ktx'), { recursive: true }); await writeFile(join(homeDir, '.ktx', 'telemetry.json'), '{bad json', 'utf-8'); diff --git a/python/ktx-daemon/src/ktx_daemon/telemetry/events.schema.json b/python/ktx-daemon/src/ktx_daemon/telemetry/events.schema.json index 628c8f4b..acad7988 100644 --- a/python/ktx-daemon/src/ktx_daemon/telemetry/events.schema.json +++ b/python/ktx-daemon/src/ktx_daemon/telemetry/events.schema.json @@ -157,7 +157,9 @@ "outcome", "durationMs", "errorClass", - "sampleRate" + "sampleRate", + "mcpClientName", + "mcpClientVersion" ] }, { @@ -1131,7 +1133,13 @@ }, "sampleRate": { "type": "number", - "const": 0.1 + "const": 1 + }, + "mcpClientName": { + "type": "string" + }, + "mcpClientVersion": { + "type": "string" } }, "required": [ From 95a265323a50971c83db6e8150f329a06fbc5566 Mon Sep 17 00:00:00 2001 From: Andrey Avtomonov Date: Sat, 30 May 2026 18:33:14 +0200 Subject: [PATCH 38/74] feat(telemetry): enable PostHog GeoIP enrichment (#243) Set disableGeoip: false on the CLI telemetry client so events are enriched with approximate, IP-based location at ingest. Update the first-run notice, public telemetry docs, and the AGENTS telemetry policy to drop the prior "anonymous" wording to match. --- AGENTS.md | 2 +- docs-site/content/docs/community/telemetry.mdx | 4 ++-- packages/cli/src/telemetry/emitter.ts | 2 +- packages/cli/src/telemetry/identity.ts | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 2aa0dbed..ea79a0a9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -278,7 +278,7 @@ use `PascalCase` without the suffix. ## Telemetry -**ktx** ships anonymous PostHog telemetry. When adding commands or events: +**ktx** ships PostHog usage telemetry. When adding commands or events: - **MUST NOT**: Add fields that carry user data — file paths, hostnames, environment values, SQL text, schema/table/column names, error messages, diff --git a/docs-site/content/docs/community/telemetry.mdx b/docs-site/content/docs/community/telemetry.mdx index 81a4f91d..c2a9af21 100644 --- a/docs-site/content/docs/community/telemetry.mdx +++ b/docs-site/content/docs/community/telemetry.mdx @@ -1,9 +1,9 @@ --- title: Telemetry -description: Understand what anonymous usage telemetry ktx collects and how to opt out. +description: Understand what usage telemetry ktx collects and how to opt out. --- -**ktx** collects anonymous, aggregated usage telemetry so maintainers can see +**ktx** collects aggregated usage telemetry so maintainers can see which commands work, where setup fails, and which parts of the data-agent workflow need improvement. Telemetry is opt-out: it turns on the first time you run **ktx** in an interactive terminal, which prints a one-time notice. From diff --git a/packages/cli/src/telemetry/emitter.ts b/packages/cli/src/telemetry/emitter.ts index 435a122b..3344e00b 100644 --- a/packages/cli/src/telemetry/emitter.ts +++ b/packages/cli/src/telemetry/emitter.ts @@ -44,7 +44,7 @@ async function getPostHogClient(projectApiKey: string, host: string): Promise new PostHog(projectApiKey, { host, flushAt: 1, flushInterval: 0 })) + .then(({ PostHog }) => new PostHog(projectApiKey, { host, flushAt: 1, flushInterval: 0, disableGeoip: false })) .catch(() => null); return await clientPromise; diff --git a/packages/cli/src/telemetry/identity.ts b/packages/cli/src/telemetry/identity.ts index 69985f00..d699ea1f 100644 --- a/packages/cli/src/telemetry/identity.ts +++ b/packages/cli/src/telemetry/identity.ts @@ -6,7 +6,7 @@ import { z } from 'zod'; /** @internal */ export const TELEMETRY_NOTICE = - 'ktx collects anonymous usage data to improve the product. Opt out: set KTX_TELEMETRY_DISABLED=1.'; + 'ktx collects usage data to improve the product. Opt out: set KTX_TELEMETRY_DISABLED=1.'; const NOTICE_VERSION = 1; From 2058c26e84304e6a2dede650e991b507f396d9cc Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sat, 30 May 2026 18:28:06 +0000 Subject: [PATCH 39/74] chore: refresh star history chart [skip ci] --- assets/star-history.svg | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/assets/star-history.svg b/assets/star-history.svg index 3f6c4a04..39efe2b6 100644 --- a/assets/star-history.svg +++ b/assets/star-history.svg @@ -1 +1 @@ -star-history.comMay 17May 24 100200300400kaelio/ktxStar HistoryDateGitHub Stars +star-history.comMay 17May 24 100200300400500kaelio/ktxStar HistoryDateGitHub Stars From c196d1f192d83f80abaaaa2a9da15a24802beaca Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sun, 31 May 2026 18:29:55 +0000 Subject: [PATCH 40/74] chore: refresh star history chart [skip ci] --- assets/star-history.svg | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/assets/star-history.svg b/assets/star-history.svg index 39efe2b6..16b5fc6a 100644 --- a/assets/star-history.svg +++ b/assets/star-history.svg @@ -1 +1 @@ -star-history.comMay 17May 24 100200300400500kaelio/ktxStar HistoryDateGitHub Stars +star-history.comMay 17May 24May 31 100200300400500600kaelio/ktxStar HistoryDateGitHub Stars From d320d54ab29f4d979027188665d3172f5a32e327 Mon Sep 17 00:00:00 2001 From: Andrey Avtomonov Date: Sun, 31 May 2026 23:44:33 +0200 Subject: [PATCH 41/74] feat(cli): shell completion for commands, flags, and entity names (#244) * feat(completion): complete known argument values * fix(completion): hide Commander-hidden subcommands from completions Replace the `__`-prefix name heuristic with Commander's `_hidden` flag so internal subcommands registered with { hidden: true } (e.g. `mcp serve-internal`) are excluded from completions, mirroring `ktx --help`. * test: cover wiki and sl read command routing * test: cover raw wiki and sl reads * feat: add wiki read command * feat: add sl read command * feat: complete read command entity names * docs: document wiki and sl read commands * test: include read commands in command tree * feat(sl): read and validate unique sources by name * feat(sl): make read and validate connection id optional * fix(completion): dedupe semantic source names * docs(sl): document connection-optional read and validate * fix(sl): require connection id for query command * docs(sl): clarify query connection requirement * fix(completion): don't resolve option values as subcommands resolveCommand skipped flag tokens but not the value consumed by a value-taking option in the `--flag value` form, so a connection id like `query` was matched as the `sl query` subcommand and yielded no `sl` completions. Track value-taking options and skip their consumed value before matching subcommands. * test(telemetry): assert first-run notice via TELEMETRY_NOTICE constant CI (which tests this branch merged with main) failed because #243 changed the first-run notice wording in identity.ts (dropped "anonymous") but left this test grepping for the old literal 'ktx collects anonymous usage data', so indexOf returned -1. Assert against the exported TELEMETRY_NOTICE constant instead so the test tracks the source of truth and cannot drift when the notice text changes again. --- .../docs/cli-reference/ktx-completion.mdx | 86 ++++++ .../content/docs/cli-reference/ktx-sl.mdx | 45 ++- .../content/docs/cli-reference/ktx-wiki.mdx | 28 +- docs-site/content/docs/cli-reference/ktx.mdx | 7 + .../content/docs/cli-reference/meta.json | 3 +- packages/cli/src/cli-program.ts | 7 + packages/cli/src/command-tree.ts | 6 +- .../cli/src/commands/completion-commands.ts | 44 +++ .../cli/src/commands/knowledge-commands.ts | 18 +- packages/cli/src/commands/sl-commands.ts | 29 +- .../cli/src/completion/complete-engine.ts | 172 ++++++++++++ .../cli/src/completion/completion-scripts.ts | 39 +++ .../cli/src/completion/dynamic-candidates.ts | 103 +++++++ packages/cli/src/context/sl/local-sl.ts | 42 +++ .../cli/src/context/wiki/local-knowledge.ts | 26 ++ packages/cli/src/knowledge.ts | 20 +- packages/cli/src/sl.ts | 59 +++- .../cli/test/cli-program-telemetry.test.ts | 3 +- .../commands/wiki-sl-read-commands.test.ts | 157 +++++++++++ .../test/completion/complete-engine.test.ts | 137 +++++++++ .../completion/completion-scripts.test.ts | 20 ++ .../completion/dynamic-candidates.test.ts | 103 +++++++ packages/cli/test/context/sl/local-sl.test.ts | 96 +++++++ .../test/context/wiki/local-knowledge.test.ts | 30 ++ packages/cli/test/index.test.ts | 55 +++- packages/cli/test/knowledge.test.ts | 40 +++ packages/cli/test/print-command-tree.test.ts | 14 +- packages/cli/test/sl.test.ts | 261 ++++++++++++++++++ 28 files changed, 1596 insertions(+), 54 deletions(-) create mode 100644 docs-site/content/docs/cli-reference/ktx-completion.mdx create mode 100644 packages/cli/src/commands/completion-commands.ts create mode 100644 packages/cli/src/completion/complete-engine.ts create mode 100644 packages/cli/src/completion/completion-scripts.ts create mode 100644 packages/cli/src/completion/dynamic-candidates.ts create mode 100644 packages/cli/test/commands/wiki-sl-read-commands.test.ts create mode 100644 packages/cli/test/completion/complete-engine.test.ts create mode 100644 packages/cli/test/completion/completion-scripts.test.ts create mode 100644 packages/cli/test/completion/dynamic-candidates.test.ts diff --git a/docs-site/content/docs/cli-reference/ktx-completion.mdx b/docs-site/content/docs/cli-reference/ktx-completion.mdx new file mode 100644 index 00000000..94f1c383 --- /dev/null +++ b/docs-site/content/docs/cli-reference/ktx-completion.mdx @@ -0,0 +1,86 @@ +--- +title: "ktx completion" +description: "Print a shell completion script for tab completion." +--- + +Print a shell completion script for **ktx**. Once installed, pressing Tab +completes commands, subcommands, and flags, and - inside a **ktx** project - the +names of things that already exist: semantic-layer source names for +`ktx sl read` and `ktx sl validate`, wiki page keys for `ktx wiki read`, and +configured connection ids for `ktx connection test`, `ktx ingest`, and +`ktx sql`. This saves you from remembering exact source, page, or connection +names. + +## Command signature + +```bash +ktx completion +``` + +`` must be `zsh` or `bash`. The command writes the script to stdout; it +does not modify any files. Enable completion by evaluating the script in your +shell startup file. + +## Installation + +Add the matching line to your shell startup file, then restart your shell (or +`source` the file). `ktx` must be on your `PATH`. + +```bash +# zsh — add to ~/.zshrc +eval "$(ktx completion zsh)" +``` + +```bash +# bash — add to ~/.bashrc +eval "$(ktx completion bash)" +``` + +To try it for the current session only, run the same `eval` line directly in +your terminal. + +## What gets completed + +| Position | Completions | +|----------|-------------| +| `ktx ` | Top-level commands (`setup`, `sl`, `wiki`, `ingest`, …) | +| `ktx sl ` | The `read` / `validate` / `query` subcommands | +| `ktx sl read ` | Existing semantic-layer source names | +| `ktx sl validate ` | Existing semantic-layer source names | +| `ktx wiki ` | The `read` subcommand | +| `ktx wiki read ` | Existing wiki page keys | +| `ktx connection test ` | Configured connection ids | +| `ktx ingest ` | Configured connection ids | +| `ktx sql --connection ` | Configured connection ids | +| `ktx completion ` | `zsh` or `bash` | +| `ktx --` | The command's flags and inherited global flags | +| `ktx sl --output ` | An option's allowed values (here `pretty`, `plain`, `json`) | +| `ktx sl --connection-id ` | Configured connection ids | + +Source names, wiki page keys, and connection ids are read from the **ktx** +project resolved from your current directory (or `--project-dir` / +`KTX_PROJECT_DIR`). Outside a **ktx** project, completion still suggests +commands and flags but no project entities. Bare `ktx sl ` and +`ktx wiki ` complete subcommands instead of entity names because their +positional arguments are free-text search queries. + +## Examples + +```bash +# Print the zsh completion script +ktx completion zsh + +# Print the bash completion script +ktx completion bash + +# Install for zsh +echo 'eval "$(ktx completion zsh)"' >> ~/.zshrc +``` + +## Common errors + +| Error | Cause | Recovery | +|-------|-------|----------| +| `error: command-argument value '' is invalid for argument 'shell'. Allowed choices are zsh, bash.` | A shell other than `zsh` or `bash` was requested | Re-run with `ktx completion zsh` or `ktx completion bash` | +| Tab completion does nothing | The script was not evaluated, or `ktx` is not on `PATH` | Confirm the `eval` line is in your startup file, restart the shell, and verify `ktx --version` runs | +| Source, page, or connection names are missing | The current directory is not inside a **ktx** project | Run from the project directory, or pass `--project-dir`, or set `KTX_PROJECT_DIR` | diff --git a/docs-site/content/docs/cli-reference/ktx-sl.mdx b/docs-site/content/docs/cli-reference/ktx-sl.mdx index 2dfba7ab..9e957d4e 100644 --- a/docs-site/content/docs/cli-reference/ktx-sl.mdx +++ b/docs-site/content/docs/cli-reference/ktx-sl.mdx @@ -11,13 +11,16 @@ the vocabulary agents use to generate correct SQL. ```bash ktx sl [options] [query...] # list (bare) or search (with query) -ktx sl validate [options] +ktx sl read +ktx sl validate ktx sl query [options] ``` - Bare `ktx sl` lists semantic sources. -- `ktx sl ` searches semantic sources (multi-word queries are - joined with a space). +- `ktx sl ` searches semantic sources. Multi-word queries are joined + with a space. +- `ktx sl read ` prints the YAML for one source. Add + `--connection-id` only when the source name exists in multiple connections. - `ktx sl validate` and `ktx sl query` remain as explicit subcommands. ## Subcommands @@ -26,6 +29,7 @@ ktx sl query [options] |-----------|-------------| | (none, no query) | List semantic sources | | (none, with query) | Search semantic sources | +| `read ` | Print the YAML for one semantic source | | `validate ` | Validate a semantic source against the database schema | | `query` | Compile or execute a semantic query | @@ -40,17 +44,23 @@ ktx sl query [options] | `--output ` | Output mode: `pretty` (default in TTY), `plain` (TSV), or `json` | `pretty` | | `--json` | Shortcut for `--output=json` (overrides `--output`) | `false` | +### `sl read` + +| Flag | Description | Default | +|------|-------------|---------| +| `--connection-id ` | Optional **ktx** connection id for disambiguation | - | + ### `sl validate` | Flag | Description | Default | |------|-------------|---------| -| `--connection-id ` | **ktx** connection id (required) | - | +| `--connection-id ` | Optional **ktx** connection id for disambiguation | - | ### `sl query` | Flag | Description | Default | |------|-------------|---------| -| `--connection-id ` | **ktx** connection id | - | +| `--connection-id ` | Required **ktx** connection id | - | | `--query-file ` | JSON semantic query file | - | | `--measure ` | Measure to query; repeatable (at least one required) | - | | `--dimension ` | Dimension to include; repeatable | - | @@ -65,8 +75,9 @@ ktx sl query [options] | `--no-input` | Disable interactive managed runtime installation | - | | `--max-rows ` | Maximum rows to return when executing | - | -`sl query` requires at least one `--measure` unless `--query-file` is set. -`--query-file` should point to a JSON semantic query object. +`sl query` requires `--connection-id` and at least one `--measure` unless +`--query-file` is set. `--query-file` must point to a JSON semantic query +object. ## Examples @@ -83,7 +94,16 @@ ktx sl --json # Search sources as JSON ktx sl "revenue" --json -# Validate a source against the live schema +# Print the YAML for a source name that is unique across connections +ktx sl read orders + +# Print the YAML for a source name that exists in multiple connections +ktx sl --connection-id my-warehouse read orders + +# Validate a source name that is unique across connections +ktx sl validate orders + +# Validate a source name that exists in multiple connections ktx sl validate orders --connection-id my-warehouse # Compile a query and view the generated SQL @@ -144,6 +164,12 @@ shows `#1`, `#2`, and later rank badges for the displayed results. Plain and JSON output keep the raw `score` value, which is a ranking score rather than a percentage. +`ktx sl read ` prints the source YAML directly to stdout when the +source name is unique across connections. If the name exists in multiple +connections, rerun the command with `--connection-id `. The command does +not wrap output in pretty, plain, or JSON formatting, so it can be piped to +other tools. + ```json { "sql": "SELECT orders.status, SUM(orders.total_amount) AS total_revenue FROM public.orders GROUP BY orders.status", @@ -160,7 +186,8 @@ percentage. | Error | Cause | Recovery | |-------|-------|----------| -| Source not found | Source name or connection id is wrong | Run `ktx sl --json` and retry with an exact source name and connection id | +| Source not found | Source name or connection id is wrong | Run `ktx sl ` or `ktx sl --connection-id ` to find the exact source name, then retry `ktx sl read ` or `ktx sl validate ` | +| Source name is ambiguous | The same source name exists in multiple connections | Rerun with `--connection-id ` from the error message | | Validation fails | YAML references missing columns, invalid joins, or invalid SQL expressions | Fix the source YAML and rerun `ktx sl validate` | | Query compile fails | Measure, dimension, filter, or segment name is invalid | Search sources with `ktx sl `, inspect the source YAML in your project files, then retry using declared fields | | Execution returns too many rows | `--max-rows` is missing or too high | Add `--max-rows` with a bounded value before executing | diff --git a/docs-site/content/docs/cli-reference/ktx-wiki.mdx b/docs-site/content/docs/cli-reference/ktx-wiki.mdx index 2d52d5af..7887a463 100644 --- a/docs-site/content/docs/cli-reference/ktx-wiki.mdx +++ b/docs-site/content/docs/cli-reference/ktx-wiki.mdx @@ -1,21 +1,24 @@ --- title: "ktx wiki" -description: "List or search wiki pages." +description: "List, search, or read wiki pages." --- -List and search wiki pages in your **ktx** project. Wiki pages are Markdown -documents that capture business definitions, rules, and gotchas. Agents search -them for context when answering questions about your data. +List, search, and read wiki pages in your **ktx** project. Wiki pages are +Markdown documents that capture business definitions, rules, and gotchas. +Agents search them for context when answering questions about your data. ## Command signature ```bash -ktx wiki [options] [query...] +ktx wiki [options] [query...] # list (bare) or search (with query) +ktx wiki read ``` - Bare `ktx wiki` lists local wiki pages. -- `ktx wiki ` searches local wiki pages (multi-word queries are - joined with a space). +- `ktx wiki ` searches local wiki pages. Multi-word queries are + joined with a space. +- `ktx wiki read ` prints the whole Markdown file for one wiki page, + including YAML frontmatter. Edit the Markdown files under `wiki/` directly, or ingest source content with `ktx ingest`, when you need to add or update wiki knowledge. @@ -50,6 +53,9 @@ ktx wiki "monthly recurring revenue" # Search wiki pages as JSON ktx wiki "monthly recurring revenue" --json --limit 10 +# Print the exact Markdown file for a known page key +ktx wiki read revenue-definitions + # Print search results as TSV ktx wiki "monthly recurring revenue" --output plain @@ -62,8 +68,10 @@ ktx --debug wiki "monthly recurring revenue" --json Wiki commands print clack-style pretty output in a TTY and TSV-style plain output when requested. JSON output wraps the items with a command metadata envelope. Search results include `matchReasons` and `lanes` metadata so you can -see whether lexical, token, or semantic search contributed to the ranking. Open -the matching Markdown files directly when you need the full page contents. +see whether lexical, token, or semantic search contributed to the ranking. Use +`ktx wiki read ` when you need the full page contents. Read output is the +exact Markdown file stored on disk, including YAML frontmatter, and is not +wrapped in pretty, plain, or JSON formatting. Pretty search output shows `#1`, `#2`, and later rank badges for the displayed results. Plain and JSON output keep the raw `score` value, which is a ranking score rather than a percentage. @@ -121,4 +129,4 @@ stays machine-readable: | Error | Cause | Recovery | |-------|-------|----------| | Search returns no results | The query terms do not match summaries, tags, or content, and the semantic lane is unavailable or has no positive matches | Run with `--debug`, check the semantic lane status, retry with business synonyms, then create a page if the knowledge is missing | -| A page is missing | No Markdown file exists for that business context | Add a file under `wiki/` or run `ktx ingest ` | +| A page is missing | No Markdown file exists for that business context or `ktx wiki read ` used the wrong key | Run `ktx wiki ` to find the page key, then retry `ktx wiki read ` | diff --git a/docs-site/content/docs/cli-reference/ktx.mdx b/docs-site/content/docs/cli-reference/ktx.mdx index 010100d8..8b9a2cc5 100644 --- a/docs-site/content/docs/cli-reference/ktx.mdx +++ b/docs-site/content/docs/cli-reference/ktx.mdx @@ -36,9 +36,11 @@ ktx wiki list search + read sl list search + read validate query sql @@ -57,6 +59,7 @@ ktx stop status reindex + completion ``` The public context-build entrypoint is `ktx ingest [connectionId]` or @@ -97,6 +100,10 @@ ktx ingest ktx sl "revenue" ktx wiki "revenue recognition" +# Print a known wiki page or semantic source +ktx wiki read revenue-definitions +ktx sl --connection-id warehouse read orders + # Execute read-only SQL ktx sql --connection warehouse "select count(*) from public.orders" diff --git a/docs-site/content/docs/cli-reference/meta.json b/docs-site/content/docs/cli-reference/meta.json index 49eb8ba7..2902f2c6 100644 --- a/docs-site/content/docs/cli-reference/meta.json +++ b/docs-site/content/docs/cli-reference/meta.json @@ -11,6 +11,7 @@ "ktx-wiki", "ktx-status", "ktx-mcp", - "ktx-admin" + "ktx-admin", + "ktx-completion" ] } diff --git a/packages/cli/src/cli-program.ts b/packages/cli/src/cli-program.ts index a3c27375..31ab8a03 100644 --- a/packages/cli/src/cli-program.ts +++ b/packages/cli/src/cli-program.ts @@ -2,6 +2,7 @@ import { existsSync } from 'node:fs'; import { join } from 'node:path'; import { Command, type CommandUnknownOpts, InvalidArgumentError } from '@commander-js/extra-typings'; import type { KtxCliDeps, KtxCliIo, KtxCliPackageInfo } from './cli-runtime.js'; +import { registerCompletionCommands } from './commands/completion-commands.js'; import { registerConnectionCommands } from './commands/connection-commands.js'; import { registerIngestCommands } from './commands/ingest-commands.js'; import { registerWikiCommands } from './commands/knowledge-commands.js'; @@ -431,6 +432,11 @@ export function collectCommandFlagsPresent(command: CommandUnknownOpts): Record< export function buildKtxProgram(options: BuildKtxProgramOptions): Command { const program = createBaseProgram(options.packageInfo, options.io); program.hook('preAction', async (_thisCommand, actionCommand) => { + // The hidden completion command must stay silent and side-effect free: skip + // the telemetry notice, command span, and project checks entirely. + if (commandPath(actionCommand as CommandPathNode).includes('__complete')) { + return; + } const telemetry = await import('./telemetry/index.js'); options.setTelemetryModule?.(telemetry); await telemetry.showTelemetryNoticeIfNeeded(options.io, options.packageInfo); @@ -476,6 +482,7 @@ export function buildKtxProgram(options: BuildKtxProgramOptions): Command { registerStatusCommands(program, context); registerMcpCommands(program, context); registerAdminCommands(program, context); + registerCompletionCommands(program, context); return program; } diff --git a/packages/cli/src/command-tree.ts b/packages/cli/src/command-tree.ts index 2eeb24e8..9b5bf729 100644 --- a/packages/cli/src/command-tree.ts +++ b/packages/cli/src/command-tree.ts @@ -16,7 +16,11 @@ export function walkCommandTree(command: CommandUnknownOpts): CommandTreeNode { description: command.description(), aliases: command.aliases(), arguments: command.registeredArguments.map(formatArgumentDeclaration), - children: command.commands.map((child) => walkCommandTree(child)), + // Internal commands (e.g. the shell-completion helper `__complete`) use a + // `__` prefix and are omitted from the human-facing command tree. + children: command.commands + .filter((child) => !child.name().startsWith('__')) + .map((child) => walkCommandTree(child)), }; } diff --git a/packages/cli/src/commands/completion-commands.ts b/packages/cli/src/commands/completion-commands.ts new file mode 100644 index 00000000..332f103b --- /dev/null +++ b/packages/cli/src/commands/completion-commands.ts @@ -0,0 +1,44 @@ +import { Argument, type Command } from '@commander-js/extra-typings'; +import type { KtxCliCommandContext } from '../cli-program.js'; +import { computeCompletions } from '../completion/complete-engine.js'; +import { completionScript } from '../completion/completion-scripts.js'; +import { createProjectCompletionProviders } from '../completion/dynamic-candidates.js'; +import { profileMark } from '../startup-profile.js'; + +profileMark('module:commands/completion-commands'); + +export function registerCompletionCommands(program: Command, context: KtxCliCommandContext): void { + program + .command('completion') + .description('Print a shell completion script for ktx') + .addArgument(new Argument('', 'Target shell').choices(['zsh', 'bash'])) + .addHelpText( + 'after', + '\nEnable completion by adding the matching line to your shell startup file:\n' + + ' zsh: eval "$(ktx completion zsh)"\n' + + ' bash: eval "$(ktx completion bash)"\n', + ) + .action((shell) => { + context.io.stdout.write(completionScript(shell)); + }); + + // Hidden command invoked by the generated shell scripts. It must only ever + // print newline-separated candidates to stdout and exit 0, so a TAB press is + // never disrupted by an error, a telemetry notice, or a parse failure. + program + .command('__complete', { hidden: true }) + .argument('[words...]') + .allowUnknownOption(true) + .helpOption(false) + .action(async (words: string[]) => { + try { + const candidates = await computeCompletions(program, words, createProjectCompletionProviders()); + if (candidates.length > 0) { + context.io.stdout.write(`${candidates.join('\n')}\n`); + } + } catch { + // Swallow: completion must never break the shell. + } + context.setExitCode(0); + }); +} diff --git a/packages/cli/src/commands/knowledge-commands.ts b/packages/cli/src/commands/knowledge-commands.ts index c7b7c8d7..b601b688 100644 --- a/packages/cli/src/commands/knowledge-commands.ts +++ b/packages/cli/src/commands/knowledge-commands.ts @@ -21,9 +21,9 @@ function isDebugEnabled(command: CommandWithGlobalOptions): boolean { } export function registerWikiCommands(program: Command, context: KtxCliCommandContext): void { - program + const wiki = program .command('wiki') - .description('List or search local wiki pages') + .description('List, search, or read local wiki pages') .usage('[options] [query...]') .argument('[query...]', 'Search query; omit to list all pages') .option('--user-id ', 'Local user id', 'local') @@ -76,4 +76,18 @@ export function registerWikiCommands(program: Command, context: KtxCliCommandCon }); }, ); + + wiki + .command('read') + .description('Read a wiki page file by key') + .argument('', 'Wiki page key') + .action(async (key: string, _options, command) => { + const parentOpts = command.parent?.opts() as { userId?: string } | undefined; + await runKnowledgeArgs(context, { + command: 'read', + projectDir: resolveCommandProjectDir(command), + key, + userId: parentOpts?.userId ?? 'local', + }); + }); } diff --git a/packages/cli/src/commands/sl-commands.ts b/packages/cli/src/commands/sl-commands.ts index a4cb644c..8f2f05a3 100644 --- a/packages/cli/src/commands/sl-commands.ts +++ b/packages/cli/src/commands/sl-commands.ts @@ -94,19 +94,28 @@ export function registerSlCommands(program: Command, context: KtxCliCommandConte }, ); - sl.command('validate') - .description('Validate a semantic-layer source (set --connection-id on `ktx sl`)') + sl.command('read') + .description('Read a semantic-layer source YAML file') + .argument('', 'Semantic-layer source name') + .action(async (sourceName: string, _options, command) => { + const parentOpts = command.parent?.opts() as { connectionId?: string } | undefined; + await runSlArgs(context, { + command: 'read', + projectDir: resolveCommandProjectDir(command), + connectionId: parentOpts?.connectionId, + sourceName, + }); + }); + + sl.command('validate') + .description('Validate a semantic-layer source') .argument('', 'Semantic-layer source name') .action(async (sourceName: string, _options, command) => { const parentOpts = command.parent?.opts() as { connectionId?: string } | undefined; - const connectionId = parentOpts?.connectionId; - if (connectionId === undefined) { - command.error("error: required option '--connection-id ' not specified"); - } await runSlArgs(context, { command: 'validate', projectDir: resolveCommandProjectDir(command), - connectionId: connectionId as string, + connectionId: parentOpts?.connectionId, sourceName, }); }); @@ -131,10 +140,14 @@ export function registerSlCommands(program: Command, context: KtxCliCommandConte throw new Error('sl query requires at least one --measure'); } const parentOpts = command.parent?.opts() as { connectionId?: string } | undefined; + const connectionId = parentOpts?.connectionId; + if (connectionId === undefined) { + command.error("error: required option '--connection-id ' not specified"); + } const args = slQueryCommandSchema.parse({ command: 'query', projectDir: resolveCommandProjectDir(command), - connectionId: parentOpts?.connectionId, + connectionId, ...(options.queryFile ? { queryFile: options.queryFile } : { diff --git a/packages/cli/src/completion/complete-engine.ts b/packages/cli/src/completion/complete-engine.ts new file mode 100644 index 00000000..7268d397 --- /dev/null +++ b/packages/cli/src/completion/complete-engine.ts @@ -0,0 +1,172 @@ +import type { CommandUnknownOpts, Option } from '@commander-js/extra-typings'; + +/** + * Dynamic completion candidates that depend on project state (semantic-layer + * source names, wiki page keys, connection ids). Injected so the engine stays + * pure and unit-testable without touching the filesystem. + */ +export interface CompletionProviders { + /** Candidate operands for a positional argument of the active command path. */ + positionalCandidates(commandPath: string[], typedTokens: string[]): Promise; + /** Candidate values for an option that has no static `choices` (e.g. `--connection-id`). */ + optionValueCandidates(commandPath: string[], optionFlag: string, typedTokens: string[]): Promise; +} + +interface ResolvedCommand { + command: CommandUnknownOpts; + /** Subcommand names from the root down to the active command (root name excluded). */ + commandPath: string[]; +} + +function isHiddenCommand(command: CommandUnknownOpts): boolean { + // Completion mirrors `ktx --help`: commands registered with `{ hidden: true }` + // (the `__complete` helper and `mcp serve-internal`) are internal and must not + // surface. Commander exposes this only through the private `_hidden` field its + // own help renderer reads, so a name heuristic like a `__` prefix is not enough. + return (command as { _hidden?: boolean })._hidden === true; +} + +function resolveCommand(program: CommandUnknownOpts, typedTokens: string[]): ResolvedCommand { + let command: CommandUnknownOpts = program; + const commandPath: string[] = []; + for (let index = 0; index < typedTokens.length; index += 1) { + const token = typedTokens[index]; + if (token.startsWith('-')) { + // A value-taking option in the `--flag value` form consumes the next token + // as its value, so skip that value before matching subcommands. Otherwise a + // connection id like `query` would be resolved as the `sl query` subcommand + // instead of being treated as the `--connection-id` value. The `--flag=value` + // form carries its own value and consumes nothing extra. + if (!token.includes('=')) { + const option = findOption(command, token); + if (option && !option.isBoolean()) { + index += 1; + } + } + continue; + } + const sub = command.commands.find((candidate) => candidate.name() === token || candidate.aliases().includes(token)); + if (sub) { + command = sub; + commandPath.push(sub.name()); + } + } + return { command, commandPath }; +} + +function collectOptions(command: CommandUnknownOpts): Option[] { + const options: Option[] = []; + let current: CommandUnknownOpts | null = command; + while (current) { + options.push(...current.options); + current = current.parent; + } + return options; +} + +function findOption(command: CommandUnknownOpts, flag: string): Option | undefined { + return collectOptions(command).find((option) => option.long === flag || option.short === flag); +} + +function isRepeatableOption(option: Option): boolean { + // Variadic options, and options backed by a collector with an array default + // (e.g. `--measure`/`--dimension`), may be supplied more than once. + return option.variadic || Array.isArray(option.defaultValue); +} + +function flagCandidates(command: CommandUnknownOpts, typedTokens: string[]): string[] { + const present = new Set(typedTokens.filter((token) => token.startsWith('-'))); + const candidates: string[] = []; + for (const option of collectOptions(command)) { + if (option.hidden || !option.long) { + continue; + } + if (present.has(option.long) && !isRepeatableOption(option)) { + continue; + } + candidates.push(option.long); + } + return candidates; +} + +async function optionValueCandidates( + resolved: ResolvedCommand, + option: Option, + typedTokens: string[], + providers: CompletionProviders, +): Promise { + if (option.argChoices && option.argChoices.length > 0) { + return option.argChoices; + } + return providers.optionValueCandidates(resolved.commandPath, option.long ?? option.name(), typedTokens); +} + +function dedupeSortFilter(candidates: string[], partial: string): string[] { + const seen = new Set(); + const matches: string[] = []; + for (const candidate of candidates) { + if (!candidate.startsWith(partial) || seen.has(candidate)) { + continue; + } + seen.add(candidate); + matches.push(candidate); + } + return matches.sort(); +} + +/** + * Compute completion candidates for the partial last element of `words` + * (everything the shell has on the line after `ktx`). The active command and + * its flags are derived by walking the live Commander tree, so completion never + * drifts from the real command structure. + */ +export async function computeCompletions( + program: CommandUnknownOpts, + words: string[], + providers: CompletionProviders, +): Promise { + const partial = words.length > 0 ? (words[words.length - 1] ?? '') : ''; + const typedTokens = words.slice(0, -1); + const resolved = resolveCommand(program, typedTokens); + + // (a) Option value via the `--opt=value` form. + const equalsMatch = /^(--[^=]+)=(.*)$/.exec(partial); + if (equalsMatch) { + const [, flag, valuePartial] = equalsMatch; + const option = findOption(resolved.command, flag); + if (!option || option.isBoolean()) { + return []; + } + const values = await optionValueCandidates(resolved, option, typedTokens, providers); + return dedupeSortFilter( + values.map((value) => `${flag}=${value}`), + `${flag}=${valuePartial}`, + ); + } + + // (b) Option value via the `--opt value` form (previous token is a value-taking option). + const previous = typedTokens[typedTokens.length - 1]; + if (previous && previous.startsWith('-') && !partial.startsWith('-')) { + const option = findOption(resolved.command, previous); + if (option && !option.isBoolean()) { + return dedupeSortFilter(await optionValueCandidates(resolved, option, typedTokens, providers), partial); + } + } + + // (c) Flag completion. + if (partial.startsWith('-')) { + return dedupeSortFilter(flagCandidates(resolved.command, typedTokens), partial); + } + + // (d) Positional: subcommand names union static argument choices union dynamic operand candidates. + const candidates: string[] = resolved.command.commands + .filter((sub) => !isHiddenCommand(sub)) + .map((sub) => sub.name()); + for (const argument of resolved.command.registeredArguments) { + if (argument.argChoices) { + candidates.push(...argument.argChoices); + } + } + candidates.push(...(await providers.positionalCandidates(resolved.commandPath, typedTokens))); + return dedupeSortFilter(candidates, partial); +} diff --git a/packages/cli/src/completion/completion-scripts.ts b/packages/cli/src/completion/completion-scripts.ts new file mode 100644 index 00000000..5761c6e0 --- /dev/null +++ b/packages/cli/src/completion/completion-scripts.ts @@ -0,0 +1,39 @@ +// Static shell completion scripts emitted by `ktx completion `. +// +// Both scripts gather the words on the current command line (excluding the +// leading `ktx`), append the partial word under the cursor, and delegate to the +// hidden `ktx __complete` command, which prints newline-separated candidates. +// All command/flag/entity knowledge lives in `ktx __complete` so these scripts +// never have to encode the command tree. +// +// Lines are single-quoted JS strings so the shell `${...}` expansions are +// emitted verbatim (a template literal would try to interpolate them). + +const ZSH_SCRIPT = [ + '#compdef ktx', + '_ktx() {', + ' local -a candidates', + ' local out', + ' out="$(ktx __complete -- "${words[@]:1:$((CURRENT-1))}" 2>/dev/null)" || return 0', + ' candidates=("${(@f)out}")', + ' compadd -- $candidates', + '}', + 'compdef _ktx ktx', + '', +].join('\n'); + +const BASH_SCRIPT = [ + '_ktx() {', + ' local cur out', + ' cur="${COMP_WORDS[COMP_CWORD]}"', + ' out="$(ktx __complete -- "${COMP_WORDS[@]:1:COMP_CWORD}" 2>/dev/null)" || { COMPREPLY=(); return 0; }', + " local IFS=$'\\n'", + ' COMPREPLY=($(compgen -W "${out}" -- "$cur"))', + '}', + 'complete -F _ktx ktx', + '', +].join('\n'); + +export function completionScript(shell: 'zsh' | 'bash'): string { + return shell === 'zsh' ? ZSH_SCRIPT : BASH_SCRIPT; +} diff --git a/packages/cli/src/completion/dynamic-candidates.ts b/packages/cli/src/completion/dynamic-candidates.ts new file mode 100644 index 00000000..2be512c9 --- /dev/null +++ b/packages/cli/src/completion/dynamic-candidates.ts @@ -0,0 +1,103 @@ +import { existsSync } from 'node:fs'; +import { join } from 'node:path'; +import type { KtxLocalProject } from '../context/project/project.js'; +import { resolveKtxProjectDir } from '../project-resolver.js'; +import type { CompletionProviders } from './complete-engine.js'; + +/** Extract an option value from already-typed tokens (`--flag value` or `--flag=value`). */ +function extractOptionValue(tokens: string[], flag: string): string | undefined { + const prefix = `${flag}=`; + for (let index = 0; index < tokens.length; index += 1) { + const token = tokens[index]; + if (token === flag) { + const next = tokens[index + 1]; + if (next !== undefined && !next.startsWith('-')) { + return next; + } + } else if (token.startsWith(prefix)) { + return token.slice(prefix.length); + } + } + return undefined; +} + +/** + * Resolve and load the project the user is completing against. Honors a + * `--project-dir` typed on the line, then `KTX_PROJECT_DIR`, then the nearest + * `ktx.yaml`. Returns null (no completions) when there is no project, without + * creating any files. + */ +async function loadCompletionProject(typedTokens: string[]): Promise { + const explicitProjectDir = extractOptionValue(typedTokens, '--project-dir'); + const projectDir = resolveKtxProjectDir(explicitProjectDir !== undefined ? { explicitProjectDir } : {}); + if (!existsSync(join(projectDir, 'ktx.yaml'))) { + return null; + } + const { loadKtxProject } = await import('../context/project/project.js'); + return loadKtxProject({ projectDir }); +} + +async function sourceNames(typedTokens: string[]): Promise { + const project = await loadCompletionProject(typedTokens); + if (!project) { + return []; + } + const connectionId = extractOptionValue(typedTokens, '--connection-id'); + const { listLocalSlSources } = await import('../context/sl/local-sl.js'); + const summaries = await listLocalSlSources(project, connectionId !== undefined ? { connectionId } : {}); + return [...new Set(summaries.map((summary) => summary.name))]; +} + +async function wikiPageKeys(typedTokens: string[]): Promise { + const project = await loadCompletionProject(typedTokens); + if (!project) { + return []; + } + const userId = extractOptionValue(typedTokens, '--user-id'); + const { listLocalKnowledgePageKeys } = await import('../context/wiki/local-knowledge.js'); + return listLocalKnowledgePageKeys(project, userId !== undefined ? { userId } : {}); +} + +async function connectionIds(typedTokens: string[]): Promise { + const project = await loadCompletionProject(typedTokens); + if (!project) { + return []; + } + return Object.keys(project.config.connections).sort(); +} + +/** + * Project-backed completion providers. Every entry swallows its own errors so a + * failed lookup never breaks the shell — completion degrades to commands/flags. + */ +export function createProjectCompletionProviders(): CompletionProviders { + return { + async positionalCandidates(commandPath, typedTokens) { + try { + const key = commandPath.join(' '); + if (key === 'sl read' || key === 'sl validate') { + return await sourceNames(typedTokens); + } + if (key === 'wiki read') { + return await wikiPageKeys(typedTokens); + } + if (key === 'connection test' || key === 'ingest') { + return await connectionIds(typedTokens); + } + return []; + } catch { + return []; + } + }, + async optionValueCandidates(_commandPath, optionFlag, typedTokens) { + try { + if (optionFlag === '--connection-id' || optionFlag === '--connection') { + return await connectionIds(typedTokens); + } + return []; + } catch { + return []; + } + }, + }; +} diff --git a/packages/cli/src/context/sl/local-sl.ts b/packages/cli/src/context/sl/local-sl.ts index 243ba94d..fb573392 100644 --- a/packages/cli/src/context/sl/local-sl.ts +++ b/packages/cli/src/context/sl/local-sl.ts @@ -50,6 +50,7 @@ export interface LocalSlSearchInput { pglite?: PgliteSlSearchPrototypeOwnerOptions; } +/** @internal */ export interface LocalSlSource extends LocalSlSourceSummary { yaml: string; } @@ -63,6 +64,11 @@ export interface LocalSlValidationResult { errors: string[]; } +export type ResolvedSlSource = + | { kind: 'found'; source: LocalSlSource } + | { kind: 'not-found' } + | { kind: 'ambiguous'; connectionIds: string[] }; + const LOCAL_AUTHOR = 'ktx'; const LOCAL_AUTHOR_EMAIL = 'ktx@example.com'; @@ -311,6 +317,7 @@ export async function writeLocalSlSource( ); } +/** @internal */ export async function readLocalSlSource( project: KtxLocalProject, input: { connectionId: string; sourceName: string }, @@ -331,6 +338,41 @@ export async function readLocalSlSource( } } +export async function resolveLocalSlSource( + project: KtxLocalProject, + input: { sourceName: string; connectionId?: string }, +): Promise { + if (input.connectionId !== undefined) { + const source = await readLocalSlSource(project, { + connectionId: input.connectionId, + sourceName: input.sourceName, + }); + return source ? { kind: 'found', source } : { kind: 'not-found' }; + } + + const summaries = await listLocalSlSources(project, {}); + const matches = summaries.filter((summary) => summary.name === input.sourceName); + if (matches.length === 0) { + return { kind: 'not-found' }; + } + if (matches.length > 1) { + return { + kind: 'ambiguous', + connectionIds: [...new Set(matches.map((match) => match.connectionId))].sort(), + }; + } + + const match = matches[0]; + if (match === undefined) { + return { kind: 'not-found' }; + } + const source = await readLocalSlSource(project, { + connectionId: match.connectionId, + sourceName: input.sourceName, + }); + return source ? { kind: 'found', source } : { kind: 'not-found' }; +} + export async function listLocalSlSources( project: KtxLocalProject, input: { connectionId?: string } = {}, diff --git a/packages/cli/src/context/wiki/local-knowledge.ts b/packages/cli/src/context/wiki/local-knowledge.ts index b7132b50..dd9b9ad7 100644 --- a/packages/cli/src/context/wiki/local-knowledge.ts +++ b/packages/cli/src/context/wiki/local-knowledge.ts @@ -201,6 +201,32 @@ export async function listLocalKnowledgePages( return pages.sort((left, right) => left.path.localeCompare(right.path)); } +/** + * List wiki page keys without reading or parsing file contents. + * + * Keys are derived purely from file paths, so this stays cheap enough for + * shell tab-completion (unlike `listLocalKnowledgePages`, which reads every + * page to populate summaries). + */ +export async function listLocalKnowledgePageKeys( + project: KtxLocalProject, + input: { userId?: string } = {}, +): Promise { + const userId = input.userId ?? 'local'; + const keys = new Set(); + for (const scope of ['GLOBAL', 'USER'] as const) { + const root = scope === 'GLOBAL' ? 'wiki/global' : `wiki/user/${assertSafePathToken('user id', userId)}`; + const listed = await project.fileStore.listFiles(root); + for (const path of listed.files.filter((file) => file.endsWith('.md'))) { + const key = keyFromKnowledgePath(path, scope, userId); + if (key) { + keys.add(key); + } + } + } + return [...keys].sort(); +} + function scorePage(page: LocalKnowledgePage, terms: string[]): number { const haystack = buildKnowledgeSearchText(page.key, page.summary, page.content, page.tags).toLowerCase(); return terms.some((term) => haystack.includes(term)) ? 3 : 0; diff --git a/packages/cli/src/knowledge.ts b/packages/cli/src/knowledge.ts index d6246fef..346d3d9a 100644 --- a/packages/cli/src/knowledge.ts +++ b/packages/cli/src/knowledge.ts @@ -1,7 +1,13 @@ import { KtxIngestEmbeddingPortAdapter } from './context/llm/embedding-port.js'; import type { KtxEmbeddingPort } from './context/core/embedding.js'; import { loadKtxProject } from './context/project/project.js'; -import { type LocalKnowledgeSearchResult, type LocalKnowledgeSummary, listLocalKnowledgePages, searchLocalKnowledgePages as defaultSearchLocalKnowledgePages } from './context/wiki/local-knowledge.js'; +import { + type LocalKnowledgeSearchResult, + type LocalKnowledgeSummary, + listLocalKnowledgePages, + readLocalKnowledgePage, + searchLocalKnowledgePages as defaultSearchLocalKnowledgePages, +} from './context/wiki/local-knowledge.js'; import { resolveProjectEmbeddingProvider, type EmbeddingProviderResolution, @@ -22,7 +28,8 @@ export type KtxKnowledgeArgs = limit?: number; debug?: boolean; cliVersion: string; - }; + } + | { command: 'read'; projectDir: string; key: string; userId: string }; type KtxKnowledgeIo = import('./cli-runtime.js').KtxCliIo; @@ -128,6 +135,15 @@ export async function runKtxKnowledge( }); return 0; } + if (args.command === 'read') { + const page = await readLocalKnowledgePage(project, { key: args.key, userId: args.userId }); + if (!page) { + throw new Error(`No wiki page found for key '${args.key}'`); + } + const raw = await project.fileStore.readFile(page.path); + io.stdout.write(raw.content); + return 0; + } if (args.command === 'search') { const embeddingService = await wikiSearchEmbeddingService(project, deps, { cliVersion: args.cliVersion }, io); const search = deps.searchLocalKnowledgePages ?? defaultSearchLocalKnowledgePages; diff --git a/packages/cli/src/sl.ts b/packages/cli/src/sl.ts index 76e1092a..f3eeb33e 100644 --- a/packages/cli/src/sl.ts +++ b/packages/cli/src/sl.ts @@ -7,7 +7,14 @@ import type { KtxEmbeddingPort } from './context/core/embedding.js'; import type { KtxSemanticLayerComputePort } from './context/daemon/semantic-layer-compute.js'; import { loadKtxProject, type KtxLocalProject } from './context/project/project.js'; import { compileLocalSlQuery } from './context/sl/local-query.js'; -import { listLocalSlSources, readLocalSlSource, searchLocalSlSources as defaultSearchLocalSlSources, validateLocalSlSource, type LocalSlSourceSearchResult, type LocalSlSourceSummary } from './context/sl/local-sl.js'; +import { + listLocalSlSources, + resolveLocalSlSource, + searchLocalSlSources as defaultSearchLocalSlSources, + validateLocalSlSource, + type LocalSlSourceSearchResult, + type LocalSlSourceSummary, +} from './context/sl/local-sl.js'; import type { SemanticLayerQueryInput } from './context/sl/types.js'; import { resolveProjectEmbeddingProvider, @@ -45,7 +52,8 @@ export type KtxSlArgs = json?: boolean; cliVersion: string; } - | { command: 'validate'; projectDir: string; connectionId: string; sourceName: string } + | { command: 'read'; projectDir: string; connectionId?: string; sourceName: string } + | { command: 'validate'; projectDir: string; connectionId?: string; sourceName: string } | { command: 'query'; projectDir: string; @@ -185,6 +193,12 @@ async function readSlQueryFile(path: string): Promise { return parsed as SemanticLayerQueryInput; } +function ambiguousSourceMessage(sourceName: string, connectionIds: readonly string[]): string { + return `Source '${sourceName}' exists in multiple connections: ${connectionIds.join( + ', ', + )}. Re-run with --connection-id .`; +} + export async function runKtxSl(args: KtxSlArgs, io: KtxSlIo = process, deps: KtxSlDeps = {}): Promise { const startedAt = performance.now(); let queryForTelemetry: SemanticLayerQueryInput | undefined; @@ -232,25 +246,50 @@ export async function runKtxSl(args: KtxSlArgs, io: KtxSlIo = process, deps: Ktx }); return 0; } - if (args.command === 'validate') { - const source = await readLocalSlSource(project, { + if (args.command === 'read') { + const resolved = await resolveLocalSlSource(project, { connectionId: args.connectionId, sourceName: args.sourceName, }); - if (!source) { - throw new Error(`Semantic-layer source "${args.connectionId}/${args.sourceName}" was not found`); + if (resolved.kind === 'not-found') { + throw new Error( + args.connectionId !== undefined + ? `No semantic-layer source '${args.sourceName}' for connection '${args.connectionId}'` + : `No semantic-layer source '${args.sourceName}'`, + ); } - const result = await validateLocalSlSource(source.yaml, { - project, + if (resolved.kind === 'ambiguous') { + throw new Error(ambiguousSourceMessage(args.sourceName, resolved.connectionIds)); + } + io.stdout.write(resolved.source.yaml); + return 0; + } + if (args.command === 'validate') { + const resolved = await resolveLocalSlSource(project, { connectionId: args.connectionId, sourceName: args.sourceName, }); + if (resolved.kind === 'not-found') { + throw new Error( + args.connectionId !== undefined + ? `Semantic-layer source "${args.connectionId}/${args.sourceName}" was not found` + : `Semantic-layer source "${args.sourceName}" was not found`, + ); + } + if (resolved.kind === 'ambiguous') { + throw new Error(ambiguousSourceMessage(args.sourceName, resolved.connectionIds)); + } + const result = await validateLocalSlSource(resolved.source.yaml, { + project, + connectionId: resolved.source.connectionId, + sourceName: args.sourceName, + }); await emitTelemetryEvent({ name: 'sl_validate_completed', projectDir: args.projectDir, io, fields: { - sourceCount: source ? 1 : 0, + sourceCount: 1, modelCount: 0, validationErrorCount: result.valid ? 0 : result.errors.length, outcome: result.valid ? 'ok' : 'error', @@ -263,7 +302,7 @@ export async function runKtxSl(args: KtxSlArgs, io: KtxSlIo = process, deps: Ktx } return 1; } - io.stdout.write(`Valid semantic-layer source: ${args.connectionId}/${args.sourceName}\n`); + io.stdout.write(`Valid semantic-layer source: ${resolved.source.connectionId}/${args.sourceName}\n`); return 0; } if (args.command === 'query') { diff --git a/packages/cli/test/cli-program-telemetry.test.ts b/packages/cli/test/cli-program-telemetry.test.ts index 8088e7f2..4e7130b3 100644 --- a/packages/cli/test/cli-program-telemetry.test.ts +++ b/packages/cli/test/cli-program-telemetry.test.ts @@ -5,6 +5,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { runCommanderKtxCli } from '../src/cli-program.js'; import type { KtxCliDeps, KtxCliIo, KtxCliPackageInfo } from '../src/cli-runtime.js'; +import { TELEMETRY_NOTICE } from '../src/telemetry/identity.js'; function makeIo(stdoutIsTTY = true): { io: KtxCliIo; stdout: () => string; stderr: () => string } { let stdout = ''; @@ -85,7 +86,7 @@ describe('runCommanderKtxCli telemetry', () => { expect(statusIo.stderr()).toContain('"connectionCount"'); expect(statusIo.stderr()).not.toContain(tempDir); - const noticeIndex = statusIo.stderr().indexOf('ktx collects anonymous usage data'); + const noticeIndex = statusIo.stderr().indexOf(TELEMETRY_NOTICE); const firstTelemetryIndex = statusIo.stderr().indexOf('[telemetry]'); expect(noticeIndex).toBeGreaterThanOrEqual(0); expect(firstTelemetryIndex).toBeGreaterThan(noticeIndex); diff --git a/packages/cli/test/commands/wiki-sl-read-commands.test.ts b/packages/cli/test/commands/wiki-sl-read-commands.test.ts new file mode 100644 index 00000000..69e3c51a --- /dev/null +++ b/packages/cli/test/commands/wiki-sl-read-commands.test.ts @@ -0,0 +1,157 @@ +import { Command } from '@commander-js/extra-typings'; +import { describe, expect, it, vi } from 'vitest'; +import type { KtxCliCommandContext } from '../../src/cli-program.js'; +import { registerWikiCommands } from '../../src/commands/knowledge-commands.js'; +import { registerSlCommands } from '../../src/commands/sl-commands.js'; + +function makeContext(overrides: Partial = {}): KtxCliCommandContext { + let exitCode = 0; + return { + io: { + stdout: { write: vi.fn() }, + stderr: { write: vi.fn() }, + }, + deps: {}, + packageInfo: { name: '@kaelio/ktx', version: '0.0.0-test' }, + setExitCode: (code) => { + exitCode = code; + }, + runInit: vi.fn(), + writeDebug: vi.fn(), + ...overrides, + get exitCode() { + return exitCode; + }, + } as KtxCliCommandContext; +} + +describe('wiki and sl read command routing', () => { + it('routes wiki read through the knowledge runner', async () => { + const program = new Command().exitOverride().option('--project-dir '); + const knowledge = vi.fn(async () => 0); + const context = makeContext({ deps: { knowledge } }); + registerWikiCommands(program, context); + + await expect( + program.parseAsync(['--project-dir', '/tmp/ktx-project', 'wiki', 'read', 'metrics-revenue'], { + from: 'user', + }), + ).resolves.toBe(program); + + expect(knowledge).toHaveBeenCalledWith( + { + command: 'read', + projectDir: '/tmp/ktx-project', + key: 'metrics-revenue', + userId: 'local', + }, + context.io, + ); + }); + + it('routes wiki read with the parent --user-id option', async () => { + const program = new Command().exitOverride().option('--project-dir '); + const knowledge = vi.fn(async () => 0); + const context = makeContext({ deps: { knowledge } }); + registerWikiCommands(program, context); + + await expect( + program.parseAsync( + ['--project-dir', '/tmp/ktx-project', 'wiki', '--user-id', 'alex', 'read', 'handoff'], + { from: 'user' }, + ), + ).resolves.toBe(program); + + expect(knowledge).toHaveBeenCalledWith( + { + command: 'read', + projectDir: '/tmp/ktx-project', + key: 'handoff', + userId: 'alex', + }, + context.io, + ); + }); + + it('routes sl read through the semantic-layer runner', async () => { + const program = new Command().exitOverride().option('--project-dir '); + const sl = vi.fn(async () => 0); + const context = makeContext({ deps: { sl } }); + registerSlCommands(program, context); + + await expect( + program.parseAsync( + ['--project-dir', '/tmp/ktx-project', 'sl', '--connection-id', 'warehouse', 'read', 'orders'], + { from: 'user' }, + ), + ).resolves.toBe(program); + + expect(sl).toHaveBeenCalledWith( + { + command: 'read', + projectDir: '/tmp/ktx-project', + connectionId: 'warehouse', + sourceName: 'orders', + }, + context.io, + ); + }); + + it('routes sl read without --connection-id through the semantic-layer runner', async () => { + const program = new Command().exitOverride().option('--project-dir '); + const sl = vi.fn(async () => 0); + const context = makeContext({ deps: { sl } }); + registerSlCommands(program, context); + + await expect( + program.parseAsync(['--project-dir', '/tmp/ktx-project', 'sl', 'read', 'orders'], { from: 'user' }), + ).resolves.toBe(program); + + expect(sl).toHaveBeenCalledWith( + { + command: 'read', + projectDir: '/tmp/ktx-project', + connectionId: undefined, + sourceName: 'orders', + }, + context.io, + ); + }); + + it('routes sl validate without --connection-id through the semantic-layer runner', async () => { + const program = new Command().exitOverride().option('--project-dir '); + const sl = vi.fn(async () => 0); + const context = makeContext({ deps: { sl } }); + registerSlCommands(program, context); + + await expect( + program.parseAsync(['--project-dir', '/tmp/ktx-project', 'sl', 'validate', 'orders'], { from: 'user' }), + ).resolves.toBe(program); + + expect(sl).toHaveBeenCalledWith( + { + command: 'validate', + projectDir: '/tmp/ktx-project', + connectionId: undefined, + sourceName: 'orders', + }, + context.io, + ); + }); + + it('keeps sl query requiring --connection-id before invoking the runner', async () => { + const program = new Command().exitOverride().option('--project-dir '); + const sl = vi.fn(async () => 0); + const context = makeContext({ deps: { sl } }); + registerSlCommands(program, context); + + await expect( + program.parseAsync( + ['--project-dir', '/tmp/ktx-project', 'sl', 'query', '--measure', 'orders.count'], + { from: 'user' }, + ), + ).rejects.toThrow("error: required option '--connection-id ' not specified"); + + expect(sl).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/cli/test/completion/complete-engine.test.ts b/packages/cli/test/completion/complete-engine.test.ts new file mode 100644 index 00000000..f3893340 --- /dev/null +++ b/packages/cli/test/completion/complete-engine.test.ts @@ -0,0 +1,137 @@ +import type { Command } from '@commander-js/extra-typings'; +import { describe, expect, it } from 'vitest'; +import { buildKtxProgram } from '../../src/cli-program.js'; +import type { KtxCliIo, KtxCliPackageInfo } from '../../src/cli-runtime.js'; +import { type CompletionProviders, computeCompletions } from '../../src/completion/complete-engine.js'; + +function stubIo(): KtxCliIo { + return { stdout: { isTTY: false, columns: 80, write: () => {} }, stderr: { write: () => {} } }; +} + +function stubPackageInfo(): KtxCliPackageInfo { + return { name: '@kaelio/ktx', version: '0.0.0-test' }; +} + +function buildProgram(): Command { + return buildKtxProgram({ io: stubIo(), deps: {}, packageInfo: stubPackageInfo(), runInit: async () => 0 }); +} + +const SOURCES = ['orders', 'customers']; +const WIKI_KEYS = ['revenue', 'churn']; +const CONNECTIONS = ['warehouse']; + +function fakeProviders(overrides: Partial = {}): CompletionProviders { + return { + async positionalCandidates(commandPath) { + const key = commandPath.join(' '); + if (key === 'sl read' || key === 'sl validate') { + return SOURCES; + } + if (key === 'wiki read') { + return WIKI_KEYS; + } + return []; + }, + async optionValueCandidates(_commandPath, optionFlag) { + return optionFlag === '--connection-id' ? CONNECTIONS : []; + }, + ...overrides, + }; +} + +function complete(words: string[], providers: CompletionProviders = fakeProviders()): Promise { + return computeCompletions(buildProgram(), words, providers); +} + +describe('computeCompletions', () => { + it('lists top-level commands and hides internal ones', async () => { + const result = await complete(['']); + expect(result).toContain('sl'); + expect(result).toContain('wiki'); + expect(result).toContain('completion'); + expect(result).not.toContain('__complete'); + }); + + it('filters top-level commands by prefix', async () => { + expect(await complete(['co'])).toEqual(['completion', 'connection']); + }); + + it('hides Commander-hidden subcommands such as `mcp serve-internal`', async () => { + const result = await complete(['mcp', '']); + expect(result).not.toContain('serve-internal'); + expect(result).toEqual(['logs', 'start', 'status', 'stdio', 'stop']); + }); + + it('offers only sl subcommands at the bare sl positional', async () => { + expect(await complete(['sl', ''])).toEqual(['query', 'read', 'validate']); + }); + + it('offers source names for sl read and sl validate', async () => { + expect(await complete(['sl', 'read', ''])).toEqual(['customers', 'orders']); + expect(await complete(['sl', 'validate', ''])).toEqual(['customers', 'orders']); + }); + + it('offers only the wiki read subcommand at the bare wiki positional', async () => { + expect(await complete(['wiki', ''])).toEqual(['read']); + }); + + it('offers wiki page keys for wiki read', async () => { + expect(await complete(['wiki', 'read', ''])).toEqual(['churn', 'revenue']); + }); + + it('does not complete entity names for bare search positionals', async () => { + expect(await complete(['sl', 'o'])).toEqual([]); + expect(await complete(['wiki', 'r'])).toEqual(['read']); + }); + + it('completes flags (own + inherited globals) when the partial starts with a dash', async () => { + const result = await complete(['sl', '-']); + expect(result).toContain('--connection-id'); + expect(result).toContain('--output'); + expect(result).toContain('--json'); + expect(result).toContain('--debug'); + expect(result).toContain('--project-dir'); + }); + + it('completes option choices for the `--opt value` form', async () => { + expect(await complete(['sl', '--output', ''])).toEqual(['json', 'plain', 'pretty']); + }); + + it('completes option choices for the `--opt=value` form', async () => { + expect(await complete(['sl', '--output=pr'])).toEqual(['--output=pretty']); + }); + + it('completes option values from a provider for options without static choices', async () => { + expect(await complete(['sl', '--connection-id', ''])).toEqual(['warehouse']); + }); + + it('falls through to positional completion after a boolean flag', async () => { + const result = await complete(['sl', '--json', '']); + expect(result).toEqual(['query', 'read', 'validate']); + }); + + it('does not treat a value-taking option value as a subcommand', async () => { + // A connection id that happens to match a subcommand name (`query`, `read`) + // is the `--connection-id` value, not a subcommand: the next positional must + // still offer the `sl` subcommands rather than resolving into `sl query`/`sl read`. + expect(await complete(['sl', '--connection-id', 'query', ''])).toEqual(['query', 'read', 'validate']); + expect(await complete(['sl', '--connection-id', 'read', ''])).toEqual(['query', 'read', 'validate']); + }); + + it('still returns subcommands/flags when dynamic providers yield nothing (no project)', async () => { + const empty = fakeProviders({ + positionalCandidates: async () => [], + optionValueCandidates: async () => [], + }); + expect(await complete(['sl', ''], empty)).toEqual(['query', 'read', 'validate']); + expect(await complete(['-'], empty)).toContain('--debug'); + }); + + it('completes the completion command shell positional from its static choices', async () => { + expect(await complete(['completion', ''])).toEqual(['bash', 'zsh']); + }); + + it('filters positional argument choices by prefix', async () => { + expect(await complete(['completion', 'z'])).toEqual(['zsh']); + }); +}); diff --git a/packages/cli/test/completion/completion-scripts.test.ts b/packages/cli/test/completion/completion-scripts.test.ts new file mode 100644 index 00000000..24723a95 --- /dev/null +++ b/packages/cli/test/completion/completion-scripts.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from 'vitest'; +import { completionScript } from '../../src/completion/completion-scripts.js'; + +describe('completionScript', () => { + it('emits a zsh script that registers _ktx and delegates to ktx __complete', () => { + const script = completionScript('zsh'); + expect(script).toContain('#compdef ktx'); + expect(script).toContain('compdef _ktx ktx'); + expect(script).toContain('ktx __complete --'); + expect(script).toContain('compadd -- $candidates'); + }); + + it('emits a bash script that registers _ktx and preserves newline-split candidates', () => { + const script = completionScript('bash'); + expect(script).toContain('complete -F _ktx ktx'); + expect(script).toContain('ktx __complete --'); + expect(script).toContain("local IFS=$'\\n'"); + expect(script).toContain('COMPREPLY=($(compgen -W "${out}" -- "$cur"))'); + }); +}); diff --git a/packages/cli/test/completion/dynamic-candidates.test.ts b/packages/cli/test/completion/dynamic-candidates.test.ts new file mode 100644 index 00000000..560f38f0 --- /dev/null +++ b/packages/cli/test/completion/dynamic-candidates.test.ts @@ -0,0 +1,103 @@ +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { createProjectCompletionProviders } from '../../src/completion/dynamic-candidates.js'; + +const KTX_YAML = ['connections:', ' warehouse:', ' driver: sqlite', ' analytics:', ' driver: sqlite', ''].join( + '\n', +); + +describe('createProjectCompletionProviders', () => { + let projectDir: string; + + beforeEach(async () => { + projectDir = await mkdtemp(join(tmpdir(), 'ktx-completion-')); + await writeFile(join(projectDir, 'ktx.yaml'), KTX_YAML, 'utf-8'); + }); + + afterEach(async () => { + await rm(projectDir, { recursive: true, force: true }); + }); + + async function seedProjectEntities(): Promise { + await mkdir(join(projectDir, 'semantic-layer', 'warehouse'), { recursive: true }); + await writeFile( + join(projectDir, 'semantic-layer', 'warehouse', 'orders.yaml'), + ['name: orders', 'table: public.orders', 'grain: [order_id]', 'columns: []', ''].join('\n'), + 'utf-8', + ); + await mkdir(join(projectDir, 'semantic-layer', 'analytics'), { recursive: true }); + await writeFile( + join(projectDir, 'semantic-layer', 'analytics', 'orders.yaml'), + ['name: orders', 'table: public.analytics_orders', 'grain: [order_id]', 'columns: []', ''].join('\n'), + 'utf-8', + ); + await writeFile( + join(projectDir, 'semantic-layer', 'analytics', 'tickets.yaml'), + ['name: tickets', 'table: public.tickets', 'grain: [ticket_id]', 'columns: []', ''].join('\n'), + 'utf-8', + ); + await mkdir(join(projectDir, 'wiki', 'global'), { recursive: true }); + await writeFile( + join(projectDir, 'wiki', 'global', 'revenue.md'), + ['---', 'summary: Revenue', 'tags: []', 'refs: []', 'sl_refs: []', '---', '', 'Revenue rules.', ''].join('\n'), + 'utf-8', + ); + } + + it('completes connection ids for the `connection test` positional', async () => { + const providers = createProjectCompletionProviders(); + const result = await providers.positionalCandidates(['connection', 'test'], ['--project-dir', projectDir]); + expect(result).toEqual(['analytics', 'warehouse']); + }); + + it('completes connection ids for the `ingest` positional', async () => { + const providers = createProjectCompletionProviders(); + const result = await providers.positionalCandidates(['ingest'], ['--project-dir', projectDir]); + expect(result).toEqual(['analytics', 'warehouse']); + }); + + it('completes entity names only for read and validate subcommands', async () => { + await seedProjectEntities(); + const providers = createProjectCompletionProviders(); + + await expect(providers.positionalCandidates(['sl'], ['--project-dir', projectDir])).resolves.toEqual([]); + await expect(providers.positionalCandidates(['sl', 'read'], ['--project-dir', projectDir])).resolves.toEqual([ + 'orders', + 'tickets', + ]); + await expect(providers.positionalCandidates(['sl', 'validate'], ['--project-dir', projectDir])).resolves.toEqual([ + 'orders', + 'tickets', + ]); + await expect( + providers.positionalCandidates(['sl', 'read'], ['--project-dir', projectDir, '--connection-id', 'warehouse']), + ).resolves.toEqual(['orders']); + await expect( + providers.positionalCandidates(['sl', 'validate'], ['--project-dir', projectDir, '--connection-id', 'analytics']), + ).resolves.toEqual(['orders', 'tickets']); + await expect(providers.positionalCandidates(['wiki'], ['--project-dir', projectDir])).resolves.toEqual([]); + await expect(providers.positionalCandidates(['wiki', 'read'], ['--project-dir', projectDir])).resolves.toEqual([ + 'revenue', + ]); + }); + + it('returns no positional candidates outside a project', async () => { + const providers = createProjectCompletionProviders(); + const result = await providers.positionalCandidates(['connection', 'test'], ['--project-dir', join(projectDir, 'nope')]); + expect(result).toEqual([]); + }); + + it('completes connection ids for the sql --connection option', async () => { + const providers = createProjectCompletionProviders(); + const result = await providers.optionValueCandidates(['sql'], '--connection', ['--project-dir', projectDir]); + expect(result).toEqual(['analytics', 'warehouse']); + }); + + it('still completes connection ids for the --connection-id option', async () => { + const providers = createProjectCompletionProviders(); + const result = await providers.optionValueCandidates(['ingest'], '--connection-id', ['--project-dir', projectDir]); + expect(result).toEqual(['analytics', 'warehouse']); + }); +}); diff --git a/packages/cli/test/context/sl/local-sl.test.ts b/packages/cli/test/context/sl/local-sl.test.ts index 1115f387..b3a9b7d6 100644 --- a/packages/cli/test/context/sl/local-sl.test.ts +++ b/packages/cli/test/context/sl/local-sl.test.ts @@ -6,6 +6,7 @@ import { initKtxProject, type KtxLocalProject } from '../../../src/context/proje import { listLocalSlSources, readLocalSlSource, + resolveLocalSlSource, searchLocalSlSources, validateLocalSlSource, writeLocalSlSource, @@ -90,6 +91,101 @@ describe('local semantic-layer helpers', () => { await expect(validateLocalSlSource(ORDERS_YAML)).resolves.toEqual({ valid: true, errors: [] }); }); + it('resolves a scoped source by connection id', async () => { + await writeLocalSlSource(project, { + connectionId: 'warehouse', + sourceName: 'orders', + yaml: ORDERS_YAML, + }); + + await expect( + resolveLocalSlSource(project, { + connectionId: 'warehouse', + sourceName: 'orders', + }), + ).resolves.toEqual({ + kind: 'found', + source: expect.objectContaining({ + connectionId: 'warehouse', + name: 'orders', + path: 'semantic-layer/warehouse/orders.yaml', + yaml: ORDERS_YAML, + }), + }); + }); + + it('returns not-found for a missing scoped source', async () => { + await writeLocalSlSource(project, { + connectionId: 'warehouse', + sourceName: 'orders', + yaml: ORDERS_YAML, + }); + + await expect( + resolveLocalSlSource(project, { + connectionId: 'warehouse', + sourceName: 'missing_orders', + }), + ).resolves.toEqual({ kind: 'not-found' }); + }); + + it('resolves a unique source name across all connections', async () => { + await writeLocalSlSource(project, { + connectionId: 'warehouse', + sourceName: 'orders', + yaml: ORDERS_YAML, + }); + await writeLocalSlSource(project, { + connectionId: 'analytics', + sourceName: 'tickets', + yaml: SUPPORT_YAML, + }); + + await expect( + resolveLocalSlSource(project, { + sourceName: 'tickets', + }), + ).resolves.toEqual({ + kind: 'found', + source: expect.objectContaining({ + connectionId: 'analytics', + name: 'tickets', + path: 'semantic-layer/analytics/tickets.yaml', + yaml: SUPPORT_YAML, + }), + }); + }); + + it('returns not-found for a missing unscoped source', async () => { + await writeLocalSlSource(project, { + connectionId: 'warehouse', + sourceName: 'orders', + yaml: ORDERS_YAML, + }); + + await expect(resolveLocalSlSource(project, { sourceName: 'missing_orders' })).resolves.toEqual({ + kind: 'not-found', + }); + }); + + it('reports sorted ambiguous connection ids for duplicate source names', async () => { + await writeLocalSlSource(project, { + connectionId: 'warehouse', + sourceName: 'orders', + yaml: ORDERS_YAML, + }); + await writeLocalSlSource(project, { + connectionId: 'analytics', + sourceName: 'orders', + yaml: ORDERS_YAML, + }); + + await expect(resolveLocalSlSource(project, { sourceName: 'orders' })).resolves.toEqual({ + kind: 'ambiguous', + connectionIds: ['analytics', 'warehouse'], + }); + }); + it('validates table-backed sources against matching physical manifests when project context is provided', async () => { await project.fileStore.writeFile( 'semantic-layer/postgres-warehouse/_schema/orbit_analytics.yaml', diff --git a/packages/cli/test/context/wiki/local-knowledge.test.ts b/packages/cli/test/context/wiki/local-knowledge.test.ts index fa70bcc5..cda5ca1a 100644 --- a/packages/cli/test/context/wiki/local-knowledge.test.ts +++ b/packages/cli/test/context/wiki/local-knowledge.test.ts @@ -4,6 +4,7 @@ import { join } from 'node:path'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { initKtxProject, type KtxLocalProject } from '../../../src/context/project/project.js'; import { + listLocalKnowledgePageKeys, listLocalKnowledgePages, readLocalKnowledgePage, searchLocalKnowledgePages, @@ -102,6 +103,35 @@ describe('local knowledge helpers', () => { await expect(access(join(project.projectDir, '.ktx', 'db.sqlite'))).resolves.toBeUndefined(); }); + it('lists page keys across scopes, deduped and sorted, for completion', async () => { + await writeLocalKnowledgePage(project, { + key: 'metrics-revenue', + scope: 'GLOBAL', + summary: 'Revenue metric definition', + content: 'Revenue is recognized when an order is paid.', + }); + await writeLocalKnowledgePage(project, { + key: 'metrics-churn', + scope: 'USER', + userId: 'local', + summary: 'Churn metric definition', + content: 'Churn is measured monthly.', + }); + // Same key in both scopes must collapse to a single completion candidate. + await writeLocalKnowledgePage(project, { + key: 'metrics-revenue', + scope: 'USER', + userId: 'local', + summary: 'User override of revenue', + content: 'Local revenue note.', + }); + + await expect(listLocalKnowledgePageKeys(project, { userId: 'local' })).resolves.toEqual([ + 'metrics-churn', + 'metrics-revenue', + ]); + }); + it('adds the token lane alongside lexical wiki matches', async () => { await writeLocalKnowledgePage(project, { key: 'metrics-revenue', diff --git a/packages/cli/test/index.test.ts b/packages/cli/test/index.test.ts index bd17e641..57ac4901 100644 --- a/packages/cli/test/index.test.ts +++ b/packages/cli/test/index.test.ts @@ -132,9 +132,12 @@ describe('runKtxCli', () => { } expect(testIo.stdout()).not.toMatch(/^ dev\s/m); expect(testIo.stdout()).not.toMatch(/^ scan\s/m); - for (const removed of ['demo', 'init', 'connect', 'ask', 'knowledge', 'agent', 'completion', 'serve']) { + for (const removed of ['demo', 'init', 'connect', 'ask', 'knowledge', 'agent', 'serve']) { expect(testIo.stdout()).not.toMatch(new RegExp(`^\\s+${removed}(?:\\s|\\[|$)`, 'm')); } + // `completion` is a public command; the internal `__complete` helper is hidden. + expect(testIo.stdout()).toMatch(/^\s+completion /m); + expect(testIo.stdout()).not.toContain('__complete'); expect(testIo.stdout()).toContain('--project-dir '); expect(testIo.stdout()).toContain('KTX_PROJECT_DIR'); expect(testIo.stdout()).toContain('--debug'); @@ -414,12 +417,17 @@ describe('runKtxCli', () => { const promptIo = makeIo(); await expect( - runKtxCli(['--project-dir', tempDir, 'sl', 'query', '--measure', 'orders.order_count'], promptIo.io, { sl }), + runKtxCli( + ['--project-dir', tempDir, 'sl', 'query', '--connection-id', 'warehouse', '--measure', 'orders.order_count'], + promptIo.io, + { sl }, + ), ).resolves.toBe(0); expect(sl).toHaveBeenLastCalledWith( expect.objectContaining({ command: 'query', projectDir: tempDir, + connectionId: 'warehouse', cliVersion, runtimeInstallPolicy: 'prompt', query: expect.objectContaining({ measures: ['orders.order_count'], dimensions: [] }), @@ -429,9 +437,21 @@ describe('runKtxCli', () => { const autoIo = makeIo(); await expect( - runKtxCli(['--project-dir', tempDir, 'sl', 'query', '--measure', 'orders.order_count', '--yes'], autoIo.io, { - sl, - }), + runKtxCli( + [ + '--project-dir', + tempDir, + 'sl', + 'query', + '--connection-id', + 'warehouse', + '--measure', + 'orders.order_count', + '--yes', + ], + autoIo.io, + { sl }, + ), ).resolves.toBe(0); expect(sl).toHaveBeenLastCalledWith( expect.objectContaining({ @@ -444,7 +464,17 @@ describe('runKtxCli', () => { const noInputIo = makeIo(); await expect( runKtxCli( - ['--project-dir', tempDir, 'sl', 'query', '--measure', 'orders.order_count', '--no-input'], + [ + '--project-dir', + tempDir, + 'sl', + 'query', + '--connection-id', + 'warehouse', + '--measure', + 'orders.order_count', + '--no-input', + ], noInputIo.io, { sl }, ), @@ -464,7 +494,18 @@ describe('runKtxCli', () => { await expect( runKtxCli( - ['--project-dir', tempDir, 'sl', 'query', '--measure', 'orders.order_count', '--yes', '--no-input'], + [ + '--project-dir', + tempDir, + 'sl', + 'query', + '--connection-id', + 'warehouse', + '--measure', + 'orders.order_count', + '--yes', + '--no-input', + ], io.io, { sl }, ), diff --git a/packages/cli/test/knowledge.test.ts b/packages/cli/test/knowledge.test.ts index 339eb659..94e4bb63 100644 --- a/packages/cli/test/knowledge.test.ts +++ b/packages/cli/test/knowledge.test.ts @@ -98,6 +98,46 @@ describe('runKtxKnowledge', () => { expect(searchIo.stdout()).toContain('metrics-revenue'); }); + it('reads a wiki page as raw markdown with frontmatter', async () => { + const projectDir = join(tempDir, 'read-project'); + await initKtxProject({ projectDir }); + await seedWikiPage(projectDir, { + key: 'metrics-revenue', + summary: 'Revenue', + content: 'Revenue is paid order value.', + tags: ['finance'], + slRefs: ['orders'], + }); + + const readIo = makeIo(); + await expect( + runKtxKnowledge({ command: 'read', projectDir, key: 'metrics-revenue', userId: 'local' }, readIo.io), + ).resolves.toBe(0); + + expect(readIo.stdout()).toContain('---\n'); + expect(readIo.stdout()).toContain('summary: Revenue'); + expect(readIo.stdout()).toContain('tags:'); + expect(readIo.stdout()).toContain('- finance'); + expect(readIo.stdout()).toContain('sl_refs:'); + expect(readIo.stdout()).toContain('- orders'); + expect(readIo.stdout()).toContain('usage_mode: auto'); + expect(readIo.stdout()).toContain('Revenue is paid order value.'); + expect(readIo.stderr()).toBe(''); + }); + + it('reports a clear error when a wiki page key is missing', async () => { + const projectDir = join(tempDir, 'missing-read-project'); + await initKtxProject({ projectDir }); + + const readIo = makeIo(); + await expect( + runKtxKnowledge({ command: 'read', projectDir, key: 'missing-page', userId: 'local' }, readIo.io), + ).resolves.toBe(1); + + expect(readIo.stdout()).toBe(''); + expect(readIo.stderr()).toBe("No wiki page found for key 'missing-page'\n"); + }); + it('emits debug telemetry for wiki search without query text', async () => { vi.stubEnv('KTX_TELEMETRY_DEBUG', '1'); vi.stubEnv('CI', ''); diff --git a/packages/cli/test/print-command-tree.test.ts b/packages/cli/test/print-command-tree.test.ts index 688818ab..387874b3 100644 --- a/packages/cli/test/print-command-tree.test.ts +++ b/packages/cli/test/print-command-tree.test.ts @@ -12,10 +12,14 @@ describe('renderKtxCommandTree', () => { .filter((line) => /^ {2}[├└]── \S/.test(line)) .map((line) => line.replace(/^ {2}[├└]── /, '').trim().split(' ')[0]); - for (const expected of ['setup', 'connection', 'ingest', 'sl', 'mcp', 'admin']) { + for (const expected of ['setup', 'connection', 'ingest', 'sl', 'mcp', 'admin', 'completion']) { expect(topLevel).toContain(expected); } + // The internal completion helper is hidden and must not appear in the tree. + expect(topLevel).not.toContain('__complete'); + expect(output).not.toContain('__complete'); + expect(output).toContain('│ └── test [connectionId]'); expect(output).toContain('│ ├── status Show KTX MCP daemon status'); expect(output).not.toContain('│ ├── add'); @@ -27,10 +31,14 @@ describe('renderKtxCommandTree', () => { expect(output).not.toContain('scan '); expect(output).not.toContain('│ ├── replay'); expect(output).not.toContain('│ └── replay'); - expect(output).not.toContain('│ ├── run'); + // Match `run` as a whole command name, not the `run` prefix of `runtime`. + expect(output).not.toMatch(/[├└]── run(\s|$)/m); expect(output).not.toContain('│ ├── watch'); expect(output).not.toContain('│ └── watch'); - expect(output).not.toContain('│ ├── read'); + expect(output).toContain('│ └── read Read a wiki page file by key'); + expect(output).toContain( + '│ ├── read Read a semantic-layer source YAML file', + ); expect(output).not.toContain('│ ├── write'); expect(output).not.toContain('│ └── write'); }); diff --git a/packages/cli/test/sl.test.ts b/packages/cli/test/sl.test.ts index 7b4b7795..ff9c1489 100644 --- a/packages/cli/test/sl.test.ts +++ b/packages/cli/test/sl.test.ts @@ -113,6 +113,267 @@ describe('runKtxSl', () => { }); }); + it('reads a semantic-layer source as raw YAML', async () => { + const projectDir = join(tempDir, 'read-project'); + await seedSlSource({ projectDir }); + + const readIo = makeIo(); + await expect( + runKtxSl( + { + command: 'read', + projectDir, + connectionId: 'warehouse', + sourceName: 'orders', + }, + readIo.io, + ), + ).resolves.toBe(0); + + expect(readIo.stdout()).toBe(ORDERS_YAML); + expect(readIo.stderr()).toBe(''); + }); + + it('reads a unique semantic-layer source without a connection id', async () => { + const projectDir = join(tempDir, 'read-unique-project'); + const project = await initKtxProject({ projectDir }); + await project.fileStore.writeFile( + 'semantic-layer/warehouse/orders.yaml', + ORDERS_YAML, + 'ktx', + 'ktx@example.com', + 'Add semantic-layer source', + ); + await project.fileStore.writeFile( + 'semantic-layer/analytics/tickets.yaml', + [ + 'name: tickets', + 'table: public.tickets', + 'grain:', + ' - ticket_id', + 'columns:', + ' - name: ticket_id', + ' type: string', + '', + ].join('\n'), + 'ktx', + 'ktx@example.com', + 'Add semantic-layer source', + ); + + const readIo = makeIo(); + await expect( + runKtxSl( + { + command: 'read', + projectDir, + sourceName: 'tickets', + }, + readIo.io, + ), + ).resolves.toBe(0); + + expect(readIo.stdout()).toContain('name: tickets'); + expect(readIo.stderr()).toBe(''); + }); + + it('reports ambiguous unscoped reads with sorted connection ids', async () => { + const projectDir = join(tempDir, 'read-ambiguous-project'); + const project = await initKtxProject({ projectDir }); + await project.fileStore.writeFile( + 'semantic-layer/warehouse/orders.yaml', + ORDERS_YAML, + 'ktx', + 'ktx@example.com', + 'Add semantic-layer source', + ); + await project.fileStore.writeFile( + 'semantic-layer/analytics/orders.yaml', + ORDERS_YAML, + 'ktx', + 'ktx@example.com', + 'Add semantic-layer source', + ); + + const readIo = makeIo(); + await expect( + runKtxSl( + { + command: 'read', + projectDir, + sourceName: 'orders', + }, + readIo.io, + ), + ).resolves.toBe(1); + + expect(readIo.stdout()).toBe(''); + expect(readIo.stderr()).toBe( + "Source 'orders' exists in multiple connections: analytics, warehouse. Re-run with --connection-id .\n", + ); + }); + + it('reports a clear error when an unscoped semantic-layer source is missing', async () => { + const projectDir = join(tempDir, 'missing-unscoped-read-project'); + await seedSlSource({ projectDir }); + + const readIo = makeIo(); + await expect( + runKtxSl( + { + command: 'read', + projectDir, + sourceName: 'missing_orders', + }, + readIo.io, + ), + ).resolves.toBe(1); + + expect(readIo.stdout()).toBe(''); + expect(readIo.stderr()).toBe("No semantic-layer source 'missing_orders'\n"); + }); + + it('reports a clear error when a semantic-layer source is missing', async () => { + const projectDir = join(tempDir, 'missing-read-project'); + await seedSlSource({ projectDir }); + + const readIo = makeIo(); + await expect( + runKtxSl( + { + command: 'read', + projectDir, + connectionId: 'warehouse', + sourceName: 'missing_orders', + }, + readIo.io, + ), + ).resolves.toBe(1); + + expect(readIo.stdout()).toBe(''); + expect(readIo.stderr()).toBe("No semantic-layer source 'missing_orders' for connection 'warehouse'\n"); + }); + + it('validates a unique semantic-layer source without a connection id', async () => { + const projectDir = join(tempDir, 'validate-unique-project'); + const project = await initKtxProject({ projectDir }); + await project.fileStore.writeFile( + 'semantic-layer/warehouse/orders.yaml', + ORDERS_YAML, + 'ktx', + 'ktx@example.com', + 'Add semantic-layer source', + ); + await project.fileStore.writeFile( + 'semantic-layer/analytics/tickets.yaml', + [ + 'name: tickets', + 'table: public.tickets', + 'grain:', + ' - ticket_id', + 'columns:', + ' - name: ticket_id', + ' type: string', + '', + ].join('\n'), + 'ktx', + 'ktx@example.com', + 'Add semantic-layer source', + ); + + const validateIo = makeIo(); + await expect( + runKtxSl( + { + command: 'validate', + projectDir, + sourceName: 'tickets', + }, + validateIo.io, + ), + ).resolves.toBe(0); + + expect(validateIo.stdout()).toBe('Valid semantic-layer source: analytics/tickets\n'); + expect(validateIo.stderr()).toBe(''); + }); + + it('reports ambiguous unscoped validation with sorted connection ids', async () => { + const projectDir = join(tempDir, 'validate-ambiguous-project'); + const project = await initKtxProject({ projectDir }); + await project.fileStore.writeFile( + 'semantic-layer/warehouse/orders.yaml', + ORDERS_YAML, + 'ktx', + 'ktx@example.com', + 'Add semantic-layer source', + ); + await project.fileStore.writeFile( + 'semantic-layer/analytics/orders.yaml', + ORDERS_YAML, + 'ktx', + 'ktx@example.com', + 'Add semantic-layer source', + ); + + const validateIo = makeIo(); + await expect( + runKtxSl( + { + command: 'validate', + projectDir, + sourceName: 'orders', + }, + validateIo.io, + ), + ).resolves.toBe(1); + + expect(validateIo.stdout()).toBe(''); + expect(validateIo.stderr()).toBe( + "Source 'orders' exists in multiple connections: analytics, warehouse. Re-run with --connection-id .\n", + ); + }); + + it('reports a clear error when an unscoped semantic-layer source validation target is missing', async () => { + const projectDir = join(tempDir, 'missing-unscoped-validate-project'); + await seedSlSource({ projectDir }); + + const validateIo = makeIo(); + await expect( + runKtxSl( + { + command: 'validate', + projectDir, + sourceName: 'missing_orders', + }, + validateIo.io, + ), + ).resolves.toBe(1); + + expect(validateIo.stdout()).toBe(''); + expect(validateIo.stderr()).toBe('Semantic-layer source "missing_orders" was not found\n'); + }); + + it('keeps scoped validation not-found wording', async () => { + const projectDir = join(tempDir, 'missing-scoped-validate-project'); + await seedSlSource({ projectDir }); + + const validateIo = makeIo(); + await expect( + runKtxSl( + { + command: 'validate', + projectDir, + connectionId: 'warehouse', + sourceName: 'missing_orders', + }, + validateIo.io, + ), + ).resolves.toBe(1); + + expect(validateIo.stdout()).toBe(''); + expect(validateIo.stderr()).toBe('Semantic-layer source "warehouse/missing_orders" was not found\n'); + }); + it('prints semantic-layer search rank badges in pretty output', async () => { const projectDir = join(tempDir, 'rank-project'); await seedSlSource({ projectDir }); From ba5bb92ab77fda7522621312539e65fff75c2564 Mon Sep 17 00:00:00 2001 From: Andrey Avtomonov Date: Mon, 1 Jun 2026 12:06:27 +0200 Subject: [PATCH 42/74] feat: README architecture diagrams + React Flow diagram studio (#245) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the tall portrait README ingestion SVG with two landscape diagrams — "1 · Ingestion" (build the context layer) and "2 · Serving" (agents query it through MCP) — wired in as transparent 2x PNGs that read on GitHub light and dark. Add docs-site/diagram-studio: a static React Flow page with custom themed nodes and the inlined ktx mascot that renders both diagrams and exports them to PNG via html-to-image (the diagrams' reproducible source). Remove the superseded ingestion-flow SVGs. --- README.md | 7 +- docs-site/app/diagram-studio/page.tsx | 12 + docs-site/components/diagram-studio/flows.ts | 328 ++++++++++++ .../components/diagram-studio/mascot.tsx | 57 ++ docs-site/components/diagram-studio/nodes.tsx | 493 ++++++++++++++++++ .../components/diagram-studio/studio.tsx | 242 +++++++++ docs-site/package.json | 1 + .../images/ingestion-flow-transparent.svg | 210 -------- docs-site/public/images/ingestion-flow.png | Bin 140531 -> 354604 bytes docs-site/public/images/mcp-runtime-flow.png | Bin 0 -> 179902 bytes pnpm-lock.yaml | 8 + 11 files changed, 1147 insertions(+), 211 deletions(-) create mode 100644 docs-site/app/diagram-studio/page.tsx create mode 100644 docs-site/components/diagram-studio/flows.ts create mode 100644 docs-site/components/diagram-studio/mascot.tsx create mode 100644 docs-site/components/diagram-studio/nodes.tsx create mode 100644 docs-site/components/diagram-studio/studio.tsx delete mode 100644 docs-site/public/images/ingestion-flow-transparent.svg create mode 100644 docs-site/public/images/mcp-runtime-flow.png diff --git a/README.md b/README.md index 686ece22..e235bab1 100644 --- a/README.md +++ b/README.md @@ -34,9 +34,14 @@ business knowledge it builds and maintains for you. > No extra usage billing from **ktx**.

- ktx ingestion flow from source systems through validation to wiki and semantic-layer outputs + Ingestion: ktx ingests databases, BI tools, modeling code, and docs through its context engine (source connectors, context builder, reconciliation, validation) into wiki Markdown and semantic-layer YAML

+

+ Serving: an agent queries ktx through MCP, which searches the wiki and semantic layer, returns approved metrics, and compiles them into read-only SQL run against the warehouse +

+ + ## Why ktx General-purpose agents struggle on data tasks. They re-explore your warehouse diff --git a/docs-site/app/diagram-studio/page.tsx b/docs-site/app/diagram-studio/page.tsx new file mode 100644 index 00000000..205ebd7a --- /dev/null +++ b/docs-site/app/diagram-studio/page.tsx @@ -0,0 +1,12 @@ +import type { Metadata } from "next"; + +import { DiagramStudio } from "@/components/diagram-studio/studio"; + +export const metadata: Metadata = { + title: "Diagram studio", + robots: { index: false, follow: false }, +}; + +export default function DiagramStudioPage() { + return ; +} diff --git a/docs-site/components/diagram-studio/flows.ts b/docs-site/components/diagram-studio/flows.ts new file mode 100644 index 00000000..cddf75cb --- /dev/null +++ b/docs-site/components/diagram-studio/flows.ts @@ -0,0 +1,328 @@ +import { type Edge, MarkerType, type Node } from "@xyflow/react"; + +import { C } from "./nodes"; + +const EDGE_COLOR = "#b3bcc4"; +const MARKER_COLOR = "#9aa6ad"; + +const labelStyle = { + fontFamily: "var(--font-inter), system-ui, sans-serif", + fontSize: 15, + fontWeight: 600, + fill: C.inkMuted, +}; +const labelBgStyle = { fill: "#ffffff", stroke: C.chipBorder, strokeWidth: 1 }; +const labelBg = { + labelBgPadding: [8, 4] as [number, number], + labelBgBorderRadius: 6, + labelStyle, + labelBgStyle, +}; + +const marker = { type: MarkerType.ArrowClosed, color: MARKER_COLOR, width: 16, height: 16 }; +const edgeStyle = { stroke: EDGE_COLOR, strokeWidth: 2 }; + +/* ============================== INGESTION =============================== */ + +const SRC_W = 300; +const SRC_H = 138; +const SRC_GAP = 24; +const srcY = (i: number) => i * (SRC_H + SRC_GAP); + +export const ingestionNodes: Node[] = [ + { + id: "title", + type: "title", + position: { x: 0, y: -96 }, + data: { + width: 560, + eyebrow: "1 · Ingestion", + title: "ktx builds your context layer", + }, + }, + { + id: "db", + type: "card", + position: { x: 0, y: srcY(0) }, + data: { + width: SRC_W, + height: SRC_H, + accent: C.teal, + rows: [ + { kind: "title", text: "Databases" }, + { kind: "desc", text: "Schemas, keys, query history" }, + { kind: "muted", text: "Postgres · Snowflake · BigQuery · …" }, + ], + handles: [{ side: "right", type: "source", id: "out" }], + }, + }, + { + id: "bi", + type: "card", + position: { x: 0, y: srcY(1) }, + data: { + width: SRC_W, + height: SRC_H, + accent: C.orange, + rows: [ + { kind: "title", text: "BI tools" }, + { kind: "desc", text: "Dashboards, explores, usage" }, + { kind: "muted", text: "Metabase · Looker · …" }, + ], + handles: [{ side: "right", type: "source", id: "out" }], + }, + }, + { + id: "model", + type: "card", + position: { x: 0, y: srcY(2) }, + data: { + width: SRC_W, + height: SRC_H, + accent: C.amber, + rows: [ + { kind: "title", text: "Modeling code" }, + { kind: "desc", text: "Metrics, models, joins, entities" }, + { kind: "muted", text: "dbt · LookML · MetricFlow · …" }, + ], + handles: [{ side: "right", type: "source", id: "out" }], + }, + }, + { + id: "docs", + type: "card", + position: { x: 0, y: srcY(3) }, + data: { + width: SRC_W, + height: SRC_H, + accent: C.emerald, + rows: [ + { kind: "title", text: "Docs & notes" }, + { kind: "desc", text: "Policies, definitions, notes" }, + { kind: "muted", text: "Notion · any text · …" }, + ], + handles: [{ side: "right", type: "source", id: "out" }], + }, + }, + { + id: "engine", + type: "engine", + position: { x: 420, y: 52 }, + data: { + width: 380, + height: 520, + steps: [ + { n: 1, title: "Source connectors", desc: "Read each source in its shape" }, + { n: 2, title: "Context builder", desc: "Evidence into proposed updates" }, + { n: 3, title: "Reconciliation", desc: "Merge with existing context" }, + { n: 4, title: "Validation", desc: "Check references & semantics" }, + ], + handles: [ + { side: "left", type: "target", id: "in" }, + { side: "right", type: "source", id: "out" }, + ], + }, + }, + { + id: "wiki", + type: "card", + position: { x: 900, y: 66 }, + data: { + width: 320, + height: 220, + accent: C.emerald, + rows: [ + { kind: "mono", text: "wiki/*.md", color: C.emerald }, + { kind: "title", text: "Wiki" }, + { kind: "chips", items: ["free-form", "auto-maintained"] }, + { kind: "desc", text: "Definitions, caveats, policies," }, + { kind: "desc", text: "and notes agents can search." }, + ], + handles: [{ side: "left", type: "target", id: "in" }], + }, + }, + { + id: "sl", + type: "card", + position: { x: 900, y: 338 }, + data: { + width: 320, + height: 220, + accent: C.teal, + rows: [ + { kind: "mono", text: "semantic-layer/*.yaml", color: C.teal }, + { kind: "title", text: "Semantic layer" }, + { kind: "chips", items: ["executable", "auto-maintained"] }, + { kind: "desc", text: "Metrics, joins, dimensions, and" }, + { kind: "desc", text: "filters ktx compiles into SQL." }, + ], + handles: [{ side: "left", type: "target", id: "in" }], + }, + }, +]; + +const ingestEdge = (source: string, target: string): Edge => ({ + id: `${source}-${target}`, + source, + target, + sourceHandle: "out", + targetHandle: "in", + type: "default", + style: edgeStyle, + markerEnd: marker, +}); + +export const ingestionEdges: Edge[] = [ + ingestEdge("db", "engine"), + ingestEdge("bi", "engine"), + ingestEdge("model", "engine"), + ingestEdge("docs", "engine"), + ingestEdge("engine", "wiki"), + ingestEdge("engine", "sl"), +]; + +/* =============================== RUNTIME ================================ */ + +export const runtimeNodes: Node[] = [ + { + id: "title", + type: "title", + position: { x: 0, y: -84 }, + data: { + width: 560, + eyebrow: "2 · Serving", + title: "agents query it through MCP", + }, + }, + { + id: "agent", + type: "card", + position: { x: 0, y: 115 }, + data: { + width: 280, + height: 190, + accent: C.neutral, + align: "center", + rows: [ + { kind: "title", text: "Your agent" }, + { kind: "muted", text: "Claude Code · Cursor" }, + { kind: "muted", text: "Codex · OpenCode" }, + ], + handles: [ + { side: "right", type: "source", id: "ask", top: "42%" }, + { side: "right", type: "target", id: "answer", top: "62%" }, + ], + }, + }, + { + id: "hub", + type: "hub", + position: { x: 420, y: 85 }, + data: { + width: 360, + height: 250, + rows: [ + "Search wiki + semantic layer", + "Return approved metrics", + "Compile metrics → SQL", + ], + handles: [ + { side: "left", type: "target", id: "ask", top: "42%" }, + { side: "left", type: "source", id: "answer", top: "62%" }, + { side: "right", type: "source", id: "to-context", top: "30%" }, + { side: "right", type: "source", id: "to-warehouse", top: "72%" }, + ], + }, + }, + { + id: "context", + type: "card", + position: { x: 920, y: 15 }, + data: { + width: 300, + height: 150, + accent: C.teal, + rows: [ + { kind: "title", text: "Context layer" }, + { kind: "mono", text: "wiki/*.md", color: C.emerald }, + { kind: "mono", text: "semantic-layer/*.yaml", color: C.teal }, + ], + handles: [{ side: "left", type: "target", id: "in" }], + }, + }, + { + id: "warehouse", + type: "card", + position: { x: 920, y: 255 }, + data: { + width: 300, + height: 150, + accent: C.slate, + rows: [ + { kind: "title", text: "Warehouse" }, + { + kind: "badge", + text: "read-only", + bg: "#ecf6f8", + border: "#bfe3ea", + color: C.teal, + }, + { kind: "desc", text: "Runs the compiled SQL" }, + ], + handles: [{ side: "left", type: "target", id: "in" }], + }, + }, +]; + +export const runtimeEdges: Edge[] = [ + { + id: "ask", + source: "agent", + sourceHandle: "ask", + target: "hub", + targetHandle: "ask", + type: "default", + label: "ask", + ...labelBg, + style: edgeStyle, + markerEnd: marker, + }, + { + id: "answer", + source: "hub", + sourceHandle: "answer", + target: "agent", + targetHandle: "answer", + type: "default", + label: "answer", + ...labelBg, + style: edgeStyle, + markerEnd: marker, + }, + { + id: "search", + source: "hub", + sourceHandle: "to-context", + target: "context", + targetHandle: "in", + type: "default", + label: "search", + ...labelBg, + style: edgeStyle, + markerStart: marker, + markerEnd: marker, + }, + { + id: "readonly", + source: "hub", + sourceHandle: "to-warehouse", + target: "warehouse", + targetHandle: "in", + type: "default", + label: "read-only", + ...labelBg, + style: edgeStyle, + markerStart: marker, + markerEnd: marker, + }, +]; diff --git a/docs-site/components/diagram-studio/mascot.tsx b/docs-site/components/diagram-studio/mascot.tsx new file mode 100644 index 00000000..467f6ee5 --- /dev/null +++ b/docs-site/components/diagram-studio/mascot.tsx @@ -0,0 +1,57 @@ +/** + * Inlined ktx mascot, ported from assets/ktx-mascot.svg. + * + * - `light` renders the dark-bodied mascot for light surfaces. + * - `dark` renders the cream-bodied mascot for dark surfaces (e.g. the ktx + * hub panel), mirroring brand/ktx-mascot-dark.svg. + */ +export function KtxMascot({ + variant = "light", + size = 56, +}: { + variant?: "light" | "dark"; + size?: number; +}) { + const body = variant === "dark" ? "#F5F1EA" : "#1B3139"; + const eye = variant === "dark" ? "#1B3139" : "#F5F1EA"; + return ( + + + + + + + + + + + + ); +} diff --git a/docs-site/components/diagram-studio/nodes.tsx b/docs-site/components/diagram-studio/nodes.tsx new file mode 100644 index 00000000..f648a905 --- /dev/null +++ b/docs-site/components/diagram-studio/nodes.tsx @@ -0,0 +1,493 @@ +"use client"; + +import { Handle, Position, type Node, type NodeProps } from "@xyflow/react"; + +import { KtxMascot } from "./mascot"; + +/** Fixed palette mirrored from the approved SVG diagrams so the exported PNG + * is theme-independent (one image that reads on light and dark GitHub). */ +export const C = { + ink: "#1b1b18", + inkSoft: "#57534e", + inkMuted: "#8c857f", + cardBorder: "#e2dfd9", + engineBg: "#15323a", + engineBorder: "#23474f", + cyan: "#55dced", + stepNum: "#06262c", + stepTitle: "#f3f1ec", + stepDesc: "#9fb6bc", + hubRow: "#eef4f5", + chipBg: "#faf9f6", + chipBorder: "#e7e5e4", + teal: "#0e7490", + emerald: "#059669", + orange: "#f97316", + amber: "#d97706", + slate: "#334155", + neutral: "#94a3b8", +} as const; + +const DISPLAY = "var(--font-display), system-ui, sans-serif"; +const BODY = "var(--font-inter), system-ui, sans-serif"; +const MONO = "var(--font-mono), ui-monospace, monospace"; + +const CARD_SHADOW = "0 3px 12px rgba(27, 49, 57, 0.10)"; +const ENGINE_SHADOW = "0 6px 22px rgba(2, 12, 15, 0.30)"; + +/** ktx logo mascot size, shared by the engine and hub headers. */ +const LOGO_SIZE = 56; + +type HandleSpec = { + side: "left" | "right"; + type: "source" | "target"; + id: string; + top?: string; +}; + +function Handles({ specs }: { specs?: HandleSpec[] }) { + if (!specs) return null; + return ( + <> + {specs.map((h) => ( + + ))} + + ); +} + +/* ------------------------------- Card node ------------------------------- */ + +type CardRow = + | { kind: "title"; text: string } + | { kind: "mono"; text: string; color: string } + | { kind: "desc"; text: string } + | { kind: "muted"; text: string } + | { kind: "chips"; items: string[] } + | { kind: "badge"; text: string; bg: string; border: string; color: string }; + +type CardData = { + width: number; + height: number; + accent: string; + align?: "center"; + rows: CardRow[]; + handles?: HandleSpec[]; +}; + +function gapFor(kind: CardRow["kind"], prev?: CardRow["kind"]): number { + if (!prev) return 0; + if (kind === "desc" && prev === "desc") return 3; + if (kind === "mono" && prev === "mono") return 2; + if (kind === "title") return 6; + return 10; +} + +function CardRowView({ row }: { row: CardRow }) { + switch (row.kind) { + case "title": + return ( + + {row.text} + + ); + case "mono": + return ( + + {row.text} + + ); + case "desc": + return ( + + {row.text} + + ); + case "muted": + return ( + + {row.text} + + ); + case "chips": + return ( +
+ {row.items.map((c) => ( + + {c} + + ))} +
+ ); + case "badge": + return ( + + {row.text} + + ); + } +} + +function CardNode({ data }: NodeProps>) { + const center = data.align === "center"; + return ( +
+ + + {data.rows.map((row, i) => ( +
+ +
+ ))} +
+ ); +} + +/* ------------------------------ Engine node ------------------------------ */ + +type EngineStep = { n: number; title: string; desc: string }; + +type EngineData = { + width: number; + height: number; + steps: EngineStep[]; + handles?: HandleSpec[]; +}; + +function EngineNode({ data }: NodeProps>) { + return ( +
+ + +
+ + + ktx + +
+
+ {data.steps.map((s) => ( +
+ + {s.n} + +
+ + {s.title} + + + {s.desc} + +
+
+ ))} +
+
+ ); +} + +/* -------------------------------- Hub node ------------------------------- */ + +type HubData = { + width: number; + height: number; + rows: string[]; + handles?: HandleSpec[]; +}; + +function HubNode({ data }: NodeProps>) { + return ( +
+ + +
+ + + ktx + +
+
+ {data.rows.map((r) => ( +
+ + + {r} + +
+ ))} +
+
+ ); +} + +/* ------------------------------- Title node ------------------------------ */ + +type TitleData = { width: number; eyebrow: string; title: string }; + +function TitleNode({ data }: NodeProps>) { + return ( +
+ + {data.eyebrow} + + + {data.title} + +
+ ); +} + +export const nodeTypes = { + card: CardNode, + engine: EngineNode, + hub: HubNode, + title: TitleNode, +}; diff --git a/docs-site/components/diagram-studio/studio.tsx b/docs-site/components/diagram-studio/studio.tsx new file mode 100644 index 00000000..7b96ae7b --- /dev/null +++ b/docs-site/components/diagram-studio/studio.tsx @@ -0,0 +1,242 @@ +"use client"; + +import "@xyflow/react/dist/style.css"; + +import { useCallback, useRef, useState } from "react"; +import { + Background, + BackgroundVariant, + type Edge, + getNodesBounds, + type Node, + ReactFlow, + ReactFlowProvider, + useEdgesState, + useNodesState, + useReactFlow, +} from "@xyflow/react"; +import { toPng } from "html-to-image"; + +import { + ingestionEdges, + ingestionNodes, + runtimeEdges, + runtimeNodes, +} from "./flows"; +import { nodeTypes } from "./nodes"; + +const EXPORT_PADDING = 48; +const EXPORT_PIXEL_RATIO = 2; + +function DiagramCanvasInner({ + initialNodes, + initialEdges, + fileName, + height, + dark, +}: { + initialNodes: Node[]; + initialEdges: Edge[]; + fileName: string; + height: number; + dark: boolean; +}) { + const wrapperRef = useRef(null); + const [nodes, , onNodesChange] = useNodesState(initialNodes); + const [edges, , onEdgesChange] = useEdgesState(initialEdges); + const { getNodes } = useReactFlow(); + const [busy, setBusy] = useState(false); + + const download = useCallback(async () => { + const viewport = wrapperRef.current?.querySelector( + ".react-flow__viewport", + ); + if (!viewport) return; + setBusy(true); + try { + await document.fonts.ready; + const bounds = getNodesBounds(getNodes()); + const outW = Math.ceil(bounds.width + EXPORT_PADDING * 2); + const outH = Math.ceil(bounds.height + EXPORT_PADDING * 2); + const tx = EXPORT_PADDING - bounds.x; + const ty = EXPORT_PADDING - bounds.y; + const dataUrl = await toPng(viewport, { + width: outW, + height: outH, + pixelRatio: EXPORT_PIXEL_RATIO, + // transparent background so one PNG works on light and dark GitHub + style: { + width: `${outW}px`, + height: `${outH}px`, + transform: `translate(${tx}px, ${ty}px) scale(1)`, + }, + }); + const link = document.createElement("a"); + link.download = fileName; + link.href = dataUrl; + link.click(); + } finally { + setBusy(false); + } + }, [fileName, getNodes]); + + return ( +
+
+ +
+
+ + + +
+
+ ); +} + +function btnStyle(disabled: boolean): React.CSSProperties { + return { + fontFamily: "var(--font-inter), system-ui, sans-serif", + fontSize: 13, + fontWeight: 600, + padding: "7px 14px", + borderRadius: 8, + border: "1px solid #0e7490", + background: disabled ? "#9bbdc6" : "#0e7490", + color: "#ffffff", + cursor: disabled ? "default" : "pointer", + }; +} + +function DiagramCanvas(props: { + initialNodes: Node[]; + initialEdges: Edge[]; + fileName: string; + height: number; + dark: boolean; +}) { + return ( + + + + ); +} + +export function DiagramStudio() { + const [dark, setDark] = useState(false); + return ( +
+
+

+ ktx diagram studio +

+

+ Static diagrams. Export is a transparent 2× PNG framed to the node + bounds — the dark-background toggle is only for previewing. +

+ +
+ +
+

1 · Ingestion — building the context layer

+ +
+ +
+

2 · Serving — answering agents at runtime

+ +
+
+ ); +} + +const sectionTitle: React.CSSProperties = { + fontFamily: "var(--font-display), system-ui, sans-serif", + fontSize: 18, + fontWeight: 600, + color: "#1b1b18", + marginBottom: 12, +}; diff --git a/docs-site/package.json b/docs-site/package.json index 2af1c19d..f418c0ee 100644 --- a/docs-site/package.json +++ b/docs-site/package.json @@ -14,6 +14,7 @@ "fumadocs-core": "16.8.10", "fumadocs-mdx": "15.0.7", "fumadocs-ui": "16.8.10", + "html-to-image": "1.11.11", "next": "^16", "react": "19.2.6", "react-dom": "19.2.6" diff --git a/docs-site/public/images/ingestion-flow-transparent.svg b/docs-site/public/images/ingestion-flow-transparent.svg deleted file mode 100644 index 86356d6b..00000000 --- a/docs-site/public/images/ingestion-flow-transparent.svg +++ /dev/null @@ -1,210 +0,0 @@ - - ktx ingestion flow - Source systems flow through source connectors, context builder, reconciliation, and validation to create wiki Markdown and semantic-layer YAML outputs. - - - - - - - - - - - - - - - - - - - - - - - - - Databases - Schemas, columns, keys, - row counts, and query - history. - - - PostgreSQL - - Snowflake - - BigQuery - - SQLite - - - - - - - BI tools - Dashboards, questions, - explores, usage, and trusted - examples. - - - Metabase - - Looker - - - - - - - Modeling code - Existing metrics, dimensions, - models, joins, and entities. - - - dbt - - LookML - - MetricFlow - - - - - - - Docs and notes - Policies, caveats, team - definitions, and analyst - context. - - - Notion - - Any text - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - 1 - Source connectors - Read each configured system in - its native shape. - - - - - - 2 - Context builder - Turn source evidence into - proposed context updates. - - - - - - 3 - Reconciliation - Merge new evidence with the - context that already exists. - - - - - - 4 - Validation - Check references and semantics - before agents rely on them. - - - - - - - - wiki/*.md - Wiki - - - free-form - - auto-maintained - - Definitions, caveats, policies, analyst notes, and - business language that agents can search. - - - - - - semantic-layer/*.yaml - Semantic layer - - - structured - - executable - - auto-maintained - - Metrics, joins, tables, dimensions, filters, and - segments that ktx can validate and compile into - SQL. - - - - - references - - - diff --git a/docs-site/public/images/ingestion-flow.png b/docs-site/public/images/ingestion-flow.png index 49bc544ff89bfcc72e6a25205ca24a1a0879a33f..59f6ad176939ae5b02334a25f2fdb206fd8c9e98 100644 GIT binary patch literal 354604 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%0AA^#Jv3d8l%Di{j5v|KN-DzS)UPM}8& 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
C+{X$ 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

%x$dLVd(7X)BoC-ZmUgc?g_q;EeQ1nY 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%0DdL^Zrvu!4$idnq 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 diff --git a/docs-site/public/images/mcp-runtime-flow.png b/docs-site/public/images/mcp-runtime-flow.png new file mode 100644 index 0000000000000000000000000000000000000000..ec56dff1e531d63670ef292c4b25ed4c00475e18 GIT binary patch literal 179902 zcmeFZXIRr&*FK7mj?RpOJQ-1xs)>pLfkA4hIwpi7AffjWM5GT1p%a{O)EFQb2t}#_ zA#_A~AEinOlF%VULqh0CXwuJ*8GX4j8xt6S|H_z^vAO(LE z{ZT+bK+r%RjuH^~QC~pdSlsu=fpSxh#kfz; zU{gzrQFSPI7(6s5h5dHGtkI``qU>P7ELEj<$A;M8mHx)hW<+CpOtR9P(O`o+ME&-lEusuPFSP~we5 z^j6tuTNCjC;guK1kkOf&#l#J*rTh?A$pxIs78$=b(Nx3UK+mS&YpNCI?68?oB)nF* zC3Sl8=lr#pHi?8)*QF6R$8_i3vLM6R3XS(_aCr66OHb2rlNhXYs6spZHB~DYSiOI@^n*NNv0*m*uJARj8*i>2k8x;NFN1MU zV~CHy2+m;Gec~R3ZA`*&I?Jx9=xDj@PIyF}^$js_K%?r-gua<#+sWYcngS$sI-P9D zwFmQ4v%_S=Pj!K$j29)UW5v~$3{pytvVk# zqjs4!F?I=|@e5bW3tqd%0~xQLT9S!`CCL{iB|@B(7S>j)mIIj*2|f8^_0Z?H~Z z7>$h^I?5>j+2Br4R(X|;G9z8gOnXwZ^gWp z0=Ps>(RWf-qr2ia8nhbT>Vu8z&TkBbSDzrJ%}pDFti21Kbb?%;5MQij#aOcN{uq{% zu|y;RpR~#UbCU+`mFf>zIuZTwo6SB{AJgABZ68zrTde*peP*(NwEj~Je#@WT7L#fz zy_kp720OjZXj6vgY~AXJ45ao}ob(}~!h>wgyHBONcP9-S8Gx2&t&H1}syab_#i^5K zjV`CFZr!SN*mi)Mi0!6JgiZcIq6a}-QrEA^AUFzwVv&=t^nU&J{WF^XxwP|4d@-{J zbZhPsE5W>{b5Q*{B3ui#9SY}Ygak?H?9TSySE>6nH7)ToOk-SQw;#XD=$tJlO?87- zc1-xbwq1+a!&B(9oX+Ysjw#H=Lt#Et3+x*@ZAyqlz$;gEgzFQtJ~7g#-wP&T<92fw zuk?sc4nB^tdGpb@41BIS(YUg4W6nuCe4-)NxAVt9Z_o2~a+V6Q__vPtil^~AU>7ry zt>X#k^?FE0W_4`;f4{ALTkp}8nG=L8a#GG|&&w2iv!$q!pTqT4GSXTzd*`BB!o>fXB{PZQDWUHEgh+1jRfJR>v& zrt37JF`XO}Fh6|FQ=;*{qwDlfqXlWFO7y~E;6*cPn~o^L-%1#PEFNepH*N9>W=zy{s`yMOl{_ggBW zB`8K?d@UEH;a2>y6OXr1-cfRn!DkLa(MooY<+Ynl-Z5@v?X^}ufi!2}5)AE>Ab_n*Cho z#-pFv<5n8xPt;xfQ4+WP-FPlOcEINq-ZsikuQ}xI{77c;kHlzvu;*m*36aX^j1)|R z_FU1m6rfG(kH3kXe7}6nSAseFBL&F%O^T?Vl2z2QHhQA= z#Q8JdQo(O~qb~JQ54>gfD0K)f#Y<}WDtF_ldyKQ)as!HUv$lPM0cHCE>zp_Z(i~84 z>h4_OHM!q43a=kBh?*uwfWfC&-kQd=<|wj`Rk<^#?npEURQ7tE6B+v;CCD5u?f?n- z@>#CH&sN+Yn{E;WA7D9!nU_dz#o*Pi42{J$lGzyGxSuF#igT5!*ek=L#=2|odIu5m zRLSyJgDjE;<#kRvocI<2Iu~xdkqTz-nx93q_G)V@AVaa~Q;{PMV^_FqNoo9UuSs{} z7NosTTi04lr#g7!eYJM#_H8I-56#qzlCJ9ylaJCrF?_8)Wl{qBpG)Sgr1d!_dEARC zEHs@~O3+7^B&_{Of@FBwol`7Vm^**yalNpP?zy!NF_E7AH6|14{=UatG6r8BgZt** z*Pp(BF8;F0Gi7Jnvvu5)!o`cJ-eb0ORQg)Ws5C&)(RuLg-ngMY@7HH!F$6C#`-{eV zO6RN+)KW(&Yj#2t%!M;ZU@s4sNoiO)gv72fux`5;y&ZG{tLF;C*4yFw=Wr zmuCIh(}IJBlKsldYA}9vckRkJ({Zp=7RG)I+Ir0FWl0JS!{M37^-t;U{pn-gag-G; zu8<$^Hv5v#m$zm(s0JTQ~unwK{ z#%dV1^q*{7KWw=5rgIjn5??S_-DYYF1b_z6^62wop^lwcBK;Ov}aKyckT9upyl#mLYA|03bi#+gn%_d zCp%V0KwP@BUUcdv;g7WjOjk$g_GuE3+;|Ay&0bT4A*RkAM|rhdjL)le4KVZ{5e16R zMU=Q5HIJpB9@Z17>W>kO!Q1@8BkLkLMqZQVWc#VsC^75(^;xIEIICkWLrWgD<$rVd zT`N`Hx=_UEgKx)LVBYj0N1$x*ORYkFE|FC}r zAakSa^-Q}c!FO|#B}q#&cYo-M5w`v(j?@_Ps9sp)?<=ruzvZPLjP7UfAHerR(m6># z?e#Rc|9SVRf<(yg@SjRY<-jE~CpHgy=AtDe{6pc_^8G10G3WNzq^wnu`;EQMbJ3nNn&G8+Uu$L%*@-Q$wo?^r|e)_OXeMZ zrCIw$SJ|8wP`?b$^;X$vI9f>Q5<}ef6bd7M0q;*Rdum^8 zbM)rGLJ8>ofj>uGn}!*uF=Tlo63ed8IzfzX4`CO|X86tann@q8lJYK+{^!lJWgw`!3T&M=J@n$f=)-N;nb++LFT(W`xGQkRHW@zl)r5{KdUBu6SPo0DM+M zj(VrMI=I3o!^)>UR1%hAH8eeV`sv^4@9UsND~9TK*fu`g?t_i~wdbV&!QVrf0`GC& ztu-O>5U$5M-JED}(!Qeyf3Cd0#A9WlU(GjSZnSSX&v#{v%)pcB#wYDfYQ1g^R5Jgv zZAnUcT5`Ik+HL0zRmxYQIbd6B6$Dx;7l#1P>^B-;eV>^l1mVE`WY0ow#myx z>$tZ?!lB55>R%twxO5%RO2_0t*>97{-vQNU$DgB~rT5-1rqiST2i{L!h3#)uWKBJA z&E5@J4+??2a+>1M=*#q?oknxKQ0WO5G|L~%;KPvDY-^OkPfAuBPz_hXRD6ZWF>cmS zf2kvH&o^bK5g)**>3Y4(S`CJ+jl&ry{75AUh4FM|}DGdKV2K1-9t z%oQ|8pNm{l0P~~Pxf0gEqlDp&@;yZ!jaIL*6$=0qbaDMz{=QLMf!m z$>;wA?{x0akL`66jpc437gjzdK&hc`Nj}iF9g!;El#iV;MU!3RQpxN0X(j1_3n<^x zq(l_9`;VdjM%n4Cwg~&*THJZ2cDQWKQ2ULKRM8ScP&d#Ai_z!NB?G7QdS`?7?uISW zI1U~JytL<~#&B)Ip50zkg1awvY(l$U<~I(z*fbhn>_xFOqRhwq_3hNWKD08VCB-h# z=4?zezH_-K-p#|1(jrn7-NYX42hV1%|18=~>5J7`d{^ux%_C#5-qOtdl0Qa72Ro^Q zKKW_x2(z`h2;AB`Q@Qu+R>yj0Kct!W3cPn9;8;Q;eZHaBc;TMTiM2Djn$D*|LPNf# zBZ@&eVozhWHwV{ghaD3+jgP>_`PtLa1ObC@>zX-QHL@hF7p)t_!tYLXmIX0y!)OMS ziKfB7Fsu)jAqj>%&WBI;zEJ%>v44Z%*8c?#_cn)YE!H+JnnF^hdrNZZ&KUdks)6Jx z1?ujfo;sWHpq1@Vu2tbyqLULH^F%FuXDK7?j^~30-%q2N;cpbiT>a;hSeSeC#!%ulKUUyauMJD21LUYTzeBITz=3iCoA%0?Mlo-k4E{exR{Af)6&% z6b^3dPnZZ;l16cs2XV%(!xml5qQDjvAl!M33M(Du!6Wm#(Lm?T_gz2PCn6^Jt$4rm z`~LZY0(w6LzA)sGA?$8dSewdK5kC*I7UWzB0cZ(2$e8COOp+e-h}DL$cTpvJ%0Tzp z2?JxD7BUliBqMGqD9k<=-g+*nJw&XsMzk773|<*TXIK>Ev}Cu>l~OtxEzTH*g5v0I4klOEtSecclG5Fb zBQu_*YwZo|5r=ch8MCSBL5XYi2p>w+PfxzN?1P$j88b3^;MlyOU`#oLEfrzmpn^(K zem?H{zNoBPk5dX%XHY%y8nwSsrUp1WdF5c2#XA1(kwM?$%q{z_5EJJ_Soln$E0`S{ z!RVj*wBgCs2H2(U#`XXEJC5noOXak7;!Rb_hCm9MueZBPBbnF?@Lxy?t*Jtj80SG| zO3&qy8a`iYh89CDf0uC_Vpd&4wp1Uts~41L;=YLZ5Sy`b!=G**^(02?5;)RLJF2eS z0@@fcI#IS6HuXOqS^oZc4l9hEX1-LYn&?b2Z@L67XuQha=-f(=ve#Xu^uE&0NK@I) zz~@z;S;dn@bJubw5GSi^u7ERU^oEX9G;Ty{-$`4uAcmte&)SJ^u4N4=b^sj~lJGPv zG}L4(?qdHw{X2}wR=JwQxBYD=3K$&!>#~w*NPvHLa=c8!oDsUUNPH_%-o!Q;(iCs5 zju%1{|>PBa6HVk|g=#pB2>b=OA`c(mzuT5D?FPSIG9`~%5^Oe}yBeTOI z9GBh=^@}Ljso$$6ujc9d8^Z$v;`V5riYgR3Mx;6#Hv4I4#i;Sqw`F|TjJxvNOHrK? zY&}}C!b=|OEtOy4qe7)-i^l2&XjKKbvLg{@0S_*#J~LbG9$#4hKwFdu*+jvXQwl>2 zV3ZwAQL!cGLr%==!nbGs@6Z3gSYZ2C`Y{2m@g85DDRUAX;}w23QpeZDzw#`9!WIY{ zWpAYiTSJekBf{SrNITL?5MS*wG9s#cM4+%jxb0MbGQhd0!CM(nu9SsXl8K~l6Lxkw zAan3)j3G>_ZevBbObdLh%O`hx3geepKwO&f;vf*LV`e~g;UPZQ z%bM4hp}4-A=1Rqy^F>mxq_1Z;H4MQJWyY0?&JME0+2T_snu|SQT4u-pN@Jy(Hw3TW zijo|keTz@7yjYN`y%)X76?W$&EPpXH0%uttiNjEhaW|3qckSjnpYntsfzj}iN_fbm z2Dr)unvn!)grHi!D^I2F=ciy4aeQ0WauLz<^5P5BzD@dZt!u-!LfXaK?liM!?Vj_| z`NPv`O#{khU+x=vseX&#ROuseA4E_(SHVQPo!@D-IsWIaJ~>r@Q%8^aG0D4$nESUw zdjERHzzvRW!8*plS?wEb{jbl&Y%Ho!?9(U~5ubIXl*)w=XA4v3w=f!4T-(yAC zE{(cpYFxKA#AFM(9ObE+PSmEHFfPndS4GHg=0=OJydl#q%TuGAbH!adOyu`pA4#ng zhE1k{n?p8w@};zQ7Sv5HsQkOgzo$(yNN>>W+qOOd_=Y zqoUU(Qmrbos){-t8|}H(Wm`^gT>EOxhrLO_6RpDki{`$;+t@&6GmXZKnm_#d+Mj|C zanFpH>s*(kUL`!SkaKQmT5TFS-)2T)BtiHeB`4b6NLutKqIQd(76$;$d1$&WNG;GY zXtHtwU#t@$4436*r6#n^4JZ+=5Qyd=<<{EPn>a8gQv;MnxMYMCji6w?04?mN|V z;jNs+^X5(0aqNoJPz-K<%^-}E7PVgcBVS;mj+C9IIyMKsgHqF) zwZKdETf7}is`S}}b88fQ_(<0^9~dt?`mkyi=q^jl(6Vo-jV*Zm^0 zDG~3w{y$!dm8&{p)A0q$IMBWy_fdaF2vZ5T#6cLLa6nCbauVz^v+eNrhxR#lFkcdA z&`g~3l&^*P0~D@Z`|XsKk2TNQ#vyztsO%Stvga=F)`T4>R0v|Fd;U57{)KRIGw5yY+p_7$5mVI)BVpPr#>G!rKYTmc~K+OoH zu_c)(!i4f{(dz0;NgSp4(mam-`hV1eYQL~gi(!@jee}a#U>_$Q+A^-_ zmXZzoOraYlW@CzIt)I;rtBWT#${D7*JMS&G?)RuijJH?4mrs2i#Ps@W;H;~Jo3|iUcGQxa-K)^k?mA=f zJE``<_E_>1*Q{g-R9k`MXkDU&o6`JE30=L$Ku|83*@7Mo8Jw%TH~VF!*vNVhOS3h1 zvhdMYFn|U2!F*mAZRzIQ)kPQ}Cv1cfmE{_C1ed4S*lCHmd#qPh`?>jT*-%6Y_T6Fb zcvI&3c^uq^gBfb-abA(8NW!z9Jc+t;#jP;_Uh&S`2bg`2k&j(BYwaebDGfng9yajx zeG3_8<%19hdttS(UJqB$hHk5Dzn1& zERjq`b&*!(xHB~&gI&>B)~I9yl9wLD|Hw7<)_ilHZMTPuSn^tTx6^e_-@HJ@PCi1z zJ4UjCvr;3_-LF6`Ls#E8j9#2;$ggeijj-3#TF=k2p))q`($?4s38?jlC?d1a%<~#$ zu8}mA88vP+9JHxjqJKcHuv=|dGP!F(`BYNUo-;WA z{?XKBXz4j4b6O0P8&&{YdSAP}`Ga{bYmm7^gQ4bMv~6HZcAn9e8YvWy2iGL3XoSZS zk1~Co_fiL0O^ArSi5)vy?SpikS;7+Tw0m#LFIpPxhV|m=F`GRL_*lVx7Xq@&XLfBJ z+AQrluq}gF7lEOQeX5M2Bu78NKr4RbCg0*T3LrcRt`g`Up3IS;lroTiXTcN<4JY9B z`)wp9hQ_qk3(ysr*YDG#Pn_Vst%kc!Vi(_xiTFeWz#aN+Bb3}Ts<5}PAAZ=Ju-&%E zX+RS2;EI#DzCtfR}ThXkSE?^4z{3$tz2hWS38&c1)_j%J;!Dk=+gfJ;gY z(kDIKj&dy{UZ%L(xN7xZkC+oddLMjj-B}uQiycT_F0vfjQZi*-bm{$=W9ZtXh-mn2 zv7rScDgwgHIS#XtL0g#v$)%;M89yyvtGNz#S?Y1=^Hec$*tHRNCNxYbF*VRRU;Iq{ zd@{C^I%nAzCHy_C{|Epa#Z}2yJjkM{2%a}9STOgw>MSX-_3%t_QpT)z!h`6c>HH)? zPgChiUbvHQk5t}@WE+feWm)ZR*P3R1sBgE3kCL{^bgrGp*6dwB0^X~B{6wNwUAHXC zb>Fhrz0NMvad(Dxg4({@?Hg1g9B=NL(Xjo>(5V(!gBxu)lZTg<_ODr=6unIZR_RhL>{hFOE(L@S#ZIy|P)7;G-X zjR&(kEqY@>2NFiBU1iD?bcL#Eqav#5o`L-=A?s?!(n5-T-?ggJAf~;Z##QjX#U^h2 zX-rgm`1DwZSmC2cDNyNxA1h06eM0TwE0UuG^-rwkXjhEOWR8J~zuIl|pyC4wJ^9C;cVc?m2u zn?n;O2}PwfYZfWu)Lq+W$2W3mo~?KQX1Kxl))4L^jrZFVX^h7$qElhz_l?grm1Tq& zVOIX|RAHq+nl-^eWxei`8r0n~!=LIJ-9lGpoAM6QrrB=YqN}=KdI+d2NZPjA0Ge`E zx2jtRZSiE2w|SMPFW2iHU23tK4&%>)0M^VT#6M?u)Gfw;b)lRpJ~AHqRJHYoLG;!p zY4u3@y|74o*WLU$4AaNWJpa=p$3d0I{X8_`%bj#ib95%kL2Rvv1)mJuh-UAQ_y|y0 zq%&qdt+!iJJ>^#!ci?^p`?*(ka;FY}{rj%nYf7h_HShYawx+xHrG@&)aDlN^ zUP%X0{Svw^z{Yhy3_vBNGk!kJWzC4xx!O`*LK9|VO=)tRtK|YZa8xqujx^;>PWB28a^YX6{XLC*cu!Y}R|Gu)cind+?v6>ORk?F^ z3uoF4&E%A5>hjdxDE%^XA(Qbd;7ys&S(Mt~PjA}4+~S1a?(3sH73_L+FhX}|cujTB z?aaidv4$Vjol*$$4(V+Z$sDP9l^No-dj2V;)A<=*E_6h)#k`AGO-t7BkC@9mYzq=0 z2iL(Fj7N2>xI&89_I#K!X4=EP&o?7U%J z2r0}f0}O<$G)i<07V;#k*pU`_Fg-`L=S~Y*SC1odZp~j}Rp(cke4VEJ*77qT?nsN+ z$cJER*IMu+g(6fx2IDjhK=LZkBpoSPtE`ES(?IJ0KEV%jfm+m3~&DteO9zsqo?;6#a`?1`Ho4=13{m`bhNQr)P2@K9?L zudf>`96O*tLW#KJTM{&+9n|!ZQ9He{?h!%=aEsM@=P*X~wJVs^xFcz4CTxGoE4mQO zAHpt8OCP`ft!%~vWizMjP1|FpgRysGF8~+2YS&_#Z0;6l60#oTwZH7Hw~<&_2Kigk zR;KVa7P|J6)MCX*oU22Sp<m{>S2#8X>9!6aO<792`sYK3=8R6X+P{9jiojd z($G4A6CCz%Uqdyb>{b`o59$2f>D+K%pvyWa&rk7jjf^_pj)~0uENyNw<(bR*aoqrzW z7vXnvMS0@dVWIv@bAQ}o#xD`mnkAvE=h*>~P-~&i`~VajcPst0Av6Kt>=~KRiwgPZ1}Yc}slmtpC_JGAvzJGHSJ@Fi}*I+@=EPy$sTWLilZ4Q@ZyS0-Ko~4<@ zbXLXzLNbZ^8abQ|Ktx+hXt|GZBD%PIDu&pvLJRS&z0Tp_XFeHbL6IBJ&X*2n)zr>eD&#@*d8b6d*(uN&JtZ z3A)VWG2QBmb)Q+L>Vm1uQ&ly{% zM4^byMgAUT(=4^WZ9o*yEmzP=p_B5gAZhh-e%e6>pi0o2RUx^$5Ir#rPPx4ERDtMf z8>q}n+D#h-8m4p(PYWAm*@fOR+!9Pb#uAd@(%nRy!`T?G^>V?(xY{1YEYoWY+J@~? zqg=hhPZ*g&owb0fV=W%Bb|%673Rc43iRd7kw?l0IWSYUOdXQQkii$L;ot$eOd~)&R zLBCY{o`W3om-^Z-t94U1Bx|p+AL0iPpMAF9jws~{CJ+Adm1dqf52$D0M0y8m@ns1f z-(i%{^~!7S;XyY3LHZ$97h63$jHD^d`8xg^Kl|%NKH5*6`7{u^{&2q(SH9rO6PZ`YSd|P7C_(} zT;pyG$EHt&A9!}`Ct`XsnOf^b`iiyFwv>S2qO%uZzHD2O(sW8?$lT2`cIePXdhUA! z^S4TrVc&a|l*^Rxt(j+DB|pM8*4WM<|FMPhZKW6!ksNWPnsG{?x!<AJD$zYJGu|svEm9L{# zF7UN@yn0i6_f~(J>-{;-caED|ZJ?m-TD*w6U zT7Nlq8Epdx#*deQv^fT)mS$Y{XqzL8b^Y~2+bz&at?2UG8hr3lUEgKr0p+b8rxQLV z$rig%@P5U18H^#lXlE>_a`aMnLKnidFL`5ly{2t4<_y${8TIxc%|D)|Zm|0kB?k3>zi2(m|jZ1HU$WJAHo7#rCO8r-Bw4DY1LGkh%wRPHW}&0C=fg zZZU(p;eETe4ABcxq4O18r**Ugy_?T%Kjoe|DA(_;&N_MEY1FOja`x~Z0`Pjh^`XrWj-ZhO?!N{n3$S}9!`3Sv2)Xt)d>w*BMUUY7E`Pb9DJ zakhr>Pf?sKbNQ~f(zFj5 zS#`CpG4y^u`INovXE%f*lpjY_RsnmKwkEG;h|P`iJg?24K>QD%>!}wZ> z9bGakHq~Lbs(SsDl(1h^Uioz!!|EzZRdwkZ@vZ0|;t9G4lc&g5#@?)U%CMGyN9R@} z)^59TN;**Tc3qBkx-g81f_N;$lxV>b)e4s=vhBcAOA;yq{~nhyJ{>g6J zN>3a#eCf8$GlOa}^cF1YNso5@h}Vtg8K~ql@~KR9|LLi3T7^T#?>+7%{hVJ6{${0l zVSI{7=Ee?3cM^JT>L$RcVnw;g87zxcFTk5*;3!FP=X2bLMT9XfUUHpgG8P|d6RpYj9l2u+rjZq6M?IoQpYpy$bHtviYb_P*KQ}sg{oUk0Gye<5MRoYx;jyr12LKLOkk|UN1a6H z_zf@J-%{bFnJ-i*j)xqk;eO0=*<~O9KzLoF9{qKis62k-mHXhR-rlpf`NjFeeOLJ{ zSe|^s+>xVSR1TbcjTEjseU-I%?*r3Fp&W8hsmKtjfHPlb-kw#(H*OKFuRi)au_7#8 z)>hSJ{OQ4m#wePaUXHJu9AZp%xaMsdA8DUT#VLbbHo7Xyte7p@K}*f;tocgQO5u7W zJg6kZENKxX$4oKAR+aR6JUVuo*G_GUAA92TUu<ajCwJwr*W}W{W?H0A!+EpB9?Q69AO2gEx1cYt$=_$(^kDvgo+q$)V z&#*|_i$UL*j{NP_H1oxm>%=Z6(8>qBFgG=R^@9sIh*j9Wj~s&#OGORX{lLeQ=VR7A zb`M#)I?2`e6LXcoX!|Iby>s+IRizfv!O?Jq8(O&g)u6Ap%Kp5k`y4#y(^vbOS!K9a zP?t@u;A}6ms4Or7$_k(xBvk}a|GK$IVFhgNqdD5DHdEX=KnF4vV3zcvE1^wnXEaud zRi{i@dyB7Vm=Eh5r*>vuP3WqwR5jfLn154dgtlmjQ~h2+q2-L#=o^$@{iw0q(T#FR z?^pKc-z?}h<^#B$sPAu(PCdANgG2Cn=9+{p&#~_?)|#j44XjJVsztL;(`~{%BIb(pX17f| zgZ*_wJW6Bq%f0ptNPV49DQ7uKOV?r<=)$NFM}K_S7Q9k*W;`^o z`sEr=f9o={v!;Re_@_l*%fa@li=Y^0Z&S+?&w8?9%+bBASb&*jUM~)|mTB7r*RB6# z2wG_tOi+J~-Of={Hui0*7m>5B@8iyF1ZHQ-5H1(@n(r8ECZZRfGmg)DFqhnol1>$_ zRV4xwr|`?ijftHxUfM}&v=qw)^jv=1(LwQ!p^!)(R;?xhM|U^BHtRcSSmK>Ix2r2i z@tl=n%DK-Fg;-@@%F+wXAS2fi*qxZ&^>HAn{ZR@@$x@s=0~!w`mCI4TeAy=cFeQC9 zGDAB%_!sbU!xE-z*(cphtJ%ZFe>~Saj$B|ob;V?-9Q574<>sLsU<@fva8BT_8o%xD znsI%T;`y(bJ;8qIeUr)6@fK(tLFp&P`Hb?8CZ0@f6;DaaAdn}_$9 zxT;X+!r{IGvqrS*`Xf|q8$^FfBD z&;w_8Y(E~468{;LIX_hI3~jzb8C&+r7d^0DXC=rb%K7Dan`t^*R4=-OpU*%QFYASd zn&YBWQ~lan*+!WA!X4AVxQrnaBCAc~(lP-rT4R7)?7 zlUZ542tyLjWL`ZVj=y6xXLesR)D{K#0z*#P@}}=Y)r+Gcw~bIT-^<8up+vyASRz~d%0}oSv@9X4&QCE+XXE0l1Ua>kr+Iqm4?D1r2Elwa zq-n~^YtowSg+Zp?9;`)_e+FpCTwFz#eLy6ZE%mch+97-`v1Dmun~ePOG)+X>R6(;J zQg~dI?!z?J`#u>M&5;&iY~Y4hww`tj zj3-<7qrg9H-61xxOp5ukPWYJGYc#*6M0{^nccoHrXX=muwNY>`uqr8DZQ!4=j-KnU zZ^&s3FoxGPoA5Id27d2_@q{%84b!x<)xlugS#5_uRFS)NCiJRq(J4)Y*-v(wuMT-w ze6VBhhW=JAb}~Dy$*F!RJQNt_pVq8zI^^8!-G$wM^6Ckyrlp}j1#~l#xrN4|h2HBHJ-aYJAJ9@;~8GSq~m~=u8a=hj{MM3o{*qdh$1E(=B#X zQ|)p+?MG-=9bK@WWBC(@MvucJp$4f6``AO)40PTz^@w^b#r!W;#rT?Ux0!Y$W9uH_ z2Op!Vy@*5ha!|C-I<;CW5@7BFP5fPVgC2kMIcD$;bzB^Asn6^j4DgrXZVnqrT6Iyx z4h>F%>}gTv{Ln;Rj=sXPv{*dAf7-F)Hs}bzLr=l%1Z+v3IH)vQxb$|Pw|!a|)(?uv z(1!GN!v^in_(fTCS@&i*rI9q*^ux+rUVs%k?6>8ZA&g+WK$#f%M{DkzM;1k zROT47zma(N5k1rH{_Q=3{FF%R7&;NO^_tAroZBcQ&Rw1tFSMfdarrB<FM6H22 zzhi@=u9R;6mnhKM=-qHLt?R(-AR&$4F?SCZ3XtHe(cU&=7|)qQPJ)}1nnIn#NKb|j z1St0H#u}?s_r?_v`*2Qx2SFJECK8>FA@$(G zh<>wTE1my>j8xnAVzl>)`Vg&_*tn1OawRKwCp+I z^{0~qx_|?fAe82zK2kSm>!ZG+B68m|C;1$SMIYbM=8{*Fc={s4rSekG(b2t7$3&El z8YLLHN2e>aXDBWXVYf%mGxk%^xAUA4;dKr&ti$PM88Aqz21ZN~6)B=i-LObq@F`n4 z-jM83tkawK$9ab9?@IWbJUeZ^ZLUV}`(*vuA(J;hb&;IKpfr6F&2~kS`zf8{Og8in zFr3$uP(9RGKzIX*I@BegbAX`g~U9H}dKvAh{>aeZ1@nrl*S)HXU|4XJCy= z$k2dW!wJ9ztHs$CGfd&);EwC03fM^;bZg$N3#8yxYO7b`W5XsjY+yAa#DxNlRGrKwnjpqO4yG8=t zbmxnFs-cJ`;QSiDp)Yj_d=S$JDfIPA2_6yxx^nrcj%s;h(DE}pB0}jb*A?I%=7`-C zRQOeJhE1Qs-+oX0cK+_!|19USCyZEG`|KkX?ge*v^T+*@LDmonoxp_&H@mO8l!r}+ zm$S|TLNt$(K=HGn$oaO}!O4M9<=UE8D4K#Ph{>W|r`m%%Y_*H$$yqjJbVn+L-M;Q! zAf_pr7;*oC?mQW+;1L^yXypx2)6y6!b zEGA$k^!R|nZJ3nGp{#518Y7tzbpYoG!E>CxE27}T3~M6>x)xvVv{mER|2dIrz*){A z^QE(6|AV#ONR)zyHH5#*Vc#$Q>ECL2!x?m8v@iNlot5y!t(3SuZZ`Zp<2I~W7tCa( zYM36%(#}>>tvQ{`hA^Bc$@7FGH^7MI(ersxDi}eSNWDNir&hZ)$=uxAnYTVxnPb>T zGwFtOz$h1^CPT+O%Fmu!b9V%8TQYK#bV=qqWg>kg5Z(g`-CA|Oh|=uCGM+;|Mbxj} z{8-V9G+bUZj-Y1PO$=Mv?Z*kv)@#EQkh|Fy`Az6|vu&pJ{Z*#10OHi*hFicx-%y8> zArbZ2Y0j14hZWADE(S;k&(`pblwa~|YU!T`ZuYNx=2y1!b*%~$9<>|m%#Sn0Avjcl zg;Rgq(BwREgj8~SahBpLtrp}JKAsF54_3`HV6BTDnRw-(Ju9$@8rL)Q79)r4XpC4% zPW;*qy;M+n@aH~nz`V~^9E8kJa_#c!uGPAN)Vc0?ygXML!sjMDNe-OLch~+f=CBqz zKAUy50JQQ5|K;sJ`W1`wZ>+lp8&S|F6mcPo!okRxL)#bft8Xg=7MBM*CfVmm9#YWw zWDPH?l`ZsT2{D+I2qh-d1Ea@bs7lg>gzB4 z7t&_q?^~O1<>}8lk{GW*wPmYHj71nQaJw84xl3ki$bG#M`1g}I<(mh?cEaP7CnE|- zY^g9s0@4P7b!Z!Du&aSH=o9Ca;uNlk(kanP+C~6Aj?sq7^7H|j#*US2Ss1yn9zjeu zgr4PG)lS1qDB@5nMRpgxr+XMxk`ALTnTCW$NWl&qUP+#Eh8iB2NUZcJBFcVi(PaT@ zwTT*2nYKw-$7__ua%m3T5l{*Bc}Hrx-(Q*VR2iON2i@@;{{&3S>Lp$(mxi#32@itr z!ab_f_$E+(Jctq|n?Tf*z6~&f&dgz8!-0!e6E;k08|)3YV5C*PIOVAkY{_;4)I6Ru z?orB3d(mcZG`Jg|vV6Pp&F0)qm;Ztk0fSzw^%=pL$t{yG*jW5IGI}CDD`1n)X9Sh} zbbBdvALhdcuuw}AXpo7{DK<9|LRc)zAv;)l080OSWA|*o8cmh}S`;jcly5VE7HCfE zkLRX|=n;`9s4lA5>YyC9-}mk2-kRxuE@g6QIy(a-K?(cYHgr--eLa}@Pq@Fj&Kbft z%xgS@MdW*+{TmXQyX$TbFU@;;$d?$AgdBNiqP~oMF>uG(ocv_ZvHy0NCZHtehm1z6 zV@cjC41Gm^n_Ih~hw7E3wBb9W?6{O8P!P#{_jdQY?SBNGp5FnWAP0rbIQy=;v-;aV z$(gZxbR{^@?)-DKF}wi~bfR=#qheUqw5vL0IdUZhmDtTgD4twM8R}e_9;4)*KzeWf zea22?$h3>gfbi=&AbaRTVPoFo&vJaZuW(hM?#d0m?E`?Gu*~OVwii4kAB@SPn_K{Z z1Bjy5+l6P}dO{UUU>m^2LqP2Fi-s*$8L?h2ogQFNfA+~_DrSf*VhNeFlr2sv#8m#b z3DNXT9erc-`{&_j3zV&#L1v-0#b@TM#$-?pvPdGi-f=+?5PHcSE=@kkB&suCkKUXV zNG=)p^~>3%Y5X8Vh)ai8DDo7Ckvb_;y`pWyrYC;$87|63MF zy$#$-`1keG@3@`g4||=5jwN6EdT>9mzW9g=%q$=KE#T&(+TQ_tQjt6fE2rv^)1aBn=e_MxOekK4VUPC!JyR%m)Ul+V~W2}{TRA4PA z|kTYw~OV$7d%ZAEiqHMUgNlX+=RmLXj?| zq&7epU5Wyd(h3sN-CYwA5Rh)hKpHm2B*uWT?RRE;p6B`gU;n*)sk?XYKG(V8eZ{%X zSwOV$-y4&M2utVlNrXbBvpkTJ@ft4E?#~@*`L{gIZC%kQOHuXt!v-d|$J6sd+C-f3 z;d?TBIn%iZ{K(IUSWkwJVJ`tiZ}*sfkKp&xLi!bV2t9=bZi~HHcQV+J88RM!C!#A(V3So0QW9`cK442v zW9C+KjL{K|5t_IH)+<*Zyns9m_&Xon$GDC2k6c^7dAy7aVFWuf-yLKHb1E`UUEt(K zAA2}*GRDjDen;8}GwOuT`3tp?ye)qwcUr3PIG}hxeOl5_Ig=$mY z($bKp(kQ*n<xryU6|qcD{T85cUj#4rBo_kQ|x#lpcnOpAN;Oz7$z zjd|Zg7l@aCl%}^R$3Q+Ou(0gegkb3p#NrQLsi)_!bxAcR4f7mP+D=#c)42jUq=MG- zpu-$ibfIIa_0X87*YU!?6F3H4pSTixhA*?iXEN-r+?80gW-+}3Z7JSJeg|9x8Lh=R|0 zNQ&p$86?cptM-%E!{CY#%VQAH=eIWw#fEo^ak}rX3vu}Fw^Nu)wNuUG^Ob-;M4U7G z`^4}jns7tt@HefOkttB?am(H%oKcKQX0*0Ny{nSJ~^wkkM&wE@o7(4}39EVx@8Da?ewd_^~BI zYHv$xrT4J%HvfYGzNkd2SAWwl%4qqIZ#5#VIm3_ZkB9F!YfbOHs5O+i%r(0@O}^H& z-Hxmjhx{zq{^Xy`nB8cD3ku44!Z+=;M5oAJ&sW%Wq2n4C*I}jYK&3zZVGxpuSoMft z8VsnLKqYcwVn9T;39Yj5V)uP}%39C~SzS}?8)%M5Z!K7 zSK0F^JI$PCN}k~_YQ4Uz`#3LzR#jDrXwS)5Eg0H{)sC__m8CImO4O-<-5FD~$`mS%I zuOXbCbYJLTaVim6w6r^Gq56l&>re4^w%9}3WH|oEx7ceeg&b_|%>}2b#@(y2J|7>S zp7KDln~h?tLaMO{9jS)p+!{>j5q);Z^%E*rrlB%tD!2Y3Nrl?Uv+6e!lge> z_p^fyIlo^|<927$nvb&2md%|RMMEGW zr&(ogCL31?$4OV3@Aw>B)4sRrsce8uvhGX0<33(yVg2ph#oG)*YB#*xjGu?Q+>coD z(+hE>Zd>_1n5ZnKh@-Ys-@vx`WVqq8>79*uUg_#jlMbf$zJ4pL8jb?KsKRD#NCPIm z-er=!H5ufcqGV8Ir@u04{e9e~dqJ1;AkW|Dxr%5<7*wTUEY2#vlH`A(m!&-e@ig1 zRdjadR{<==fqbOyg)I~Usk0wu%lq_chQ!Wp+f$jWSL>4vbjy{Oiw8^2V%mH{8C(CD1}rh@4Pif=X;xrnh9AkUCr^ zvf!3ZI@pZ-Cwdv5O9W ziGRPedfo_Dj2aoKYC{7@Ecx^~aPa^mw( zf93`eJ%!STE+Y&i*6Q2V(KfOphai1oBS~i1`jL+X_KR*qm*xKX&4#BxmUn6ABV_7k zKMGcZh~%j}+Bp%c>A;lWgMT2Y5rxw2d9~dx zqfTO*6~@%vQ@;gseTy8p-Wp!=Pr z04eFm){cA(rBVJDge=!m)duo<_r(#>0S6(=PLhQpOXzXKFg*9avo3#h?`xrmua`-c z-FW95u_#*G+P?Y~9pv-T$DT!%IDMyzN))gj?0=a~vW0}i2F^wd80cY-Ci4I11~oa2PhW!228i37 zluLi_`JtCLQO>z=0CJtcqVZq+(wab$G-x4Ddn)z*Wi{Zpoy))XfOm=P|4I`A`D{tu z)%WZ5xxB3j;NR!TRXzECuUW2M05JWuXPf`u!NJXAtN+n@yU7F_b|ZjB17JrUNt0AN zQMl-UQTq1@<%X5%-BboF>i_?b{6H}f3*hj3M>6~WkeYP#uao#(uI1_JDYZRjcEj!G zk1?D4!n7x4J!caGF~ta(qNY6}d~kX_-1RH#)K3MPBcCl*{}KS{JsBart!wCs?VWor zTYq`2VKdxqK2p(i=F6Mj4B#8aJTQf3NN9CPe8c>u^)|7ltHy-7(bk_c+`=4(dZ5)L z?xx-D;!)LUr(S75T*>&qLjG)tRg~R(X6W4iW_?MPa`i0R_P#_(;h3s}eqn0= z5}uezottHM0<-UKmq4RndU`rHtY&7fW+nhL*C$UK1~PJKR2t^ax|VCBwNUbSG!wVHH;-L8cf4f_j15%Oo;jaJLL1WQpa z$kvU8t~n~FJ=ba_iyF5lYUaX`w#oH;;v0Y;FF{VVy!5X=T~9UgSc#vj!}#u=lwl2x~D#h>iNIB_8_;c$J%Hr88rbg?YHovMN?_p-iL|n)*4z~+y2nyNC z&Mwr*W&Dy03V*wlxK`i(DX>YVYRoi*BbK-|sNRlprDRjpO-`KK0Qt@A)n*}74ChNCQP2|ij&q7!@av` z(6&~On_9rboh#anTv5Z;$w@8~w-v(K#x3-Kg_+D2`;t^9U8lVrDI`K`w7Lv4&^)tW zy4EFF--a=EwTftqNp8pnBnBfWq>&oZHgc{<5*KtyC621Wj6^J2}}55U-^d3B-{kgXuMOn31v@bz1!Z0+h?bR^PYi$LH4d>60@k5paf`MUibCu|7lVD^5ih+eK@DU!`X4Uj?csC$04a1j^?CT^hHrZ+Vl0 zs+;ht>+{j-Bhr+bIT;qS_HbklwDO|#`qy*o!^OrdCcB;SbCHT1MTzzRW5Y_#5@)$N zZ;LkVvO>KheP^xS!vXou#Tk%6C?A$&ywvNnT`oTJ zdJdD79Wxui3M*i}Q0umyw$tO8 z+VI6i=EV644)^&P@x}LqR^pTxUlXAb=fVt90xrnh7-HncdWRu^h zPMGRr+9juVx2<5w4D9Uen0YGKH+$++yR;+{6SG^-sY^Y&1>M^!2N^9egGRI2uS$vC zk}fDXB51T>(KjvALOZ(|#E=oPWVeS2&K?in_npULp{Sy{L!3Jr+1KI)4Qs9gOEQ7$ z^$-xgTG=Z=-&swI8@G!*Qv%sG(C>v;wgjn%m*x%I{BoDC&xME3`tJCz@61IM>qt)f zQoJ399^t|}qIpMX!b5fGzV3eL{mACDSg{9zI7k0YA0S6FiVpJ7-kz@n(Ci7@!tAvQ z6@^tdET}rpg&|hq%+vdeVzcZx@q(_@wxWHY$zds^)dY?tC#p~KoN_Xk!VhV(Hex}k zVtwalpe)lZ%ZJ6x^JB3eov6O{hRu+t&CxAvd9Tb3>wy@xF2LG54-;J$CE9(7$my^9 zyYZva#fF0IwM)r}j_NOK*HS3^m)0XAyu=xVjEB-Uo7TgM+m~cqA60MS>M%uApPxG@ zrDNg^&t6fNB{|2>HLth(IwzR{IRG5r!k>K6Kn2&5mMJzW*msFAqL~BLMg~+}q zjc87|hrp;YaZ~M(2Vv5w*o}l4MQwO}d7VYoONAcy#v;n6K#pLl+#P$R33h!hHb4=3 zz1O0~EaHv44nykxjqMt?raczF-s$ZY`lVAokIFncgH;GDcKf8Iz7 z5e${YHI&1q7@K~+xncQzta}!~Sw3X;;vHvTE9gD8BwR z8pIND??v)2kqN%j=YS+NfiQq0D5xFY1xLi3@9qY@0tDbHLPG@WuCFx_WgXXQoiK~k z!p%q5sU!V;ed0f+^FKO$^xDWa12qHI%Bjb>=Q;O(1WI&o`g0CVS%OlR#rkFnrC`ln zKJZLfdW6q3OFOJ~Ve!2LK2#0ED&^)7$^v)(2p_3O3DwVdYJBivrFmkb0`m@7Ez!*Q$~!aHfgq1fCj{eg)pGSa35lX7-5Cn5;1g ztXMsaqZ>h&d;?*M9sFajR~X;>NTz2*{UV~eH4tEN0KRCh#YLY&Af z1!0L=FR?@2hfO73FbuflHUnc9~P^su5JeoVRU6z zkz*He!+y%We{5&^!dI;Wa7JUdJKx{;8xkgke0NnGzg+}p6YfyRxca#STOSr3~A@uylP|sp8=4 zj-L+*g#w{gqqZZcl#T&oPOpTAzvN71{7;78Xo^&qnd5<(4wT?QvU;u?9ADf^6H-(8eXyV9f2+e;>Lo&}z}$e;~K?(KW2 zTC$C|jtPlG&1BG7oL)bhZ@8}2+8$OcT8mEEcbj>TkuR%h1l z&>7viowWNQrfs1s>y^dcl{#?yYLv_Rb~S>#s1Dtkuo5A&_h27bVF<{chFRL{_1(7s z`%IR+>lYJj(Tl(cv2z0|h$aPx`H9|!ObeG#4sYco$EDJp^$1@!oX6;lbR7l6d?HQT zmG*5-AQ)|1kMLgo3U)c%F#mS;kgS~0nCah!q65zZ*Fw@+^ZC@-Tlb*sv(y=N-g z?%Rcn2mWyxds(8`b>;l=dW!%p%a?aQ^^vdCAO3RkVgcj?kKFbJPn^YbcOfMLv8BpX zZ?LMtq__Q$9m*T$yocuTRrgrh?CvbsUT@srxCWc~cCyf*dPJ7IUe^2L*&CyJmtkv4 zBWc^fCwWP@alOspBrPqWJ_4A4!utBU@#r|##d~+*bTQ_ifBxAbAP~CgbKzlO!>5Gy zThh~>X=)i7u9l~SC*R6Vkic_CIAlBl)qUi2@&Is8CUa&r8=d(Fn~w5byK52F zNz47bZimlId*Y04vzh0lCXY7fcE*tmqy`S># zU8?JpyLvl^q;8hA2h^-O-Ui^H#6pZ_KHO_6E54|&&_E22la*76eo42YiPFXhl2X4nkhZ z|1IQD;kR$T!e&_c(W6H`EsyY8cV3JZCHaiff(Z3#tb&rwU{+Y2E3Sx!Rdi^lso`x< z(9ilNPr{ndp(waDZmQ8Ie^+Tk@)EpcdvzS^N+JAf-E1!d@a(FD?DaLGLk=d727;KmO%r5Pw7}t9f zw^_tsUX>>DpTwtKa8uoh!Vuwq9{$bxskB?YINj0*6k-Qj?zYY~j?DzsLrIs_pY`OL zxqu^$#d6R?C><@cwosa}%Y1x!&B^Tvhh=O{32R0Nh(9vp$;?~iWbf@-OG3iQ^Y7TC z+=A??N>aB)oR|ApINRSa8qX6x?xXWRL}l#ndm#pGP2j^K9D^y6Y~g+px6Z$CayqCz zS77%wD6%2-1A~L4B9qz|7JWqh+6@AP5Zhmn+)`0d;Zd71KR@pWU27z)r4k77h{?fKWA&?7 z3*}unaFey^+U+k|>2>xDt8GUG3w28;aa$8hDZVd(eM`mhUcP)3?9V^{rKN?Qm9>j1 zvV({Jt_)Baqx#s!CV@)LwCI~RZ#;fB6K%asE?v4LGYVhsYsOW9yhi(U39|s43#P9U(Jn;?!Mf9M2l?sS1i5aW`>U85lP6u!qRCl=S~;(4!LPmxV6& zZB&;f@A!XI%kC8|xAe|Vv?oV{#7gPX<;&W(l-sv&*HOp_8`2(r?`Mm(&2V0_bBW9cZ~dv9wPpdQh8b_2b0PO(#vCOgF$rM!&$0{mg|Ox^IqEk;29~v2|xQX z?t=tq_W;+h=~5W66nw2{2$VAx;7JG)u-Utfo`m&H7IdnBLDh~S!fRLg{=L`rJz~>K zdJFieUUe{E?xMf_S75$YWggqBnVY?c3s37;24zQ;c*BMP%AVb7TIwO`@uo;=r+^bR zMhf%_LENYjNXxlxCFZk#)ra7;^Ya-fXSH^AbgYkC61Smst7R&rHQtLC?|_`ku-t?E z$1n{M)`JSru@YlP$MxWl5KTL~0#j4d^X%+}%ga{x@87?A^X6kZIy&Gp;#*B4yBRJg zc2oc45A%hz&fY3nbP#t&LVdN3jg2cKq$MQ{02+Iiy9?HV-t3V=l6tu}DEwGAzuJ`< zl}u8T-s~UiYeC6V!h5~dnf>;Quj#7V+Ho?Zt#c@c z7I@v(fYL<0Yq2eAqSCe@fTtdgVYu#GX*0}cHBsLOWu*W;Q7`ICiv=+;=jj>am-{~X!)ET=^WBZ&^xi5( zIwMC7oeF)(ySxabpi$jDMni|9w6ru~=jGDQuCBq(SVR}B`k0huNNZYtQC*#$j4$s; zhgWqj*u3sUF;AnE=;(A6p~ib4yS>J8d&4aP?FDphgw=juQK)f?_I8pQ%54tsfvxn# zR1B(ZdLpiA^N()!$}Ty1KYSSKv-2!G*=ys3Y6`iw*98>agW5uw)6cL=i0&?#&VCKq z0#%x|YE@(7WRSL4G6#HW@+R_0OUq+M3Ws}=B;|disJ>{*YZGy&8Uex%^YYz@FFZUv z%ln%`eMB5$3EpU(0P=OoCx(Uvz#en~)WR^QPMyMoNNu@^NKX6skxjtZkfEfJLO$kx z>!_bX(7zRJpjxRNtfF@B0fkpl-h`y#iK5h0IpC~vMntFXMp1p`SShbf&&Tlt)k6II zLzA_#IvAPa`n5evy3dvI3Mv>I zH!6E>uR7Y=0&$&IVqjoEgKGRXt6C#r3XuaMwS=1sbRFn-acR!<=?3FJ=-YNH^ z=Vh&*Y9ua$taGq>`e@M?>x3-rLj6pweC^@!^{wT8P2jc%UmZDhu!@G3b~H2Im}AHX z2+2SMXoPY8H)RXWV#;x|7FqHwx0n zbffj(^Fjer^lIj#RPbYF>W2$1FrK8!LdT@r86|pJzPMpS2);UYb)F#dVt&02*K{la z@8pWk&9>f{M;@v|HhCvD?b&2e1(qO!k&JLKe-r#Jqj;r?(|!IK7`Jtabej3*6Hr$v znBY24gkYT@0F!IWEF#L9aq%e8ATXd)IlLuum2Z5!ayLV4KkKzeRpyb zfO0azR}&gucqAQ5C8>Fje7%UcL!7sF$eq&yPH8?NbjJ(iY`H_;QJLd zrQ>=N@JUl3!c9Q}AGtFAoU*1(v(b62c);(#A=1?JtzM~25Vnf#E?vBRTMtlmH~scf zkJKjrC0^cRb~9`fp`Nd7cHI8!_ z^Jx=bQdnDrY%6BZxX}ZXXy2qsAs@2wHkoTrN&9dMae|L+gpDcS6R6%qTvl(A=9y^# zOHw8jkhxCASy))CfQ;&AJqlE5vpA$ZY6Qx4`8_~C`#y&bKsah`7{q6)< zdhFh0)$?BCx`jA{#v;kZ_htvRuNv0A$Rw{vFebJ--Edibn{dM{3k1s?TcX6Ju-2eMa0HAtSTZXs83alqz4KgM!C7x4Z8>Bxc@gc zx1#|XOq~tj|HyntHRiopc;qvtJ5Xe{x(Ui_Q($jCx)%>(I_+z67UgeNnvZdgq}^A6 zPkR%Noz_yeIMBeG;-#+w@)S;rn^B0*R5)0R>`o$vO!-m{C3&v72Zx4kA*W1BL6M-D zYGT6nQ^j@jQC(8WOQ5QtLXCu`y`>0~)AS4si{;$_gwjQ!6Fbsz@g_b>2uf3$o)j&r z4lD}De(z_sZ&(qtvMbdUwi|PxVj%p*TF=Mb%xV1dWOvKL?Udx?qFhbc0gw|FV~XmH zL8x?~dSxiL94L+l2}l_bK%_>Etb!2XyGQ@_DN@~XSTfyY-P?f6ag>Buiw6VmCJoa| zO8VYqL`8)Mpp30@qRoW_ML%A;6p&sqes*yOInhL|j*bpxHMK`CUQlBo<^darENdHp!V`_2@gUhKl=gJ#vMj&KonCnJ z=1q`6X0zx#v^%k}u~G4?#RH3~*8wSwmgS<)eFX&oE+aN52`v!)(eMogOpI6Cr+_ds z4~wj}oo@0Dj)>4n6om@A{d}64S(zJ6G%R`ABv|u9Dg{$qyGZK+iX8GDDK7O!0Q?50 z8a)k{KJoxyN{fjZL672?;41@f&K>kKk*tQkh+hTwD*K z(RNi=Zb^C7UsVEW++tLXc=MZ{ zZz_1mf&cpu$N6t`B%CjfXqfWxV}Vo8?fCG6ki+->JNE^O)dp5fGhe+rn1T_ioxk}e zFmUh@#Lw@4a1xUG=n5a-06=nInNfp6Z6o&uE$8!)3s?RRO;@~jW}dK1x%DmWfu(U( zW@hGJcZt^hn$_RG!kR(q3u3BhuOb9GFMovxBG>nShkGxz>jHki>FmuvBXmdJVFce# zrMtQEd!+~+@ka311BGwBrk+0WS@(C3@!8*u6u#y)Lv0moEBJMj+&>4DsQ;1MY-a2R z|2|#)z4hMkk5Z(*omH^_*M+m!3zXuicS6qU{~0=rZblKjzkYQ4UeK>)WIvmy!BFZg za%S@4)GJR5esv|v6+ZgC&fNE&NlF1+AA0OmTxJ!d{2Q|mzxf>trIucIvjd+VUmxqT zYm10{_j_Ufz+cLR)89|F>W6Uq?xRhV0EyqK z$8zbnYw+7M6^ngH-4~`OVp(OX+V{%dbv~fs1H$`6tE&`MIUJJiq#i ztV;h98QlMXFnpE>&L))GWa~$Ci^a(=G8ia0BHXh`RMFA!o`3ck{Up3V?S1Lx z=5`I$Y4*IPUr3eO{D)76O@%*ie>yJ36857MNG{bq)V2HUD)RJf(;=;RVOxjkN*d*V zq7G9>5!U-N1M-lo_(0A=Q|M8*&`%T8Ga}I@s#zsxT4lboROKoymsVX=y!%Tr{Vs?6 zQOu&(d~DXQmt|P^r1Ut6;CY`PiR6~)CVI169LuVE_ATK_G|&7mMp-%j5wzD0w6y{E zy)I0e>J{oYAYs022F(S{GXCRPmt(oZYy=6TLX{u*KKfmvl9$@?r$&^MC-W*&B=@BC zP_vBu`g{MOa0hUMT;qfm(mK=s+0n5!6RVpVx@C2DlzfS&DKb zK7d>Pc54;gUi*X3bl}<6x!4_n96yUxjSRZ^I`qR*_Tb#=+{?c;lkN|}d`vWMoC+cl z?SMD=IcxDtYG0^a=#FYFm|}8_Xa2f#>%E3~YF~OaJivxAssLM){M$fN`?-HB(D643 zYR`<2Pct6*n>}(caXH5N%K0#7+{3whKx#RR#QecAo1U*5gE`!4oN#HTZ261jH)*=HZeDIaLF z2}619`!iO*%N#x}D76Aztp&%ly5Q+UPg`i*)OheXPfsHXa91vBYj|6$jyM{Diffcy z)2D%_j67Q)P^;@;Mw&7B;#OHQ=cH^u2f(AGMw2;SoIeVL|8glER(`U z2l#YCxBf|UK61tT4R7O{%dSWAwscXc8G0=iCmq;^YXvXxoJhTY`>|n8`SYgPssbbM zE0VYv*~63D{$Z!!88v;-@szzV>zH#pIU{Aiq0H)s_yo zD`?Z)_aH_|$T8jQi$@=322*5-uVYDJ4p?!uvnqYB*d7!6CE77LC)>X$)dBz6W&fuM?I{pFHg}!+~FVT!f8)hBZG}Y7z z9e;y5+GV4A5JpT9af0_2r!1$zl*)sKXusX#(;oMJ2a?6}(t+I(+LeAH6 zc07|Xs+Mferdn=l!f&0E>oOPq+~l82*6n_3{_fKQ``#_HLee+Tq%=y`7I&fLhf~-b$#~~z6Rhfn51?_YPEaO6Z7hlu3cXd z;@5+-zz}yjj%*T4mtMh`sEQn30U&)DvEP(Y8rF(at@K6!r!jrr7 z;iqph>G^Az!&}}pALTfq04Nt0pq~@-y`xm!pBHg9gGY~l3aJCb+~Qx!&&wE11@>wV zjXwZ+b(WRk({lymA65J}xbp6EU|yz4c_VBKk!4DEo(VHf;Ttv!@B?3>hEFPwSaw2vFDf03kvq5EiR27#+|3JF z>N|($?PSR6IHb0yd&#@b{ljW3rA?`>hS--{%{f`;pDay2h!Q2pD-DDeA}~uR)`ja` z+vCzVw2D*Qm_d{Y$)W#^8h&zll7%k7WCI8bcaCyG0x~Z`fNp;(Dn={uS zdg=Y;@qOkCpEHU}%)4JjHD%4my-Iu`3GjmJ$_ZipVg#k5C2IF42ItE^=q%$gx442v>q(>Ka9s{9Y&rznX~eHhnam z5&BNH4VX`-kU;Lna|LwDWDitNo*fgI$WV(~ON^m&m`*G&cTT;c9&nmDz}e1*mqrzv z)A|8AwJHP* zPZ8_~b&xup)(H~1MnYjwWXtg7?2HGTQ2m0+Kti6TVQ^bDuF$w}Vy7{sVZhY87V8TA z-ZM0lb`Y=H+4p0FeMsoG{4<7Y_Aeli*T;WL*H5;RMscLX#$3cda`*(0uINaGhq>xR zKot(yrG7%)3AEN>ZvWk5H?`zrUcQQ*%39oJh%3<3xcT0?+Mh-(;XqsFYo3>FKI<@D z4i6H^+6-1>H>)JDaKpFSER6hfP7>CYEJ?nLnvks#WZLZ*gw3?9z0KA4?l?b+?QoJE z7?spMQdyi4#DuNi9)Bm(Bvef*I)!jIQX3=*?AW*TZXSOi`n7beM>5UbQ?y>-RF=p5 zTnJ)-$jWedU2%E{Up~F5+q4kOJ0w`8m6U8C*5hs>iyi+_N1Cg3T_*U~JGVDj7cI>J z^+vzxKL^)Dlna{R!TN|$d*N+Z1D&M-2%72u*{ zih!{&UVjSg^zU++h9;gVyx`9Dn`0Uo2%#^B0y*VbO*8nYj@!YkSVPNGO5*4y0`XKn zCg9ngou&xCbtxN^_4*a-8xi5LuI{Jg_x6lr;<|M4Rg(+^r?XTwRT zRk;*6@2f_Iu)mRu+TZ7WcriQZXca&m*xE9n10=WL1FmZ zM*@DN&cu6aIzir3WHvf`Yu#FGqv|L`?)aa0YsYlRA*mQYbudyUYpCeF%R?u(fD7J7 zxV?{X9y-F+==;_v@rSt)UJ^h6Px+ZPGYjG-eZv-gS^-PYX_vgU&OwJ`r<){6>EScxQ}*S-(z7=(UI?b7pi^pd}}V4vERE} zT30>X6d#XiI5D7!cX{F5Wu*;{cWfG$ zF5Kp74B@hyLa0%^x;7--A5tgvZ1LKJ$@{q~FZ0AAkLQyjWlU4DMY%XLR7_HCrk>u`jp{N(1= z*V*ERDaHICD-dVhVJ*L9G<-#eW3oGu83MWZH);vjc*9Ln&BO|^F+Wv?$~Hexif!WG zW)BrsO`l7hVbuNd<+AQ+jN7YsfUP-8bgnVx_n03CJgYnt%M;R_Rn*ePAfejM|J-^d z6x+4vJ6oF+pK88fVa>3)T!Ex-(2f>fsL#vDtBB5%9TvT)lzO6+$z$2Xx5oW@%9NXh zAz(Bq8d-aU$90}P2DK8?ZQ398kN}yEDo8&dRZP#LIhj31?)ow4HD!+xTYKW7^y~qr zN@)uffK)LNt4;<}w@Slgq~{dw>$iBp45r&9DwXjMLY^&uf%*GSh{{L}V7KK@H94G8 zbO}-!2%nU{X@Rb)<+Pc(@2lnEeAhq4mNUsEDWGC4=g|tv8MW$)8bRG~_{hQTQdM?x zbBK)fL}ktP@PCJs$WsBk#wj75$5QeX1Cs>O_tkfRX~Gu>_GkcNZrq0;7Y_dor8GEa z9)N~@j_}i5OygNjy`kUcV`TLUs`5`?%}vw0mw5WVD>`j?o1Okca#7Iy`8TbtT`U|I znZKKZR(Zd(H&t&d2WurHrqB8xds5+Ae9{G`(_AIMH&xBsX{y^>bx*fUh83f(s2vhc zifG)Q=Z+AoZXcDcO(JK%DMGqrYa*R!^K9?hR?B5P(GDvfTG4pliq^J%9Q6-(+K`NB z?hN_$w;|5wi1b=d(OLoZ2(`ZN<>Z2lp!W*HIIZkaj_Ce$g^O+Wt_A&rDhN2Lsi@Zq zCHjMhT@0&4Kc-TFxUQ)4eXqDlnkcGAqv9_;^)=`UjWy~~l%ec3$x3+>rN@{Y-OP3# zE7vKy(Yk_H4ayO&mF-oGF6?RT0ZDUJ1?rFMvXb`{X%e?%UZ{jf^#E+pkp7)TrtaQC zTgx_mi5@+z`9Q|{kRiex}@}w(Fy-Xik-JVfZ+o<`X^VA2{YAJ(vjnyTc zO;Y|*^n!u24_$IXz55zH98O#P({boiaQt#4n1+z%asq{i|8E;9<- zDzH};PBgj9e5-)WJQx(tBab@%C^0)9?7aod}ePCqYsM1K;C>c<{?N1HdAS_|u zVxDBCya@21EIflZ;ssj?oIFZYhGi30n`-7hKV$S@-Sh~3dkvNy8}aE{M6QvUUCNw8 zI?LQ{Ktm5iL$N>`b4U!e`NO|^*{>9QY=&!zd#xJ?v$T) zMF}_{840Wh%>(Pv<-PqD)sRV8pn;u4b=PMb-1EJv1qqEO+D830x?77KI#&UYqPDVq zz%PIk(RB9~MG)OcpP(UEQTs)ZQK6tvP30a{nG#F(8r-Rasv*6c*dwb`dxoX?`Vb+(H-)t&n9F|&i zd`AsA)NP)8oVeFsFM#}*l$qon7^C%msozIw#KnSs@oM&0X}i&P2RaOv)bP5N<+?+H zqNV-#>Z_U{nw+h&^9r02NjSKl)iQG6t|f(6gOA7 zlSY){yCo`eEJUieU9|s18B-Hwa$C)&B@tACE`C0b1QUStdA-TY3wacVJis@6m@d3P zBGV;PDDYMt?eh5#F(Aad9-#bq?=~YJDdv`27BHZ`d7iD+bYM3Xg!KU49)n zvuKum|0nKI+<1*?A~C4GGX_atEx@2@eMX*mNUa(RoxtWHjyW0JDauzhfWRq^%_j&_ zE}Eaxhsa+CDek*(v&dF^yw!tEuSANFARaf2<+^lLl>e@KW@}XE!6<+-aV##u(E7oZ z(t7hr`hBW%r7A&nFy}Vk@4kZl+;oSGmY<;ZAW@iV%>pH!=3tAB*~xsy zB+orzp3`nbI0yDZL*%04eL9)Zj-#YttLTe>GYXe3`HWZf{%D42@#L%3Vk@IET8?Wd z-Rv8a9z#MBPULrcP#Q;3FMKt9*+`m`En$geG0@BWbG>S501jy6{C%u zr$hEGTL{;+SAvhpRrC1@@Q1&`D9H(iqH+m*@vzJ}?zHokr5;A3hF>D1V$QEH2Yl!M zhOh6ATRqh92o$3K_V9>U9M)aSeqp|AYBKXJIAwuSiM{3KaL;RB@n&e~n6dXTCi&QT zWbL(Hs`L}yjP)3GwbeB4DFgMU=w+43p&dTS$126yw)?4Hr=G1)PMBs2LG zDQf)8&77{0jtiK-p0sDa`MBERIUJ3v+~bzIq}b-c-^JIdl{+}&lqeEN=yL?Qk7>dE zU-ebFkms*clV;|!^F#_=h8;38wqPVl#kST)?yFbV-oHfq%oR$RfjL;Y6wpPY9lMey zb|zFhjcTQ?-V|I*0yp!nqPB?IqvGS&LFV?zIv6|gu$n!1rTl{gYmV71%Wif9GDcUE z1>>>K-1dHQUut1-VR%pGi(M7*^0WhHa>>Nq-dJ-rJ?}urKiNi31eh~QMzFFxTm_Q> z4HBDEJcoSo)Xgu}FXMnuE&aFJBFZa>?Rrx6R2Gb+o%_f96m4&iG7MiQnvh-h zD)KQ@KI&x~rIP01h%-elWbx!3mbR8yhKRzDQ{E;uRlvg#J9;DP`e)Hw+CaGPXXQFvflFP!X(`9$NXjh#Jr`>oo zeRr{%jlv#}{W+F?rPOMnK?7IW_k<@l)4n~*Ba8_B)SUu}ZOdTY5OH1iv^;MmCUeCg zzFxrBv6WV$+{|~Je=XXV${k|^(m8t&X}*U1iTlTG9!n4WEG_En)ql<1W$(%%y66t9 zPGZ%CesTV%9RQ=$`&YhqJaRToD-cEY^|Z7}A>hsGt~C*#ZP}tuKe=DF0nK}G5S}4* z_O^Dw4F%&@@9>2~FbXo7;1JKhZ(eHRPUmOzokts1h*5>SWrhe}2 zb7OC^eID{4IAqoLg(U7k$Hr50L`GF`*_&KYxa+s{i}y2EQ;G~v+7?S8=(s1CD@i2ylpkHeLi)udI4y8W^q;V;}_qrj{$-Bv7%!cd1vwY2%XNio;KxX1uI0!*Pz9+B zQ{5Wtu}+vsaJsNeBn-=ZH!bX+t9{;=o6rQWHCT4C_o;&Iy5~)CQUX0>M{-#i>rt?C z?8P^r^_#DXv#uDtPiA+2dD)x5S&X4&Sg1!em0-?xqv{`dZkDe|fqLW1KSs}~a0rX& z-00M_)sdyz{eXu{y#WX07cu5MO}>>FwgdNcj9&FXXC>_Oml zv17r%7DoLnn{(2=k1z)p)PO#5(7XCLq|;<}4`gd=A7GVlP?wcjcXy@Asqoexyt!$i z@-SwY!Nov-DVb93i`i@Rk&mLETMeEbI+sX@QhNM~;O*0ScK0jPLsUlbQ82>F;$o`s6M8Q9y{1nu(^W^fe zf~pdva!82Q`{)Fw@0QZ22BMZHYn~v){`4^Y=IneWZDZ2%{kev3tz+ar!YIwk@$8+Hj9lEh|){@IS(8jZ0@+Renab6XpctEp)RPzi_sOpGyQq z5uxdn=YG`zi$Z5T*UojPeBcw>31O=TOD%uxYoeq&WKY*wEZ=B;W=)C7B$qLqoheHy zcX~Gtfmlb%?*8bP$tjF^r?h_%JZ6Hnz3s!K{#f+AKguZwz_0^=ksG=9Kg9a7vUpO! zi|a9-wb}*d;>^Z==6N`l^VH4%#twG9FyT{iig$}%1v*n-fnu!&)~H z_A^_T-xlU;qXj6L&tJA=NCa4n+W)vmL5;8XIv5S$@tomg&k4H2vm6kEQ|(`spQZ7t z<+ZlT=z&{N-~#`u4ExZ@c>Q($^G&@wVfxo~x9H=6vAlA2sYtf9_A2r7ylPp(hj`C3 zxGa{WlS|BVT^dZC@|_b=V|@DfhWOg{4p#jl+#SD?kz5?ydB+oo1RQk#M?)SKJ=yM1 zkK_XuTWjv+Pug(HveiPr#WtZ2a%biZ5$!tDewt zl!S3$n@%b?Z0>hip}PrgYZPBkmGGE_MsnHP7*8q=*L6wL{$G_rlEI{|v=dVY_^Ve9QxWr@!^x#l~W&XtMHKhoh- zzWYk>__EI()f3{#sFwAt>s49Oq$#=pT186%jVBPrt+d?6wR zS9fcS!a-4k@H1GsWb44rfM8_Vp*K&WJ%@VcGLig)qK-{H`IsL%oLP zGAdqDP*CPj4S4?VF?{uQu^jw6b15#kj#$oNLhdeQorGdy5~8m{o|nKFbg{8kc*D4} zDepI4{%Jw99Ryo_hCc_9MO`l_YHmgqe40w`f9pTrU${wv8psZj3htp0|GoGo{ip=$ zB1l4U93;dbC&K1m=$ra-_Dk>0G%iv&HL8m=|DOt8Woy3RWLmC!t?~fNQL>ExM*tVK zs!h2aS2OFmjS7#@wkP^=r3}O5G&|fh>dhS+)2yj$QIyBgq5JP53SN5|7MQ2h#VYE9 z=gldj{Pb0-?^P+FirI~i9BbYvL)&&Y?IK@NfR0eU|F_Hy1IUoic88cyK%v&$-v{z4 z*hp@0au^ctt(oRS=tn-wF|ZJKugT>0=xT}cSQG@mo;COXJK4}He{2W&${*&6<$#~( zf}iIuF@STb0?vX;AMVH~nhnW|$ThBMIIQ_|Cqkfvop;x<1{x^)#O($(_dkT{ztket z|AZlHoomO#{aX`QoDk&}2jDX$cl5WUduz~q2!(g4BG8q1uuC7E<)AD}?f<9(eR>;T zkq!ci-!RM*dBgZPKxtd`zp@u=&1gH=>Y(;vNbvg3%3X_Lta> z%9np``BP*_3g`Z3dDVCB{VZR}A4`nUDlmU-xib8B;6-6sgn~`$)&w1djXxj1BN65* zGwgh`_L+`dpuB5MvKABN65{{aROo!~MN+VnY-0^32g`9ugYuTKtU0OW_iaetb3Q2~ z9W#wmj6Tg2%<|?Ax*0sdjwR8L9{?K3v5l67kuE_<-xF?)1AV9 ziW!aTQfvl>oyiSa!taa>aM0@yXnt9Nv_}uTn5lC4&;Q-Y|8#0uj-pRT^3UT8Qq*x{ z)f04+&Hso8PTd}cQKU!MS-JEY^m>DFQTpnd)wm~aez2mcpBD|i`Rb~j=~IN;7pK2w zqVvD^tf|d$@umS#eTzkhK2d{P!zK7M|8B;tww}sMHPK*|ziCIC(?^x+YgTTE;i&z~ z6jduf_I%mr@M~+z~f3^hhYc?tlf7DOS1>PcdL8QL0A`U`$f5|AP|D<(2Ia zmbZ4T+K(xb<=OW*$}bj2{?|4)P>FReUJ;WoYrJqT+GLU<(xN(<-Tsw7zVit0osxU> zWEBGx(aondi)5`P3vUwsYF@;FU`4z&DH+5=R!deYhgY-5-d`{@S$}Wt+Ecn==kTSf zFz>MH?oxNyzY7e7H=h?#98K1P>#@#Fd)Xk69$Rd-o(ruH(sq}McA5@%WYn#m@sBLN zsIKfzO<7NUjohnrpKTSPhL8>iMY}gP3|O++zXUOx3jJM=vrj)2qNwzoDkViI@;Zrxq)Zd`O6;4zDopGrh03i5~eTtli_6wsq)`kg2b`+6=j2{ z?K0F+VCXfj!xXUVjPA5Kvn>!l>PG`fMbC6oDpEVq)eRqWxr)$kDr8WLRbK%1e=XMe za1U_}Eb?ycJ0TG&H#}uw#L_Fo#?~D~lXaOCf&vHyc_|g{pKz7U8KP!9+lvFvtg}0c zk_Rq$1GFuDi;nWs()wJ^Vh{JzZ|b=_X2iBf4-+ObO@EnqPlc{LkxwewVb*B|2WJos zRyv17?vxS!oY!&oSxo;>Qharhnyd9$F;qRfKA)3Mf;}zf^wX$No7vXe@4=h=(ta=S z+v$JFC^Zw<&P`bDlm#E3n({^r)$;nC7F|BDLdKZEa^UO}&VnR?$vW*N)2B?MG@j@f$zmbB7x94k=fhv#AM(6q#XK^OH;I=UZ$1b3Sn!ke`Zi_h$jb5uv3R6aGOu_MmPrzzS$ zV&|d>&p3G<7d-jZTz*Pa&EN`K>X|PJ3YD*gQ}&KeQ7O#6xNT|1;MEX9rMbh>E++F9 zk)RlB+st%o+Vt=O55>ptF@5T$3*Dki7PCoF;eT#Gt(ka^gU&PdMq${Us`(f}B%|bL z_rAvC8=*C9U89C2=vqN0Y#s}X-UW*j+qwr#6YUGt-3~GlB$}X#t{|tqDmbC|qUkRJ zitVx2`+1j%yc^+)l1i=h>n+9yzYjKTyGEk*njtPPK63JYx5J((h{>vADw@NHZnwEM z6AxxD4BVkq^3F!e9C(1>Ib_UUdH9~Zpm8%xX+uwZNGzJ~iAsyT^tnMwdz*q+Ikp=) z%%6|jxgKBYU?}3fdndw^5%B>UGNSJH*u5qR@?SjVqVggw2jfR1CBMPO|Mhn4T@u;R z)E?Bchrh;4*gDPZG#=RX8qg(9n0K>i>A4CT^uS*)rS(_sch}9 zG9?(>s&Ahh*C0YUud$!yWZF3s88!)!f8k?cXq`~g{Zl4?#3Nfg{IHi}_1{yRHDf_B zmZMSY8%db6q|^RXA$7=1$E8{2bXD3@g+kW#=&|G#QtGz#a#~uJd^GD~&2s+WyDdTl zb^UxJ5})WQ=a&2_`_i88xIY?KW}V~p6#c7zmI!CdvaDGiU1)ZV=ak*R3b`9Fwweon z4L=snmS@3Ju{#^U$4WTyenS;YM|(`}DJq$TWf$NbjwzK4fs-^N^rt!x>__^&^_*V+ zQ{v}yjw)+#(KJ$s^>oYyC#K}w6wUz|r1sL9|J{klCO`8V5H`6)^Y=vHs;IIKX8rWy z5D|QQDhBUaDBHhum`vSpRd6KA*rOm`yMz?Oi+Z8`T+gH_pg9;dUuKq4b4U@k*I5MO7Bqg}XP@bwwVq_xOHx1nu&IXC&Auc0`DuMA-a z`AM1{hpkM5Q}qcY_|bRcDRur;-YlJv>8)GoQw5Q_ud+|GE+n2& zsdm>+tYPjME$66Hhr?f>N6SXUxn495A?WO=5BF&2Z#!vwXW%NWMNKfl)2|<^bbGIi z4Viy-f1JJ)AD?xp-BZuRthzs_eB4KereGL0zM625=WbcHRcVF*w~)Zf1{lb%BjuXL z-!7bp-guS3{7n8p`hqueA9m&~dtR$jiHG}JEdt*7 z)iD}*<*G+3I^I?l5z8w5Zwvh1PKOzJ(FpMMtBRQEjEHI z=cA<$Cw0MSldCL&O9tlTq{`QktF;u!4zW-^C$xLs{pd)ajztZF?UiygV zXd2lZ;OsFSmAQ?+#Ip$JXf({1-n3*R>LjcsKVlOwE&=gtyMAR<S zr%6nFar4h-kM^Z7CoNMlc1h6RYUh>UWYZY&z^w#wn|h^ap-;NP#HzOb7M-2mv*Z8; z(dOFf39_WFQE5nzarW4}py88`yQG`h>{}go+KEe6?Hz^{7%u4@LM6J{)6`oRPIIta zzj-)yqRG}fL`y_t1j*Cbo1S*l4=ix59Wtdn&8bKs0~bW2`dvc=AZYrQ#vr?yQcq=d zP4^q71uw+ZHo7#9Z~x4+sWRCb=F--?6)+zf~l54;ySIW}lTKw4*l9vW1hd9hBnf}dk z!IxH<@f4U|3X-+aWSqEWjXl*MQEWo)aeCO0K>m(l6a7!y^Rtnf-X*xD_y>IBLuW;E&e0_;4kAOa-8b9)&cZky7iP@xyRH2xJK-KOJNw`T*8fib zY;nda0oEf``p}a6>`|t$YDlWvQ`Etp&Xc(ElV7Gs4UPq4+pXT=E9u9d4sv;*HVZYq zzZ=|B@>%znZZ|%*|2XXpGJz@6YGvpS%g9N2#;+H1l|>WY!lnfc8*w`e^n0)*bEDGE z%)`PWjZwlTv44~ChAhg*o~!*C;brg_=(u*OH{V0FLXjNbMOI#coyXHRa@fV13{{XM z`!ZZUbn3*UPKiqnj$QFw+MIs06i$uXp0_Q41|qe@S2Ex2f2g0EWeaTLcFJDbnjo3B zLik^fCu6rIuXSyr%BQPNjR>7acy(XYBzKKPl~8M%nKw4t*{*-!prR5~B90z6$0r-f zWfTg-vL_4HGk!w@b@5r8mN8VC3sSjuzK>(`T&SaDQm9-BiH#h6N zvBUVWxR0OCOh;GZYS##MHKj5Lw@X_fw8)9Rqi@}Lu9D7c!%zTfA*#bK_OT%BE07GL>Z zUh4ttm9pD3D&%8Ezco@BJ#L=aJIK_0#Og`TlinrfCgm^XK>K%+gX@YUlPwE3PgXZW zt*W}_U2!>-&DtErHed0e$7%4#6WQ&FhPiTEnu4PK;OWJx?1eFCP2tE)utnx@Gv<4l z0lY@ub)qP8`tT`@y<1DvWcCg7!};3>zr(JG6>IdKZaguql*pawXgQ~JVD&B_@5hz% zJ6pzcUyF*mJ?@SyK+T)%&lHB3&0tw_c3=hZCs(u8Iqw0e;+n(rUxG5mlEMT7bN2}3 zy82hkm)8(yP2LtKaLOviy(!l#{M!{X5+9HyGT>E9J}9WLm%Hju?5g0y;#>bk*#2*!1kwr}Tn*BwV3pV6|QO)bE-VqPS^1 z7@Rw2N8!dZ4Bd}R$NLMQa4B3 ztQH}Dtzqri@pU;_JAQOP>g#^Df&(+3T!XRmXH+Kts!&!&wHBC%HLVOvz+;sni_@Bg z`YcMj;iMezkFQzD4bBd1Xty-ZplZ+bez(cn78%Wv=l!%ErJL$!%LhH~=3sUdxw`H0 zsT6#A;`MzM=q{V5>8e{wJ)vcF3&)bg&&}QgGkdof44lA3y9oGrrDLOhlQjZ&K9A6i+FNv1%C21tY+s3=srjEFKz<{cJfzO>Oi9amS!in}^fcN%oltV_kI zn3?_3PAQ3DD?ZQ7{~ht>9Rf&_={xVd4LQrLcJ>}9WJ2rbB<8#Y%dzQUBQlDg$h)hn zt6!63oTQc)^vxO>!tRyy8X!-ddjCruD>nBPXPaV2JFG)n;)%<^OBa~JW@z*B@{Y7< zQCCfA?Vzq&O*l~?0>q>W>B2`0%56b?iQk$Lfb;4WJTP7mzw37rl_|?7n(;H|okgQV z1F+8Ssn?nWP$YYj3*_t1Rpp;Fy|_|C!hY3iDrnXGCt@Iqsn78Fvt*aFgLFBg%2C)+ zGf#T)%>d7VJ&SbDR_TZMQQJ9|^bBuMi%jTqWjRzCZ4JYc3PG(69j9yG#&#@BkAX&+ zY-|9nLvPcWCx%iov6h81>7tYQxq<>H;@cClS+nyvY1CUYK{%hYQRa18d*z^c$QVmz zpSS4_CB>M(dH!4wji;8@K!9Ne!3$sFkwo!lW=GBW`JTHA9x{n&ji_N25W|`XyydoR z56lag@`H&I)Z~L6#RE5jl(|&3&!<3hx^b2R&pLbG?YxgX#L6#0W@sV#8hAPRmnp@< za8OcYLaOJmL;u3p5ZvfeYQ}H+QS%1tOo|mJr^q$e>dTk=AoG*su9d`D6w4lOF62!> zxJoC#KpLj%e|u6pPPd_!$I38Xy#ba`%e>tvQn>pTaDMfhf2{E}uBi+K=m$QuQs|iU zoRFjoANVb9BE%{^OdDm^ZM-xcAtg@m;o|2={6ldy;0oSOfbwBz8`I*rv;oqa4;(jh z*#C&-rDeZj0SO22#-m%rexMj|HBUR^<<&IW3XC!AiGt{s(|X=ad?njW(5SAGrDNkq zB^v*w{rmT)asYPC$^c~4{?UfjKbfx;Amze&Xe!&e3Z=QPxuy^`?EpnMrEh^oZo$i-^VTl}P9~PryVa;D$6E54h@dp>gY1r)S zMSEz=`C|z*Ht0One-Hg^rD{k`EGWBBwf)_BmoITRv22$qmKl#qFL{N}+HrKTN);|q z0>xw!Cvv8>v>`{YW2LumcpTI+F0VgsL}-8RNl`~XfEp0)nro&TtVB^*q$#5HBepKyYzz20gG4JK=S2zDP0lyzJB z=e%U|(;%9WxqZf^U1uulccC^dYcIryR9aOnzjaKSwHPuWUV?;0)3ES3Mg@?OihR*b ze8J}Ie!wNQF|k6p_zmOJe#2y1k_4%`rm4GH;Mhw~ls6twkfZm{0RH6^g{N-WO18tC z`-utI<|u1l0e@PBX!o=(rB)bs0*;wUa96{ppkk1mw1Nd(Ptqn$!^$K%TrTH+GG zhJ~vrY)~?`y3MwO2Kd&V(b#GQB!5Q}4);12T3r5#Ga!%Gx1TU=zOc2*s&DC4aw8=^ zx7S$Z?F#V=%f$Ub4YsRJne!FSit{tvK^A@`%04q$te{ijGHtqqu4UO&_Qh04#dWI) zc+^_*O5OL2Wf@t0u0qBr9`3ZP>e@wrze7-?ZqLsZT=26%ZO9-G&-OiFoYbT3OOiUbIK*?>! z`sZ^)Fi0%|wr+GrRgF@eNtQIMn+bnaLG5nh>tUcCVn+w*FE5mcn~ELnRt5*HgnwY1 z;#HjW_b%NXGc4?XQ!itwc$7h%Wpyp~s$k9mEI+bRo}=cxf0GCXCqhb3J$`AiB2>stcqF2NtDEp29!7XAR1ND=~Af+bYdQtJ4qcNPX1*URl z!k|UAl*Ouw)!Z=hu8+m*kBOwNk>4!jY6r4~;6z1#PH*}0WY+GZpBmS6`BIzEPO7LM zGY()^w+ZxzE)at1_nfCicr5}lYk3QU>~GTrJkMi}0(q)|>nXJmbr!9%sQ`sY?`16I zlD_T1a0v{2PY!9)u1u*F4*YxR$115mhofHk=?!AGTeNPdH)FKwkuAma10u( z_OX8qIu7kytIte1%lR(-v~(;y3Fd~8Ei_M2;rjWq0qqb?Y`o8L&!9gu1Odg%KX|ML zBJCj9=b!VpR=#7YZ^T~Rmefi53pSi7ls?O;NWS+>9QQnK3~+>?3F@K}E44xx_nHG* zk1~+npDk-VP_u$`v>KrH5&2rxIIGe>fb*6 z&};B%%`qd46y#osrBbDu#q{cQBiXmWQBFwh(#Nt}bS%Bb!#xS-UQ*k#NeT>)Y?6Ie zEs{rTNJ3lBH@!&z9J`$Ayj%s+#ARw}PPNx@GmN7m<~*KIB*@BjFh3q=l5t6&7J@9X zGUWi+kpolszB#6(G{fL_rIV$SOg;|yV?|avLIdn(X?*hiOrorM#MOya5mLp1-&{xtaH=Hwm``2#M&%dVk7|Q48<86LQSB`e{Fa3f za?FcN>jQ{ zarkm_)1I-)<;cdGPC>rGT_T!D-o|aO8E$$4u+4vIS!5~s*6QHsm|QVGKT=0lk61t( zY}9P-6DVPdqp|aN`u^Xz@9c2f&fwG8z1aNHmXyOzcnZePP<1!JDYH*KB4*^v+nl4r z&JAk+t)3T^3}H=1b)D8smNT_FO%_aD?cI3(2$QRd3$%sC3PZxVsmioNpr*r4X!vga z$zVZkG(j86OSHepTv$2?JU6g)J@zVIDMWl!dp%7TqyE@k)sKsZ;2^xwd=gY<>g2wG zGDzLNp{RTw4`A@(*H4&v?#Pyf4eHT%zkh66+LWQYb2*hz`(3Yu&mu-JA;mm(nP!xbZZ8IGO1e4%0$Z%O0cF|-HG^v zo%xST>50oVa;+L}4sK*Iw7T;*)qk!cliH6bX|HVmeAK*e#s*TDZzSD>TQ}##Jjs2XQitqduiv13=Y=f26xUXG?DQg%zr5{ec8n0mLHMDX^uI;pMMa)eICi7(N| zWL%dMNqW)=OR13$g!s+6cWf32NrD*h2EDMRVqN34pE0nJPL*kN%=XoCZ!N9D9gjId z0dvb@Eu?IvM;OT6p(yQ;QypP6Dq#oAg~Uxdg<(*R>^ucpZhi4ec82|~s(x;-C9G`( z_Ep`NY~vS2p2BGTaE}O2PPESchgLpbuMV+JW(wo~?s|)kZ!$#W#dYRj&3A zyrMlFU&oz&>~iz)k{)e5NLN#|ZC5WCkE6;*OX;8^h|Ezsn?9INk2q(3`*8n~2_2T9qGiVke9!|l9h$NnW{g$ik>5^i?G6wcY>qJWy)Q10(t-l zI|yLQ)rtmFqQSxJKgWz%RM|nbS{(M3!%i3r@WAmPQ3q8=tsw68eM1gu^3*&iQRm!J zBH&jIouAeIW^ciANmd+cufJe1X@$;bK5!m^pSb;pT*1E@g96_C;UPmbas0(QTmdg% zPrhz=mGYaa?ikYSf4=BJg><}m9^aoIPZ|SP3N#xQruSy0@`=+)X%n1%C`!RY3f@)N z{qRJK3qSHD-6wup+LEBqNKtn!uRyL1hi517~plCGoB|m-R52gZ4Z5zm3iw| zvdiLdqu=pGQR6rG7fb&LJ4y&$&aow$E$BfmY+Ui`G<`Uk5xKR;P!_NT*WB>ho(*H6 zpjDs^D%Ix5m&mE~p|1V;=*U_F}#g-XA54Z>fzNo^d*p*ufpRR8ZA4k6XNQ*D`4*HQRbR8~t9vZk|&pu(D|%`?^GuOj=1uuW7z~rtr_4qf#%N*n0CYL`2fQ z7F)bhRJOJGN5iJdXsHn?Z`?FR+WCl=zAu@-e%ulRDrvER78>{*&+c;JPT6n9Kg@+l zu$gW)ac5m|DrMAzYRBm}y8Y=9k_#?jXRdQGd=qTkluqf}D!dt9xw+lFK{7#?C?Iax zUBOt--)3uuR7}yu3$Rt`vvkKB!+eX_jZ-Ub{e49WBqM=zu1>og3ssE;sS92Gm&Alx zr1Js<0=!m4gD~DD?Hk6#nz|1h{6Cc5WAIuagz`2@)d_l};iVf{ImaW+_X&MYJYDMo^aJy{xph99286g;nDdFFHT;<{^yhbA^H z!7z&gs~^xdd12sy@4kI?aq#X;hZ1hrk^F@jZ5tirPnyH*5Nou9*Y;L@gcekUpT4FU z83ViJk|piHIj`>znI$WRCL$ExB=Mp&)YYSmrvumXu}+P-BliUHzCgqUxctVSUt!Ia zn>WO20}$U!M>Xt%*DKmGy?4aHfXaMm7irSW`Xgs+>t~k)P9F7?nu*v+J~dt58w#OB zE-~?z3p>Myb0v!aMh|V1pP%#}^skeXp2vbBdd_57w zIfm+)5%U@hY1Tnjb9!m2#baL$g6a;?uhZ6Ku%Z;+vEtD6rq)23rnLE!$7)glz?MH= zWil2FLuBW~Cm8Q8>bQze;z}Fl?Y%7`uk^ulCX zFkqhi!9Qf35lQanrEQ1ojQDJBFHPFAcDu4XGzwuD1nt&&SF}gfz)5-~oF+z2W7K-h z*vzYnMXy1!k-CbqYzH93;I___${#ZWYYS7GbxlqkVnC+xE;ASh#kKC5@oM-H1}$JR z1!c!hz4Z8F0$eK3kjM6RqC;n25Cs)8qymh2#?F$MCgY@O2=n;#!g^kt64b0e=Aca@ z%b2gEP3nu~Sm@DOd)(1BV3bTy3l3aQFvCUm&6b6fzW@@ooul&&DhHg4Cw@Jny>|cK z>zZH>v{x4Qoiz71o)5A=COpk+Z{L$$ftTpRqkt-?r^(!8B*6E6-{O z0|SXd?wVdvCov8RX&RJlBV3y^ns!Hq`|J7zIe8t`T*_x;IgLfmr|9}Q+Z^0^wElsb zs#ENero>ZVRs3XE1^F>j7vEdNreXY1;Cfk+CnKnS6{GT~-$9|%QR*iuXH1&CQKLx7 z19+Kow4|CEZyAyN{Fk#sKHx26LykYzZV`UXG*W=PtX zx*R(swo8Q%Z{3MmMj#3wJ&R1MHDP@c(4giH$~UENputeDy(@?_UAX!0OaIdD&454|+kD%2R|zGmh(t<_Tg za$Aab(#3C$y6pWMoDzuT)NFD>)bt*keL`kDy8yCOF=Z5ED%#o%ZX)fu7AjG0NjyM) z{Ti$FL^XXBQ)Q9qS^SFq``f9uR}B6u4HZ(|+P0MxTEYh@qc=G7jf4iTxT*Fy5J(`# zaMo-fou?$JN+0%-(YJue!SDei5*o&q^T47wPCOT*C0AonEI9h3m*?tGvydbtP6Db! zqNK8N^*hc;h*~NF0HVFMGl5>uBX)6p!nv4smIC>hW#lkz z=XaLHlhp!O|A^q)xmQT?`ZmLKk31Bd_2QR;6WxB$9Vk&k!#TDsII9t^y8s&p&my6m={P^3(-%s z3cQO_eA_PO<#QH6+*3GtDQW88-vZ5%8zP$63axPl2X*Q;Z7Rk_vHC2@)PwJ{6B1+( z&7hgr$TuA?0g+7^E>IrEi%Pp#`cY zNwntB=OD~|Svh6P6T;KH6yGSVw`yEm?USl)a}?)kkxzv0DbRm4QyAE`XL}n#l8Y zNzMki?H7X;>T11N1;EqgxZU-DU%s+oYCpK$tz4 zDxF!Nx<`(B*~-uq`-8Rxff4Vi$>s4_?Mn7=NYs9Du<-Oc%- zW6PHn_#wDexZD&4#VENAcdbB{>13So3Tiryt?X zWdA-Q(PlZy2|0zW+&!Wzm~s7*XbfIEiizvtv{lPQn=fE?MfHN7b5$T90{D3TlY1E`Nhz7h4G;dZ*2a8L(*rHtx4An&3l(JGo{%eK2pg?Gy1=Cd z%AakZXK?xUSS>{?*RTv;2mIneet}s1@nJCD8|{`mvXMVl{#Pi#ADRyjwK4y^Sl0n# zR8Ke}3aF`TL%qs4TYt2H=Et{9pgxOMHu8NBNQ{KcBs~$kI%IBc&Wm+qGGE){@{mq_ zKLN4m<1;B-1rOc6R+L6?GFH@=G8$w5lyEz4Kt2^xCK(PeBAqk`qr)~{a0BTVSGcm?hcBx*7N@I7nl z=zyQQ?crlvG!Esnt2Ot>rlhve=+eiJVA?+=ZGny?k#|-`znw7_P0t7WE6YIb*3DC) z^DD!2Oj`i^lN+O5jsFstj+4N(TY`osAitw%7yI7Od(u!9f-Z!2xBuJ@)cb?-xAA*C z6yyZ!bP*DBmcVhUMSRrA0<+x|a&F#C$~tUd9(S%$^A4Cfk}!=o2C@2mcz7z95w5JS zA73KWZ#MPc7;4UO9@j z&MYNxMQEu%|>mUFJp1;akY#k1dZ~ZL^`P0tUK$K$f-&)G4>ci+f zluHLQg-1KZx)afAu0;A+0YO2mtH*-FTtprppTO2ndlUaKjPqF_Fq5aYYDnT7Ho7xPih4*&ky@+L+qZ%*xZw@$pf4zg7HkiDT zaY*kDmNW!_`z=Qra-cItHqkQ)%!m#=AXMdC<>e3%FuQ*J#oxW>$w=vEA1wmOoN94d7 zKorB}GI!o6$FP=M&uKqsY<~MTFi3XxAs`Z#D;~%TfjRfjqsVhm-3a^3Tv%kL$RMXYkxgNARw@|;lB=7$`L3V+>I{7_L zM5JPA5{0>O<0fT*K5XKvRvs4@BG(IQz>CtXk6{UJ=x?tHnYI8w>GP6i7uDU-4agh* zUL+)U)WO00IrgVI0fr;IG)Sxz1-b;U73S7fp2F$SCvvNs=sJ)1pg&&$<={@#Pq=lP zpNISpv~id)a62iqk&P5dK-<=J2wX6TY zA9`sEqB1+~!cKA3bV7nQ8 zJX`Ep=e4Qae5$jOFD5L8%>&`TE2|9nDVvmhXfY}8%58_~41D>{i^0MAmqT;v&`&d? z@|Gd%MbOl9&3%yOQ)lLg{57RLM@!g(%(iszM0R!#E;Z>I*x>EV;N0KYm2#Ch4V3Gu z3mA}~@Q|Xxh+dvdjQPc!#X&W41qlA~vi)r&!PX?TwuWJy2oKbNYu*C0!yKyl+V~7g z@-QId`05oQ+-MM2DnsuMi91UrmfJ=jNu`jY$S|wuRAN_q+;bd=gz0#XddvY z@$O@mo@@V}B28mdSfspwtU+DZB-JyoM$P8~<1T5OW7yMQKf81}S5Ip=>q|=v>qo2F z{JGJUI{~ zZE&z99xzJ!m)i9ow4t1*S^sN%a|#c&Q7zj!w0D~uh8h~YkgYtt@lX*_S|eN!W^sy{ zsI>DbE6;&~fDcjbSR>o6`kw1l)*G=laNb3ckW9H2PX-erh$Yj1tKQ$HShLw~Vq3$G z_ivZ@Q}zl7q9(~OJU$n22I@R%VirY?gmT+Ku#0|5juLl(kMV#Q#^+iD9r(fQ!C3b^ zsE?t7D6stH9o=!hAh-=&H;5-F;N5_vFo11h@#Go$q$kyWEqekuU zAYuMtnAB>u#GR$7D)d-Qdo*(%xG30^Oz7F|e2=je%DHjr^`0J>MzB4#t&r-1{%WxWI9pIY4X+TCmkMmj+$r2SmT1H2|5|m%n5>5+oOkMJj zuc&w}rzZ!s6xfONeLnTm3M|l4Frh&_@TI(FlRv^D5b895J9{&#R9F<7A6F#70-n|* zq-W;G-zT)Gh9X6HGK(`q(zptSXH0_cdNfl)%4&Dlai;@uM2toH(06-fY#K&xXplkU z0=!8h8RpXO>Q_M&sy%m^w4*C%v6gD^z!aQfvuq+S5s6*$Fb~ckkNj5S<-JGHE3&?_k`o%d?C%X5mQ2{#{>Ki0w3 zIBz)IzJ9$Z_s=ccYG^@Fz^=n?9}7yp?&|KHfnvSS6z59Ccm@PM5v3s~xX||LzkQSg z@epVVtV|-8mmBn)DjYdn)`JExZvs!_Sa(uX17(aUZIJ!j<~yQH9FbrQX*(M&eIx+V z&XEjROm)Bl4a^KZ3X@t);7=@8S(RHF$xG$r4m^Z&Nd#- zr9Baz;-L0Ea1M-*VCMdQ`#ryg3P=q3*Bb$FHXrz-8S&x4#kh}Cw;wiw83hh8GVP-H zlBl4!O#G*)o+68$mziVN-W-VmyT~N+=VK{T)EUo!stJ*je|#o&waL@CoK}_Qfswl9a2z2EHATTEAuGdx~bMrpxXE}aF> zo%Q5;>=ttVRNJH(5-0lig=j5v!4d-%$$G}^y$vt1!`HX>7KnwuYi?hVexIfp{~d|TOPI6EijJ3!A?qT0wOVg ziI$e<=S47!*%iX6cs=G&e0XZ{3?f?Xxo154_m-m?UI77JD-kwN;1cHhg|AXGNveai z;WKo63tqqn)NCYaPkk4y7!ZQzE{m({nwd2mFhBekCeM@Z9_H;boqu>Jqc&7imb%?_ zrT;#cIONi$kKkm_P}2_`l`yR&D9snp0kRg)yZm|>ACk@s zt1rN2z=5K-5be{u$!{jUdPmndPuwjMxGJ@|3Ae2pkDru+fXJ*1$V4(wRxyh2aB2a1 z9;6YcPjyO({^*yoS9+;?*ewZS&n;A(r%oT@M$2!GYm(1~Zb)6GTB2;cl&!6a1U3~A zjlZ4e4C$>VCiUo|Nf(tzduGumY70?JM0Bsl>T_(Q>oL3!Y-qu!t4-~|-Wq|ay?I`> zJR93E$y{dMIFtj!?67#X)!n`?AkYKz)2E!)_UE$Z$sa`)$-u(EqM|dO$zH&Xo8rEH z6^>?<)*?xHdYVq5RD`@pQ+lb7%9A)HnY|4mU6)DH-UiAzZ<96RWUTD*!~QyjhXoy{Nd&61b*90h)w zA^^s=SW#aoxe>Gcz!5{?z5$;t^sN&cCe|vzBoCYr41m79^A)}&>~}i+EtqBV}NhOX{{(oFu1yq&Y(mo)9fPf&OfV6ZdAp+8!(%m2_ zAteopv~&qbcQ;6vv^0nAZloLjefadd_x^h=J&P0Xu8C)6&z?C3K_lW_1<*7VD>N?m zat&xlH_*R!KnR)dx6GBBwK1=(to|qf<&YF>(Vy8bo()Haj&~bef_W#{E>OVCBV|UN z+?E#jF;0LuNITHR{NxPEe^)t?x?L?`Vsd_5e6C)tpA*fbot;nnNv5?p+c>EK-~9?% zLdLWg8Fn(9JV-*K_ICS3AcQY{SX%&wvspnJ9 z9cPKF7k!sRg(n8=4+XE0(B*DF2}Z`pr-2{P(l9Cu2&7`ESA;`1FU>nJ)7njbX1|2n zBM12M<1D-PW7uBub%Iz$Z+}>hFFsj=OjEmikP+`LmA7X5Zq}}5Pe4!IC$niZ5l|_Af_W&AAxXuX(}9I<3q$#`#E~wVV6ShT9fwy3fto& zyc1Yq(8)Mr`ehly1n7ipRO0%RN@o(3Tat=i(h`Z$zrV z6{6fo6{&`~V;AYl)8p|~CY=l&X31moOm5w%PJj9>hBioJdAfW5iy!k+>a0)fLXsE}^ zE%{X2RiNYk16@XXD-C-W@d|WtW%u_0d5#!UwVhk=0<5;A(0U|EdAAW_K6V3<%huuk z=CG61)~UubSh(6i{>wH8LCr5w(RXD`zM2AzB{zi-)Rza^GR6 z?x7U1=p7>BfEn#XP*pylD}H|r-i4^6|4C9gAmpv@yc1x(T+76>psp-;zQ0n%^T3Q4 z_Ehuu(twDF>CElI9fR*WL_@9)N~Cb$1`q_HK>G58?sR+BzDchtYg|(D9V%*?f;x$d zX%3-7t<(meGx&G|Bya%fs{W+1;CqY{;JFNDQ_tAAvMCOi)yZS2%iB0586j}+gbqHu z(Fn2diiJ5UgcB6s(@!P4SIP2F5L8~)ig|=bV10Qu{-mv=-BBwhUMIQpw>7YYPhFRn zSA4fWOzeO|I-Gq(uxAwFxC%*eK82u)qO7JW{Iel=P`m6$-+gBo{hBzPje-}@Sdp7$ z%4UYRUG^+EKVFS{?lfHO8z_Uh^? zZ4HL)JaIb&@J1?jVce>_eGkIthoB&Qa5fUnWxrUx)PaZ-tXXFhG<);jD1oSFx~=DE zcddD*wlB)W2U@>#K#jsEA|hFyuGEwB@bKMsx59=7iBoVj_o#K9uY_F5c$=b(JlcIJOkgcBgs_&e`y!WcLaat}V^uGIyO;pRX*h z*fN7>Vt~S0Tog-Y;@$l{N;0paDB^3sZ@*tzTZx`yE@b3_WsicC3n5ULTU3l4+z09n z5mxX?op#P31^N}7dR8zqLvf3HnPM{-3UzktPMFcwq2#)?#}T|jMJL${<#w^0|TfVYA65L zATASBrBw<|$`V9E2L+e!0rVY4QxMVStPlI`UCx%|cV&Mn!6ui~*6JwA!gT>9z>uq> z-qx{kez_Og_WRZfuX1NHtv2aG)azMR>%zb@4;O8gn`@Bzv*o%n!L>Kz!mN*JbGV?a zotXF6tvjgPdcKTGn|=Yr&I5U8=L^DN)=LoXzQ!Nh$0Z>V@K{3Pi6kpQW-LSNfvdAf zyMd2hs3S}^f|M0J<2zDSK-H$%gWgL#OdNgP6pdQw`^{=&kEr7EWgQ z{%Myy&WfG~j<-eEdj<>|Wr=t|8D>Wx%ulCfn)1X}f7}z6$vRXV9w_k{QIkjD9!bZJ zA5v}|Aq#9furL$35cScE9AbbG9qpg2NRS})VqjP~J1FS|Zp8HTa|kYp5aAWi-0N@5 zJ=Umnp`QbzD7@|=*RFC}xZ$$v`%&V4dO5_UaJ(3Qn>YHh@o+*ah=4kXZ)vOOdAxTROp3Lkl| zG29O4VsMBF6qc?dBcnI_sB}VS`c4;s#w_2YwPuH z+#1*`kgUpjl9@=*-3cXc21=5rm_&s!M0Jvw3k&X31*%p2AvcG-LAz(CK$HkFH7Mt~ zU0nyAmg{XfXp;c`5Wh>A0v^w;{B70$aeGLj00v+c2;a=VD&%-^N+JH*pnO-2Sg{sH znX68Xx0&Y9G+WU|3-q<+%~jtd<(&EtCp~z)SLv~~t5B0E;l`p-0%Es5Sh^?^th?M% z=_N#T-7O%g7AbfSlCANlvOM2o=ycW?x(2gWH1`$|k9!@O*WqfDWQm6#IxvQ5(tIv0 zmRO~w-M2Y*x_+6Bgse2L-Dae89LcHM$PY|mW8-cVW@$$bvpT`lP0%DrWzo_7!pKF9 zO*HfT!9Q8cvWfpxlS@VhPApjLgnXHsD_AtT_a(O*6~L&d$_6QkJpmD^*O(QoGHQ>m z6ZeEoYhJI!#^hG?E)_MTB)FU&-kJUtARCL!K2GrW7nayjciFj;iT+gL^Q6q=@Fg#B z8AVd!I2eTS~l+I|BQl9z0i|lxs9yKbL|*Fo9-)#iD-d@vr)-&45~>aMQRt0RKEiVA;D2 zdc496xRv8p$?r&w>5kM||KvpDd;G#xx6#a(T*uWV7dYT@r+B8_(brYhn!CFBJG*

zRlK(2yo~S{-!Vtx(^=~ZckObiDTkefFR-qRVCl>1WO5jK^&~7{otqH;$=?qN(y(t0 z18&tOa>5PiVklUFJbDtf^G4khorFuiVvQ$ zY`aeDn**gyyTxiED3KeoQRB@C&h~Y(<(un^smT&!ohFCJGCcfvi9CgJgonRBb*zWr zC9PEjnZ~JC=qWV0p=}N;jOp0$P=RM>t3+jv5N*99IqdwhpO!Ts3F~FR@vk7nL^Znlnm3fZgfvZRjxTy~+fet>ew11`97DE7$j5Z*In>-tDt6 z{GhnDYG;pQJ?Gi!;e2#t}neSUNBaY>hlyt7%r+^b7; z!ja*k#)8i+WZVY_9`L&?zl??tPv=rz#0TAV`Lt2NOwyL}E5SQhT10g2J8>U`ap$T8 ztd*nN>J27YF}cvWDK(`PVb500H1f;VsxFU#>m$*IEF{N*S8PuZ2h;*#pt zBI%sP9H5|3h|l$1OY`1TQqRAtudy3i&SP2;0+X^&m4|i$xhZ`Zc9(bPAAVU9{->~- zrwHvgln~IR5>tcHbxwsBG6Ujc&wR@lk5g0>HB)PooaNB7SahfI`gkRQN>nN87<2 zO0Jf4OIUMUBN^*LhwkUZ?1Xpbqg%Yh?zoCmFlb;QAHKEngWe~laqeR+5ChkhT;C(| zM71xfIu2$oS&oM^h!N_D^V(7yZ;u^>g@vpE#`ILbOL+KnuAgNk%Zx(73?|F@hW}Ky z@ub{rtAf>FQ8Ib?%|JI`$C;CO_A2R>SNEIl#~(-Rjt_%yk9xheUE|I~r@#HAc> z(tiP^@k-E-4>g>jqhWw1yltTNZN)6!DTceYJ6yqoZtNdz%_@D=v{h?z;AK+JecO0B zxaM3tlX98lp_FNgI6a25mY5Reg>*MJGuYg)L0UPdJbaqo*cXL*ok`&vxq=rkd$5M!O2_Cc6L}u+C%Pg*LFN~tN*=vSuxqM^Y%^ARFY#u`xG8d zjLDS}yLY+A&B1ohMNb)%Z7SHb&~IR3;|H?Ks>IT(ovg$ljiNeMAj7oXq~t?qAQh4= z+trT*?j3XgQ|gtk2Wt~093};_FAd{h@{bkqQ|55AY2s_x(2zw_H#WD9drq^dGpzwb zo8-HOKrfhm@}B>-nIH7(l+fxXTZ8BnR@3u)(8;~!Jg@jo&Jl&X%#~;iur!we03ZD` zNuECmiTTK}rPhlnah`g`YIA)H2xa4#?NX6m^atF_R}b5paX@$R&x*}J5+hav$$u=j z_YBzo61{F7vobq{n#!;y5?3!cB7n&1fWnZ`Nx zaNfb2%Z*0~$08L6pO4fhe-0b^4w(2UEVCe3z15&UiYG`>Q1M-U*U==-HzOEaLZcbi zO_w_SdB^Y}uzlh?G){x1I0cLH{c}P0ll&TVs)n-@Md@c}KjvQDVH>_|n9R?AxTJTE zE-dk`xl{C;1nglDb=-eWJ%P{%r$Q*=*~N@mV}-8y%};oWbvj#rL-0ohxP<4ksfa$DcY`(}(lx?e4#DKpuiK_r#~$ij&~x<}Llgf~y=j zq-z?_J3rz0Lm*2h0BO20V!Rr8B1Qe-CP2yFAwTLRY{~grYfpC@gC!)T6ZA^dMV06S z{X+<#sv%XNxGf1b4ah^>gL8zpv%u3BIVZ{lHe@8$4N4dfwfyCq_D%n8Av{Ga|EN1y zT&=nD=BA=cehA1v8faPx78$>9j>zR?cKutn3;yMYKGD?K8EX7T^69K_OY8_kQ(c z$)7*lr3=;@MF!+9e$ba?d#j^-QBSWk9i85>14U&wD@1? z^+wmjpxDav6CDR4{@*#?VNq&*@Ar}r5)!gYV$is4aFgmuy-PhzH3u0oG5 z-0z>3Ywv7fD;Pp340~h!#?fu{J5fVJ1A(_Xs|cv520HLxc6$Jia3~N4@d^`K)xgko zT~WpnmaZca+eU@biY-TP-%Uz_S~0KuR9UGeD~;CR@8h$HSd76nkGxT(ONZWAquRO^ z*i;nlQRi;Y7`Tuw*@=(b-%sES;Xwif!zTICW!=ppQp8g6$)eN)&6c<7ke|y}tEy7_ zh*La}&xs6uNg@vWISp+T@JhnoPPZ8xsDI+J!83DOiuz|pn^a|ht3H_)f({8FSre47 zt>j0=V{{tso8A5Nh5U8~XJ|~|vpbepKuhRq2o^H7FBzO?vskJ`{Usbd_9t^^WJ7av z^OwB5I*=9!!X|xLXxC2x9E2!!Cw|H@qDDd@nnwf(`rJX`2<+p#*Jpj6_c-7YYR=l%FgnFmo50ZS>-9`Cy&C zjb-sZ?vi9N~4XC+l084eRtXBQI`_iArEc8B+8o}vVkHyLqs^yu~r z*mF^2sSi@*X;LuT&g6zypG8DUuM0y$ac3e*s?3P)g@f_H8b--VV^Lm7C5QbhOiawd z>*L+ePG#Q-zZ)q#xVaG2&d#}=*jxDvkxfob9!K}QtA~Tb@=yOLK3}@7(Y1u)^Fnw( z6!A5M2`lMCVJxM%2ev-%*v=@c@WK)bstQMj9Fw+XMYW;3n=?};(dvn&wWmZZQ zse?Ut$Y)x9wr6&9mp7UEN|p1W8nQ15e5%MH9ONthFtavNszjFT{`R8 z9sQH*bscZ_Y_@n(fQR6!oOdzmT1MzJWFeP|`Y)wE|$BwkL0Y+Q1>@8_0z z?c^Sf2z4&osH&@%ox+IjAz-7h#JC5L&dxqJ`(0z+Ov!Z9Rp}xND<-qO3EGcVVwC?O ziGHY$9R6GFx^6z$Cdi?w)*C@{s;%lTqaq>>36w18xR};D1FafL5wQIIgitXshf-X|p;;2ZCYVQ3?H!VG&P4Is}P@qLIV zytn>&6P1w_J-4%=ch=Y|*AUSt3O&3>u!a27pjt_;C;KX~YOy*cRDNP(0=o6?rW-Vb z6$)G;*l*8O^G(vz5FmX09XcLwbCj>ORI^2F^y#q!Tpbv|6Xae3Y0~9iLQStI2tJ)T z+#j2xQx8vOaZYz|w)pn4{^VlOg(DaD-|f5W75YJvSF7|Yp=$LaFETe`8;z`nYWxg4 zI3qG~v*hT3=yP&ndjcKNR?@|oKc6HlF4F+gMhM&3_KKg$X{k7;F*wESAck`W_|EO5wK>`iFqzEN#V)x((bvRbFCjHa% zY{NLQW&uj{Sk^5NXcbc4r$X&<-*fsqJw=-+Iq56-4O8*Xo?ah`98Zxnz0=2FTN5${ zO_JqlLu2BDj>;)0eu|KQjkuF|uHFY@uxAi4n~pY~wuA|vO5rlcs$vDRayT0Tbk3P3 ziU?TNT2U)~8~*R=fLCypVxh$;N$?Sm#zohG$2(>0^JX>xH;QkF3;y>k)x$&Rtv%rZz+JAZm76-sIRuvy6`zF}XRQig+5 z$S!>- z`Jt!gnzq#2HjxktmVdCz9o>Nc6hrD+KCUhW;xf4O^v#|yoFK~8rxJW-H6!)(CbKTc zE{u=&TQ-C{IUy@8yZ;A_gx)Dze%cCLXeVBJv@l4IV1k`qN-F+R_<7ygV5xPGdVMln z@%(8&@5V&aL`hag#yc*55(IGs|16TGL|>Q?*<22a_In?cEk59G8_Fs6OsX3JZqv+C8(0qsrFg2@qanM`cfe-0m zHLH*@QBW|^S*=!|Zzch|Hz}k~iesN-yu}kE(N-=WBJE8sDH4JJF_jX3%`8vvf?@Pq zJIHMH>|dw{CIqI`c)Hqu&dSbos&IlZD3~@vJd2lVB9yA1AwtXPD^LCujymoJlkaDJ zSct^UfX@KiWV zxReLX-3yNeGA1`mUS9q;RHdh!STZ?>))lzo9%{L09UGhD+@T%1CV*!b^Fgpa-(*;x%LJ^1ebeCo3 z4;4$ni^N7%`ivl7@?N#nupaT9L+N(;bPfMEf?{BQdP2m7_!WKL6X}6|a!F9rBq`)) zFms0=(la3o!)JPh@x{uK!+k>$1(ig#&H-fx=aV2(_;1pvsHjjpf3DvVyhFJqEGoKi zI;W!Yh@#P96P$#Z@5IIV}^)qB4f9rvhb!he|( z=VbSaLo&wgsM_S+yYpZX1xHqPx=U&o?{(5*9@U?Y_kjE0ncNYQt01X30`~PnBLzi{ z9Ibb_u4i*H1tg)E0`dcQM?hr1(8McDUM2$u?@v|4Wx?8p+E}S=$QsINUF%ZFHd`BR z1jV3>7GkWr$)W*UDVykW-mC2aGKb&vv}T(9$<9<@XsFlvSdoYKFCjiK#Ljj{a+7fF&#EIGj^Q;XiwVB|b>Jg77IpK#^U^5LIJ+jl?%xf$#~HSo=1z%Xccj!tI)`s#Z-u7VT(^mlZj`( zr_}wBQ;icF`3brR{JYGNXn2vSoh0spQw95Rm#xkwQg9CB&`ESVjr8mW^({SY}4T?oD= zMXZVM1r{}%83jp3Fc! z_Ijh@@f9e?=CgR*z`y3;*oXW#RGA@8J@3IwUB@`ch`jJ}>mojoyeYHWp_(+0a1X+J;iaj@4e{wM_s4Xro?%CN=#uIL53ZcQX z3Y+r-rr&2v1`DgJMy2)Fm!}I&pROsl?6yXqEp@!rT>}dA-!`L@nK{X5Kw9jc;lx}TmDgM!Hh9WsuK{#|EV zMZ7_yU44D+Lm864&)0PD3)19simg*NHfr86KjpbOd+fmZ?-+r;-%+6Cs~#{E!}^C3 zQ^*5u=97|=m>>hep^C4lks%qa;hqakhHAj{sC66WmjINQnoG0SfI$7&55d!K(WYK^ znOo_h=)p}EB2cGCq2Ji8cpP>n{maW))9i1~*R0+g{=zP|nChE9Nb@SR8k0=DF%1ua$0iSn_qjiA!GUG0X0j8Ll?J_X%s4u{5iQDv08jb z*48#QEm!BLFI-NfmQ)nweqNp5wvHA|kv^#5Wv8N|(rXI<83`BG)*bw)gW-O)j54e{<2GL6(MZ_lkfq(5dv# zdf!PGykx1=LOW{tLIx#?$;Atu*v;x z6@pG_V))r*j{xZ5v+B0sUSy&(eH8uZ$>kKETR@g4DlsO(#rC zB^d_kUeaN!UAX6zA2%=7yU^FWys+I}$;JnE(orZfikT0dwjU41V~!qm=bIP+66h~E4ml_E<*wpci5Ur=B%zqXd85FHIl^)6GSZT+aJ zX>Mz~4|zmP974ceNY2WNt*4g~+GS`mn$Ho(X6}RjjB~UzJ027BT+f*cHLJgV136sU+ex^&pAiue z{XRMIsj9+&{RqsmY>JMWn$OVi%izkZSCzkykD)w`&d0PeiElogoxO(j)7OU%JcdBZ zK5@G|VR1%}=CMTa7RaKfrR`qkWVf1q;rLKXQ*&W+vpbOJUwJUtS`bbuzW%lvZ5`?P zC!5yr=Muh@k}O2Be#?X8fI(@)RkABnT%I%uQI{TF%bB)|2GLiaGOrN@=OP0*ijfk_ zVr0C#uUw!WdmNYOc49hRZQHSaK&MjJIx#UZU&{5-(qJGNPGoFn zFpcWw+}LZ^c;dpN*l{08WX$Pw#>w$yVy)P)%U8ADIcm_;G=fsPSig-OthH7J;Z&Mv z$UJOBpYB}-Ssr@ay5QBjoHpa>(EH})p&~wf!E<2T5u8x`uBXzbDn2sOkIh_pDTH^F zgo9(k1h|Be-kdK-eB-`kkV@dZ4+1_BwjkNc`L?YV-js_O0GSO-)S|+#%0bKjunw z)MtkMa&&amJ~t$Um^T=jrxN=x1-@x#s_LcL$b)>fLn`OfU4@OEm6er+Aa?E6zJ%K- z4Hax*LLS!#Av~N2yjQ;sqI9-)XKH`96BLnBQDsfnOiZYqE%@%0SWJ!DT(%REk|OQH zK4W8p=3n3kAtIq~cYsYBtg_L>*w(TYZCl!^spM#z=i@H)hi@7a&igy3q5f@0{Yj+@6Frbx`#NU%e_4uUE56*a3W~&l5CZcwusp=o*iq#*W<1dIBYK0Becl1$`d2CG&fgeB2{SvzJNW zd{>I*FZ4M>GUjQ*tF!s$d*3(??aNxtee$&~@%j?Ez4jU|;TY7*^I5K4)@A)B?71Aa z{dQ+u6rD9nN=h7et7(2X?5KN>O3KKz{P+-}vSIJO-)4Mv(8DAp(`@-ZDNmq4*PIK#tLao{tZWp#u=tWTg9QnL75YYqZ2PU{wI+_6B z1RKnfKGdxhe0Xs1;=>{!+UbIk3ndPo#_JQ*hO@;(q#-NY)jDsFoAcW49U!UZH?op` z7HYWw&HHiggcpe2y*d^oI%U^Z)PLm?Cguzi4w^Xp3gUA(FFo-lyX2V&+ zlCMjJWMt42J=|#>w#Si?kbIp7Z<%8s@?IaIPESwM&a}69qtZSL&JQpjD_m&Bz~c>d z*QnS$HA5DS?(FVv31pJ#SnZu6iRsy!+8Y0{uOs#SH8Qu$$zt1I6%Af@V*%}(W6hYtMDD7JSL}A#ExU-@0 z<|0p)HtN)^Hil8_=j9&L3$>1+AuMvyV1cdC0w8GpQ1SYfe)=y__V4$^Fc|>cdEdkSX zN~;ZD&0yXsj7woYTA(!~Us_Ht85IpzS?PQw>uU>BAPsHs^ z!p@G-5kf3zXed;@wKLaZ!aG_d?;XKMA?_X75I#+a><{H zaw#V(u~2@B_vXxNqT`3#rNjDA1_2p4x%J}D59yhi%FeY}G6_EmG}wCz)|QvQSkBa> z8TTg^S5&n6KkoQ&b@meIQ)TBV9gl0A5wobl73ug_Z5PJ`_982U?3Q&7ja1Tckf`q8 z7amt7h%Cl^pA~8Ie!BzJvk)eVm@FAnXzk$89z?+YyMs6{EG$gVrXE!7AZ2IA1AclC zJ8RHEN<$-;B_k!p3h?i{(gf4t%&jP;=dP0fkwzHa-3ZTnRjpFOpt!gNRSnFw&Or(` zz-|bO{harRQ&V-~GUu{_NC_USCT#o$(?9mdi&L(W#26SDN({T?(|S}2uPUZri3AgR zWAJKzMnuD3kL+k~U-ZVyTo}p-q#V0F9@F{V%RUFaaNiluVsbxQ$YNIb+~1U=K$S=M z`)g28+x}8VcfskTxz#*WMQ0fCH-ryc_&c`?Za+8s{f&(coY@ngoMMkBRSGrlOG-*w zOngBGF(JS$_VX?8wQ9~-9mo-!)-y9bUF>|EB$7qHH(5ad%y-*g-_L8BS7>Nx#w*P6 zd@zXRQm$>Rt((BZ(k!MNCQIegM3&CivKA(clU_RU_iK&IXKA4bY9&Bc%W;KcuAgUIxKhUeew2=)%55LO;IgbB&fHhfSjG3%@;L}ro8LXjj(t>*YFJZASxQb zgpLlG7dpwbY8|{Z8H?nnfF}0@zNzW&FPDUWC0qu}yvCx&uP?J*>pMH_uP81n%NuZD z!$$*B!?ILntZ?S{RuP?jOIQYjYH_iAn(@tk2R=yQfUkAa!_?T^-`{UYu+$#ZK_2q( zMUCs>sx;1QQBmWKG8Lg(OK&flj@vPtA@eY~eEZLzzv2HwCPEeb_E*h~N8s`(rkvXk z-rT}Vf>~m25<>oPKL~`8kr7y=Qq^!JD%wua^-@5p-!#X1dQgGzeVi`86hkB`Dw?l- zjl}tJSCDZ4ZGo>|Le>dybov+fDRmFe* z{^jb*HNt+@#R@LJpr9w`5SWYx(%#yK6J?qGMZjJMmwgCRsd1CI$b>K_*MNRbrH<%pRdEK~KMiN)cgF;P-ZF4xS|*fV`(<%aw|Iug>=B@qx5q^mEu0F$3xK5)Oj zh=m5{SSKy)w8v^%nwtj#)EF}rTu{KEUS$~sAmg|CorB=>?Fp8==^~(NakM+e#bTes z-@7Lb`uY!TnWYSQbmE!)a04Suy>Mj`#(0u7PMPY0hv2^<`8h8yuWLn4PL49>TqDwH8yAEF1zRrU{((uUu+jH`y#} zISq}CNx8VDwl4r&p{tT{N;Ca)q6O1$P@C_}@vz~XO55WCrdPBW8XlHrv>HnHpRiyv z9V(Xq{n0b5V3ZdD&MmFkmUnN*4rdzm)hA`9?BR|p=n`#{m*J?^1Bmd9osvcpgbW^DJb5lB9}^xF);-2 z&&hHnQ`6G!WqckW`aF*@uUFf&!iP-`O?7M@8)N@!d=EBP(Bvv;&X*dS3SlHMizjE% z0nQ_p2zYsUBYOJ3a|AtQ(PqsnC^PPl)7;(K%5!-c(JeLF-`ne5R#w*hZpC}ouypcc z&e6fatEZuF+%6*3;JWJS>QKDV-CSL}3(DU0;MV^>awhnFhCa#w<1-(F4DDrCJ$ zeExSWp)CVU2~Xngp!LSA?SK=P+9XLd_XQAI^Vj;SFu zF=rPSa@M2h!3&ilZT?mtG&IIQzaQo{8Y@9@XC#hJPO+K|R&&~Q`|b3dE16jrH#e77 zbmdy$SOEbEX=$>;Tx84jXiy)BE#2|r1;m)SI;VRumt965lLA~BMalBj1i=b5_m*Z2>C&6maLqYB2Sw# zXrd0+BJb$@OZsb|`%LT9DMlj0-?_52C|Y|qegXcn)EZYNL3`{wsswuGYY30}dpjrK ztEGIK=m!8C9y&TY;uf%k=eww=;-`bt(#V0tNmVeSzfxTsNO|<|*+q8qJ0LSysx2)Z ziaBhJqH#IAXRo;evvWLOc?wW}5@{5E!-!6!JKCK4rL=byx9QMh$G!SxNNXGKv_ zQ5#JCv$g$0g02xC>A*oGCF#b;CtHTP1D@UElauwVo7SYpzTbzBX^ypIQ0wG)Ue}#S z`bR?56q#{aH$f-?t?p6z9a$5}k>$PuI6*jx$4z>)8W`D$&SuOo`ihdsU;w1q1{^0a zpbMPcaqkU=(y4SUED98Gv9S$p5`Yb9&>IJJUTM5-z^ZlFp*^*SzDg72)!r>%1b?>B z71ezKqH0~rxM0wv*k;A*35U(=NQrk~9gKdB+lXc=<|&;W4Zr<8ZXh_AtAuK|K7bB{ zNK5TrpRkRMO@?+^%98dZ0D-#K7arXfRFss61|7jKpRNLSG{;)hzeV_|g23;Y7Qyme zOB0-mv<-Em^f5A8+u8V#JQy(fsj{eVaI4?{*VPK8BWpZsV{+dQ)Vaz%)e*vJ#{^`q zE}d{eh)&F7SlV}fxc0(f+eB4EBTXTz4<9=HVu;x{ap#9z50?NwDQ95OS-iSP0>2I!v zf9GeXQZE)%840Gy>b89GcP8xvYHWM99`$gw-YY+!9>5z!$nVD6`VMwROsFZ~be(|I z*Aw6duMChI3H~UDLDfUklSx5ezs9KJF|ap`3lZ&w%`)|S;5=KtNJ&X~fQH8A;_{eE z=CcyRvrk*cV{=UnZZ5qj(ME=bx_h(S(8`^OvbmNpa#5hE&o3|H05Grzt1+{VJp)GL zxR$`7T4RUIX}|f9iHQj)6d)k-e%P*|gWJ&dn;y56Xm z4A&ZF1pHUlW&;pY8k$GKAB^-hk1#Q;=7P5GLh1fcJWzZ8CvsS` z*PpEeeAu|(E;T;Ir8sq>sj2z91(n6jLI)gi;FcNHpYv5RL`m%LRLwO*p~nrDV`F1@ zz;Snvo}ON6w5PKZHpdd9UbFYJb;x>pdd>@-SaTk?&$6Z{Q)@1(=}>eap7mbjhRhsX zKc$#s2~W_2L9&St#Q&uofx@_Mg{cTGfM~8RwycV)s){OKU0+@OzCPuRd)UrqHu40( zc{Bw6?1PhZc^Y?cXA-!WMmHPWkD+4&73O0|r53!r9#^MxmH?DwwrnZC`%=c^cA;g)U~=Xm-8W} z8;#1d;g2zHXA@l8n15Fg_L>_6qYV7PIw#ca$qFRkOewI@0CdnE{H%6fPX2CbkV~(;c zvX8d59X@CTq%kV)>1BY*nlN~;5JW*Do>M&l%S)UNm03*&JgqdzautM35+M)*0+8t< zFMUtqBK()}mhPM+S;_i4*zs9L)Cw1|$B(b1|9uupmTP}E_N6^y&jO7qz4otoq)`%d zbqy62Y`Bcz9x$CsGq8fTk29s5a5btdMFFCvtLsfpHX1K6prVxerfPgK(cX>-GoFol ziYYOA=W3`_kYE+69ZSsmx_uC?Rs1W^~Ig4HU0j?`t|XW z$E^5+Ch@qqD~-Vmt)jYf4PNWeypI{Dmn$(kZ2+Op@9s9U(_=zuy1Kef9t{#h@*is2 zcD%-OC$p5n~(&-=?A$KtFtD?{8tP3$y!x{5v(Jg_; z^)Vh9*>0R>mi^Y~w>RfBUx~OghlcF{X(QHt+-r?yh%a%!o_p99z8dipm_Dw1SOpn<=b4r{~k%v`#VB2|uJZY4YuJ@ z9O1hn#l(sz=rj}Wt&!z>yOH(&236G{Et1e{iPuOZ5Xjb(oL( z)hpZ&A4)F>!E$*4L@2qfma-x-|9o69WYO+1tDA-b^DnX4C7A%nD*8exXYOV1UAc z1JsPoa<4-;lEJ5adRDoHYTY^F|r)}I67VIKnrX%M{vYO88G?s7SDsZU*7VZ zQ3z9ipPPt806UOqZFR-{V)lw7i61*~5>ps1wE}_qcv>P7n~CY1k#%F17aLL<9^qtj zC-R;y%&&su0JlZmM-a@J)7N8Ycn#9liavdzRt9?`g@U5rnT*VV6kz5Q|@b=9?f_P=aBaG)pL!bn1v z+Aa3>Z3l2TSpyHuo6Bt$V3O07ja~wm<|8bd0mB5~=P2{?_akD~xV_f6v_EUob`ay% zH8;K-Z4s87(|`LHpTG==L9e?IMkQ6b*EVl}GZGObd{2|;v`RzBi49+&UZ9|p zHaHJ_A>UX{;iKu#2MniXXJ=t&S`N(MAc}H(RQk(I&8K1w_D9cfn$dHpw%_l}UTl{Y zE$O(qDPx=iV1 z2MgpV#zAk&`enQoyF*|A`r_wY99{#TNxS34hVl3yfak}d_4PB`=cudwM*q7e*LQ0Y3qs|I zdgH6I!z!OWdsbow#Rq!3I1L!Pn=~#+zG{bfzmnP#|7U|PKiPi6!VH@LXMhJQI=~%I zaCx#*Q0V}iP5X!ajj11&+P4kCKGHL>Bbnyph)GI+q7JaOw)+xai?mJqNLM8fDkd#1 zA|X$P!sc!L!QLw{k03ukKd;=1HUsNJ$xF_6MB3U)mNC0bE-sg9lDwCO%Bm*oj~fdw z&aqv-{6nb!{BMQ=%nTvL5)AYlY~bHxX}CH-z53mHDx0UIWLD&Mi4J_?MFYUK;J}DE zcrP;i`xh^lk7Trqg|i@rC0SheQO)u8Jq#wRG>c_JW4*DHUcUf8PinKH=*tWCmVt$| z`T2Flh3jDxl#-P>d%rptQ4QSj{QDwrcotGPWu{JuHjp47aHo0NUI_UD$3E54DMf@l za>LL>NRB#(HUJ+E9}@_}k}IHL;L3p&c1yx8KEeK zccIoFJ7M5l==5>FaeP0b`rz|#zKeW{Y!g1-G&*E~Ih(j0%W0nz?g zGd6+DZd4K!q1dM%#cfX~aY%9YxMMWB<)Gl!&1+81`u$(S3Nl67*O#UZkN?YG0uJ5p zg65)dpFHui-@4(qvtxDGoAplMx^4AFT}x4A^+)~i-Z2=S4wb^!f0nH<=W-L(&oDzTNU{SvaVVGfUo(A6XL&dQ>C!_7?^(=(waBvkQh<8cQFAASINgaAiQl;qiD#8BQN zEa$Zoe=EEmWDWs07yS22OxE#|3v8+0C{htllt_L&B6uxyS!W(H``Z5ML9ov@5`-EB z<7OD>?BOi8bU64fI@NCeis;eoP^I}sgkvqMb_Mpw!94YOODdZb` zGyB&*zk7w=IqGvAf`fyt?d*hxGo>e);|0-y;eWXlBAI z5@ZOBAK|({YKjE=?*u>AEwUs}wXJPWJ@aaj2nh*$C#y8r3m4{RXC~K_l}Dd&ZO{Pm z@#dE=Jpx2Xj-G|La-XJ>ZGe#0JvmJ_ZO}q6?NjCtEi{PS$b$ht>Tbq>$%|BK6bbKA zc28|3CsK*1gXPP>wYP{Q3tX_My?>h-%(V?`7#wuK*THJ0Z|7ttQQCL->&Px)O{Ndc zE-{J2>EQ`FIw0*wpJgMPA0xm8#UsYnza0$29Iv(~_e`BnQlxELM}FJX1}&(x)9`DS zmwThQU>^|lWP~dnehM#FFE*tkqY;nSQuRuUyeUoAQ-s{L<}AV~+NMVWiGrn!ImV-I zJFTcGjQoE{8y3KFH#UgpRh=dUB_+vo8qB8~8=ZaKLh}E|)mMg9wMAVYB&AV8x)czk zyE_!5C8PzUyE~PT6af(q-QC?FAuZkAE#2Qb_j=#=eZKv};Cb}yv-etajydL-V|{o8 z{F&v735IhYTUzZ$B__gy#Uj(4?Q`$n)CBM8dAGE^uW~xyN4&AN3~KK(w~qJwV~Y?L z*D1P7j?;p&N$VH!R-fp+%}g~B1HPz6?CtF_3+q-I@fPyf{}tu`n{>us{beY3qO3e3 zPx}TLO*c#}RIIGJeoCzN_Zrrc;-e~!sk(Z}GBBVM3*ygmS&4Y-7FP>^J~VfU-p_$u zbvTIgIi~4&wIGO!7-(pt!l86{sL)MTrD#)}x%#f_@%f+LvNv39lmZ>*5$}s&ilDo7 zg+(8qCnUW84I%yW#tz{B-b?;Sb(8dUtcs*h9mOeU1C#G*p@1r|=3J5I;VRJ!Y8P`# z(D~ZhHiBb)KxXUY&<)N!d0V}@yNEI*??bpblq9h=uq&e=J6;eI1N{^ujkWB&^;Uq@ zn8=7NVfx1Hs$CO-!`dHgDA%v!Kuo*S*;w~Lj1`=feei#UI8^zX|LVz=!f#A=zDUBg z<|%8u(ZvC$fPOHbRI{tNqgS1El_@jdpZJiT+gAN{SN0P%Vx`$44x?6$Ursz0s7xUc zv%HtRkx<=FA|YXM-=a9(8-u%v zOm02DLyn3`bXlxk%+etaBR&VZG}OYy=BMh`3oD{&?r&*gKG>!#JQF&?(Z<5)XyJFE zW}=KH75`@mf?uj&gfO^NUoham>V=1h(vjiHV;^8(sb80+ZSsBL$(_54OZt|{9_th| zs59a-tk~pU$2*N@J=-?&7&AiNbfO&ZT7d0YHx_wk-l}wEUk26T4{e{Z+K{L>#=#+> zBB~Nf^WgB-+Zrco8UbEJ|JFhs9dN@Z)9qLbcO)d7<+_ip@q>ZS4;2dqq!=SG~3 zFiATzGmDan2LC3Ktou{JWa>!pULn!O!H$0j_2=zx&?Zkr+Cd#xI^CA4*cTHImz=kr zjc^dJdZ)I)=tzTGw32iXKJso>MioS8nz-%l-b*3f&sA2vnN8ua=Mi$<#n?Xyp$=Le zzd|d!IATpsQnm47v%&m~v4sV3%{1W_;5T?BCFhl<_USo>4spp`j17g~XSBmSCVJxNc`S6`n#1#~Fua_`I1(%OrR z&k|IkIo5)XkG^IFBpAE7--yt}z(dGLS&<5SuHde&Bs2VeN=UA(l~Hdk6fh43zAscP z;v*xDKidAo9G?v7h(7B|6%u6K%p#~X;4n;1;UIhcGVk&N5R zcj8x(;ottIc~N6P(rv?fcaa@}NQo?l3rG}@i94W{s#l|yrTP_&zE)oyN0d*Dvp zj8dJyNh{&nUA6Nbx0aqmP7_-|?cO zW1W1eZS?uK6A}biR81VsCXw#k9Yax-ILIy={IdLa#Y{D-mf zn*e12{*r!^Mu$sh{xG6at%@C(KQLg<6Kwh>m^U`;6aFq0NUA*CwxaIGGGCX|xXU(b z$d@-4QHb7Bg1P(XZ{JZscM~wdQnTB?n`n!5`LNy|J7$|Gkan+s*<9KsfTWs^b^qQ) z^GAR-oN;&vCn9l5&tgJtw{NFc#0%Pdw76ChHuVCH^F~^?+WLn2^tf#9wNm`sdah4o z;q!1A@R>zg4s;l*yi1s%M`tS+$14qJV8uIbcc1c?B27? zdI_vnTmHQ`T^;bS(TO?n$&z*PW`?}sgDCjup8(7U@h$8}-0@^XiR*=q5_xu3ov3K`Pf zm_6#aOSiEW0u-u;R`V6)9+;$(Eo3@&f*d4DWp;bG|2`Z**p2_Yf~eesInAF0(p*3Wx#tsB&6Jswtgw2H20)eC|0!#9E$9Sw$I;;Y&Lkn4qa@KhM3{ zq-T9&eUSW}*F^|RgR#_hdWAa0yLQj8Oc8gZ-^b#mgc1mR>wosRAxxwVt!ZZ1uXMEG zAfaQpMxTPH&gRUX2kw=;yP3_FWUu8iO;w4DjS0xB*c8cP6VP#1bH-fID1AokbA{;_ z{-|>OpqR;YB9pavuJPwnn7sGD>wx$#2FcwDg3m)=5NaO(9ajVuw3J1Bm3Qyvai0{K zHp24E45`LiaK!N!hZS8phb)15ZC!19kp&jVk4xNk^*jHym~Qi*-Z$K*BMkhmdzmA) zMvt@Jd?LNU`mj-%$G83#{ucC}-#mYG?Q|G=FIeBv-3PyLaM1IsS8{5;0i}UBq$0j# zX=h*nm;0Y_MEh>+2}l z`H7>;?1Cf0JBp*IYn^W7Vn9H(Bh#RLK`mT1 zKYvWO45iskFgECuhb2?}LcZ0hIvN6KVMrNo}zYb^D$? zCe`vSB)neuv*-ATgH3{hEOwnNl~&UvqT$4plrIShN)}ruTZ*LIKW6J{1zev>OrGx# z=euDO-CR>~Qo~PLe_BbgdRRXF*@x%u1w3QK`dYJREFvm0;GcYr!HNGKC_8Db-u!mOf#E4mUI4n+}7qP3Q!(Q z#vfcGnkL78Y@(ov7JsV1C`&SwPhUSp*K}(ejv33vl!Hj3tCJEuo18(t&a{{u@~zF} zh61!}=UxA? z#e+kff8$*G%>QV*(usb%{$W2N7F;N-k7FuENxs8O=PK!jwx|godDT>x|7LJ-WMRI% z9VOo_{d4H!^h?!zwC$Gx{I%gZ+E7|re9l67HDjU$xy@W1a$oaJuYPTB6y@FZ5;R`V zwx|kc#HUGQqHk)L7WdNfe9_t*twd^ z-Ad;9BVG56*K&jxwQcv7qv=~!&8Ph_JhpoX&6;&clO630p0~*)>`!c3ICKtqs!=Tc z;hNm_!G?A9YD<3;bY*XUG7Mr1$DdJ*iHB-ycq+pW7{%<~ml^J(B|!_}EJRUzixr8w zKJkJ=-~_;+@T#iwtNGk5dgSNbn8cxa1sLg$^zVGB)XpF0 z6Wn9TV`9!2GY!h40JJTAbMf<(_oUL#LW`IfYyx}`8d4TGOUhE1rO=6dfA+Pox_Qx! zu$8{tXgNmZt2WE3DSYlK^l?kS@odG>a;}b8@AkA{q_jLHBVd8odF@nFyU`gwraP>Y z$Hm$G_J#iFDL5733JV$dfy#V7~xgtB*IB~0C($P*;c4N z()_M;3>Wq(Ac%Oi+mul9;m`MIn%zd;RWNRH;sSdt zU_^8&!OpPs!^k6-3_|~pdy>BotPtPt z?a@FG+L=^D(d+PA&oaX^=I?jy4m39v5)DiEm&Y+!RiX>Oxen%v)Xz;Hn%AqRD^!r1 z9L}Z^DrDs3M+*2eZESAO_U!|fd&*~OP`fttTDn1rR9yDI(W@S4*8h!U>E;^~MJ8G8 z(JyYLB7?MQlCe%X=g?a+xH)q^N%;Ht+#>~Ct1dP-ah(xn7<|9=e}rq!;l2*GmIl`Y zX>A<`%gll7kU+=*=`68!+_$fxYhxS%eIMl$L(4io|7dzph$KaHQIEIZeeCaR6aPvg zMOkI?M~5;~HNQX+7J z`|5eN&;In(5W!Pv%#Kk*=|s|2ZI+XF8HYc~pnOZ8XlV)OhW6&PL2e;VI;d5sVY)OO zw5*5>k8envKD3jqBS{%d(b~Rneb>acZq|5t`V>$1aDZ@Vwb#BqcU%-30fe5o!Jfhw z97H%HcE?CLW$}GRm)5<=rVFavp7m~%LZ%$`-L3#!te-y|`9*HU-rb~lPnCOgpI@4f zc$8L`k7v2$=jmnJtvTZsqReRs3IHWgPFHz6u!Pioih-eT?JV)11pzrke8P zT}HM__B&bPUfM{((#fdggb>aL>n`@yuB~qkT&BJ@LfXL~+fCuwKSPd4ZXKAdITNGY zK*2;ELmL7w{PHfv2giN|cTlem3AP=!#^4OeNDJAE3Yv--7gEmnbfGPZEr*Y8S-t)s zK-mBS#A_*PzqaBBmFT$I$T zbW$9Wx{1m2tjq-qhq-4>(G-$Oo!L4b$K726wZwSv(jNSdmZPXP0uP1q`rTbKM27>O zc&oauRdaWQ?2arD3QAr=ek_vq1X=Y|MLO)cS16-23cdkQsQg{46x`NaHKV*vt;X`I z%fX*_M(b-^mD5$wWnUw8_>7|1#%i^JM|Ruw2OOPz*@-U{a8BR(|x!d`lXZ z_wAZ^6V94i(PFUP#Qlk)PRkk_Aqd){huU{5Ig6#nLqhwx&Scib&xBHiHyUsK<>xg= zvsbpc7k*n@Y?oz>WcAaf=WP>STFtw|kXMcMqF;OivAffD;LBBVY5fd{Rg-fYyeS714IjHy<(UwHNFti zZuBN)k{^6_GNm6z_f#7Yy8t3c9C}s(!T?B?PtT1{ikmc!XX!g>o|_L{*mZnfnOE2Q z{dgdphz*y8xEdcVB9hUA~2)yaK4_h6Fv3$3vHXOGho#s#*p9gpVFDOH)esEib#pej*xp*br z{6e=}^ubQhoaS^cjwI&71yFF}rL6qhj&$ahuIN#dD7{o)3KY}5%`Za){iB3-lK{{d zk`sqUs(I!x%F4)h9Xo6~iM4`4W@|VfZ&0`SgDrXCD@N8*7c6Lbf1%}`u)B6nKln$o z8#~B`F64av`yN^2;FOa8%3vMP=*0q_s+We_fNd!J0uFqwYG5Wi)pNuRIn}i zM6;fNItzP>4pQ>g3kiHn9;f3c=j4JB@X`tt47A;a=ZjwZFPcWKdY)^;DsKj4SCxHEIpN%8`2@5}A&?+r$T zo=r`TQcY-tj< zfkKaWO~^C+Vlw2fe2~SCj=6;e94A>GJ`35FPGSJ#c3%IKdu=cp_&v#AZ?Z@lR0nco z>`$%`5o>XriE9q#K0%AZ2YWnVU*yjJzyrqKWVtMk@}3{R?$v1VqK$y^2EA?ZE@9t4 zD%@5n+ZDjDL2Hco^4%ro%x~HUrmTFqK$ENBIHdn(zVS!?f3&~Rr1*L|D5@$xzoZOQ-@dcC^5zn!L1y?Lb6Sf*b#*pr#Vq}`c^Mv$glz`7seA!-5wM*k0?ceKNr zvHu4K1OojsEO(5v;@e+wLKO3OwhZ3Io*KLc83Q>!LdV=#D>hA${&>xzqRj9?H0F$i zbdR-Gf!Pyq#}PJ~k>qhq6N&DXuN1hs9nDAIbRp(V=^MB+70QQ~*#5uxm*?E}|EF4AH4hCm%naDal)t(X{aO83hCR9+|sJ zx|Brs(&(WNn}#q0E%Kqyee5}CAO{TdyNvYooB5jbLrb0}(MLxmg_U?;>6FYZww{)6 z@Y%}w{Maux&Yq5YleML>qmfc?i$g!&WvbJ}_k~S7tTm(@ZK({Ubqq(luEZu>9u?S}E#$zv zpA8d3PXfS3L+gqQmX&Lti;UYQEm+XuPdFor^SohGjL&t}R6u}bp2|yD}cju-b+O^O( zb~AMJ={#H)tLCw6b|^T)h9WL4>*VPKxhO}L>NKE2569|ZZuc)7pYfqS)%C;}T}aLW zALdci;<*|puL? zTxRdvcZYYJnKp;!H~(8A$nyCA(@9ezUESEgCntF7@ECiR9u-wzlg3tLLkSlb47vnU zF#(aq&kE_>dL9mp{8+1ZY9H0OKKhg*G>Av&?_}Doe|9xudwG4a^;u}XUVneXO5^_I zO;ag~B1gK~be@>YCla=4XXQ8a#S5sCHOk9%pq!W5wcwxvM_uBO;h2@{Od<`s8;5%m z|KQAD+5Qs$i`1&fsQ&lw=F4sFH3GV@8js~C_+D3>Vt}z6*D~l~reoQ4%>pfzJ!>$| z``JIe7=uXl`j*Fj2OYc;4Vi;G@;RpcnU%vL{>mY6q*(FO2!6K%sx`I*XO3aaq5wcX zrIC)S)5oS8TFfh51N!XKx6@N|!I~`Pm(BM_UI@sDJKq*j8p74@FPi0pPYmoQMdSYZTLd$xS^;E>-!^)j5=-9Na6Mqhf zM?^*Zu=3PVtnCUBb*|B?+w^WK5CIA)YAPz~-S4J-K`qpKN*8a@LZr04mdh}Ok!Vrpb83Bcr!u+a@~WPH2go zHtE9cnQ}Cfgl5B0<;$5}-rm>%t-XJh)^^&joSP*MM6p-RHNVkG=psQB;_RZ%et3&sC^ znzGLj86}&ZktIp9ZXJfx?x;ggw}KsK(3jnFhQ z`;8tCY#H;l%jfYG!bIUHP6lc6uKD?x$vk;a$Lt);Aj0G}pydGvsjsxi(1DdqfMjf4 z7{mwGbR~?3W7XkYm*Zts-dmfk$4|!>)IUp|&$*oPd{r4G>+z5UAAxP;7BW-nCy=xxm4HJjdIVH3QZOiDWiP7_DW`<%lnmnA(tK6kelz~OwPlX) z^Lg6dz3Q<@g0`ihRX9!nnP?5>KK58G4$&1C7^0JedPk~%OWVEK@5z0CH;qq%8w`A# zQ;qdi5V$aXD>4kTX6s$|kq#szcM<53FLuMRTCUYLZ%McmUEHl=--H)=obO)beDU@X z;{Es>Pc_pHh(Xi8CB5w=2!B9v-GJp{y`KqM!+n%aT|i^VH@XJ%jxS z5EN|PV!6^N})I|A2wr)@YUQ zmAeHEVED*?AYH0k@RS#`IC`}Z)v9TFbz_9c(OA|hetJL2oV`p@7k1DJ9i_E<^RxA$+J`nCYsEEq29 zPu4v*pb4b{e+11)IHi-woSF-TJ-mEt&E>SJ@}eKGH3;7=uirUFV7t7-*|h#5V=$JE zj(x!DUC*zuZyS4C!HeG(%A8NQ3U58c!_U5?O>XO(sFw_0 zfkNG#`a@h8-E_TX)FAEx?O-x8#OR~(s+4POb))#M{=VZr>8~&f&G}J_#1AjySHE0r zwgG+vt3d}D%MjE*FdRZ5Kvh0>y^u_Hd3KWJ`f=_%+hNs?M+urg#EcQ01axfT27Hi* z-jX2mYY}%$%7i0<&I#y`QQ$K_Wb3Bzf6nG{$z93K=1hD0W#jU!BFl6%z3zBu8Mv4> zc;YRV+@n#_$|ua9$IaiI`X7nkoQ(*P&DGQ_Y>u|@J15-nDsK9E5J~VzbcJk_p?;Byr_g|xY#$3QQDR2V%OfDqmBoTIpi!) zR7qD=or!pBQpC28=yG@FDAGsAB&r|OgZ9a#!>U@7h|x68^B862vx)t?%jcQKMryb3 zH8gThJhvNbaHi-)a$dEF`1w4{ep1QlKNB3Th$U zS#0}NEM)elazi;A5#AI2W9vUsN6f7@@sBBjV6 z5B03=n0<4&!NSB!GsRk`G7|Z8uUP#7l!_lOHWR8tldqqbOwXGd|H|E;r6hyPkJ z=VDq+l^V!>P#vov%3HIDeRE~{#{P-r{+@w_30g>}KWSjPFv8}>8aQaGAy=eTKIGFW zCjmj^vvbVxz`&?GQ;y@88uhhmd8Cr&s87Vsg`*~yR=52GeeQ}?@SV9Ibqt_$%!fow zfPo>m9RY6KOIZ6eB8g8W%c}-3##LTg(DJE32|1`BK{RU_(q#0wZK(h&ucXab56!)I zt>ayG@7a!WFk7aa2wQ6nCFnbhG>@ImfaS#Fb5?(TwH#x@vLEtzX46WmT12<`pvM8g zwj=?cGO6<`A6emUz*I%kyM?m5cRlPSg?ZB7emE2(ejuw5q z-i;jrnRDFiqOr-y7&ExSbBecXH6MBwsTTP_52B(Re;vkH)w)o`YQZ@GPEVxiUPVYBMtJ36WQY3;tSJBj8rFQ0J}pNj-6 z{fPY(QTqP-^6c!Zt}9zDkr4C$NU`u?{xWP_%9#oOV@nq^~WqpxL<~o>E)eeaFs~^={3Z%d_dzQ(yp^ZBb}}M z{W9g`w`0HdW!m7k3}ZxO#1C_&l6(TFhE=yu&@PE;4^5s|D#WX zC!{x+J7FxSki+I{s_g!xjHiW+1LM<#iQac)fGVrCKf#ApgAVZ}R5U_qYG?69RpEnM z-`8~klh0Labggjft|WDhIN*|-5#t3MLAm)-9Jb{(x}iS|PS zvSAH$AY&>$SxTdiq_+%K<5KXtEh0Ltg;}tv*Vs znakDB25qmckcPIKR7mNj;3^gK6d3{3aBua=VA*K^Tz(0PjLVK z*Em@<^78sx7m0JX+n@JI!R8d|f zxU;tQZLlKXpoZV42+0>U=s8$Nlf^d_clFjyx+3MWH`P<;H$#_~iR89B?UMVIDWIae zCA!v^S=4p4+F_4+O4ULZH=cudB$x?r|WeNyo7*Cs?_T6{=L^AxqzKAEd{UH zm@RZYNC7&bwRw~`I^*R3gTp`jNLODLR}u(Lob=8GsTt4!Erh_(a*5z?d$?K-;Oh6| zQzOrsoyee#neom=1=>~<0J2tH7TednnSK#)ku+Ko4wAN2l@-%1iF0y!+_ubX{G~Kwm)7KvSs; zSRG3%dqymBLKm@w&*pN}i>5BNYF(Sk{hHjDBEz*Dr96u0z?NvK*>C+vf#L(6HCUr@ z3&H980a_n^u3VhWXx12egg2eL!l&S61Yx!l2;Y4?5073g9{S z-G?Tppx!(^ivaO%1yNi{tJcy})lKe>!)F4>ZzAc(R#|5sX z_zoGgp~hbNIPHwWHY<~M)48H{&HeN_dGa$(589L}oQP*!?g@KqpyG(HDx06+1h)(9vBAVjep2u$~qT7=s3j6q^hu zgdKdI)TNbcT|=76EZ5fW;&5GJ@}f)mLt)hahQ9>S|FRT7geVG2cvowzuII!MKw~cr zbI1_Ksb*$VFbLr0ZOT(qW;!GKNGNrNxh!;$GYD}%n}QD&6EPN-GbZJ6ek9v+H?mX? zdaR9CZBK5`e=0+r0e8t)i75q^4b7}BjJd>_0ThJy{jN>p$uD&1A1=lptD{$k&n!*G zufxBy-5(R}4+U>Mdv)uHDCEBR7K)uJN5kmCQRgKcVE}X>lh+P{F9b<}VqFaMufgaW zpPB3Pp1?Juz=b-CBNBesl{GIP488k5{Pj+zk!k}0mBt{NHftO)NeNz1mH(21(FZH2P2efH{?gfmVRh18U-=El=ZqG>4-fqvN!r}M359r?kpcvlf<+1&Cz^g^_ zXV)@7P+QY^8S-@*0Zm*-(?YezS&zQHiAA+xdhfs3qK#xCEe$o+nIXhHiVY46??7|d znA|#U2F)Gh2xuqJ%x!;*e8vHxM%qqUb-TKnDicOn4HAC8Y-Kf5hYX}h^GoGAltIyn zCc2ki%LEKCvg8plqG=z3s0e70LfOOtb@;(H^S=@6^dLWAd;4!~p?U)9fYq5z^3_gR z6&yAL1TmVh){yw@74W0UdQjej&?c8~#l>|vqqUqG`X;L@(x>oe|AjCI@4H`L51vl1$)XgWr9$ z;72+C8p}t<+uS#!^9u^dk;Ir{abz!*eWuMitVkOk;9i5sJpCn(0(6XW=4MCXzbqtQ ziQJsSlnRYmDP}D|j9px2NdGxzYC5{UV%mmcNERKfOG%~ z0m3_h8PhIsFc0Vpvn>CdEaP7*o!$i``%!O=@P~;T@;_2x|3&@x0F35F^z{yk*(+1A zv9p0eu3#i4Cex)ARAgz|{mPG@!SHeBb6PmaE5@Rwl^r!&Js-7?NcQ%j_qAJFYv@{9 z4am(3dA+ZJ`Jkkl6q1oWA()~{_bad{nyPsMstPb12?x>8)XWg&9Tl8FbV^%<0pXqW z$}qWpopN*^tA)_V)MVm76nd6D#>hy|g$am<<)OD)I5?z+6I`I2WjXky&T6UeBS*rD zZl$#oDUv$^LOX-G#}#wSF5=X$Yr|E1iLS2Y0fJ;{#Z?G&+h6wl1gN25qSFj&qlv($ zn@me(IVs&hzG$$LYjET^`k*QI4?g$=Yj}fAoUgJe+jAV_x_b4pUbtp`ZK=I#S89^i z@lWMKXw>w&|+%_4Ze_)EVk1(MyU*hVr-1cFZpw5~hDxEoE3^fd!ii|43Pl+VV`n1Y%5(-2=ye>4MlSV^P~*(K8rE;psySSmedqIG zD2@7J-?c#19QOQ|qocpG+_GL?x$chhy4_R3whK_iy1MUey|JQw!H5BFR}_%^XAAr$ zOgedrT43kHT7#$C6Npcx1wI2!@U~YooCxUrFo7kl(DFbCbTN$ye~n|(gMPF!<*F`z zd(WlYdbtwR+xrOO*guH>2OQr5QKY{^tm4xX0f#HoA%rWx#LZXrmzo!W_&V@=_ai|T zeJRd)-pN?X;SMGXM9do0px>`d{T#=DO(%_}ZXiuYhD3BOTBtpOc-|2kw8yCI7zqdN z&4z=CQ3&K3%N>`L&&5ms#vY77Ch%hPeE5gbXCT_+N*4l>B|-y9=iG*4gH*U?o~;^M%`WKE=lQv)9P z0>1{=!G-26@m~W)TQ}|y4ZZ7tzA-&v$#XuXcRsqTb^XJuzZbudbHBPT##vIL6Uo4# z2$eK`5$V+O{AV8`^ziT0yOx4h%j=8#G)PozQa`nJqJog9h@r4vZ9{!4(EB@cRrmA{ zN|{R`0a^9Py@%B4=FQASI~sxO!bh%)+{OE zr@*yu$Yk~9Q8G)rs~Dia0YubP?EJ_;@E;uUMcPlSEhgL?&w@b~XbZ#*eS}dSMa3oi z0dy*%wk*gTjdFDM$3LfmzfoW=3ea4o4beb)&2E7(HS>lFXSipQ0MzEuAn}80GB`B6 z;EcKr)gOYTuFi4#N5a5>@`;KHPJEO`(d?1EOF(1}E%v#o^0Uvj_KzGwAn3V@OV zEkk?XJOw0g^J#G%@Q=DOGU*|C3o13$^0B@WKhFGmg0 zRq4WcpNA$_9`x;QI@>59R0yI7seTl}bl4m}ha^R?Dc2S~bgvr}wM$OpC8+TAutUjc z-v^7>5q{`Y7B2W=>}}0`?%-|gv$1&y|8FGBodE|Sr1Nd<<*o8~ww~g;b=K}ni^_4Y%iR7_*{1Vgd)*DbeH`b*q(#&ARz9p zq0TV3BnGbmDEitynVTyBHN>Cr#9GhQk3~Cufd0W5Vf`3c`TCS%oq*1kkpq2FtiUGU zB3w$s$Wl92EUqeAO66p-_eXs*?Q>e{G^B?NF;8W-l^F9)pFp6#ZwqlC$ZygCjCux4 zI%qUnEI3|11jOCrJ1@8XhiTvd69PNmLB+5%R@O1IX)h~ViDC`U?cyS1%w_~@txjNM zX5-$K^U|*{-OtuPrY0xH!X(nVlTZrqnY{D+o-1RO5RmcoJwnZA2Y3Qp{QT&+AO5@Q z%+$inSiZsRw2^It3(I~H&b{7Xs{fxW-_5JR$q(>S(DIC&p|P1%IvCXsE+sEOyDO1C zA$S@-+8t?{g3M*%m)0RIZf%yQQX-vlU+cL{a(=h~r-X~+e8M-FBoHw;?_U12U^*eD zTlUi_!s*ThO1Krp1vN0Fz9OF_+uk&r+L&KUCt78pD{e1t_Q@rhp==SU#F z;YFmDmO#|n*Dn+FCJzC^!)dB-PjvKc?K$wtX#4ex2n+xL-yA$|Qu0!*2KGm(4OpPf#j0NW8tfm0fQbYo>V`yTIvfKzfr zKYm0SGv_2BAzcRfIBolPML8QdE(cu+cX@f+>QrM6*zo1=99-=1=*coWINN>t^a&9{ z@sgFS_jSmT*%mVGCepRE1}==^8~bK7H9g(&a{$A>E^FE=8X6lCcFF+`-D#UxAj8ns zeoICSoRIrZIUv4Sv3Pr=BwrR*5fOt!8_`nfTw!NMz^>Q|qXR9V(KdCw0_siP7qBzY zVAqHj@ZeV>jsEs5^4nTwI(M<^XTL~HZeX}oq?0qq<95q}`sW087oCATuCDHhO#|OD zB}jy7v0Q9+AAVP-r3lxb?*;fuy*-W7X|~1h4u!WAygrq_(DlNoeprkP5B?I41d&T- z8_?G{l6R$xAeqbY+80rRR2mmlKfBzrt899Rq>KwwT7e&Ru*RAs{j9(TW9B=LD8fB{*) zbua@C8z6Q+^FYAI?_ypCKSY_XA$G9|g2%2}*ImG1y*)n0s$9&^ufJ*H8=Mq;u#yDx zq^(DF1bX83A0A;I%!ViJy@Is*Qg#d%uQvG_d3%$c-`8N^xU{D!=lwb@;{J?Ij>zqT zl~MANiHk^D9FwA+XG}wks!T-j1Rpkf+lKA%L5$>Hx8ez8n5vc&35z(S=;1z~r=bRf zSA8qPzW)Xvm0r{})PF~pmg@ew`X$BNQ2O8Z0&Ih&L9IWI!(E~?E_8l+?$^{LUbm~E zA;bEy-DqtCH$1|`%*^=wyjZu{SCGHjT)OZT0}cHXJ60cq)63T|YXvFYlXWzcGMh=v zCNFGBoXKKs32SRiZEc-CGs(_kt3vsXNPoJ)oIggiYP3OSgq)M5ga{d5~=)hUF z3s+}HA8|&vFkHA4JVdczEJ|w!>3NAZUnKZABJGw`3UV-RC=J%RR2ZQR%X4CKf`IVd zdkIm|<~Cysp9e(28=*g~*G0b$KN0O*?0f}LNoTf3BuklSzk!i>f|8$MwC4}cZQUOe zFbF=OufYly|3mQ|{F^K>n*=G9;6yO$$x2D4AaEj3XeAeUTIX}&g9JbP?{bzNcS*lQr(a`}(zf`~5tB)y>jeE8@U=gbwCZ zoF^-*w!2-Nz|cbagIH3pGg!_g7-O8ac9OP<&PO(5&v>V&Yd!c$;+c~I=*(^RY*B8B zxxw&uWP%4B)l14W!5}9{Zd7+886_@wBC)& zwW#Ol%yMOvmSVqsl-Slii3N z;F>LAd3)<{0aF_EB9fAbQNK41p60vJ@yH4z*Z@)HR6 z0CoLYoK}JOF0$wEo0L#>0F**LHE3v5Ma~NfS~zNfw0>m0^<`>{u5x=A=MD*dgsH;C zHZZb;xtxs+oA@d4FZ`6*dHDvB^(`+^IXTf=C5^t3UP1-Ii$`nrvJl@AA8N0 z51%hvn8#3Kj^36$kH&&0sc0vU32^a{0~mxphqrwwcm4ClkC(3hjS2o&EM ze1NQG<_?}7e~g_5_!7)=Tlv!Ahucr(Jcu~H;`DM9SZTXXz}|TOkMTEds>7{EswU!m z+&T4HVR@W;GhN}q56K+m==f|@NH+LO(ZoJOEULs@U6I%QRxs;~ZRYvPi)j6+tunrh z-B_pnKa%^alpRv$;@(+K+Y2hh<+88eNT_|x*1Y+yvf~O$&W^T`yk8|Z6K4g$E(hZK zx%qx;SkmV`4Ve5q-J8|Bx!qYus+t6A73pegx<>>kVdwPRXLA!=Lgq5E%qWA zqk87A?7mtNafg2u%=T39v)-8M0l=|w4tO9d-NF_BLzLi42_a2mr z$)XVN5^L{!IH`DEPFugZa=N_&W%{!1loo6<_oqbR*)L>gAc6W{4EEn|`87O0xs*r^EG!~{4JkxK`NPO*1Ls@dN($UpC@Gon zU-rK4>5&ol*3}KS;jX!Kdc?x}+o1Q)SEaKzV8nd<^DE9DVz;;Z_Q2>Uc1HL88&t5a zUOl)@OuVD|}U_rGT4uy`Y1#*fsp2062ynk=bF9OS$YoE@|ddhu_H&z0bo8L zs*iD`)=V*L5p8_>@SUI@Lx!260|$nA1i+>s0)+#ErOuyH79C+A^Iv)lya5}%Z$^o| zNR~9QFbq9rKR%31voaZjIrYCy{P*`$#Sh@jv?LGfiQ1R@&MT3y>>X=zCjEfXs4OZ8h>1zmm| z9g_DFt9a2|t400Hvj`PS!3F?zcnGjp1qcr>aBn``-`^7#cWrg0j(uzT0%!lq?(j0f zTko5$?gCxm|Mx5;&SGKboN8Kb9(Lrg$sloc3U622jnPvcWOLI;0@0o?upDLh@QN2t;M6Mi)5=#Q|I(J_8 zP=+x2n{$g_zI!KT%mJ>S0UVU);|(q)G_%Wqf1*@xT+2(Um=pW|{=t3NW_N= z<8P*8sgXoQK+n@Z9Sr0NZEV7&p1k-q7?LTHq5kZ6-&R6;o@+WdB(eJ3(PdS)(LYN} z(I2e*a@2usQt_1*v*`^lcRO)CyOOsQpw^>EdEg?YMf6v*ow24mn5jenVb#+WfN^DZ z{q~o3R~$vRTiRI}FU40se>+#+z z*Wvt;2Gg?h%dYOO#nX?**LzPomO5g^r#FgcT!%FEUfsIypFW3nTR!YR#HiAzx(nYF z7ZlVl6nZwPb<~bLJDNK-oxHqA@3h@U;g>^6mqXE;6BMY-dIQMmWnMRr8_yEZk=*R+ zC+xx(xu+p8gWdoAGu$2w)*r@bfTj=e>88W(Y!w<1-Ul7orS%xA!^L4>VG2q%$MZiU ze{hKKiz)c>Zukuhk;_4R?9P2-j@~nd8sM;_9r3`KQJ|@F|rd)n9!ajwcPl(J{W$FC6}% zN_KgT0>npgfv=N@Ds~=4ohLU6dpt$z#9S|8&G_4 zeD;EX^v&?;1Q2kMIQd-cGHo$eBFK`;A33`E1VYPwcR~cxrvw05fta<_WpN5WKg3_+A`M_Pef!c;JEw@4}TfQk)IsMZH+Y%A*66kX!?y_ zzSgMURyQ#>rCr{)ExKPr0WArd0;|2md}Cx;3eV4_30Pq!Y{9^q`oCube0@6fSMao? zoF(P1a+|E$qT2h^{Q4uBjO=JSsRn&~mi6HL=4`QfP}ML{l!Lt)!SnwWACg(QkU*zuJnk0lM9$&&WMqh+Z;9}Xj#_33aB~7@Hr?M=q5rLG; zE{xLWroBYI34K@ZOa?)?NIpx%w#8`&pZ^MhFx!bkpcK3V&g*K zFrYH(c7B=AW`}O;+-ga$$CKI)8WE`~8%G@FJI<2oWtcH4Uh^ zF~4%#)7$^l-4E4n7h~+qHuj3i$DtxCq`)PByeKYO+**T5 zBV$S;sPjN~>TBu@Z{w96ZVyFg(XfLU!gA)xH~`iPrS!RO1p@m-a{o{1=b4AL8z6i3 zCf{@gDT38EDfIu zKXd+35fl3r3&;!B)YOULFjD@;m&64>07D`rXg`Ffz%)v&7ehnA5%_zQ#|K7vFvf@y zvnep^V4_0+0S5~vt>{$pj#?2E=OD{kNMOzi3iM){3r+eK3f}3Bos8*#C=5D8AP*P_ zU}KQ(RW0s7{b^#BO$QV#!6)J1fO+)Vu?#K*7_M|WpPx%U*W;P!X{n`fnQn`*)t(KkB4;!B3xbxZpoJ z`iUA52+kcG4x$;|4n8h~lcS@vs{^$~trgx{K#=UK$>6_)lE1D)koTX99gCTqoHWA| z)p_yQNZ=i(PPT**T)sT|M`}ZqF*;I3gi2Y=$lNj-|HMwQ268H*3OzdXgR9HSStIQK z+>bfGzg}`|i%q^x!|2s;m7%`!AKm(Y_xq zIlkf`-qMD}Fxl8HEv+GeVSX^(3|y@a7WnG@dk@i!GRZe0kDE<$a|C z{msM4+}S=!f1n)xeEy7%&b0*f2pzx`K z`zlfJ|703r$=qe{AGYE|r+V{U#pTXIqO~cqf%{u8Ycu)kG(@w#`QB%WTZDLi{G6GNjt(7X9S(@k z(_wRy)Of!|J+T@f{7=NTvi$og8s<70`^i_Tr72fc)#+f;4|b2$Anzup#Tes{{Nus% zgd9{{+*P70_O$G98gY3bqoBgI+~-qLRkL{2*g{qI8`s`Zb= z2zEz#0i3I6jeGgTc9U$M%W{z)vU zqCoMg@3b~@iylg+M-8xWe5yD*)$x7G&~kmWT-^OI^gm7;xRn(DeDs7KNBJ9ialIz2 zT<(pb@#^8(BevSeHPf75fZX%iksZVmkf@io_w5{o3mKWftS{edcY7ZT1w|UtlG6qc zN(T{w4|+Z~C(Hk-3F}1vz0VSwnw8pi_G5Z(?twk|l{Ec~uC6B+_L4~FyR*TQ_|i0C zU7ehbKDR^WEz}aOyEaaDYkv3W-^?2A1ig%RJMw|dH5Higq9qi_R;NnPO1_g}$Q?*R zs!h0B@c@X|?o>G@F!>o4s7)XYjwBDKY`hWi%R9sG##)izeLc5g`Er9+;0Ls|POBKX z*Y^Q*KeE+=(ZV+kib?E8%Sc7qwelA=#))=Pa6OrFL6a56Of0FwkBZf-*%WAFzqvPm zAm()elYBEhJFL$SSK<3dn@90Dlw-KP54VI!9<{D0ssC!~{QV4Nl7;?5P*Y#dDixk8 zE-CSi7aYF*qpW<`uAaU;dK9=>H4TCILtaaWs?gE5$AbO5gb%JviA$^H?U+^H$E+Mz z7ln3#l`A#5=1T==b~sVl^+NNvVPX`)F)PCHVy&no%pLh)x(~_QQQADk-OYY?Sc(ng z@)+Tv92zXlPp2&AfJme+?+-gK&_70emXmwU@t&NN(sC47*87B?n6E^%#k%@90BeBT z)BcTL6G)W(_>u-I)2g0JGXFUUb;JK0Io)#i=F7uKA}TMnPrtKS_22O4n6Am~7TLOO zE5ys>7$Fo!TKtLU6bQTs9UuBWHa0eSyFjX<#^^c%9v)mA0t_-TBmhmsB;k~%RJ)G& z9UdNY^UrcZK7A$tpVD)PznI}8>1XByk5|bM@s;E7U^7At3cfCbIUr2Ga{FbZt{Yuj z8!;rf9So6br>0~Y-op{DnL_+mI}kfMze*(93Bo~IX$)gzvWKj=Ao3_rM&jZla&x{v zq2qnh=fKzK`VILWK9>dn1R9;WiAfzkTXV?VjL83SRi6o=f|L9w*ucg@w`>cRy;;+I|Ul6nU6OGPo zG-dRih*Njvbay3ty>wDiQHmLJq6fnJJif0xyIaZ(EOpslv)>=6fyQSSb)Wm~FHxJB zhK$^J3Ebx&iI|a}fO}wQY8q+z(q7uY0Tlvgm4KeYQ`A{oqcP_MXaHR^Sv>l7ra|@l zUSK~es?HL`1K;~|xlf6}oa{-B!+sG9wWX;o=0giLn5@C)k#yB5c@ z@?Hf9N*dZT27MjRQPI(Yz-JaI)&A(~{|4br`gt%ht`a`ZYL(Dh!hw6Kgw~6YHH|^U%frSYK1^GYK&Q3j~^S<`?dz=_Vz<1 zVus;gs^`%vwUDqCSb+S80iBCYQ zlm+pG&_6jW?z*il3!_S*Inzu+r5l_F2o9TB8-asSGzK%QO-(Lnmk&2X%= zx}+^-60vq#muR`L@OQe!+%x8SLQTm3@1Z~XH_sNb&g<5l4hMw;8B;w&dmVrUM$V?b zSlw7(Rx*T>1+Sr@;UCa2OJBLNz7Ll_dRpAv*a%&6E>J;^=6BzVnwzQKq@?2a#(5S+ z8Ct&WA@E0}u#^mN?|3 zdMD1)YpyyvbAUpc>!st8-?DfWMJePTc#GyQf7N>b zieh!G|D4eN{c!h07N6zL&P-~V$K^c9CE3};A?JIoQzPHMDXHqZdf~~5i$D52u{Yv} zbL{lPalg-UjD4}fsYLN`$(Cyt$G_S{QnIi8WFmC}TfSALZz{ZAPVjUAI5zsSYZggFw zjY~Pzy2>`KRs1-==i}wd#sMyoSDQuCJ=;ts1MN#eOz;$eJ{|0cDywlZ6S2#FWiT+s;c2P!oI6RTyrI5p$vhN z5*c+`FanE-xe4`CKHQ#@Nb{1B7LATJQa|i?#`2 z|7ZU##LE9J8Y>&5)gSJX8J4-uCB!96vh}eLJw2}s&iCADI0gQIOsiWgWokQls1@I{ zdU%2@ad3~|c#=BfxIm}SHZY*aqhG892qNu=Z$cyjVul>%R#J=Pp{8POZD+nwLwBiw z0+qU3)O#Y*+gl|d{-1_nU5p2orkf#+dU&a&?#b97Yb6WAx79dpFX<^&$IZQcz zf14?H@inHmIO4OQ<0%e!G?(^PQ0%GnYwGG#b{DLdbd%tJ6iiLVH^`|>Bw=n1_Jo5o;!?@V4X zxYwuwh?#joKQhTd94powA0azGPYmg)0{n}R??lH=&NzPiFC@z8FBwqrpwv0O>5^`n z*+fl!9m#8c#>b~$$`lz~dOOo2+oPCMz71Qr40>(&U~dPXH$_PiyEm}4wH=4UiO|0E zW_lJzJKnL&T_HP%vss764<}N{0~UTni!i^y8_hA|SZ~15sxY4I*O~6X=X0I>9J2Tc z4^gq^Y+JL;gy>*7TS+SB-DoP;&juuZy-QLT`rqEDlHlJi(sOs8j~`B^`FbzCGl1HV ztH(1yOw4inQV}BZ+7TDz@JE4C5^Y^lABy^1Ow7%`)oCFxFX0D6$E6srR{aQ(a(`$aKaA|u&4|1FImp= z?fmtM#t`>cZ~t%*9bC(xa0~Tep8aSIVBo)&^6(&mQ1flk3Oy{;J6xOw#>WF5$+2U8 z*|+&u_%x_-=bp)!C>Yk}fT*j;<6ZlnPyTsWC5;m9`1rWEWI2mX{zEgWVv*+ebw9-k zsX62JwbSn({#zAWvHv4Wp^J?TRua&tS@KK9BRn_HwLh0hTLD`aM3xe}y6&|#(@;T2 zj1cTyNlz>Eg2F;}hhJ;}chHUBBjGUO3=nI>$X(l5TLzUq$U-8*;NFc|^_L_iwat00aj>h76Bs-ANe<}TKb{FEL3t`O`GgBJyLDY9<;g~WF0>j@Jt zp#9B$GS2U~-JwX%SB5Q_$U>sx_7k|Bg@h1+@sR&7th5(Zgrt-ob!UuRq5)qDNZ}j& zOofHIV%vABs`trDeKHsPxfAFAJD$FMQzr;q5}6ws_moiNiT+QJu|nMqVA>{Y^r%4WE4X84Re<=<=i~$^ zinI7^YOK^GQSVBjAkS_V~-^;uIGtNkHB)5G*UAgd`saP*QR|up;=IX6jXB27M>6 z|J2m;>(}P<5AWq!65IRRE2gFiC7A(hQ&CF^gGil+mycin!Z3p?*^1yX!MBDYmFhRe zd%(=aN>N885L`Hrdj{jPmig9lRU2(lJr=-P{fXmwb#+Bye|5psb#i}YzvBQIQptDe zwd5j&y8hIP{5EWbgb8`GC%_-HSC)KF7*t2|$2uTc%Ysu24pP8Hx^(PTo{>J^v$9&Dj6>aB}Df4_%ah%%ex>rxw&+lq8Y>F&TN3-Jy^yO*YrT* z56=6-=lLGkh3_v=qKO1jheCehAmSzjRGpWjqS7L+t#w~NJgV&7dWe^?nh%+{OQj?H zFl{MAbT(fYel}m7;+5`~b8sa|Y5I5Ev|=gx+Z|_A7A*h~Z~`6dyN1@r;$%$^4^IO^ zzN;7R^OzG&c#7L8r%lC69Te>_-r2o8^4lA{uY9m}Z%&6%)YNoWgx1OpxI&0Od=U_)!8K$ovET zR@-n-?OggGsGq=mFEhIa6?8ADX}XKc>sVl#Dw9*ZF`)_v3%rpU~}wg<;n&2!Xkv#iaYnAAycsuqc+#)n`WnV1VmJW{w7Sr6>{dF1Ae>l z3pFqVapksC!tMV+PhBDa%~TqbYyoJ&fQm(r7h92&va+Lp4nbThLh8Lq$WBwSRRXEs z{jDIR1w`WRb>Rh;XuzY?Am{gnOjQ~xio*wpw-33uP$MFTRMj^d2DXD3#)W0@8=xJ!`-izri<qvG! zl^Ov|B*oWg*ogn;8eJaPr725e?YEcMveQFjUBR&{^SshW#lUKfn5q_~Hx8=b$*HR& z1!UGPa7B;gKFYk67jasa#31LufCfj3c#%|caPi?RwvDIVOA4)H3PgV^_x-P22vz7o z`;JOhTH#1f26bdwSbNo%&q;Uy>nKw;r%k>2{$px`Hzn%-6%%&g0kaB$Aw!_?-=fsk z)Xp;AumfizP@Y9v(U1mUGvNWZ0uT`^C%k*@3Ot@Ufk%5!9VU{2eI}vbL-5Z}CUcws zNpRVnB?K7-JfNV9YKPVNDryj%a=*d10F?j^4cXia!k`M(xEiDyvZ{m;S*QgH5N>Y~ z46Lm~;^K_5bDq#W-J*qj1DyiX{XGRN4-lYhe>IR8eeqMPSc(>x`Ug{9#=?&028I{Z zPC~QRW76-mrj?UF2$NP!9B1_aC11km=fE#f1c!ys%tzFNcQI^V;4nJ@dXYY z$^ZbRda5a3g|Xam&Ptc}&saaji20nA3dN6yZyBKE?%X!@6U{FY8O{;2(EvAhur}=( zF)r65MS>GJI4#VV_9mKhd`iv(ICJj@^kiDXL38i)6)2%~VNe9}=xac%nV6s+7YnZ* z3A5f1fg=jc%JL$6SzmP2G0#ll{+n$_838mnI?!RGQAjq;!YP2KZ2uKgl&u8)IVybJ z#Woa-3;C<;;2|AfdB;UWlq3v&g~Gx?zp9HV{p=aY44jr0`?5KBS01(R7?~EAwva%e zq(8`F2xI6Q6nAt43dsI|R6tIVCeNa+s`>Fx+;Ux7aDB-4t-S)3%%W_>zk<{DzT=xH zbq~$w=lcz}8Es{X#(k6sU*w~XlygP1#ufoaQ`zlBvF`_nD8@R9dQoA=$>>-BYAMU7 zu#!_%4NBdW2PIfdO%1mtRmz_Yz=m;xQw&t0A3-85EG&#oOo{yQ=Z_S{gHL^RRznd1 zWN~pZRQsS#p<4Lt>I!9fy|#KKI=v}`!7A2;9S%@*92^`t{e;vQ6*Xd_f5hQUGT1Eb zF2hSd+mN~lmr{;wNF@hYS$j_>A;=XmNqGgMIHrX%FVADPOc1{$;{%p1IzRZ-13Cf>EO7eqa%pYP`seJI$#~uxdmi*h z7~wQilMF*SUJ4i_?C47QSBd1Rs)TNK;jLdL@$*WWbX2F*bxtOi&o;r!w!$`FT3SY5>Os{HL6J#>D}}EQI3a$+#zGN z4#gUm^sw{0(_@jodE*EC#Hp=SmY6UJ}AV+rDIkOEc>Txca%KCHoQNgs-vku&j={j*diTQFTW212rz5y z_ix#9Yi*3>!7fqdQqojE5gcS}Dzt|dgGYl;02%y_*V}-V@pmul!=Hp6c9&I)_#0!{ z*RR>#>=yzqE6f3_xX}MsR2rL8-}^~z)c1J9l}|+`pNpj`f<{`KpWh9YB)_0&G>vID z^1Rcy#UOCOrdJAj!BOrO-Dj;5%elHgLeCzOwqo*{qtUWdcQE)+@2~C$w8( zap8fRME>R2x%9$aXkV%h@2km`{VzPmK#>@16p8_c{gvFj#BS|LovFS)FO{07e$22R z7E>*`vt4oo(21b^wLs-mivc1rugmQdHJi!6hJZFR;jXXyc0i$5*i?fWlg`oRNf|ipSf+Q&!%1;&TH+~-?{_L%;sNZQfNO(&dD@ghZ3ZFhD>|9^|%GXF0 zBC>b8OZ@sCaUhE8fY5fQQ2`4Lqotlz*zd|-@(Stw!_L7AJ$`GWD%g^7Opi$tw=YI+ z1m#C+uv-fJ10F8P{EBq4wB5Fx2Z>d12bdUIy!3r07KLUsw2lAdEg?^cI$Azg;sEPx zu|QtN4ODv_^k-S1l6oaANzM3zKrw>`6&p>=Qj-K|L<8O60ALdXQxCt8H2Syjuu6dy z9@3}nCi6|ckC`^cVr5N}*l)N$C61|({~W&&DSrzB5Lo+->^Wm%N`TFzKu%(F?RpXa zK^{cn&v!?Uj-4tZ*Ao&GjIPp~4W|2QG3|DGjHSy=S6;=R{O;{NTu3J5B_j$asjwfz zRIoM9YDq$iF9)?5#GgW&=Pwk>g2-#RhJ}1Ag;$0AR*IXQxqnvg#1F z1w5B%=N>y5O?(e*RSoI^Rt!Mugh22<5&$$aa(e{VxGM`lLJ%wXq%&1jwdDn2zp`GReJ)@s`Y@3#v})Qr9Qr1HkLk*h0yD1G#MxW){+@}N(r zySE{aW2HCPX0E^`$Lk{E%~TU1=v2jF6VERKlnnWTo5rVM%Z%qzH@KcNx~Qo_g^Ime z^|e_iw?r{@RwQ^2mutcE2l$TW5%6*Idh1M|h()vUDs02YMPZ#^8VrXMbAL;3J*N2pNBpN0dp}JO-`copdF(=1%v3d@ zrz$OtJn~xLDIJ2g+d=0gZu{YR_@jsb% zjaBS#z%ou}^}KZOmqS6&&&)X>!gNy=Q@ZV6DaC`q!DtllzTWaZKRvcP^=$HmGuzN@ zaG+#=81t4<#8(OzkqC(@tE?0kyXEUh=SKh`01#oJwkbRYVA)a1)+T)F+aqZp2nDcj z8i4{jFh&QBW;96GWsY-<;WJ5K>8d#89^oCn=k9bC5ojlA5?;r8v%`IVOD-wt!57D( z*8&K*!`h{I#_vr~TsCJ3HT?TQDpY9|@_KBF@gtdSs*7u^g9Yf1A4->3C_o^GPN1P7HqHfiW3Vd&pvynCn2orL zf=c}$sj0fj#e-*H?aOB22QDB2ZcgTqfh97)Fo>W-x!5XDvYV=*Y&FD1Zg<-`NKC1h z&I+k;b#xuU+|t!|b>&iHeo!Wga&dE%ROuc-#ln;Z{&%2P1H%ylu#!@AyMf);XicfJ zqZ;ta^)^YA_m40eDh#Py-`5DZyw^(V()Xca*hjC)LNtrd*9Nefu?no zkr0u(eP-c-=v?Mz=#xWsQ=O~bYK)0m$x))fuI-yS)CRj@D*MTZiZ8cbz~0nD@Td{FSYA z+h}&DYHKfJ0BQ2ra7Dbn;|N7AKXY+ zv}jB-6{Y7rAwj|?&bqtj#!|~rb^RYT5g>0hd0b#N9Pfu{lp8vSlPd!)W$&8Hr&&08 ze8Bm&Q(xaZ0@NtjDQ>$QN!6wol%Ld9THYRz?kp6d=%l5+(@?P~p998WWN%(p4lcEd zUT*K@pdQ*@`s7D{M`?QRj8?Tj6#{B0PBJ4T|5zyUqTg^SfPZX{QbT>upHC*Cu5t4O zRdNNNAB2}Kc+8oUVddqk-OMww8eBJo7#bT#`Ze8lFwAN$fFKg0Mu5r4JpK$7eqERs z8+%@;T5-nnq{ESz>peuzdq>b_#_L<>&3anmW6~hHQZ?lg1W_DdS4WKj?69x}E;z6# zdG6|#_qW6Jg*~V;KEOpz18op5gFS@>a5#n8>Z)L9i6mQ#3}_yU?|cK``nYAxvZRAncbRGXy#)Go6Rb*d zFY~r;GJ7HCcNjXQ!JoPobS z>&WVPM&bW0<{ivw)OkM{g^7tN?}fKaUNZ$46(o7=P;8D@@BjwKaIH0VUy=`#SR1WN zx6$B0vG)f$tEUpUgAftR*J$8H`2$^=sbsC&`CT_qD}5*tko6S-__lbec}UE589et; z7p9MVaesv*HG(MGLS)})#!arNrDx~lDIvo)?_o*vB#!K&qLSbK3$lWHHItS)eFE$- z{3MPVy;xqsZl9Rh39DAcL4>6e&|4O(!7A(DzatkaG<()Am;0Y?;!sX+ZfITpf5hF zj)wX^q2|)WXF@u+ah)Kavd~5f)TacVwgi(S5 zGtH@1gd;-0(AGLyFI3Rb)1vC~rQ=`AuuTZa?ZRxoEJ(oE^>lL%yBmwjAE04cPIy4j zBqOEt-J}udn>Ff}sQ^Yrq0MdBclnyvwPBqvO;?Bg{be5gMXXw3CzY^o_>XC}h1IQ> z!x_qnQpMW2?hRnH4am)QrzhJePndr9Qio;sEcF)`6|p;RV`MF?fx$~eiZ{O6{RxVH zvTOz*bK!mbId!eG&_dImk*BWs3ut8s-ki4Y?`cCIe41ZEcZc#j2h5ArjNe^3-mY5G zhkT*}`y79i;uQ7N!T{3?OprhL82}3@=BKZDybFrcD7xOHTK#Ow%EAYSJM>|^h^?N& z+^SlfKHm6u#25PDXGCmAxg?jzSr;^6S~?9l(TXedN0L!_goj5L`kxbG@3W+`YO6Pu zXmoVvARi7Ar@0g*0FVZz8Ia52?gc**{v03F=9_ynOFCGiZMX0g4-NqFFO$E1F&6ON zN~@OV(W+Wh!u7qhCIQ%XzzGpP@H_Yb^Mu^j@b~@t8R{ekRQP#sL%7`eJAY9&$IjsX8q;G$DEG4{(x~R~B&yGz z%M2^qu}p7NqIHgUM;siTIFcR{`N4r!GrTb*nwp6Ro1#seJzHO2z@=LUy13uyY_{B7 z%#TCl*P1uCX`aqOx=s!mx1EX-Rxg%x@EyfM2^n+DX>T%P`%MtS?TmYkw41rsM>d$0NNhNzrIDOuCC5A$?Do3VqHl)K>uWMGk-)z z{&f(vQ4kopmgbcnB=fbex|TT^WB6*9qrcAIa&Rh>KCsh09QC;%M*L|@i2lO!+Og03 z&=Go8@4D;X>VtuB0p?2Y*XAA!50>casNGF0Q1?^#dl!>l@xNOBI#s<`LFKph1_Kn= zBZt`fAViJMANXiE9wWB^p%;GISiSpi-?fTCDv!g*cM+C$+qL2FM}a7&)osGZZRJ1D zD^UYya6;g%+%PlIW5cF6F&T}(DSs2CJNla1Ew{h(^=Di62v4Dd-6^-_n5^g5RDfsD zvFrI&Z@#R;oo!@e35YihSrh;e?ITd?dGB!p1O1N9cyzxg`(18d@5r5Ae`m?_;`+hy z@||Wr;ye5#OmueLFCAcD1UL(+Laq(DtSt245?ocO;96LzQMu`bb_@B_ne9S;nDIAw zdmEUV)+L7l5*?U%0pep;b`7yrCAw2Bq5fv5Ry#jk{w|VY7MilEDkjs5Um<`$pUpAv zRxO>pZ3P$=0bsY3dKWMI578vk9`qt~xNN*Wx>ji%0D6?{q*MHMsaC^4`|Z8oaJZD} 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 literal 0 HcmV?d00001 diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index bf496066..15bc75f3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -85,6 +85,9 @@ importers: fumadocs-ui: specifier: 16.8.10 version: 16.8.10(@tailwindcss/oxide@4.3.0)(@types/mdx@2.0.13)(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(fumadocs-core@16.8.10(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.4)(@types/mdast@4.0.4)(@types/react@19.2.15)(lucide-react@1.16.0(react@19.2.6))(next@16.2.6(@opentelemetry/api@1.9.1)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(zod@4.4.3))(next@16.2.6(@opentelemetry/api@1.9.1)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(tailwindcss@4.3.0) + html-to-image: + specifier: 1.11.11 + version: 1.11.11 next: specifier: ^16 version: 16.2.6(@opentelemetry/api@1.9.1)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) @@ -3722,6 +3725,9 @@ packages: html-escaper@2.0.2: resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==} + html-to-image@1.11.11: + resolution: {integrity: sha512-9gux8QhvjRO/erSnDPv28noDZcPZmYE7e1vFsBLKLlRlKDSqNJYebj6Qz1TGd5lsRV+X+xYyjCKjuZdABinWjA==} + html-void-elements@3.0.0: resolution: {integrity: sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==} @@ -9623,6 +9629,8 @@ snapshots: html-escaper@2.0.2: {} + html-to-image@1.11.11: {} + html-void-elements@3.0.0: {} http-errors@2.0.1: From 1959f493d6f064f3b2d82e33b8a41ed36fe7075b Mon Sep 17 00:00:00 2001 From: Andrey Avtomonov Date: Mon, 1 Jun 2026 13:18:37 +0200 Subject: [PATCH 43/74] fix(brand): README lockup wordmark in Outfit to match docs-site (#246) --- assets/ktx-lockup.svg | 15 +++++---------- 1 file changed, 5 insertions(+), 10 deletions(-) diff --git a/assets/ktx-lockup.svg b/assets/ktx-lockup.svg index f1bcd2dd..2e45f8a6 100644 --- a/assets/ktx-lockup.svg +++ b/assets/ktx-lockup.svg @@ -19,14 +19,9 @@ - - ktx + + + + From 5faa16b32c9eb0c7a30096e04c6f3b2525fdae5e Mon Sep 17 00:00:00 2001 From: Andrey Avtomonov Date: Mon, 1 Jun 2026 14:08:58 +0200 Subject: [PATCH 44/74] docs(ktx skill): harden setup guidance from agent-driven demo run (#247) Fold field-tested fixes into the ktx skill, verified against current CLI source: - prefer file: secret refs over env: (env: re-resolves per-process and resolves empty in later ingest/mcp shells) - pass --skip-agents on data-only setup runs; explain the trailing agent step's misleading exit 1 on otherwise-successful runs - dbt ignores --source-warehouse-connection-id (maps by table name); required only for Metabase/Looker/LookML - never go silent during slow setup/ingest: poll .ktx mtimes and post progress so a long run does not look stuck - judge readiness from verdict, connections[].status, localStats.semanticLayer and wikiPages; perConnection under-reports - add troubleshooting entries for the 'Run in a TTY' exit 1 and secrets that resolve empty only during ingest/mcp --- skills/ktx/SKILL.md | 104 ++++++++++++++++++++++++++-------- skills/ktx/troubleshooting.md | 41 ++++++++++++++ 2 files changed, 120 insertions(+), 25 deletions(-) diff --git a/skills/ktx/SKILL.md b/skills/ktx/SKILL.md index 58893d7f..ef316b63 100644 --- a/skills/ktx/SKILL.md +++ b/skills/ktx/SKILL.md @@ -20,11 +20,25 @@ a local **ktx** setup. `https://docs.kaelio.com/ktx/` or this skill — not against `--help` output. - Ask only for values you cannot infer: project directory, connection targets, credentials, account identifiers, and source selections. -- Never ask the user to paste secrets when an `env:VAR_NAME` or `file:/path` - reference would work. Pasting a literal URL is also safe — `ktx setup` - auto-externalizes URLs into `.ktx/secrets/-url` (see workflow step 2). +- Prefer `file:/abs/path` secret refs over `env:VAR_NAME`. `env:` refs are + re-resolved against the process environment on **every** `ktx` run, so a var + exported only in the setup shell is gone when `ktx ingest` or `ktx mcp start` + runs later — the secret silently resolves to empty and the connection fails. + `file:` refs read from disk and survive across shells. The same caveat + applies to `--*-api-key-env` flags: the named var must be present in every + shell that runs `ktx`, including the `ktx mcp` daemon's environment. +- A literal database URL is safe to pass — `ktx setup` auto-externalizes it + into `.ktx/secrets/-url` and rewrites `ktx.yaml` to a `file:` ref (see + workflow step 2). Source credential refs are **not** auto-externalized: write + the secret to a file under `.ktx/secrets/` (`chmod 600`) and pass a `file:` + ref. Never ask the user to paste a secret when a `file:` or `env:` ref works. - Do not commit `.ktx/secrets/*`. - Print each command you run and its result. +- Setup and ingest can run for many minutes (LLM-heavy source ingests take the + longest), and from the outside a slow step looks identical to a stuck one. + Don't go silent: say what's about to run and that it may take a while, then + post brief progress/liveness updates while it runs (see step 4) so the user + never has to wonder whether it stalled — otherwise they may kill it mid-run. - If a command fails, identify the cause and change something before retrying. ## Gather inputs once @@ -68,9 +82,10 @@ Do not discover these inputs across multiple setup runs. --llm-backend claude-code \ --embedding-backend sentence-transformers \ --database --database-connection-id \ - --database-url '' \ + --database-url '' \ --database-schema \ - --skip-sources + --skip-sources \ + --skip-agents ``` - Configure one new database connection per setup invocation. For multiple @@ -78,6 +93,13 @@ Do not discover these inputs across multiple setup runs. - Pasting a literal `--database-url` is safe: the CLI relocates the URL into `.ktx/secrets/-url` and rewrites `ktx.yaml` to a `file:` ref automatically. + - `ktx setup` runs agent integration as its **last** step. In `--no-input` + mode with neither `--target` nor `--skip-agents`, that step has no input, + prints `Run in a TTY, or pass --target .`, and the command exits + non-zero **even though every database/LLM/embedding step succeeded**. Pass + `--skip-agents` to defer agents to step 5 (as above), or `--target ` + to install them inline and exit 0. Judge data-layer success from + `ktx status`, not from this exit code. 3. **Resumability and `--skip-*`.** Re-running `ktx setup` against an existing project resumes its config. Use `--skip-llm`, `--skip-databases`, @@ -99,6 +121,23 @@ Do not discover these inputs across multiple setup runs. together with `--no-input` (*Choose only one runtime install mode*); `ktx setup` accepts both. Use `--no-input` only for ingest. + Ingest one connection at a time. It can run for many minutes with **no + stdout** until it exits (LLM-heavy sources like Metabase are the slowest), so + don't assume it hung, and don't pipe it through `tail`/`head` — that buffers + all output to the end, so run it raw. Tell the user up front that the step is + slow, then keep them posted instead of blocking silently: run the ingest in + the background and poll for liveness every minute or so, reporting a one-line + update each time (which connection, roughly how long it's been running, and + that `.ktx` files are still changing) so a long run never looks stuck: + + ```bash + find /.ktx/worktrees /.ktx/ingest-transcripts -type f -mmin -3 + ``` + + On success, the `Ingest finished` summary table shows `done` in the + `Source ingest` and `Memory update` columns with no `Failed sources:` + section. + 5. **Install agent integration:** ```bash @@ -117,29 +156,33 @@ Do not discover these inputs across multiple setup runs. Context sources (dbt, Metabase, Looker, LookML, MetricFlow, Notion) are added **one at a time** — `--source` is not repeatable, so run `ktx setup` once per source. Source setup is resumable against an existing project: pass -`--skip-databases --skip-llm --skip-embeddings` so only the source is -configured. Map warehouse-backed sources (dbt, Metabase, Looker) to an existing -database connection with `--source-warehouse-connection-id `. -Prefer `env:VAR` / `file:/abs/path` refs for keys and tokens over literals. +`--skip-databases --skip-llm --skip-embeddings --skip-agents` so only the source +is configured (the trailing agent step otherwise fails the run — see install +step 2). Map Metabase, Looker, and LookML to an existing database connection +with `--source-warehouse-connection-id ` (required for those). +**dbt ignores `--source-warehouse-connection-id`** — it maps to the warehouse by +table name — so omit it for dbt. Use `file:/abs/path` refs for keys and tokens +(see the secrets rule above); `env:` refs must be exported in every later `ktx` +shell. ```bash -# dbt — pick exactly one of --source-path (local) or --source-git-url (remote) -ktx setup --no-input --yes --skip-databases --skip-llm --skip-embeddings \ +# dbt — pick exactly one of --source-path (local) or --source-git-url (remote). +# No --source-warehouse-connection-id: dbt maps to the warehouse by table name. +ktx setup --no-input --yes --skip-databases --skip-llm --skip-embeddings --skip-agents \ --source dbt --source-connection-id \ - --source-git-url --source-branch \ - --source-warehouse-connection-id + --source-git-url --source-branch # Metabase -ktx setup --no-input --yes --skip-databases --skip-llm --skip-embeddings \ +ktx setup --no-input --yes --skip-databases --skip-llm --skip-embeddings --skip-agents \ --source metabase --source-connection-id \ - --source-url --source-api-key-ref env:METABASE_API_KEY \ + --source-url --source-api-key-ref file:/abs/path/metabase-api-key \ --source-warehouse-connection-id \ --metabase-database-id # Notion -ktx setup --no-input --yes --skip-databases --skip-llm --skip-embeddings \ +ktx setup --no-input --yes --skip-databases --skip-llm --skip-embeddings --skip-agents \ --source notion --source-connection-id \ - --source-auth-token-ref env:NOTION_TOKEN \ + --source-auth-token-ref file:/abs/path/notion-token \ --notion-crawl-mode selected_roots --notion-root-page-id ``` @@ -171,25 +214,36 @@ After setup, run: ```bash ktx connection test ktx status --json --no-input +ktx sl --output plain # lists compiled semantic sources; `ktx sl` has no --no-input ``` **Judge readiness from `ktx status --json` fields, not the exit code.** -`ktx status` exits 1 whenever the LLM is `none`, even when embeddings and -every database connection are healthy. Treat success as: +`ktx status` exits 1 whenever the LLM is `none` (`verdict: "blocked"`), even +when embeddings and every database connection are healthy. Treat success as: - `verdict: "ready"` at the top of the JSON, and -- every `connections[].status === "ok"`, and -- every `ktx connection test ` exited 0. +- every `connections[].status === "ok"` (other levels: `warn`, `fail`, + `skipped`), and +- every `ktx connection test ` exited 0, and +- for each ingested source, `localStats.semanticLayer[].sourceCount > 0` and + `localStats.wikiPages[].count > 0` — these confirm the source actually + produced context. Do **not** rely on `localStats.ingest.perConnection` to + confirm source ingests: it reflects only completed warehouse ingest reports + and under-reports (often lists just the warehouse connection). -A non-zero exit with only the LLM unconfigured is still a usable context -layer — report it as "ready, LLM optional" rather than retrying setup. +If the LLM is intentionally left unconfigured, `verdict` is `blocked` and the +exit is non-zero by design — that is still a usable context layer, so report it +as "ready, LLM optional" and judge the data layer by the connection and +`localStats` fields above rather than retrying setup. ## Troubleshooting For known failure signatures (`invalid ELF header`, `Native CLI binary for not found`, `Missing Anthropic API key`, -`claude-code` probe failure, `KTX cannot work without a database` on resume), -see [troubleshooting.md](troubleshooting.md). +`claude-code` probe failure, `KTX cannot work without a database` on resume, +`Run in a TTY, or pass --target .` with a misleading exit 1, and a +secret that resolves empty only during `ktx ingest`/`ktx mcp`), see +[troubleshooting.md](troubleshooting.md). ## Final report diff --git a/skills/ktx/troubleshooting.md b/skills/ktx/troubleshooting.md index 812b45fc..20f72d23 100644 --- a/skills/ktx/troubleshooting.md +++ b/skills/ktx/troubleshooting.md @@ -77,3 +77,44 @@ ktx setup --no-input \ --database --database-connection-id \ --llm-backend claude-code ``` + +## `Run in a TTY, or pass --target .` and `ktx setup` exits 1 + +`ktx setup` runs agent integration as its last step. In `--no-input` mode with +neither `--target` nor `--skip-agents`, that step has no input and the whole +command exits non-zero — even when every database, LLM, and embedding step +already succeeded. The exit code is misleading here. + +Fix — pass one of these to the data-only setup runs: + +```bash +# Defer agents; install them later with `ktx setup --agents --target `: +ktx setup --no-input --yes ...other flags... --skip-agents + +# Or install agents inline and exit 0: +ktx setup --no-input --yes ...other flags... --target claude-code +``` + +Either way, confirm the data work landed with `ktx status --json` rather than +trusting the exit code. + +## A secret resolves empty only during `ktx ingest` or `ktx mcp` + +Setup succeeded, but a later `ktx ingest`/`ktx mcp start` fails to connect or +authenticate. The connection used an `env:VAR_NAME` ref (or a `--*-api-key-env` +flag) and the variable was exported only in the setup shell. `env:` refs are +re-resolved against the process environment on every `ktx` run, so they resolve +to empty wherever the var is absent — including the `ktx mcp` daemon. + +Fix — write the secret to a file and use a `file:` ref, which reads from disk +and survives across shells: + +```bash +mkdir -p "$PROJECT/.ktx/secrets" +printf '%s\n' '' > "$PROJECT/.ktx/secrets/-" +chmod 600 "$PROJECT/.ktx/secrets/"* +# then pass: --source-api-key-ref file:$PROJECT/.ktx/secrets/- +``` + +Alternatively, ensure the var is exported in every shell that runs `ktx`, +including the environment of the `ktx mcp` daemon. From 22ddf5524cbcf5863aa7b05dd7ac7a9cf3be659c Mon Sep 17 00:00:00 2001 From: Andrey Avtomonov Date: Mon, 1 Jun 2026 15:42:42 +0200 Subject: [PATCH 45/74] docs(readme): add launch video to README hero (#248) Add a clickable launch-video poster (linking to YouTube) directly after the intro note and before the architecture diagrams. GitHub Markdown can not embed a YouTube player, so the poster image links out instead. --- README.md | 6 ++++++ assets/launch-video-thumb.png | Bin 0 -> 138135 bytes 2 files changed, 6 insertions(+) create mode 100644 assets/launch-video-thumb.png diff --git a/README.md b/README.md index e235bab1..d44905d5 100644 --- a/README.md +++ b/README.md @@ -33,6 +33,12 @@ business knowledge it builds and maintains for you. > Run **ktx** with your own LLM API keys or a **Claude Pro/Max** subscription. > No extra usage billing from **ktx**. +

+

Ingestion: ktx ingests databases, BI tools, modeling code, and docs through its context engine (source connectors, context builder, reconciliation, validation) into wiki Markdown and semantic-layer YAML

diff --git a/assets/launch-video-thumb.png b/assets/launch-video-thumb.png new file mode 100644 index 0000000000000000000000000000000000000000..c7505732586c241341ffb2b052bc2f035575e540 GIT binary patch literal 138135 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

+ + Watch the ktx launch video (1:56) + +