mirror of
https://github.com/Kaelio/ktx.git
synced 2026-06-10 08:05:14 +02:00
Merge remote-tracking branch 'origin/main' into ktx-agent-command-history
# Conflicts: # packages/cli/src/agent-search-readiness.test.ts # packages/cli/src/agent-search-readiness.ts # packages/cli/src/agent.test.ts # packages/cli/src/project-dir.test.ts # packages/cli/src/setup-context.test.ts # packages/cli/src/standalone-smoke.test.ts # scripts/package-artifacts.mjs
This commit is contained in:
commit
a8bf0470ec
56 changed files with 411 additions and 3910 deletions
|
|
@ -130,9 +130,7 @@ Scan artifacts are written under
|
|||
```bash
|
||||
SCAN_OUTPUT="$(ktx scan warehouse --project-dir "$PROJECT_DIR")"
|
||||
printf '%s\n' "$SCAN_OUTPUT"
|
||||
SCAN_RUN_ID="$(printf '%s\n' "$SCAN_OUTPUT" | awk '/^Run: / { print $2 }')"
|
||||
ktx scan status --project-dir "$PROJECT_DIR" "$SCAN_RUN_ID"
|
||||
ktx scan report --project-dir "$PROJECT_DIR" "$SCAN_RUN_ID"
|
||||
ktx status --project-dir "$PROJECT_DIR"
|
||||
```
|
||||
|
||||
For non-SQLite drivers, prefer credential references such as `--url env:NAME`
|
||||
|
|
@ -147,12 +145,11 @@ version, and is managed by `ktx dev runtime` commands.
|
|||
KTX requires `uv` on `PATH` to create the managed runtime. Install `uv` with
|
||||
your system package manager or the official installer before running Python-
|
||||
backed KTX commands. KTX doesn't download `uv` automatically; run
|
||||
`ktx dev runtime doctor` if runtime installation fails:
|
||||
`ktx dev runtime status` if runtime installation fails:
|
||||
|
||||
```bash
|
||||
ktx dev runtime install --yes
|
||||
ktx dev runtime status
|
||||
ktx dev runtime doctor
|
||||
ktx dev runtime start
|
||||
ktx dev runtime stop
|
||||
ktx dev runtime prune --dry-run
|
||||
|
|
@ -223,7 +220,7 @@ KTX provider. Enable it with an environment flag when running an LLM-backed
|
|||
command:
|
||||
|
||||
```bash
|
||||
KTX_AI_DEVTOOLS_ENABLED=true ktx dev ingest run \
|
||||
KTX_AI_DEVTOOLS_ENABLED=true ktx ingest run \
|
||||
--connection-id warehouse \
|
||||
--adapter metabase
|
||||
```
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
---
|
||||
title: "ktx dev"
|
||||
description: "Low-level diagnostics, scans, adapter commands, and mapping tools."
|
||||
description: "Low-level project initialization and runtime management."
|
||||
---
|
||||
|
||||
Hidden commands for low-level project management, diagnostics, direct adapter control, and shell completion. Most users interact with these through higher-level commands like [`ktx ingest`](/docs/cli-reference/ktx-ingest) and [`ktx setup`](/docs/cli-reference/ktx-setup), but `ktx dev` provides direct access when you need fine-grained control.
|
||||
`ktx dev` contains development-only project initialization and managed runtime commands. Scan and ingest commands live at the root as [`ktx scan`](/docs/cli-reference/ktx-scan) and [`ktx ingest`](/docs/cli-reference/ktx-ingest).
|
||||
|
||||
## Command signature
|
||||
|
||||
|
|
@ -16,145 +16,45 @@ ktx dev <subcommand> [options]
|
|||
| Subcommand | Description |
|
||||
|-----------|-------------|
|
||||
| `init [directory]` | Initialize a Git-backed KTX project directory |
|
||||
| `runtime` | Install, inspect, and prune the KTX-managed Python runtime |
|
||||
| `scan` | Run or inspect standalone connection scans |
|
||||
| `ingest run` | Run local ingest for one configured connection and source adapter |
|
||||
| `ingest status [runId]` | Print status for a stored local ingest run |
|
||||
| `ingest watch [runId]` | Open a stored ingest visual report |
|
||||
| `ingest replay <runId>` | Replay a stored ingest run through memory-flow output |
|
||||
| `mapping` | Manage Metabase warehouse mappings (same as `ktx connection mapping`) |
|
||||
| `completion zsh` | Generate zsh completion script |
|
||||
| `runtime` | Install, start, stop, inspect, and prune the KTX-managed Python runtime |
|
||||
|
||||
## Options
|
||||
|
||||
### `dev init`
|
||||
## `dev init`
|
||||
|
||||
| Flag | Description | Default |
|
||||
|------|-------------|---------|
|
||||
| `--name <name>` | Project name written to `ktx.yaml` | — |
|
||||
| `--force` | Rewrite `ktx.yaml` and scaffold files in an existing project | `false` |
|
||||
|
||||
### `dev runtime`
|
||||
## `dev runtime`
|
||||
|
||||
`ktx dev runtime` supports `install`, `start`, `stop`, `status`, and `prune`.
|
||||
|
||||
| Flag | Description | Default |
|
||||
|------|-------------|---------|
|
||||
| `--feature <feature>` | Runtime feature level for `install` and `start` (`core` or `local-embeddings`) | `core` |
|
||||
| `--json` | Print JSON output | `false` |
|
||||
| `--feature <feature>` | Runtime feature level for `install`, `start`, and `status` (`core` or `local-embeddings`) | `core` |
|
||||
| `--json` | Print JSON output for `status` | `false` |
|
||||
| `--yes` | Confirm runtime install or prune actions where supported | `false` |
|
||||
| `--force` | Reinstall or restart where supported | `false` |
|
||||
|
||||
### `dev scan`
|
||||
|
||||
See [`ktx scan`](/docs/cli-reference/ktx-scan) for the full scan command reference.
|
||||
|
||||
### `dev ingest run`
|
||||
|
||||
| Flag | Description | Default |
|
||||
|------|-------------|---------|
|
||||
| `--connection-id <connectionId>` | KTX connection id (required) | — |
|
||||
| `--adapter <adapter>` | Ingest source adapter name (required) | — |
|
||||
| `--source-dir <path>` | Directory containing source files | — |
|
||||
| `--database-introspection-url <url>` | Daemon URL for live-database introspection | — |
|
||||
| `--debug-llm-request-file <path>` | Write sanitized LLM request structure to a JSONL file | — |
|
||||
| `--plain` | Print plain text output | `false` |
|
||||
| `--json` | Print JSON output | `false` |
|
||||
| `--viz` | Render memory-flow TUI output | `false` |
|
||||
| `--no-input` | Disable interactive terminal input for visualization | — |
|
||||
|
||||
### `dev ingest status`
|
||||
|
||||
| Flag | Description | Default |
|
||||
|------|-------------|---------|
|
||||
| `--report-file <path>` | Bundle ingest report JSON file to render | — |
|
||||
| `--plain` | Print plain text output | `false` |
|
||||
| `--json` | Print JSON output | `false` |
|
||||
| `--viz` | Render memory-flow TUI output | `false` |
|
||||
| `--no-input` | Disable interactive terminal input for visualization | — |
|
||||
|
||||
### `dev ingest watch`
|
||||
|
||||
| Flag | Description | Default |
|
||||
|------|-------------|---------|
|
||||
| `--report-file <path>` | Bundle ingest report JSON file to render | — |
|
||||
| `--plain` | Print plain text output | `false` |
|
||||
| `--json` | Print JSON output | `false` |
|
||||
| `--viz` | Render memory-flow TUI output (the default unless `--plain` or `--json` is set) | `true` |
|
||||
| `--no-input` | Disable interactive terminal input for visualization | — |
|
||||
|
||||
### `dev ingest replay`
|
||||
|
||||
| Flag | Description | Default |
|
||||
|------|-------------|---------|
|
||||
| `--report-file <path>` | Bundle ingest report JSON file to render | — |
|
||||
| `--plain` | Print plain text output | `false` |
|
||||
| `--json` | Print JSON output | `false` |
|
||||
| `--viz` | Render memory-flow TUI output | `false` |
|
||||
| `--no-input` | Disable interactive terminal input for visualization | — |
|
||||
|
||||
### `dev completion zsh`
|
||||
|
||||
| Flag | Description | Default |
|
||||
|------|-------------|---------|
|
||||
| `--install` | Install zsh completion into `~/.zfunc` and update `~/.zshrc` | `false` |
|
||||
| `--dry-run` | Preview runtime pruning without removing files | `false` |
|
||||
|
||||
## Examples
|
||||
|
||||
```bash
|
||||
# Initialize a new KTX project
|
||||
ktx dev init
|
||||
|
||||
# Initialize in a specific directory with a project name
|
||||
ktx dev init ./my-project --name "Analytics Context"
|
||||
|
||||
# Re-initialize an existing project
|
||||
ktx dev init --force
|
||||
|
||||
# Check managed Python runtime readiness
|
||||
ktx dev runtime doctor
|
||||
|
||||
# Start the managed Python daemon
|
||||
ktx dev runtime install --yes
|
||||
ktx dev runtime status
|
||||
ktx dev runtime start
|
||||
|
||||
# Run a low-level ingest with a specific adapter
|
||||
ktx dev ingest run --connection-id my-dbt --adapter dbt
|
||||
|
||||
# Run ingest from a specific source directory
|
||||
ktx dev ingest run \
|
||||
--connection-id my-dbt \
|
||||
--adapter dbt \
|
||||
--source-dir ./dbt-project
|
||||
|
||||
# View ingest status with the visual TUI
|
||||
ktx dev ingest watch run-abc123
|
||||
|
||||
# Replay a stored ingest session
|
||||
ktx dev ingest replay run-abc123
|
||||
|
||||
# View ingest status from a report file
|
||||
ktx dev ingest status --report-file /tmp/ingest-report.json
|
||||
|
||||
# Generate zsh completions
|
||||
ktx dev completion zsh
|
||||
|
||||
# Install zsh completions
|
||||
ktx dev completion zsh --install
|
||||
ktx dev runtime stop
|
||||
ktx dev runtime prune --dry-run
|
||||
ktx dev runtime prune --yes
|
||||
```
|
||||
|
||||
## Output
|
||||
|
||||
`ktx dev` commands are diagnostic and may print plain text, JSON, or visual reports depending on the selected flags.
|
||||
|
||||
| Mode | How to request it | Use case |
|
||||
|------|-------------------|----------|
|
||||
| Plain text | `--plain` or default diagnostic output | Human-readable terminal inspection |
|
||||
| JSON | `--json` | Agent parsing and automation |
|
||||
| Visual report | `--viz` | Interactive memory-flow and ingest debugging |
|
||||
|
||||
## Common errors
|
||||
|
||||
| Error | Cause | Recovery |
|
||||
|-------|-------|----------|
|
||||
| Doctor reports missing runtime pieces | Packages, Python environment, or linked CLI are not ready | Run `pnpm install`, `pnpm run setup:dev`, and `uv sync --all-groups` |
|
||||
| Ingest run cannot find adapter | `--adapter` does not match a supported source adapter | Use configured source names from `ktx.yaml` or run higher-level `ktx ingest` |
|
||||
| Replay/report file cannot be read | The report path is wrong or the run id is not stored locally | Run `ktx dev ingest status --json` to discover stored run ids and report files |
|
||||
| Visual output fails in CI | TUI rendering requires an interactive terminal | Use `--plain --no-input` or `--json --no-input` |
|
||||
| Runtime status reports missing pieces | Packages, Python environment, or linked CLI are not ready | Run `pnpm install`, `pnpm run setup:dev`, `uv sync --all-groups`, then `ktx dev runtime status` |
|
||||
| Runtime daemon does not start | The managed Python runtime is missing or stale | Run `ktx dev runtime install --yes`, then `ktx dev runtime start` |
|
||||
|
|
|
|||
|
|
@ -1,14 +1,13 @@
|
|||
---
|
||||
title: "ktx ingest"
|
||||
description: "Build and refresh context from configured sources."
|
||||
description: "Run and inspect local ingest memory-flow output."
|
||||
---
|
||||
|
||||
Ingest context from your configured sources — dbt, Looker, Metabase, MetricFlow, LookML, or Notion. The ingest process extracts metadata from your tools, then uses an LLM agent to reconcile it with existing context, writing semantic sources and knowledge pages to your project.
|
||||
`ktx ingest` runs adapter-level local ingest and renders stored ingest reports.
|
||||
|
||||
## Command signature
|
||||
|
||||
```bash
|
||||
ktx ingest [connectionId] [options]
|
||||
ktx ingest <subcommand> [options]
|
||||
```
|
||||
|
||||
|
|
@ -16,80 +15,59 @@ ktx ingest <subcommand> [options]
|
|||
|
||||
| Subcommand | Description |
|
||||
|-----------|-------------|
|
||||
| `status [runId]` | Print status for the latest or selected public ingest run |
|
||||
| `watch [runId]` | Open the latest or selected public ingest visual report |
|
||||
| `run` | Run local ingest for one configured connection and source adapter |
|
||||
| `status [runId]` | Print status for the latest or selected stored local ingest run or report file |
|
||||
| `watch [runId]` | Open the latest or selected stored ingest visual report |
|
||||
| `replay <runId>` | Replay a stored ingest run or bundle report through memory-flow output |
|
||||
|
||||
## Options
|
||||
|
||||
### `ingest` (run)
|
||||
## `ingest run`
|
||||
|
||||
| Flag | Description | Default |
|
||||
|------|-------------|---------|
|
||||
| `--all` | Ingest every eligible configured source | `false` |
|
||||
| `--connection-id <connectionId>` | KTX connection id | Required |
|
||||
| `--adapter <adapter>` | Ingest source adapter name | Required |
|
||||
| `--source-dir <path>` | Directory containing source files | — |
|
||||
| `--database-introspection-url <url>` | Daemon URL for live-database introspection | — |
|
||||
| `--debug-llm-request-file <path>` | Write sanitized LLM request structure to a JSONL file | — |
|
||||
| `--plain` | Print plain text output | `true` |
|
||||
| `--json` | Print JSON output | `false` |
|
||||
| `--no-input` | Disable interactive terminal input | — |
|
||||
| `--viz` | Render memory-flow TUI output | `false` |
|
||||
| `--yes` | Install the managed Python runtime without prompting when required | `false` |
|
||||
| `--no-input` | Disable interactive terminal input for visualization and runtime installation | — |
|
||||
|
||||
### `ingest status`
|
||||
## `ingest status`, `watch`, and `replay`
|
||||
|
||||
| Flag | Description | Default |
|
||||
|------|-------------|---------|
|
||||
| `--report-file <path>` | Bundle ingest report JSON file to render | — |
|
||||
| `--plain` | Print plain text output | `true` for `status` and `replay` |
|
||||
| `--json` | Print JSON output | `false` |
|
||||
| `--no-input` | Disable interactive terminal input | — |
|
||||
|
||||
### `ingest watch`
|
||||
|
||||
| Flag | Description | Default |
|
||||
|------|-------------|---------|
|
||||
| `--json` | Print JSON output instead of the visual report | `false` |
|
||||
| `--no-input` | Disable interactive terminal input | — |
|
||||
| `--viz` | Render memory-flow TUI output | `true` for `watch` |
|
||||
| `--no-input` | Disable interactive terminal input for visualization | — |
|
||||
|
||||
## Examples
|
||||
|
||||
```bash
|
||||
# Ingest from a specific connection
|
||||
ktx ingest my-dbt-source
|
||||
ktx ingest run --connection-id my-dbt-source --adapter dbt
|
||||
ktx ingest run --connection-id prod-metabase --adapter metabase --yes
|
||||
|
||||
# Ingest from all eligible sources
|
||||
ktx ingest --all
|
||||
|
||||
# Check the status of the latest ingest
|
||||
ktx ingest status
|
||||
|
||||
# Check the status of a specific ingest run
|
||||
ktx ingest status run-abc123
|
||||
|
||||
# Watch the latest ingest report
|
||||
ktx ingest watch
|
||||
|
||||
# Get ingest status as JSON
|
||||
ktx ingest status --json
|
||||
```
|
||||
|
||||
## Low-level ingest commands
|
||||
ktx ingest watch
|
||||
ktx ingest watch run-abc123
|
||||
|
||||
For adapter-level control, use `ktx dev ingest`. See [`ktx dev`](/docs/cli-reference/ktx-dev) for the full low-level ingest surface including `run`, `status`, `watch`, and `replay` with output mode options (`--plain`, `--json`, `--viz`).
|
||||
|
||||
## Output
|
||||
|
||||
Ingest run commands print progress and create a stored ingest report. `ktx ingest status --json` returns the run state, adapter, connection, and summary information.
|
||||
|
||||
```json
|
||||
{
|
||||
"runId": "ingest-local-abc123",
|
||||
"status": "completed",
|
||||
"connectionId": "dbt-main",
|
||||
"summary": {
|
||||
"semanticSourcesChanged": 4,
|
||||
"knowledgePagesChanged": 2
|
||||
}
|
||||
}
|
||||
ktx ingest replay run-abc123
|
||||
ktx ingest replay run-abc123 --viz
|
||||
ktx ingest replay run-abc123 --report-file /tmp/ingest-report.json
|
||||
```
|
||||
|
||||
## Common errors
|
||||
|
||||
| Error | Cause | Recovery |
|
||||
|-------|-------|----------|
|
||||
| No eligible sources | `ktx.yaml` has no configured context source for ingest | Add a source with `ktx setup` or `ktx connection add`, then rerun ingest |
|
||||
| Ingest needs credentials | The source adapter requires API or git access | Configure the referenced environment variable or secret file |
|
||||
| Latest run not found | No ingest run has been started in this project | Run `ktx ingest <connectionId>` or `ktx ingest --all` first |
|
||||
| Ingest run cannot find adapter | `--adapter` does not match a supported source adapter | Use a configured adapter such as `dbt`, `metabase`, `looker`, `lookml`, `notion`, or `live-database` |
|
||||
| Latest run not found | No ingest run has been started in this project | Run `ktx ingest run --connection-id <id> --adapter <adapter>` first |
|
||||
| Report watch fails in a non-interactive shell | Visual report needs a terminal | Use `ktx ingest status --json` for agent and CI workflows |
|
||||
|
|
|
|||
|
|
@ -1,163 +1,39 @@
|
|||
---
|
||||
title: "ktx scan"
|
||||
description: "Run or inspect database scans."
|
||||
description: "Run standalone database scans."
|
||||
---
|
||||
|
||||
Discover your database schema — tables, columns, types, constraints, and relationships. Scanning is the first step in building context: KTX needs to understand your warehouse structure before it can build semantic sources.
|
||||
|
||||
Scan commands live under `ktx dev scan`. See also the [Building Context](/docs/guides/building-context) guide for a walkthrough.
|
||||
Discover a configured database connection's schema, including tables, columns, types, constraints, and optional relationship signals.
|
||||
|
||||
## Command signature
|
||||
|
||||
```bash
|
||||
ktx dev scan <connectionId> [options]
|
||||
ktx dev scan <subcommand> [options]
|
||||
ktx scan <connectionId> [options]
|
||||
```
|
||||
|
||||
## Subcommands
|
||||
|
||||
| Subcommand | Description |
|
||||
|-----------|-------------|
|
||||
| `status <runId>` | Print status for a local scan run |
|
||||
| `report <runId>` | Print a local scan report |
|
||||
| `relationships <runId>` | Print relationship artifacts for a local scan run |
|
||||
| `relationship-apply <runId>` | Apply accepted relationship review decisions as manual manifest joins |
|
||||
| `relationship-feedback` | Export persisted relationship review decisions as calibration labels |
|
||||
| `relationship-calibration` | Summarize relationship feedback labels against current score thresholds |
|
||||
| `relationship-thresholds` | Evaluate relationship feedback labels for offline threshold advice |
|
||||
|
||||
## Options
|
||||
|
||||
### `scan` (run)
|
||||
|
||||
| Flag | Description | Default |
|
||||
|------|-------------|---------|
|
||||
| `--mode <mode>` | Scan mode: `structural`, `enriched`, or `relationships` | `structural` |
|
||||
| `--dry-run` | Run without writing scan results | `false` |
|
||||
| `--database-introspection-url <url>` | Daemon URL for live-database introspection | — |
|
||||
|
||||
### `scan report`
|
||||
|
||||
| Flag | Description | Default |
|
||||
|------|-------------|---------|
|
||||
| `--json` | Print the raw scan report JSON | `false` |
|
||||
|
||||
### `scan relationships`
|
||||
|
||||
| Flag | Description | Default |
|
||||
|------|-------------|---------|
|
||||
| `--status <status>` | Filter by status: `accepted`, `review`, `rejected`, `skipped`, or `all` | `review` |
|
||||
| `--limit <count>` | Maximum relationships to print per status | `25` |
|
||||
| `--accept <candidateId>` | Record an accepted decision for a relationship candidate | — |
|
||||
| `--reject <candidateId>` | Record a rejected decision for a relationship candidate | — |
|
||||
| `--note <text>` | Attach a note when recording a relationship review decision | — |
|
||||
| `--reviewer <name>` | Reviewer name for a relationship review decision | — |
|
||||
| `--json` | Print relationship artifacts as JSON | `false` |
|
||||
|
||||
### `scan relationship-apply`
|
||||
|
||||
| Flag | Description | Default |
|
||||
|------|-------------|---------|
|
||||
| `--all-accepted` | Apply all accepted relationship review decisions for the scan run | `false` |
|
||||
| `--candidate <candidateId>` | Apply one accepted relationship review decision; repeatable | — |
|
||||
| `--dry-run` | Preview relationships that would be written without rewriting manifest shards | `false` |
|
||||
| `--json` | Print the apply result as JSON | `false` |
|
||||
|
||||
### `scan relationship-feedback`
|
||||
|
||||
| Flag | Description | Default |
|
||||
|------|-------------|---------|
|
||||
| `--connection <connectionId>` | Only export labels for one KTX connection | — |
|
||||
| `--decision <decision>` | Filter: `accepted`, `rejected`, or `all` | `all` |
|
||||
| `--json` | Print the export as JSON | `false` |
|
||||
| `--jsonl` | Print labels as newline-delimited JSON | `false` |
|
||||
|
||||
### `scan relationship-calibration`
|
||||
|
||||
| Flag | Description | Default |
|
||||
|------|-------------|---------|
|
||||
| `--connection <connectionId>` | Only calibrate labels for one KTX connection | — |
|
||||
| `--decision <decision>` | Filter: `accepted`, `rejected`, or `all` | `all` |
|
||||
| `--accept-threshold <value>` | Score threshold treated as predicted accepted (0–1) | `0.85` |
|
||||
| `--review-threshold <value>` | Score threshold treated as predicted review (0–1) | `0.55` |
|
||||
| `--json` | Print the calibration report as JSON | `false` |
|
||||
|
||||
### `scan relationship-thresholds`
|
||||
|
||||
| Flag | Description | Default |
|
||||
|------|-------------|---------|
|
||||
| `--connection <connectionId>` | Only evaluate labels for one KTX connection | — |
|
||||
| `--min-total-labels <count>` | Minimum scored labels before advice can be ready | `20` |
|
||||
| `--min-accepted-labels <count>` | Minimum accepted labels before advice can be ready | `5` |
|
||||
| `--min-rejected-labels <count>` | Minimum rejected labels before advice can be ready | `5` |
|
||||
| `--json` | Print the threshold advice report as JSON | `false` |
|
||||
| `--yes` | Install the managed Python runtime without prompting when required | `false` |
|
||||
| `--no-input` | Disable interactive managed runtime installation | — |
|
||||
|
||||
## Examples
|
||||
|
||||
```bash
|
||||
# Run a structural scan of a connection
|
||||
ktx dev scan my-warehouse
|
||||
|
||||
# Run a scan with LLM enrichment
|
||||
ktx dev scan my-warehouse --mode enriched
|
||||
|
||||
# Run a scan with relationship detection
|
||||
ktx dev scan my-warehouse --mode relationships
|
||||
|
||||
# Dry-run a scan (don't write results)
|
||||
ktx dev scan my-warehouse --dry-run
|
||||
|
||||
# Check the status of a scan run
|
||||
ktx dev scan status run-abc123
|
||||
|
||||
# View the scan report
|
||||
ktx dev scan report run-abc123
|
||||
|
||||
# View scan report as JSON
|
||||
ktx dev scan report run-abc123 --json
|
||||
|
||||
# List relationship candidates pending review
|
||||
ktx dev scan relationships run-abc123
|
||||
|
||||
# List all relationships regardless of status
|
||||
ktx dev scan relationships run-abc123 --status all
|
||||
|
||||
# Accept a relationship candidate
|
||||
ktx dev scan relationships run-abc123 --accept candidate-xyz
|
||||
|
||||
# Reject a relationship candidate with a note
|
||||
ktx dev scan relationships run-abc123 --reject candidate-xyz --note "false positive"
|
||||
|
||||
# Apply all accepted relationships to the manifest
|
||||
ktx dev scan relationship-apply run-abc123 --all-accepted
|
||||
|
||||
# Preview what would be applied
|
||||
ktx dev scan relationship-apply run-abc123 --all-accepted --dry-run
|
||||
|
||||
# Export relationship feedback as calibration labels
|
||||
ktx dev scan relationship-feedback --json
|
||||
|
||||
# Calibrate relationship detection thresholds
|
||||
ktx dev scan relationship-calibration --accept-threshold 0.9 --review-threshold 0.6
|
||||
|
||||
# Get threshold advice based on review decisions
|
||||
ktx dev scan relationship-thresholds
|
||||
ktx scan my-warehouse
|
||||
ktx scan my-warehouse --mode enriched
|
||||
ktx scan my-warehouse --mode relationships
|
||||
ktx scan my-warehouse --dry-run
|
||||
ktx scan my-warehouse --database-introspection-url http://127.0.0.1:8765
|
||||
```
|
||||
|
||||
## Output
|
||||
|
||||
Scan commands write scan artifacts under the KTX project directory and print status or report summaries. Use `--json` on report and relationship commands when an agent needs structured output.
|
||||
|
||||
```json
|
||||
{
|
||||
"runId": "scan-local-abc123",
|
||||
"status": "completed",
|
||||
"mode": "structural",
|
||||
"changes": {
|
||||
"tablesAdded": 42
|
||||
}
|
||||
}
|
||||
```
|
||||
`ktx scan` prints a human summary and writes scan artifacts under the KTX project directory unless `--dry-run` is set. Use `ktx status` after a scan to inspect project readiness and next setup work.
|
||||
|
||||
## Common errors
|
||||
|
||||
|
|
@ -165,5 +41,4 @@ Scan commands write scan artifacts under the KTX project directory and print sta
|
|||
|-------|-------|----------|
|
||||
| Scan cannot connect | Connection credentials or network access are invalid | Run `ktx connection test <connectionId>` and update the connection before scanning |
|
||||
| Enriched scan cannot describe columns | LLM credentials are missing or invalid | Complete LLM setup with `ktx setup` before enriched scans |
|
||||
| Relationship apply writes nothing | No accepted candidates match the provided run id or candidate ids | Inspect `ktx dev scan relationships <runId> --status accepted` first |
|
||||
| Calibration is not ready | Too few reviewed relationship labels exist | Review and accept/reject more candidates, then rerun calibration |
|
||||
| Relationship scan has limited evidence | The connector cannot provide optional validation or statistics | Re-run with a connector that supports the missing capability, or treat relationship output as lower-confidence context |
|
||||
|
|
|
|||
|
|
@ -59,7 +59,7 @@ dbt / Looker / Metabase / Notion
|
|||
|
||||
A typical branch shows a semantic diff: "this ingest added 3 new sources from dbt, updated 2 join definitions based on schema changes, and created 1 knowledge page from a Notion doc." Analytics engineers review the diff, verify that the new sources look correct, and merge.
|
||||
|
||||
Teams usually run this on demand while setting up a source, then schedule it once the source is stable. A cron job or CI schedule can run `ktx ingest --all --no-input` overnight on an ingest branch so the latest dbt manifests, BI metadata, and documentation updates are ready for review each morning.
|
||||
Teams usually run this on demand while setting up a source, then schedule it once the source is stable. A cron job or CI schedule can run `ktx ingest run --connection-id <id> --adapter <adapter> --no-input` overnight on an ingest branch so the latest dbt manifests, BI metadata, and documentation updates are ready for review each morning.
|
||||
|
||||
Once merged, agents querying through the KTX CLI see the updated context immediately. No deployment step, no cache invalidation, no restart. The files are the source of truth, and agents read them on every request.
|
||||
|
||||
|
|
|
|||
|
|
@ -239,7 +239,7 @@ Agent integration ready: yes (claude-code:project)
|
|||
| `ktx: command not found` | The KTX package is not installed globally, or the shell cannot find the global binary | Run `npm install -g @kaelio/ktx` and open a new shell |
|
||||
| LLM health check fails | Missing, invalid, or unauthorized Anthropic API key | Export `ANTHROPIC_API_KEY` or rerun `ktx setup` and choose the file-backed secret option |
|
||||
| OpenAI embedding check fails | `OPENAI_API_KEY` is missing when OpenAI embeddings are selected | Export `OPENAI_API_KEY`, or rerun setup and choose local sentence-transformers embeddings |
|
||||
| Local embeddings hang or fail | The managed Python runtime cannot start or the local model runtime is unavailable | Install `uv`, run `ktx dev runtime doctor`, then run `ktx dev runtime install --feature local-embeddings --yes` and rerun setup |
|
||||
| Local embeddings hang or fail | The managed Python runtime cannot start or the local model runtime is unavailable | Install `uv`, run `ktx dev runtime status`, then run `ktx dev runtime install --feature local-embeddings --yes` and rerun setup |
|
||||
| Database connection test fails | Credentials, network access, warehouse, database, or schema value is wrong | Test the same URL with the database's native client, then rerun `ktx connection add ... --force` or rerun setup |
|
||||
| `KTX context built: no` in `ktx status` | Setup saved configuration but did not build context | Run `ktx setup` and choose to build context now |
|
||||
| Agent integration is incomplete | Setup skipped the agents step or the target was not installed | Run `ktx setup --agents --target codex --project` using the target you need |
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ Scanning connects to your database and extracts structural metadata. KTX stores
|
|||
### Running a scan
|
||||
|
||||
```bash
|
||||
ktx dev scan <connection-id>
|
||||
ktx scan <connection-id>
|
||||
```
|
||||
|
||||
This runs a structural scan by default. You can control what the scan does with the `--mode` flag:
|
||||
|
|
@ -25,25 +25,18 @@ This runs a structural scan by default. You can control what the scan does with
|
|||
|
||||
```bash
|
||||
# Scan with relationship detection
|
||||
ktx dev scan my-postgres --mode relationships
|
||||
ktx scan my-postgres --mode relationships
|
||||
|
||||
# Preview without writing results
|
||||
ktx dev scan my-postgres --dry-run
|
||||
ktx scan my-postgres --dry-run
|
||||
```
|
||||
|
||||
### Checking scan status
|
||||
### Checking scan results
|
||||
|
||||
Every scan produces a run ID. Use it to check progress or review results:
|
||||
Every scan prints a summary and writes local artifacts. Use `ktx status` after a scan to review project readiness and follow-up setup work:
|
||||
|
||||
```bash
|
||||
# Check status of a scan run
|
||||
ktx dev scan status <run-id>
|
||||
|
||||
# Print the full scan report
|
||||
ktx dev scan report <run-id>
|
||||
|
||||
# Get the report as JSON for scripting
|
||||
ktx dev scan report <run-id> --json
|
||||
ktx status
|
||||
```
|
||||
|
||||
### Relationship detection
|
||||
|
|
@ -56,49 +49,7 @@ Many databases lack declared foreign keys. KTX infers relationships by scoring c
|
|||
| 0.55 – 0.84 | `review` | Plausible — needs human review |
|
||||
| < 0.55 | `rejected` | Low confidence — not applied |
|
||||
|
||||
After a relationship scan, review the candidates:
|
||||
|
||||
```bash
|
||||
# Show candidates pending review (default)
|
||||
ktx dev scan relationships <run-id>
|
||||
|
||||
# Show all candidates regardless of status
|
||||
ktx dev scan relationships <run-id> --status all
|
||||
|
||||
# Accept a specific candidate
|
||||
ktx dev scan relationships <run-id> --accept <candidate-id>
|
||||
|
||||
# Reject a candidate with a note
|
||||
ktx dev scan relationships <run-id> --reject <candidate-id> --note "These columns share a name but are unrelated"
|
||||
```
|
||||
|
||||
Once you've reviewed candidates, apply the accepted ones as joins in your semantic layer:
|
||||
|
||||
```bash
|
||||
# Apply all accepted relationships
|
||||
ktx dev scan relationship-apply <run-id> --all-accepted
|
||||
|
||||
# Preview what would be applied
|
||||
ktx dev scan relationship-apply <run-id> --all-accepted --dry-run
|
||||
|
||||
# Apply a specific candidate
|
||||
ktx dev scan relationship-apply <run-id> --candidate <candidate-id>
|
||||
```
|
||||
|
||||
### Calibrating thresholds
|
||||
|
||||
As you review more relationships, KTX can evaluate whether the default thresholds (0.85 accept, 0.55 review) are optimal for your schema:
|
||||
|
||||
```bash
|
||||
# See how your feedback aligns with current thresholds
|
||||
ktx dev scan relationship-calibration --connection my-postgres
|
||||
|
||||
# Get threshold recommendations (needs 20+ labels, 5+ accepted, 5+ rejected)
|
||||
ktx dev scan relationship-thresholds --connection my-postgres
|
||||
|
||||
# Export your review decisions as calibration labels
|
||||
ktx dev scan relationship-feedback --connection my-postgres
|
||||
```
|
||||
Relationship scans run with `ktx scan <connection-id> --mode relationships`. This command only executes the scan; relationship review and calibration subcommands are not part of the current CLI surface.
|
||||
|
||||
## Ingestion
|
||||
|
||||
|
|
@ -115,19 +66,7 @@ Each ingest run follows this flow:
|
|||
### Running an ingest
|
||||
|
||||
```bash
|
||||
# Ingest one configured context source
|
||||
ktx ingest my-dbt-source
|
||||
|
||||
# Ingest every configured context source
|
||||
ktx ingest --all
|
||||
```
|
||||
|
||||
The public `ktx ingest` command uses the source configuration in `ktx.yaml`, including the source `driver` and any adapter-specific paths or credentials.
|
||||
|
||||
For adapter-level debugging, use the low-level `ktx dev ingest run` command:
|
||||
|
||||
```bash
|
||||
ktx dev ingest run --connection-id my-dbt-source --adapter dbt
|
||||
ktx ingest run --connection-id my-dbt-source --adapter dbt
|
||||
```
|
||||
|
||||
Useful low-level flags:
|
||||
|
|
@ -152,7 +91,7 @@ ktx ingest status <run-id>
|
|||
ktx ingest watch
|
||||
|
||||
# Replay a past ingest run
|
||||
ktx dev ingest replay <run-id>
|
||||
ktx ingest replay <run-id>
|
||||
```
|
||||
|
||||
The `watch` command opens an interactive TUI that shows the memory-flow output — every tool call, LLM decision, and artifact written during the ingest.
|
||||
|
|
@ -235,7 +174,7 @@ Orders in "pending" status for more than 48 hours are flagged for review.
|
|||
Every ingest session records a full transcript — tool calls, LLM responses, and write decisions. You can replay any session to debug why a source was written a certain way:
|
||||
|
||||
```bash
|
||||
ktx dev ingest replay <run-id> --viz
|
||||
ktx ingest replay <run-id> --viz
|
||||
```
|
||||
|
||||
This opens the same TUI view as the original run, letting you step through the agent's reasoning.
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ Agents should configure and ingest context sources in this order:
|
|||
|
||||
1. Add the context source connection in `ktx.yaml` or with `ktx setup`.
|
||||
2. Store tokens as `env:NAME` or `file:/path/to/secret`.
|
||||
3. Run `ktx ingest <connectionId>` for one source or `ktx ingest --all`.
|
||||
3. Run `ktx ingest run --connection-id <connectionId> --adapter <adapter>` for one source or `ktx ingest run --connection-id <id> --adapter <adapter>`.
|
||||
4. Check progress with `ktx ingest status --json`.
|
||||
5. Review generated `semantic-layer/` YAML and `knowledge/` Markdown files in git.
|
||||
6. Validate changed semantic sources with `ktx sl validate`.
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ generated local project.
|
|||
|
||||
The managed Python runtime smoke requires `uv` on `PATH`, isolates
|
||||
`KTX_RUNTIME_ROOT`, verifies `ktx dev runtime status`, runs `ktx sl query --yes` to
|
||||
install the core runtime from the bundled wheel, checks `ktx dev runtime doctor`,
|
||||
install the core runtime from the bundled wheel, checks `ktx dev runtime status`,
|
||||
starts and reuses the managed daemon, stops it, previews a stale runtime with
|
||||
`ktx dev runtime prune --dry-run`, verifies confirmation is required, and removes
|
||||
the stale runtime with `ktx dev runtime prune --yes`.
|
||||
|
|
|
|||
|
|
@ -95,7 +95,7 @@ note, not a warning.
|
|||
Run local historic-SQL ingest:
|
||||
|
||||
```bash
|
||||
pnpm run ktx -- dev ingest run --project-dir /tmp/ktx-postgres-historic \
|
||||
pnpm run ktx -- ingest run --project-dir /tmp/ktx-postgres-historic \
|
||||
--connection-id warehouse \
|
||||
--adapter historic-sql \
|
||||
--plain \
|
||||
|
|
@ -103,7 +103,7 @@ pnpm run ktx -- dev ingest run --project-dir /tmp/ktx-postgres-historic \
|
|||
--no-input
|
||||
```
|
||||
|
||||
The full `dev ingest run` path also runs curation WorkUnits, so it requires a
|
||||
The full `ingest run` path also runs curation WorkUnits, so it requires a
|
||||
configured LLM provider.
|
||||
|
||||
Inspect the latest manifest:
|
||||
|
|
@ -127,6 +127,6 @@ table.
|
|||
- Missing grants: confirm `GRANT pg_read_all_stats TO ktx_reader;`.
|
||||
- Empty snapshot: rerun `scripts/generate-workload.sh base` and keep
|
||||
`--historic-sql-min-executions 2` for the smoke.
|
||||
- SQL-analysis failures: run `pnpm run ktx -- dev runtime doctor` from the KTX
|
||||
- SQL-analysis failures: run `pnpm run ktx -- dev runtime status` from the KTX
|
||||
repository root and confirm `uv`, the bundled Python wheel, and the managed
|
||||
runtime all pass.
|
||||
|
|
|
|||
|
|
@ -1,8 +1,9 @@
|
|||
import { Command, InvalidArgumentError } from '@commander-js/extra-typings';
|
||||
import type { KtxCliDeps, KtxCliIo, KtxCliPackageInfo } from './cli-runtime.js';
|
||||
import { registerConnectionCommands } from './commands/connection-commands.js';
|
||||
import { registerIngestCommands } from './commands/ingest-commands.js';
|
||||
import { registerWikiCommands } from './commands/knowledge-commands.js';
|
||||
import { registerPublicIngestCommands } from './commands/public-ingest-commands.js';
|
||||
import { registerScanCommands } from './commands/scan-commands.js';
|
||||
import { registerSetupCommands } from './commands/setup-commands.js';
|
||||
import { registerSlCommands } from './commands/sl-commands.js';
|
||||
import { registerStatusCommands } from './commands/status-commands.js';
|
||||
|
|
@ -52,7 +53,7 @@ type CommandPathNode = CommandWithGlobalOptions & {
|
|||
parent?: CommandPathNode | null;
|
||||
};
|
||||
|
||||
const PROJECT_AWARE_ROOT_COMMANDS = new Set(['setup', 'connection', 'ingest', 'wiki', 'sl', 'status']);
|
||||
const PROJECT_AWARE_ROOT_COMMANDS = new Set(['setup', 'connection', 'ingest', 'wiki', 'sl', 'status', 'scan']);
|
||||
|
||||
export interface CommandWithGlobalOptions {
|
||||
opts: () => object;
|
||||
|
|
@ -150,7 +151,7 @@ function isProjectAwareCommand(path: string[]): boolean {
|
|||
|
||||
const rootCommand = path[1];
|
||||
if (rootCommand === 'dev') {
|
||||
return path[2] !== undefined && path[2] !== 'completion' && path[2] !== 'runtime';
|
||||
return path[2] !== undefined && path[2] !== 'runtime';
|
||||
}
|
||||
return rootCommand !== undefined && PROJECT_AWARE_ROOT_COMMANDS.has(rootCommand);
|
||||
}
|
||||
|
|
@ -175,9 +176,6 @@ function shouldSuppressProjectDirLine(path: string[], options: Record<string, un
|
|||
}
|
||||
|
||||
if (commandPathKey === 'ktx ingest watch') {
|
||||
return options.json !== true;
|
||||
}
|
||||
if (commandPathKey === 'ktx dev ingest watch') {
|
||||
return options.json !== true && options.plain !== true;
|
||||
}
|
||||
if (commandPathKey === 'ktx connection notion pick') {
|
||||
|
|
@ -229,7 +227,7 @@ function createBaseProgram(info: KtxCliPackageInfo, io: KtxCliIo): Command {
|
|||
.configureHelp({ showGlobalOptions: true })
|
||||
.addHelpText(
|
||||
'after',
|
||||
'\nAdvanced:\n ktx dev Low-level diagnostics, scans, adapter commands, and mapping tools.\n',
|
||||
'\nAdvanced:\n ktx dev Low-level project initialization and runtime management.\n',
|
||||
)
|
||||
.showHelpAfterError()
|
||||
.exitOverride()
|
||||
|
|
@ -314,7 +312,11 @@ export function buildKtxProgram(options: BuildKtxProgramOptions): Command {
|
|||
|
||||
registerSetupCommands(program, context);
|
||||
registerConnectionCommands(program, context);
|
||||
registerPublicIngestCommands(program, context);
|
||||
registerIngestCommands(program, context, {
|
||||
runIngestWithProgress: async (ingestArgs, ingestIo, ingestDeps, defaultRunIngest) =>
|
||||
await (ingestDeps.ingest ?? defaultRunIngest)(ingestArgs, ingestIo),
|
||||
});
|
||||
registerScanCommands(program, context);
|
||||
registerWikiCommands(program, context);
|
||||
registerSlCommands(program, context);
|
||||
registerStatusCommands(program, context);
|
||||
|
|
|
|||
|
|
@ -6,7 +6,6 @@ import type { KtxConnectionArgs } from './connection.js';
|
|||
import type { KtxDoctorArgs } from './doctor.js';
|
||||
import type { KtxIngestArgs } from './ingest.js';
|
||||
import type { KtxKnowledgeArgs } from './knowledge.js';
|
||||
import type { KtxPublicIngestArgs } from './public-ingest.js';
|
||||
import type { KtxRuntimeArgs } from './runtime.js';
|
||||
import type { KtxScanArgs } from './scan.js';
|
||||
import type { KtxSetupArgs } from './setup.js';
|
||||
|
|
@ -35,7 +34,6 @@ export interface KtxCliDeps {
|
|||
connectionMetabaseSetup?: (args: KtxConnectionMetabaseSetupArgs, io: KtxCliIo) => Promise<number>;
|
||||
doctor?: (args: KtxDoctorArgs, io: KtxCliIo) => Promise<number>;
|
||||
ingest?: (args: KtxIngestArgs, io: KtxCliIo) => Promise<number>;
|
||||
publicIngest?: (args: KtxPublicIngestArgs, io: KtxCliIo) => Promise<number>;
|
||||
runtime?: (args: KtxRuntimeArgs, io: KtxCliIo) => Promise<number>;
|
||||
scan?: (args: KtxScanArgs, io: KtxCliIo) => Promise<number>;
|
||||
knowledge?: (args: KtxKnowledgeArgs, io: KtxCliIo) => Promise<number>;
|
||||
|
|
|
|||
|
|
@ -71,20 +71,3 @@ export const slQueryCommandSchema = z.object({
|
|||
runtimeInstallPolicy: z.enum(['prompt', 'auto', 'never']),
|
||||
maxRows: z.number().int().positive().optional(),
|
||||
});
|
||||
|
||||
export const publicIngestRunCommandSchema = z.object({
|
||||
command: z.literal('run'),
|
||||
projectDir: projectDirSchema,
|
||||
targetConnectionId: safeConnectionIdSchema.optional(),
|
||||
all: z.boolean(),
|
||||
json: z.boolean(),
|
||||
inputMode: z.enum(['auto', 'disabled']),
|
||||
});
|
||||
|
||||
export const publicIngestReadCommandSchema = z.object({
|
||||
command: z.enum(['status', 'watch']),
|
||||
projectDir: projectDirSchema,
|
||||
runId: z.string().min(1).optional(),
|
||||
json: z.boolean(),
|
||||
inputMode: z.enum(['auto', 'disabled']),
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,47 +0,0 @@
|
|||
import type { CommandUnknownOpts } from '@commander-js/extra-typings';
|
||||
import type { KtxCliCommandContext } from '../cli-program.js';
|
||||
import { completeCommanderInput, installZshCompletion, zshCompletionScript } from '../completion.js';
|
||||
|
||||
export function registerCompletionCommands(
|
||||
program: CommandUnknownOpts,
|
||||
context: KtxCliCommandContext,
|
||||
completionRoot: CommandUnknownOpts = program,
|
||||
): void {
|
||||
program
|
||||
.command('completion')
|
||||
.description('Generate shell completion scripts')
|
||||
.command('zsh')
|
||||
.description('Generate zsh completion script')
|
||||
.option('--install', 'Install zsh completion into ~/.zfunc and update ~/.zshrc', false)
|
||||
.action(async (options: { install?: boolean }) => {
|
||||
if (options.install === true) {
|
||||
const result = await installZshCompletion();
|
||||
context.io.stdout.write(`Installed zsh completion: ${result.completionPath}\n`);
|
||||
context.io.stdout.write(`Updated zsh config: ${result.zshrcPath}\n`);
|
||||
context.io.stdout.write('Restart your shell or run: source ~/.zshrc\n');
|
||||
context.setExitCode(0);
|
||||
return;
|
||||
}
|
||||
context.io.stdout.write(zshCompletionScript());
|
||||
context.setExitCode(0);
|
||||
});
|
||||
|
||||
program
|
||||
.command('__complete', { hidden: true })
|
||||
.description('Internal shell completion endpoint')
|
||||
.requiredOption('--shell <shell>', 'Shell requesting completions')
|
||||
.requiredOption('--position <position>', 'Current shell word position', (value) => Number(value))
|
||||
.argument('[words...]', 'Current shell words')
|
||||
.allowUnknownOption()
|
||||
.allowExcessArguments()
|
||||
.action((words: string[], options: { shell: string; position: number }) => {
|
||||
if (options.shell !== 'zsh') {
|
||||
context.setExitCode(1);
|
||||
return;
|
||||
}
|
||||
for (const completion of completeCommanderInput(completionRoot, { position: options.position, words })) {
|
||||
context.io.stdout.write(`${completion}\n`);
|
||||
}
|
||||
context.setExitCode(0);
|
||||
});
|
||||
}
|
||||
|
|
@ -88,7 +88,7 @@ export function registerConnectionMetabaseCommands(connection: Command, context:
|
|||
' ktx connection mapping refresh <connectionId> --auto-accept\n' +
|
||||
' ktx connection mapping set <connectionId> databaseMappings <id>=<target>\n' +
|
||||
' ktx connection mapping set-sync-enabled <connectionId> <id> --enabled true\n' +
|
||||
' ktx ingest <connectionId>\n',
|
||||
' ktx ingest run --connection-id <connectionId> --adapter metabase\n',
|
||||
)
|
||||
.option(
|
||||
'--map <metabaseDatabaseId=targetConnectionId>',
|
||||
|
|
|
|||
|
|
@ -230,7 +230,7 @@ describe('runKtxConnectionMetabaseSetup', () => {
|
|||
|
||||
expect(io.stdout()).toContain('Connection: metabase');
|
||||
expect(io.stdout()).toContain('Discovered 1 database');
|
||||
expect(io.stdout()).toContain(`ktx ingest metabase --project-dir ${projectDir}`);
|
||||
expect(io.stdout()).toContain(`ktx ingest run --connection-id metabase --adapter metabase --project-dir ${projectDir}`);
|
||||
expect(io.stdout()).not.toContain('mb_example');
|
||||
expect(io.stderr()).not.toContain('mb_example');
|
||||
|
||||
|
|
@ -784,7 +784,7 @@ describe('runKtxConnectionMetabaseSetup', () => {
|
|||
|
||||
const config = await readFile(join(projectDir, 'ktx.yaml'), 'utf-8');
|
||||
expect(config).toContain('driver: metabase');
|
||||
expect(io.stderr()).toContain(`ktx ingest metabase --project-dir ${projectDir}`);
|
||||
expect(io.stderr()).toContain(`ktx ingest run --connection-id metabase --adapter metabase --project-dir ${projectDir}`);
|
||||
|
||||
const updatedProject = await loadKtxProject({ projectDir });
|
||||
const store = new LocalMetabaseSourceStateReader({ dbPath: ktxLocalStateDbPath(updatedProject) });
|
||||
|
|
|
|||
|
|
@ -743,7 +743,9 @@ export async function runKtxConnectionMetabaseSetup(
|
|||
|
||||
io.stdout.write(`Connection: ${connectionId}\n`);
|
||||
io.stdout.write(`Discovered ${discovered.length} ${discovered.length === 1 ? 'database' : 'databases'}\n`);
|
||||
io.stdout.write(`Next: ktx ingest ${connectionId} --project-dir ${args.projectDir}\n`);
|
||||
io.stdout.write(
|
||||
`Next: ktx ingest run --connection-id ${connectionId} --adapter metabase --project-dir ${args.projectDir}\n`,
|
||||
);
|
||||
|
||||
if (args.runIngest) {
|
||||
const ingestRunner = deps.runPublicIngest ?? runKtxPublicIngest;
|
||||
|
|
@ -759,7 +761,9 @@ export async function runKtxConnectionMetabaseSetup(
|
|||
io,
|
||||
);
|
||||
if (exitCode !== 0) {
|
||||
io.stderr.write(`Ingest failed; re-run: ktx ingest ${connectionId} --project-dir ${args.projectDir}\n`);
|
||||
io.stderr.write(
|
||||
`Ingest failed; re-run: ktx ingest run --connection-id ${connectionId} --adapter metabase --project-dir ${args.projectDir}\n`,
|
||||
);
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,53 +0,0 @@
|
|||
import type { Command } from '@commander-js/extra-typings';
|
||||
import { type CommandWithGlobalOptions, type KtxCliCommandContext, resolveCommandProjectDir } from '../cli-program.js';
|
||||
import type { KtxDoctorArgs } from '../doctor.js';
|
||||
import { profileMark } from '../startup-profile.js';
|
||||
|
||||
profileMark('module:commands/doctor-commands');
|
||||
|
||||
function outputMode(options: { json?: boolean }): 'plain' | 'json' {
|
||||
return options.json === true ? 'json' : 'plain';
|
||||
}
|
||||
|
||||
function inputMode(options: { input?: boolean }): { inputMode?: 'disabled' } {
|
||||
return options.input === false ? { inputMode: 'disabled' } : {};
|
||||
}
|
||||
|
||||
async function runDoctorArgs(context: KtxCliCommandContext, args: KtxDoctorArgs): Promise<void> {
|
||||
const runner = context.deps.doctor ?? (await import('../doctor.js')).runKtxDoctor;
|
||||
context.setExitCode(await runner(args, context.io));
|
||||
}
|
||||
|
||||
export function registerDoctorCommands(program: Command, context: KtxCliCommandContext): void {
|
||||
const doctor = program
|
||||
.command('doctor')
|
||||
.description('Check KTX setup and project readiness')
|
||||
.option('--json', 'Print JSON output', false)
|
||||
.option('--no-input', 'Disable interactive terminal input')
|
||||
.action(async (options: { json?: boolean; input?: boolean }, command) => {
|
||||
await runDoctorArgs(context, {
|
||||
command: 'project',
|
||||
projectDir: resolveCommandProjectDir(command),
|
||||
outputMode: outputMode(options),
|
||||
...inputMode(options),
|
||||
});
|
||||
});
|
||||
|
||||
doctor
|
||||
.command('setup')
|
||||
.description('Check KTX install, build, and local runtime readiness')
|
||||
.option('--json', 'Print JSON output', false)
|
||||
.option('--no-input', 'Disable interactive terminal input')
|
||||
.action(
|
||||
async (
|
||||
_options: { json?: boolean; input?: boolean },
|
||||
command: CommandWithGlobalOptions,
|
||||
) => {
|
||||
const options = (command.optsWithGlobals ? command.optsWithGlobals() : command.opts()) as {
|
||||
json?: boolean;
|
||||
input?: boolean;
|
||||
};
|
||||
await runDoctorArgs(context, { command: 'setup', outputMode: outputMode(options), ...inputMode(options) });
|
||||
},
|
||||
);
|
||||
}
|
||||
|
|
@ -1,109 +0,0 @@
|
|||
import { InvalidArgumentError, type Command } from '@commander-js/extra-typings';
|
||||
import { type KtxCliCommandContext, resolveCommandProjectDir } from '../cli-program.js';
|
||||
import { publicIngestReadCommandSchema, publicIngestRunCommandSchema } from '../command-schemas.js';
|
||||
import type { KtxPublicIngestArgs, KtxPublicIngestInputMode } from '../public-ingest.js';
|
||||
import { profileMark } from '../startup-profile.js';
|
||||
|
||||
profileMark('module:commands/public-ingest-commands');
|
||||
|
||||
interface PublicIngestOptions {
|
||||
all?: boolean;
|
||||
json?: boolean;
|
||||
input?: boolean;
|
||||
}
|
||||
|
||||
function inputMode(options: { input?: boolean }): KtxPublicIngestInputMode {
|
||||
return options.input === false ? 'disabled' : 'auto';
|
||||
}
|
||||
|
||||
async function runPublicIngestArgs(context: KtxCliCommandContext, args: KtxPublicIngestArgs): Promise<void> {
|
||||
const runner = context.deps.publicIngest ?? (await import('../public-ingest.js')).runKtxPublicIngest;
|
||||
context.setExitCode(await runner(args, context.io));
|
||||
}
|
||||
|
||||
function parsePublicIngestConnectionId(value: string): string {
|
||||
if (value === 'run') {
|
||||
throw new InvalidArgumentError('run is reserved; use ktx dev ingest run for low-level adapter syntax');
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
export function registerPublicIngestCommands(program: Command, context: KtxCliCommandContext): void {
|
||||
const ingest = program
|
||||
.command('ingest')
|
||||
.description('Build and refresh KTX context from configured sources')
|
||||
.usage('[options] [connectionId]')
|
||||
.argument('[connectionId]', 'Connection id to ingest', parsePublicIngestConnectionId)
|
||||
.option('--all', 'Ingest every eligible configured source', false)
|
||||
.option('--json', 'Print JSON output', false)
|
||||
.option('--no-input', 'Disable interactive terminal input')
|
||||
.addHelpText(
|
||||
'after',
|
||||
[
|
||||
'',
|
||||
'Examples:',
|
||||
' ktx ingest <connectionId> [options]',
|
||||
' ktx ingest --all [options]',
|
||||
' ktx ingest status [runId] [options]',
|
||||
' ktx ingest watch [runId] [options]',
|
||||
'',
|
||||
'Project directory defaults to KTX_PROJECT_DIR when set, otherwise the current working directory.',
|
||||
'',
|
||||
].join('\n'),
|
||||
)
|
||||
.showHelpAfterError()
|
||||
.hook('preAction', (_thisCommand, actionCommand) => {
|
||||
context.writeDebug?.('ingest', actionCommand);
|
||||
})
|
||||
.action(async (connectionId: string | undefined, _options: PublicIngestOptions, command) => {
|
||||
const options = command.opts();
|
||||
if (options.all === true && connectionId) {
|
||||
throw new Error('ktx ingest accepts either --all or <connectionId>, not both');
|
||||
}
|
||||
const args = publicIngestRunCommandSchema.parse({
|
||||
command: 'run',
|
||||
projectDir: resolveCommandProjectDir(command),
|
||||
...(connectionId ? { targetConnectionId: connectionId } : {}),
|
||||
all: options.all === true,
|
||||
json: options.json === true,
|
||||
inputMode: inputMode(options),
|
||||
});
|
||||
await runPublicIngestArgs(context, args);
|
||||
});
|
||||
|
||||
ingest
|
||||
.command('status')
|
||||
.description('Print status for the latest or selected public ingest run')
|
||||
.argument('[runId]', 'Public ingest run id')
|
||||
.option('--json', 'Print JSON output', false)
|
||||
.option('--no-input', 'Disable interactive terminal input')
|
||||
.action(async (runId: string | undefined, _options: PublicIngestOptions, command) => {
|
||||
const options = (command.optsWithGlobals ? command.optsWithGlobals() : command.opts()) as PublicIngestOptions;
|
||||
const args = publicIngestReadCommandSchema.parse({
|
||||
command: 'status',
|
||||
projectDir: resolveCommandProjectDir(command),
|
||||
...(runId ? { runId } : {}),
|
||||
json: options.json === true,
|
||||
inputMode: inputMode(options),
|
||||
});
|
||||
await runPublicIngestArgs(context, args);
|
||||
});
|
||||
|
||||
ingest
|
||||
.command('watch')
|
||||
.description('Open the latest or selected public ingest visual report')
|
||||
.argument('[runId]', 'Public ingest run id')
|
||||
.option('--json', 'Print JSON output instead of the visual report', false)
|
||||
.option('--no-input', 'Disable interactive terminal input')
|
||||
.action(async (runId: string | undefined, _options: PublicIngestOptions, command) => {
|
||||
const options = (command.optsWithGlobals ? command.optsWithGlobals() : command.opts()) as PublicIngestOptions;
|
||||
const args = publicIngestReadCommandSchema.parse({
|
||||
command: 'watch',
|
||||
projectDir: resolveCommandProjectDir(command),
|
||||
...(runId ? { runId } : {}),
|
||||
json: options.json === true,
|
||||
inputMode: inputMode(options),
|
||||
});
|
||||
await runPublicIngestArgs(context, args);
|
||||
});
|
||||
}
|
||||
|
|
@ -74,18 +74,6 @@ export function registerRuntimeCommands(program: Command, context: KtxCliCommand
|
|||
});
|
||||
});
|
||||
|
||||
runtime
|
||||
.command('doctor')
|
||||
.description('Check managed Python runtime prerequisites and installation')
|
||||
.option('--json', 'Print JSON output', false)
|
||||
.action(async (options: { json?: boolean }) => {
|
||||
await runRuntimeArgs(context, {
|
||||
command: 'doctor',
|
||||
cliVersion: context.packageInfo.version,
|
||||
json: options.json === true,
|
||||
});
|
||||
});
|
||||
|
||||
runtime
|
||||
.command('prune')
|
||||
.description('Remove stale managed Python runtimes for older CLI versions')
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { type Command, InvalidArgumentError, Option } from '@commander-js/extra-typings';
|
||||
import { type KtxCliCommandContext, parsePositiveIntegerOption, resolveCommandProjectDir } from '../cli-program.js';
|
||||
import { type Command, InvalidArgumentError } from '@commander-js/extra-typings';
|
||||
import { type KtxCliCommandContext, resolveCommandProjectDir } from '../cli-program.js';
|
||||
import { runtimeInstallPolicyFromFlags } from '../managed-python-command.js';
|
||||
import type { KtxScanArgs } from '../scan.js';
|
||||
import { profileMark } from '../startup-profile.js';
|
||||
|
|
@ -13,6 +13,16 @@ async function runScanArgs(context: KtxCliCommandContext, args: KtxScanArgs): Pr
|
|||
|
||||
type KtxScanModeOption = Extract<KtxScanArgs, { command: 'run' }>['mode'];
|
||||
|
||||
const REMOVED_SCAN_SUBCOMMAND_NAMES = new Set([
|
||||
'status',
|
||||
'report',
|
||||
'relationships',
|
||||
'relationship-apply',
|
||||
'relationship-feedback',
|
||||
'relationship-calibration',
|
||||
'relationship-thresholds',
|
||||
]);
|
||||
|
||||
function parseScanModeOption(value: string): KtxScanModeOption {
|
||||
if (value === 'structural' || value === 'enriched' || value === 'relationships') {
|
||||
return value;
|
||||
|
|
@ -20,82 +30,18 @@ function parseScanModeOption(value: string): KtxScanModeOption {
|
|||
throw new InvalidArgumentError('Allowed choices are structural, enriched, relationships');
|
||||
}
|
||||
|
||||
type KtxRelationshipStatusOption = Extract<KtxScanArgs, { command: 'relationships' }>['status'];
|
||||
type KtxRelationshipFeedbackDecisionOption = Extract<KtxScanArgs, { command: 'relationshipFeedback' }>['decision'];
|
||||
|
||||
function parseRelationshipStatusOption(value: string): KtxRelationshipStatusOption {
|
||||
if (value === 'accepted' || value === 'review' || value === 'rejected' || value === 'skipped' || value === 'all') {
|
||||
return value;
|
||||
}
|
||||
throw new InvalidArgumentError('Allowed choices are accepted, review, rejected, skipped, all');
|
||||
}
|
||||
|
||||
function parseRelationshipFeedbackDecisionOption(value: string): KtxRelationshipFeedbackDecisionOption {
|
||||
if (value === 'accepted' || value === 'rejected' || value === 'all') {
|
||||
return value;
|
||||
}
|
||||
throw new InvalidArgumentError('Allowed choices are accepted, rejected, all');
|
||||
}
|
||||
|
||||
function parseNonEmptyOption(value: string): string {
|
||||
if (value.trim().length === 0) {
|
||||
throw new InvalidArgumentError('must not be empty');
|
||||
function parseConnectionId(value: string): string {
|
||||
if (REMOVED_SCAN_SUBCOMMAND_NAMES.has(value)) {
|
||||
throw new InvalidArgumentError(`"${value}" is not a scan connection id`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function parseRelationshipCalibrationThreshold(value: string): number {
|
||||
const parsed = Number(value);
|
||||
if (Number.isFinite(parsed) && parsed >= 0 && parsed <= 1) {
|
||||
return parsed;
|
||||
}
|
||||
throw new InvalidArgumentError('Allowed range is 0 through 1');
|
||||
}
|
||||
|
||||
function relationshipDecisionArgs(options: {
|
||||
accept?: string;
|
||||
reject?: string;
|
||||
reviewer?: string;
|
||||
note?: string;
|
||||
json?: boolean;
|
||||
}): Pick<
|
||||
Extract<KtxScanArgs, { command: 'relationshipDecision' }>,
|
||||
'candidateId' | 'decision' | 'reviewer' | 'note' | 'json'
|
||||
> | null {
|
||||
const decisionCount = [options.accept !== undefined, options.reject !== undefined].filter(Boolean).length;
|
||||
if (decisionCount > 1) {
|
||||
throw new Error('Only one relationship review decision option can be used: --accept and --reject conflict');
|
||||
}
|
||||
if (options.accept !== undefined) {
|
||||
return {
|
||||
candidateId: options.accept,
|
||||
decision: 'accepted',
|
||||
reviewer: options.reviewer ?? 'ktx',
|
||||
note: options.note ?? null,
|
||||
json: options.json === true,
|
||||
};
|
||||
}
|
||||
if (options.reject !== undefined) {
|
||||
return {
|
||||
candidateId: options.reject,
|
||||
decision: 'rejected',
|
||||
reviewer: options.reviewer ?? 'ktx',
|
||||
note: options.note ?? null,
|
||||
json: options.json === true,
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function collectRelationshipCandidateOption(value: string, previous: string[]): string[] {
|
||||
return [...previous, parseNonEmptyOption(value)];
|
||||
}
|
||||
|
||||
export function registerScanCommands(program: Command, context: KtxCliCommandContext): void {
|
||||
const scan = program
|
||||
program
|
||||
.command('scan')
|
||||
.description('Run or inspect standalone connection scans')
|
||||
.argument('[connectionId]', 'KTX connection id to scan')
|
||||
.description('Run a standalone connection scan')
|
||||
.argument('<connectionId>', 'KTX connection id to scan', parseConnectionId)
|
||||
.option(
|
||||
'--mode <mode>',
|
||||
'Scan mode: structural, enriched, relationships (default: structural)',
|
||||
|
|
@ -113,13 +59,7 @@ export function registerScanCommands(program: Command, context: KtxCliCommandCon
|
|||
.hook('preAction', (_thisCommand, actionCommand) => {
|
||||
context.writeDebug?.('scan', actionCommand);
|
||||
})
|
||||
.action(async (connectionId: string | undefined, options, command) => {
|
||||
if (!connectionId) {
|
||||
scan.outputHelp();
|
||||
context.io.stderr.write('ktx dev scan requires <connectionId> or a subcommand\n');
|
||||
context.setExitCode(1);
|
||||
return;
|
||||
}
|
||||
.action(async (connectionId: string, options, command) => {
|
||||
const mode = options.mode ?? 'structural';
|
||||
await runScanArgs(context, {
|
||||
command: 'run',
|
||||
|
|
@ -133,226 +73,4 @@ export function registerScanCommands(program: Command, context: KtxCliCommandCon
|
|||
runtimeInstallPolicy: runtimeInstallPolicyFromFlags(options),
|
||||
});
|
||||
});
|
||||
|
||||
scan
|
||||
.command('status')
|
||||
.description('Print status for a local scan run')
|
||||
.argument('<runId>', 'Local scan run id')
|
||||
.addHelpText(
|
||||
'after',
|
||||
'\n--project-dir is inherited from `ktx dev scan` (default: KTX_PROJECT_DIR or current working directory).\n',
|
||||
)
|
||||
.action(async (runId: string, _options: unknown, command) => {
|
||||
await runScanArgs(context, {
|
||||
command: 'status',
|
||||
projectDir: resolveCommandProjectDir(command),
|
||||
runId,
|
||||
});
|
||||
});
|
||||
|
||||
scan
|
||||
.command('report')
|
||||
.description('Print a local scan report')
|
||||
.argument('<runId>', 'Local scan run id')
|
||||
.option('--json', 'Print the raw scan report JSON', false)
|
||||
.addHelpText(
|
||||
'after',
|
||||
'\n--project-dir is inherited from `ktx dev scan` (default: KTX_PROJECT_DIR or current working directory).\n',
|
||||
)
|
||||
.action(async (runId: string, options, command) => {
|
||||
await runScanArgs(context, {
|
||||
command: 'report',
|
||||
projectDir: resolveCommandProjectDir(command),
|
||||
runId,
|
||||
json: options.json === true,
|
||||
});
|
||||
});
|
||||
|
||||
scan
|
||||
.command('relationships')
|
||||
.description('Print relationship artifacts for a local scan run')
|
||||
.argument('<runId>', 'Local scan run id')
|
||||
.option(
|
||||
'--status <status>',
|
||||
'Relationship status: accepted, review, rejected, skipped, all',
|
||||
parseRelationshipStatusOption,
|
||||
'review',
|
||||
)
|
||||
.option('--limit <count>', 'Maximum relationships to print per status', parsePositiveIntegerOption, 25)
|
||||
.addOption(
|
||||
new Option('--accept <candidateId>', 'Record a reviewer accepted decision for a relationship candidate')
|
||||
.argParser(parseNonEmptyOption)
|
||||
.conflicts('reject'),
|
||||
)
|
||||
.addOption(
|
||||
new Option('--reject <candidateId>', 'Record a reviewer rejected decision for a relationship candidate')
|
||||
.argParser(parseNonEmptyOption)
|
||||
.conflicts('accept'),
|
||||
)
|
||||
.option('--note <text>', 'Attach a note when recording a relationship review decision')
|
||||
.option('--reviewer <name>', 'Reviewer name for a relationship review decision')
|
||||
.option('--json', 'Print relationship artifacts as JSON', false)
|
||||
.addHelpText(
|
||||
'after',
|
||||
'\n--project-dir is inherited from `ktx dev scan` (default: KTX_PROJECT_DIR or current working directory).\n',
|
||||
)
|
||||
.action(async (runId: string, options, command) => {
|
||||
const decision = relationshipDecisionArgs(options);
|
||||
if (decision) {
|
||||
await runScanArgs(context, {
|
||||
command: 'relationshipDecision',
|
||||
projectDir: resolveCommandProjectDir(command),
|
||||
runId,
|
||||
candidateId: decision.candidateId,
|
||||
decision: decision.decision,
|
||||
reviewer: decision.reviewer,
|
||||
note: decision.note,
|
||||
json: decision.json,
|
||||
});
|
||||
return;
|
||||
}
|
||||
await runScanArgs(context, {
|
||||
command: 'relationships',
|
||||
projectDir: resolveCommandProjectDir(command),
|
||||
runId,
|
||||
status: options.status,
|
||||
json: options.json === true,
|
||||
limit: options.limit,
|
||||
});
|
||||
});
|
||||
|
||||
scan
|
||||
.command('relationship-apply')
|
||||
.description('Apply accepted relationship review decisions as manual manifest joins')
|
||||
.argument('<runId>', 'Local scan run id')
|
||||
.option('--all-accepted', 'Apply all accepted relationship review decisions for the scan run', false)
|
||||
.option(
|
||||
'--candidate <candidateId>',
|
||||
'Apply one accepted relationship review decision',
|
||||
collectRelationshipCandidateOption,
|
||||
[],
|
||||
)
|
||||
.option('--dry-run', 'Preview relationships that would be written without rewriting manifest shards', false)
|
||||
.option('--json', 'Print the apply result as JSON', false)
|
||||
.addHelpText(
|
||||
'after',
|
||||
'\n--project-dir is inherited from `ktx dev scan` (default: KTX_PROJECT_DIR or current working directory).\n',
|
||||
)
|
||||
.action(async (runId: string, options, command) => {
|
||||
const parentOptions = command.parent?.opts() as { dryRun?: boolean } | undefined;
|
||||
await runScanArgs(context, {
|
||||
command: 'relationshipApply',
|
||||
projectDir: resolveCommandProjectDir(command),
|
||||
runId,
|
||||
applyAllAccepted: options.allAccepted === true,
|
||||
candidateIds: options.candidate,
|
||||
dryRun: options.dryRun === true || parentOptions?.dryRun === true,
|
||||
json: options.json === true,
|
||||
});
|
||||
});
|
||||
|
||||
scan
|
||||
.command('relationship-feedback')
|
||||
.description('Export persisted relationship review decisions as calibration labels')
|
||||
.option('--connection <connectionId>', 'Only export labels for one KTX connection')
|
||||
.option(
|
||||
'--decision <decision>',
|
||||
'Relationship feedback decision: accepted, rejected, all',
|
||||
parseRelationshipFeedbackDecisionOption,
|
||||
'all',
|
||||
)
|
||||
.addOption(new Option('--json', 'Print the export as JSON').default(false).conflicts('jsonl'))
|
||||
.addOption(new Option('--jsonl', 'Print labels as newline-delimited JSON').default(false).conflicts('json'))
|
||||
.addHelpText(
|
||||
'after',
|
||||
'\n--project-dir is inherited from `ktx dev scan` (default: KTX_PROJECT_DIR or current working directory).\n',
|
||||
)
|
||||
.action(async (options, command) => {
|
||||
await runScanArgs(context, {
|
||||
command: 'relationshipFeedback',
|
||||
projectDir: resolveCommandProjectDir(command),
|
||||
connectionId: options.connection ?? null,
|
||||
decision: options.decision,
|
||||
json: options.json === true,
|
||||
jsonl: options.jsonl === true,
|
||||
});
|
||||
});
|
||||
|
||||
scan
|
||||
.command('relationship-calibration')
|
||||
.description('Summarize relationship feedback labels against current score thresholds')
|
||||
.option('--connection <connectionId>', 'Only calibrate labels for one KTX connection')
|
||||
.option(
|
||||
'--decision <decision>',
|
||||
'Relationship feedback decision: accepted, rejected, all',
|
||||
parseRelationshipFeedbackDecisionOption,
|
||||
'all',
|
||||
)
|
||||
.option(
|
||||
'--accept-threshold <value>',
|
||||
'Score threshold treated as predicted accepted',
|
||||
parseRelationshipCalibrationThreshold,
|
||||
0.85,
|
||||
)
|
||||
.option(
|
||||
'--review-threshold <value>',
|
||||
'Score threshold treated as predicted review',
|
||||
parseRelationshipCalibrationThreshold,
|
||||
0.55,
|
||||
)
|
||||
.option('--json', 'Print the calibration report as JSON', false)
|
||||
.addHelpText(
|
||||
'after',
|
||||
'\n--project-dir is inherited from `ktx dev scan` (default: KTX_PROJECT_DIR or current working directory).\n',
|
||||
)
|
||||
.action(async (options, command) => {
|
||||
await runScanArgs(context, {
|
||||
command: 'relationshipCalibration',
|
||||
projectDir: resolveCommandProjectDir(command),
|
||||
connectionId: options.connection ?? null,
|
||||
decision: options.decision,
|
||||
acceptThreshold: options.acceptThreshold,
|
||||
reviewThreshold: options.reviewThreshold,
|
||||
json: options.json === true,
|
||||
});
|
||||
});
|
||||
|
||||
scan
|
||||
.command('relationship-thresholds')
|
||||
.description('Evaluate relationship feedback labels for offline threshold advice')
|
||||
.option('--connection <connectionId>', 'Only evaluate labels for one KTX connection')
|
||||
.option(
|
||||
'--min-total-labels <count>',
|
||||
'Minimum scored labels before advice can be ready',
|
||||
parsePositiveIntegerOption,
|
||||
20,
|
||||
)
|
||||
.option(
|
||||
'--min-accepted-labels <count>',
|
||||
'Minimum accepted labels before advice can be ready',
|
||||
parsePositiveIntegerOption,
|
||||
5,
|
||||
)
|
||||
.option(
|
||||
'--min-rejected-labels <count>',
|
||||
'Minimum rejected labels before advice can be ready',
|
||||
parsePositiveIntegerOption,
|
||||
5,
|
||||
)
|
||||
.option('--json', 'Print the threshold advice report as JSON', false)
|
||||
.addHelpText(
|
||||
'after',
|
||||
'\n--project-dir is inherited from `ktx dev scan` (default: KTX_PROJECT_DIR or current working directory).\n',
|
||||
)
|
||||
.action(async (options, command) => {
|
||||
await runScanArgs(context, {
|
||||
command: 'relationshipThresholds',
|
||||
projectDir: resolveCommandProjectDir(command),
|
||||
connectionId: options.connection ?? null,
|
||||
minTotalLabels: options.minTotalLabels,
|
||||
minAcceptedLabels: options.minAcceptedLabels,
|
||||
minRejectedLabels: options.minRejectedLabels,
|
||||
json: options.json === true,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,353 +0,0 @@
|
|||
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
||||
import { homedir } from 'node:os';
|
||||
import { dirname, join } from 'node:path';
|
||||
import type { CommandUnknownOpts, Option } from '@commander-js/extra-typings';
|
||||
|
||||
export interface CompletionRequest {
|
||||
position: number;
|
||||
words: string[];
|
||||
}
|
||||
|
||||
interface CompletionCandidate {
|
||||
value: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
interface CommandWithHiddenFlag extends CommandUnknownOpts {
|
||||
_hidden?: boolean;
|
||||
}
|
||||
|
||||
interface ResolveState {
|
||||
command: CommandUnknownOpts;
|
||||
pendingOption?: Option;
|
||||
positionalIndex: number;
|
||||
}
|
||||
|
||||
export interface ZshCompletionInstallResult {
|
||||
completionPath: string;
|
||||
zshrcPath: string;
|
||||
}
|
||||
|
||||
const KTX_COMPLETION_BLOCK_START = '# >>> ktx completion >>>';
|
||||
const KTX_COMPLETION_BLOCK_END = '# <<< ktx completion <<<';
|
||||
const KTX_COMPLETION_BLOCK_PATTERN = new RegExp(
|
||||
`\\n?${escapeRegExp(KTX_COMPLETION_BLOCK_START)}[\\s\\S]*?${escapeRegExp(KTX_COMPLETION_BLOCK_END)}\\n?`,
|
||||
'g',
|
||||
);
|
||||
|
||||
export function zshCompletionScript(): string {
|
||||
const zshWords = '$' + '{words[@]}';
|
||||
const zshCompletionCapture = [
|
||||
'$',
|
||||
`{(@f)$("${'$'}{ktx_completion_command[@]}" dev __complete --shell zsh --position "$CURRENT" -- "${zshWords}" 2>/dev/null)}`,
|
||||
].join('');
|
||||
const zshCompletionsCount = '$' + '{#completions[@]}';
|
||||
const zshCompletionCommand = '$' + '(eval "print -r -- $' + '{KTX_COMPLETION_COMMAND:-ktx}")';
|
||||
|
||||
return [
|
||||
'#compdef ktx',
|
||||
'',
|
||||
'_ktx() {',
|
||||
' local -a completions',
|
||||
' local -a ktx_completion_command',
|
||||
` ktx_completion_command=("\${(@z)${zshCompletionCommand}}")`,
|
||||
` completions=("${zshCompletionCapture}")`,
|
||||
` if (( ${zshCompletionsCount} )); then`,
|
||||
" _describe 'ktx completions' completions",
|
||||
' else',
|
||||
' _files',
|
||||
' fi',
|
||||
'}',
|
||||
'',
|
||||
'compdef _ktx ktx',
|
||||
'',
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
export async function installZshCompletion(): Promise<ZshCompletionInstallResult> {
|
||||
const homeDir = process.env.HOME || homedir();
|
||||
const zshConfigDir = process.env.ZDOTDIR || homeDir;
|
||||
const completionDir = join(homeDir, '.zfunc');
|
||||
const completionPath = join(completionDir, '_ktx');
|
||||
const zshrcPath = join(zshConfigDir, '.zshrc');
|
||||
|
||||
await mkdir(completionDir, { recursive: true });
|
||||
await mkdir(dirname(zshrcPath), { recursive: true });
|
||||
await writeFile(completionPath, zshCompletionScript(), 'utf-8');
|
||||
|
||||
const existingZshrc = await readOptionalTextFile(zshrcPath);
|
||||
const nextZshrc = updateZshrcCompletionBlock(existingZshrc);
|
||||
await writeFile(zshrcPath, nextZshrc, 'utf-8');
|
||||
|
||||
return { completionPath, zshrcPath };
|
||||
}
|
||||
|
||||
export function completeCommanderInput(program: CommandUnknownOpts, request: CompletionRequest): string[] {
|
||||
const words = completionWordsForPosition(request.words, request.position);
|
||||
const tokens = stripProgramName(program, words);
|
||||
const current = tokens.at(-1) ?? '';
|
||||
const previous = tokens.slice(0, -1);
|
||||
const state = resolveCommandState(program, previous);
|
||||
|
||||
return candidatesForState(state, current).map(formatZshCandidate);
|
||||
}
|
||||
|
||||
function completionWordsForPosition(words: string[], position: number): string[] {
|
||||
if (!Number.isInteger(position) || position < 1) {
|
||||
return words;
|
||||
}
|
||||
return words.slice(0, position);
|
||||
}
|
||||
|
||||
function stripProgramName(program: CommandUnknownOpts, words: string[]): string[] {
|
||||
const [first, ...rest] = words;
|
||||
if (!first) {
|
||||
return [];
|
||||
}
|
||||
return first === program.name() || first.endsWith(`/${program.name()}`) ? rest : words;
|
||||
}
|
||||
|
||||
function resolveCommandState(program: CommandUnknownOpts, tokens: string[]): ResolveState {
|
||||
let command = program;
|
||||
let positionalIndex = 0;
|
||||
let pendingOption: Option | undefined;
|
||||
let positionalOnly = false;
|
||||
|
||||
for (let index = 0; index < tokens.length; index += 1) {
|
||||
const token = tokens[index];
|
||||
if (pendingOption) {
|
||||
pendingOption = undefined;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (token === '--') {
|
||||
positionalOnly = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!positionalOnly && token.startsWith('-')) {
|
||||
const option = findOption(command, optionNameFromToken(token));
|
||||
if (option && !token.includes('=') && optionTakesValue(option)) {
|
||||
if (index === tokens.length - 1) {
|
||||
pendingOption = option;
|
||||
} else if (option.required || !tokens[index + 1]?.startsWith('-')) {
|
||||
index += 1;
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
const child = findVisibleSubcommand(command, token);
|
||||
if (child) {
|
||||
command = child;
|
||||
positionalIndex = 0;
|
||||
continue;
|
||||
}
|
||||
|
||||
positionalIndex += 1;
|
||||
}
|
||||
|
||||
return { command, pendingOption, positionalIndex };
|
||||
}
|
||||
|
||||
function candidatesForState(state: ResolveState, current: string): CompletionCandidate[] {
|
||||
const optionValue = splitOptionValueToken(current);
|
||||
if (optionValue) {
|
||||
const option = findOption(state.command, optionValue.optionName);
|
||||
return choiceCandidates(option?.argChoices, optionValue.valuePrefix, optionValue.optionPrefix);
|
||||
}
|
||||
|
||||
if (state.pendingOption) {
|
||||
return choiceCandidates(state.pendingOption.argChoices, current);
|
||||
}
|
||||
|
||||
if (current.startsWith('-')) {
|
||||
return visibleOptions(state.command)
|
||||
.map(optionCandidate)
|
||||
.filter((candidate) => candidate.value.startsWith(current));
|
||||
}
|
||||
|
||||
const commandCandidates = visibleSubcommands(state.command)
|
||||
.map(commandCandidate)
|
||||
.filter((candidate) => candidate.value.startsWith(current));
|
||||
const argument = state.command.registeredArguments[state.positionalIndex];
|
||||
return [...commandCandidates, ...choiceCandidates(argument?.argChoices, current)];
|
||||
}
|
||||
|
||||
function visibleSubcommands(command: CommandUnknownOpts): CommandUnknownOpts[] {
|
||||
return command.commands.filter((subcommand) => (subcommand as CommandWithHiddenFlag)._hidden !== true);
|
||||
}
|
||||
|
||||
function findVisibleSubcommand(command: CommandUnknownOpts, name: string): CommandUnknownOpts | undefined {
|
||||
return visibleSubcommands(command).find(
|
||||
(subcommand) => subcommand.name() === name || subcommand.aliases().includes(name),
|
||||
);
|
||||
}
|
||||
|
||||
function visibleOptions(command: CommandUnknownOpts): Option[] {
|
||||
const options: Option[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const current of commandChain(command)) {
|
||||
for (const option of current.options) {
|
||||
if (option.hidden) {
|
||||
continue;
|
||||
}
|
||||
const key = option.long ?? option.short ?? option.flags;
|
||||
if (seen.has(key)) {
|
||||
continue;
|
||||
}
|
||||
seen.add(key);
|
||||
options.push(option);
|
||||
}
|
||||
}
|
||||
return options;
|
||||
}
|
||||
|
||||
function commandChain(command: CommandUnknownOpts): CommandUnknownOpts[] {
|
||||
const chain: CommandUnknownOpts[] = [];
|
||||
let current: CommandUnknownOpts | null = command;
|
||||
while (current) {
|
||||
chain.unshift(current);
|
||||
current = current.parent;
|
||||
}
|
||||
return chain;
|
||||
}
|
||||
|
||||
function findOption(command: CommandUnknownOpts, name: string): Option | undefined {
|
||||
return visibleOptions(command).find((option) => option.long === name || option.short === name);
|
||||
}
|
||||
|
||||
function optionTakesValue(option: Option): boolean {
|
||||
return option.required || option.optional;
|
||||
}
|
||||
|
||||
function optionNameFromToken(token: string): string {
|
||||
return token.split('=', 1)[0] ?? token;
|
||||
}
|
||||
|
||||
function splitOptionValueToken(
|
||||
token: string,
|
||||
): { optionName: string; optionPrefix: string; valuePrefix: string } | null {
|
||||
const separatorIndex = token.indexOf('=');
|
||||
if (!token.startsWith('-') || separatorIndex < 0) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
optionName: token.slice(0, separatorIndex),
|
||||
optionPrefix: token.slice(0, separatorIndex + 1),
|
||||
valuePrefix: token.slice(separatorIndex + 1),
|
||||
};
|
||||
}
|
||||
|
||||
function commandCandidate(command: CommandUnknownOpts): CompletionCandidate {
|
||||
return {
|
||||
value: command.name(),
|
||||
description: command.summary() || command.description(),
|
||||
};
|
||||
}
|
||||
|
||||
function optionCandidate(option: Option): CompletionCandidate {
|
||||
return {
|
||||
value: option.long ?? option.short ?? option.flags,
|
||||
description: option.description,
|
||||
};
|
||||
}
|
||||
|
||||
function choiceCandidates(
|
||||
choices: readonly string[] | undefined,
|
||||
prefix: string,
|
||||
completionPrefix = '',
|
||||
): CompletionCandidate[] {
|
||||
return (choices ?? [])
|
||||
.filter((choice) => choice.startsWith(prefix))
|
||||
.map((choice) => ({ value: `${completionPrefix}${choice}` }));
|
||||
}
|
||||
|
||||
function formatZshCandidate(candidate: CompletionCandidate): string {
|
||||
if (!candidate.description) {
|
||||
return escapeZshCompletion(candidate.value);
|
||||
}
|
||||
return `${escapeZshCompletion(candidate.value)}:${escapeZshDescription(candidate.description)}`;
|
||||
}
|
||||
|
||||
function escapeZshCompletion(value: string): string {
|
||||
return value.replace(/\\/g, '\\\\').replace(/:/g, '\\:');
|
||||
}
|
||||
|
||||
function escapeZshDescription(value: string): string {
|
||||
return value.replace(/\s+/g, ' ').replace(/\\/g, '\\\\').replace(/:/g, '\\:').trim();
|
||||
}
|
||||
|
||||
async function readOptionalTextFile(path: string): Promise<string> {
|
||||
try {
|
||||
return await readFile(path, 'utf-8');
|
||||
} catch (error) {
|
||||
if (isNodeError(error) && error.code === 'ENOENT') {
|
||||
return '';
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function updateZshrcCompletionBlock(contents: string): string {
|
||||
const withoutManagedBlock = contents.replace(KTX_COMPLETION_BLOCK_PATTERN, normalizeTrailingNewline);
|
||||
const hasCompinit = /^.*\bcompinit\b.*$/m.test(withoutManagedBlock);
|
||||
const block = zshrcCompletionBlock({ includeCompinit: !hasCompinit });
|
||||
|
||||
if (!hasCompinit) {
|
||||
return appendBlock(withoutManagedBlock, block);
|
||||
}
|
||||
|
||||
const compinitMatch = /^.*\bcompinit\b.*$/m.exec(withoutManagedBlock);
|
||||
if (!compinitMatch || compinitMatch.index === undefined) {
|
||||
return appendBlock(withoutManagedBlock, block);
|
||||
}
|
||||
|
||||
return [
|
||||
withoutManagedBlock.slice(0, compinitMatch.index),
|
||||
block,
|
||||
'\n',
|
||||
withoutManagedBlock.slice(compinitMatch.index),
|
||||
].join('');
|
||||
}
|
||||
|
||||
function zshrcCompletionBlock(options: { includeCompinit: boolean }): string {
|
||||
return [
|
||||
KTX_COMPLETION_BLOCK_START,
|
||||
'_ktx_completion_command() {',
|
||||
' local dir="$PWD"',
|
||||
' while [[ "$dir" != "/" ]]; do',
|
||||
` if [[ -f "$dir/package.json" ]] && command grep -q '"name": "ktx-workspace"' "$dir/package.json" 2>/dev/null; then`,
|
||||
' print -r -- "node $dir/scripts/run-ktx.mjs --"',
|
||||
' return',
|
||||
' fi',
|
||||
' dir="' + '$' + '{dir:h}"',
|
||||
' done',
|
||||
' print -r -- "ktx"',
|
||||
'}',
|
||||
"export KTX_COMPLETION_COMMAND='$(_ktx_completion_command)'",
|
||||
'setopt complete_aliases',
|
||||
'fpath=("$HOME/.zfunc" $fpath)',
|
||||
...(options.includeCompinit ? ['autoload -Uz compinit', 'compinit'] : []),
|
||||
KTX_COMPLETION_BLOCK_END,
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
function appendBlock(contents: string, block: string): string {
|
||||
if (!contents.trim()) {
|
||||
return `${block}\n`;
|
||||
}
|
||||
return `${contents.replace(/\s*$/, '\n\n')}${block}\n`;
|
||||
}
|
||||
|
||||
function normalizeTrailingNewline(match: string): string {
|
||||
return match.startsWith('\n') || match.endsWith('\n') ? '\n' : '';
|
||||
}
|
||||
|
||||
function escapeRegExp(value: string): string {
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
}
|
||||
|
||||
function isNodeError(error: unknown): error is NodeJS.ErrnoException {
|
||||
return error instanceof Error && 'code' in error;
|
||||
}
|
||||
|
|
@ -310,8 +310,8 @@ describe('runKtxConnection', () => {
|
|||
expect(io.stdout()).toContain('Mappings:');
|
||||
expect(io.stdout()).toContain('1 -> [unmapped]');
|
||||
expect(io.stdout()).toContain('Next:');
|
||||
expect(io.stdout()).toContain('ktx ingest prod-metabase');
|
||||
expect(io.stdout()).toContain('ktx dev mapping');
|
||||
expect(io.stdout()).toContain('ktx ingest run --connection-id prod-metabase --adapter <adapter>');
|
||||
expect(io.stdout()).toContain('ktx connection mapping');
|
||||
expect(io.stderr()).toBe('');
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -326,8 +326,8 @@ async function runPublicConnectionMap(
|
|||
io.stdout.write('\nMappings:\n');
|
||||
io.stdout.write(listIo.stdoutText().trim() ? listIo.stdoutText() : 'No mappings found.\n');
|
||||
io.stdout.write('\nNext:\n');
|
||||
io.stdout.write(` ktx ingest ${args.sourceConnectionId}\n`);
|
||||
io.stdout.write(` ktx dev mapping list ${args.sourceConnectionId}\n`);
|
||||
io.stdout.write(` ktx ingest run --connection-id ${args.sourceConnectionId} --adapter <adapter>\n`);
|
||||
io.stdout.write(` ktx connection mapping list ${args.sourceConnectionId}\n`);
|
||||
return 0;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -29,11 +29,14 @@ describe('dev Commander tree', () => {
|
|||
await expect(runKtxCli(['dev', '--help'], testIo.io)).resolves.toBe(0);
|
||||
|
||||
expect(testIo.stdout()).toContain('Usage: ktx dev [options] [command]');
|
||||
for (const command of ['init', 'runtime', 'scan', 'ingest', 'mapping']) {
|
||||
for (const command of ['init', 'runtime']) {
|
||||
expect(testIo.stdout()).toContain(command);
|
||||
}
|
||||
for (const removed of [
|
||||
'doctor',
|
||||
'scan',
|
||||
'ingest',
|
||||
'mapping',
|
||||
'knowledge',
|
||||
'model',
|
||||
'replay',
|
||||
|
|
@ -102,6 +105,12 @@ describe('dev Commander tree', () => {
|
|||
it('rejects removed dev command groups', async () => {
|
||||
for (const argv of [
|
||||
['dev', 'doctor', 'setup'],
|
||||
['dev', 'runtime', 'doctor'],
|
||||
['dev', 'scan', 'warehouse'],
|
||||
['dev', 'ingest', 'run'],
|
||||
['dev', 'mapping', 'list'],
|
||||
['dev', 'completion', 'zsh'],
|
||||
['dev', '__complete', '--shell', 'zsh', '--position', '2', '--', 'ktx', ''],
|
||||
['dev', 'knowledge', 'list'],
|
||||
['dev', 'model', 'list'],
|
||||
['dev', 'artifacts'],
|
||||
|
|
@ -117,90 +126,15 @@ describe('dev Commander tree', () => {
|
|||
it.each([
|
||||
{
|
||||
argv: ['dev', 'runtime', '--help'],
|
||||
expected: ['Usage: ktx dev runtime', 'install', 'start', 'stop', 'status', 'doctor', 'prune'],
|
||||
expected: ['Usage: ktx dev runtime', 'install', 'start', 'stop', 'status', 'prune'],
|
||||
},
|
||||
{
|
||||
argv: ['dev', 'scan', '--help'],
|
||||
expected: [
|
||||
'Usage: ktx dev scan',
|
||||
'--mode <mode>',
|
||||
'structural',
|
||||
'relationships',
|
||||
'--dry-run',
|
||||
'status',
|
||||
'report',
|
||||
'relationships',
|
||||
'relationship-apply',
|
||||
'relationship-feedback',
|
||||
'relationship-calibration',
|
||||
'relationship-thresholds',
|
||||
],
|
||||
argv: ['scan', '--help'],
|
||||
expected: ['Usage: ktx scan [options] <connectionId>', '--mode <mode>', 'structural', 'relationships', '--dry-run'],
|
||||
},
|
||||
{
|
||||
argv: ['dev', 'scan', 'report', '--help'],
|
||||
expected: ['Usage: ktx dev scan report [options] <runId>', '<runId>', '--json'],
|
||||
},
|
||||
{
|
||||
argv: ['dev', 'scan', 'relationships', '--help'],
|
||||
expected: [
|
||||
'Usage: ktx dev scan relationships [options] <runId>',
|
||||
'--status <status>',
|
||||
'--limit <count>',
|
||||
'--accept <candidateId>',
|
||||
'--reject <candidateId>',
|
||||
'--note <text>',
|
||||
'--reviewer <name>',
|
||||
'--json',
|
||||
],
|
||||
},
|
||||
{
|
||||
argv: ['dev', 'scan', 'relationship-apply', '--help'],
|
||||
expected: [
|
||||
'Usage: ktx dev scan relationship-apply [options] <runId>',
|
||||
'--all-accepted',
|
||||
'--candidate <candidateId>',
|
||||
'--dry-run',
|
||||
],
|
||||
},
|
||||
{
|
||||
argv: ['dev', 'scan', 'relationship-thresholds', '--help'],
|
||||
expected: [
|
||||
'Usage: ktx dev scan relationship-thresholds [options]',
|
||||
'--connection <connectionId>',
|
||||
'--min-total-labels <count>',
|
||||
'--min-accepted-labels <count>',
|
||||
'--min-rejected-labels <count>',
|
||||
'--json',
|
||||
],
|
||||
},
|
||||
{
|
||||
argv: ['dev', 'scan', 'relationship-feedback', '--help'],
|
||||
expected: [
|
||||
'Usage: ktx dev scan relationship-feedback [options]',
|
||||
'--connection <connectionId>',
|
||||
'--decision <decision>',
|
||||
'--json',
|
||||
'--jsonl',
|
||||
],
|
||||
},
|
||||
{
|
||||
argv: ['dev', 'scan', 'relationship-calibration', '--help'],
|
||||
expected: [
|
||||
'Usage: ktx dev scan relationship-calibration [options]',
|
||||
'--connection <connectionId>',
|
||||
'--decision <decision>',
|
||||
'--accept-threshold <value>',
|
||||
'--review-threshold <value>',
|
||||
'--json',
|
||||
],
|
||||
},
|
||||
{
|
||||
argv: ['dev', 'ingest', 'run', '--help'],
|
||||
expected: ['Usage: ktx dev ingest run [options]', '--connection-id <connectionId>', '--adapter <adapter>'],
|
||||
},
|
||||
{
|
||||
argv: ['dev', 'mapping', 'sync-state', 'set', '--help'],
|
||||
expected: ['Usage: ktx dev mapping sync-state set [options] <connectionId>', '--mode <mode>'],
|
||||
argv: ['ingest', 'run', '--help'],
|
||||
expected: ['Usage: ktx ingest run [options]', '--connection-id <connectionId>', '--adapter <adapter>'],
|
||||
},
|
||||
])('prints generated nested help for $argv', async ({ argv, expected }) => {
|
||||
const io = makeIo();
|
||||
|
|
@ -219,12 +153,12 @@ describe('dev Commander tree', () => {
|
|||
expect(scan).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('dispatches dev scan through Commander with injected dependencies', async () => {
|
||||
it('dispatches top-level scan through Commander with injected dependencies', async () => {
|
||||
const scanIo = makeIo();
|
||||
const scan = vi.fn(async () => 0);
|
||||
|
||||
await expect(
|
||||
runKtxCli(['dev', 'scan', 'warehouse', '--project-dir', '/tmp/project', '--dry-run'], scanIo.io, { scan }),
|
||||
runKtxCli(['scan', 'warehouse', '--project-dir', '/tmp/project', '--dry-run'], scanIo.io, { scan }),
|
||||
).resolves.toBe(0);
|
||||
|
||||
expect(scan).toHaveBeenCalledWith(
|
||||
|
|
@ -244,12 +178,12 @@ describe('dev Commander tree', () => {
|
|||
expect(scanIo.stderr()).toBe('Project: /tmp/project\n');
|
||||
});
|
||||
|
||||
it('dispatches dev scan --mode relationships through Commander', async () => {
|
||||
it('dispatches top-level scan --mode relationships through Commander', async () => {
|
||||
const io = makeIo();
|
||||
const scan = vi.fn(async () => 0);
|
||||
|
||||
await expect(
|
||||
runKtxCli(['dev', 'scan', 'warehouse', '--project-dir', '/tmp/project', '--mode', 'relationships'], io.io, {
|
||||
runKtxCli(['scan', 'warehouse', '--project-dir', '/tmp/project', '--mode', 'relationships'], io.io, {
|
||||
scan,
|
||||
}),
|
||||
).resolves.toBe(0);
|
||||
|
|
@ -275,375 +209,53 @@ describe('dev Commander tree', () => {
|
|||
const io = makeIo();
|
||||
const scan = vi.fn(async () => 0);
|
||||
|
||||
await expect(runKtxCli(['dev', 'scan', 'warehouse', option], io.io, { scan })).resolves.toBe(1);
|
||||
await expect(runKtxCli(['scan', 'warehouse', option], io.io, { scan })).resolves.toBe(1);
|
||||
|
||||
expect(scan).not.toHaveBeenCalled();
|
||||
expect(io.stderr()).toContain(`unknown option '${option}'`);
|
||||
});
|
||||
|
||||
it('rejects dev scan without a connection id or subcommand', async () => {
|
||||
it('rejects scan without a connection id', async () => {
|
||||
const io = makeIo();
|
||||
const scan = vi.fn(async () => 0);
|
||||
|
||||
await expect(runKtxCli(['dev', 'scan', '--dry-run'], io.io, { scan })).resolves.toBe(1);
|
||||
await expect(runKtxCli(['scan', '--dry-run'], io.io, { scan })).resolves.toBe(1);
|
||||
|
||||
expect(scan).not.toHaveBeenCalled();
|
||||
expect(io.stdout()).toContain('Usage: ktx dev scan');
|
||||
expect(io.stderr()).toContain('ktx dev scan requires <connectionId> or a subcommand');
|
||||
expect(io.stderr()).toMatch(/missing required argument/i);
|
||||
});
|
||||
|
||||
it('rejects invalid scan modes before dispatch', async () => {
|
||||
const io = makeIo();
|
||||
const scan = vi.fn(async () => 0);
|
||||
|
||||
await expect(runKtxCli(['dev', 'scan', 'warehouse', '--mode', 'deep'], io.io, { scan })).resolves.toBe(1);
|
||||
await expect(runKtxCli(['scan', 'warehouse', '--mode', 'deep'], io.io, { scan })).resolves.toBe(1);
|
||||
|
||||
expect(scan).not.toHaveBeenCalled();
|
||||
expect(io.stderr()).toContain("argument 'deep' is invalid");
|
||||
expect(io.stderr()).toContain('Allowed choices are structural, enriched, relationships');
|
||||
});
|
||||
|
||||
it('prints dev scan subcommand help with the canonical command name', async () => {
|
||||
it.each([
|
||||
['scan', 'report', 'scan-run-1'],
|
||||
['scan', 'relationships', 'scan-run-1'],
|
||||
])('rejects removed scan subcommand %s %s', async (command, subcommand, runId) => {
|
||||
const io = makeIo();
|
||||
const scan = vi.fn(async () => 0);
|
||||
|
||||
await expect(runKtxCli(['dev', 'scan', 'report', '--help'], io.io, { scan })).resolves.toBe(0);
|
||||
|
||||
expect(io.stdout()).toContain('--project-dir is inherited from `ktx dev scan`');
|
||||
expect(io.stdout()).not.toContain('--project-dir is inherited from `ktx scan`');
|
||||
expect(scan).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('dispatches dev scan report in human and json modes', async () => {
|
||||
const humanIo = makeIo();
|
||||
const jsonIo = makeIo();
|
||||
const scan = vi.fn(async () => 0);
|
||||
|
||||
await expect(
|
||||
runKtxCli(['dev', 'scan', 'report', 'scan-run-1', '--project-dir', '/tmp/project'], humanIo.io, { scan }),
|
||||
).resolves.toBe(0);
|
||||
await expect(
|
||||
runKtxCli(['dev', 'scan', 'report', 'scan-run-2', '--project-dir', '/tmp/project', '--json'], jsonIo.io, {
|
||||
scan,
|
||||
}),
|
||||
).resolves.toBe(0);
|
||||
|
||||
expect(scan).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
{ command: 'report', projectDir: '/tmp/project', runId: 'scan-run-1', json: false },
|
||||
humanIo.io,
|
||||
);
|
||||
expect(scan).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
{ command: 'report', projectDir: '/tmp/project', runId: 'scan-run-2', json: true },
|
||||
jsonIo.io,
|
||||
);
|
||||
});
|
||||
|
||||
it('dispatches dev scan relationships with filters through Commander', async () => {
|
||||
const io = makeIo();
|
||||
const scan = vi.fn(async () => 0);
|
||||
|
||||
await expect(
|
||||
runKtxCli(
|
||||
[
|
||||
'dev',
|
||||
'scan',
|
||||
'relationships',
|
||||
'scan-run-review',
|
||||
'--project-dir',
|
||||
'/tmp/project',
|
||||
'--status',
|
||||
'rejected',
|
||||
'--limit',
|
||||
'5',
|
||||
'--json',
|
||||
],
|
||||
io.io,
|
||||
{ scan },
|
||||
),
|
||||
).resolves.toBe(0);
|
||||
|
||||
expect(scan).toHaveBeenCalledWith(
|
||||
{
|
||||
command: 'relationships',
|
||||
projectDir: '/tmp/project',
|
||||
runId: 'scan-run-review',
|
||||
status: 'rejected',
|
||||
json: true,
|
||||
limit: 5,
|
||||
},
|
||||
io.io,
|
||||
);
|
||||
expect(io.stderr()).toBe('');
|
||||
});
|
||||
|
||||
it('dispatches dev scan relationship decision recording through Commander', async () => {
|
||||
const io = makeIo();
|
||||
const scan = vi.fn(async () => 0);
|
||||
|
||||
await expect(
|
||||
runKtxCli(
|
||||
[
|
||||
'dev',
|
||||
'scan',
|
||||
'relationships',
|
||||
'scan-run-review',
|
||||
'--project-dir',
|
||||
'/tmp/project',
|
||||
'--accept',
|
||||
'orders:orders.customer_id->customers:customers.id',
|
||||
'--reviewer',
|
||||
'Andrey',
|
||||
'--note',
|
||||
'Looks right',
|
||||
'--json',
|
||||
],
|
||||
io.io,
|
||||
{ scan },
|
||||
),
|
||||
).resolves.toBe(0);
|
||||
|
||||
expect(scan).toHaveBeenCalledWith(
|
||||
{
|
||||
command: 'relationshipDecision',
|
||||
projectDir: '/tmp/project',
|
||||
runId: 'scan-run-review',
|
||||
candidateId: 'orders:orders.customer_id->customers:customers.id',
|
||||
decision: 'accepted',
|
||||
reviewer: 'Andrey',
|
||||
note: 'Looks right',
|
||||
json: true,
|
||||
},
|
||||
io.io,
|
||||
);
|
||||
expect(io.stderr()).toBe('');
|
||||
});
|
||||
|
||||
it.each(['--accept', '--reject'])('rejects empty relationship decision candidate ids for %s', async (option) => {
|
||||
const io = makeIo();
|
||||
const scan = vi.fn(async () => 0);
|
||||
|
||||
await expect(
|
||||
runKtxCli(['dev', 'scan', 'relationships', 'scan-run-review', option, ''], io.io, { scan }),
|
||||
).resolves.toBe(1);
|
||||
await expect(runKtxCli([command, subcommand, runId], io.io, { scan })).resolves.toBe(1);
|
||||
|
||||
expect(scan).not.toHaveBeenCalled();
|
||||
expect(io.stderr()).toContain('must not be empty');
|
||||
expect(io.stderr()).toMatch(/too many arguments|unknown command|error:/);
|
||||
});
|
||||
|
||||
it('rejects relationship feedback JSON and JSONL output together', async () => {
|
||||
const io = makeIo();
|
||||
const scan = vi.fn(async () => 0);
|
||||
|
||||
await expect(
|
||||
runKtxCli(['dev', 'scan', 'relationship-feedback', '--json', '--jsonl'], io.io, { scan }),
|
||||
).resolves.toBe(1);
|
||||
|
||||
expect(scan).not.toHaveBeenCalled();
|
||||
expect(io.stderr()).toMatch(/conflict|cannot be used/i);
|
||||
});
|
||||
|
||||
it('dispatches relationship apply command args', async () => {
|
||||
const io = makeIo();
|
||||
const scan = vi.fn(async () => 0);
|
||||
|
||||
await expect(
|
||||
runKtxCli(
|
||||
[
|
||||
'dev',
|
||||
'scan',
|
||||
'relationship-apply',
|
||||
'scan-run-a',
|
||||
'--project-dir',
|
||||
'/tmp/project',
|
||||
'--candidate',
|
||||
'orders:orders.customer_id->customers:customers.id',
|
||||
'--dry-run',
|
||||
'--json',
|
||||
],
|
||||
io.io,
|
||||
{ scan },
|
||||
),
|
||||
).resolves.toBe(0);
|
||||
|
||||
expect(scan).toHaveBeenCalledWith(
|
||||
{
|
||||
command: 'relationshipApply',
|
||||
projectDir: '/tmp/project',
|
||||
runId: 'scan-run-a',
|
||||
applyAllAccepted: false,
|
||||
candidateIds: ['orders:orders.customer_id->customers:customers.id'],
|
||||
dryRun: true,
|
||||
json: true,
|
||||
},
|
||||
io.io,
|
||||
);
|
||||
});
|
||||
|
||||
it('dispatches scan relationship feedback command with filters and JSONL output', async () => {
|
||||
const io = makeIo();
|
||||
const scan = vi.fn(async () => 0);
|
||||
|
||||
await expect(
|
||||
runKtxCli(
|
||||
[
|
||||
'dev',
|
||||
'scan',
|
||||
'relationship-feedback',
|
||||
'--project-dir',
|
||||
'/tmp/project',
|
||||
'--connection',
|
||||
'warehouse',
|
||||
'--decision',
|
||||
'accepted',
|
||||
'--jsonl',
|
||||
],
|
||||
io.io,
|
||||
{ scan },
|
||||
),
|
||||
).resolves.toBe(0);
|
||||
|
||||
expect(scan).toHaveBeenCalledWith(
|
||||
{
|
||||
command: 'relationshipFeedback',
|
||||
projectDir: '/tmp/project',
|
||||
connectionId: 'warehouse',
|
||||
decision: 'accepted',
|
||||
json: false,
|
||||
jsonl: true,
|
||||
},
|
||||
io.io,
|
||||
);
|
||||
});
|
||||
|
||||
it('dispatches scan relationship calibration command with thresholds', async () => {
|
||||
const io = makeIo();
|
||||
const scan = vi.fn(async () => 0);
|
||||
|
||||
await expect(
|
||||
runKtxCli(
|
||||
[
|
||||
'dev',
|
||||
'scan',
|
||||
'relationship-calibration',
|
||||
'--project-dir',
|
||||
'/tmp/project',
|
||||
'--connection',
|
||||
'warehouse',
|
||||
'--decision',
|
||||
'rejected',
|
||||
'--accept-threshold',
|
||||
'0.9',
|
||||
'--review-threshold',
|
||||
'0.5',
|
||||
'--json',
|
||||
],
|
||||
io.io,
|
||||
{ scan },
|
||||
),
|
||||
).resolves.toBe(0);
|
||||
|
||||
expect(scan).toHaveBeenCalledWith(
|
||||
{
|
||||
command: 'relationshipCalibration',
|
||||
projectDir: '/tmp/project',
|
||||
connectionId: 'warehouse',
|
||||
decision: 'rejected',
|
||||
acceptThreshold: 0.9,
|
||||
reviewThreshold: 0.5,
|
||||
json: true,
|
||||
},
|
||||
io.io,
|
||||
);
|
||||
});
|
||||
|
||||
it('dispatches relationship threshold advice command args', async () => {
|
||||
const io = makeIo();
|
||||
const scan = vi.fn(async () => 0);
|
||||
|
||||
await expect(
|
||||
runKtxCli(
|
||||
[
|
||||
'dev',
|
||||
'scan',
|
||||
'relationship-thresholds',
|
||||
'--project-dir',
|
||||
'/tmp/project',
|
||||
'--connection',
|
||||
'warehouse',
|
||||
'--min-total-labels',
|
||||
'12',
|
||||
'--min-accepted-labels',
|
||||
'4',
|
||||
'--min-rejected-labels',
|
||||
'3',
|
||||
'--json',
|
||||
],
|
||||
io.io,
|
||||
{ scan },
|
||||
),
|
||||
).resolves.toBe(0);
|
||||
|
||||
expect(scan).toHaveBeenCalledWith(
|
||||
{
|
||||
command: 'relationshipThresholds',
|
||||
projectDir: '/tmp/project',
|
||||
connectionId: 'warehouse',
|
||||
minTotalLabels: 12,
|
||||
minAcceptedLabels: 4,
|
||||
minRejectedLabels: 3,
|
||||
json: true,
|
||||
},
|
||||
io.io,
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects invalid relationship calibration thresholds before dispatch', async () => {
|
||||
const io = makeIo();
|
||||
const scan = vi.fn(async () => 0);
|
||||
|
||||
await expect(
|
||||
runKtxCli(['dev', 'scan', 'relationship-calibration', '--accept-threshold', '1.5'], io.io, { scan }),
|
||||
).resolves.toBe(1);
|
||||
|
||||
expect(scan).not.toHaveBeenCalled();
|
||||
expect(io.stderr()).toContain('Allowed range is 0 through 1');
|
||||
});
|
||||
|
||||
it('rejects relationship accept and reject options together before dispatch', async () => {
|
||||
const io = makeIo();
|
||||
const scan = vi.fn(async () => 0);
|
||||
|
||||
await expect(
|
||||
runKtxCli(
|
||||
[
|
||||
'dev',
|
||||
'scan',
|
||||
'relationships',
|
||||
'scan-run-review',
|
||||
'--accept',
|
||||
'orders:orders.customer_id->customers:customers.id',
|
||||
'--reject',
|
||||
'orders:orders.customer_id->customers:customers.id',
|
||||
],
|
||||
io.io,
|
||||
{ scan },
|
||||
),
|
||||
).resolves.toBe(1);
|
||||
|
||||
expect(scan).not.toHaveBeenCalled();
|
||||
expect(io.stderr()).toMatch(/conflict|cannot be used/i);
|
||||
});
|
||||
|
||||
it('dispatches dev ingest run through the low-level ingest Commander registration', async () => {
|
||||
it('dispatches top-level ingest run through the low-level ingest Commander registration', async () => {
|
||||
const io = makeIo();
|
||||
const ingest = vi.fn(async () => 0);
|
||||
|
||||
await expect(
|
||||
runKtxCli(
|
||||
[
|
||||
'dev',
|
||||
'ingest',
|
||||
'run',
|
||||
'--connection-id',
|
||||
|
|
|
|||
|
|
@ -1,11 +1,7 @@
|
|||
import { resolve } from 'node:path';
|
||||
import type { Command } from '@commander-js/extra-typings';
|
||||
import { type CommandWithGlobalOptions, type KtxCliCommandContext, resolveCommandProjectDir } from './cli-program.js';
|
||||
import { registerCompletionCommands } from './commands/completion-commands.js';
|
||||
import { registerConnectionMappingCommands } from './commands/connection-commands.js';
|
||||
import { registerIngestCommands } from './commands/ingest-commands.js';
|
||||
import { registerRuntimeCommands } from './commands/runtime-commands.js';
|
||||
import { registerScanCommands } from './commands/scan-commands.js';
|
||||
import { profileMark } from './startup-profile.js';
|
||||
|
||||
profileMark('module:dev');
|
||||
|
|
@ -13,7 +9,7 @@ profileMark('module:dev');
|
|||
export function registerDevCommands(program: Command, context: KtxCliCommandContext): void {
|
||||
const dev = program
|
||||
.command('dev', { hidden: true })
|
||||
.description('Low-level diagnostics, scans, adapter commands, and mapping tools')
|
||||
.description('Low-level project initialization and runtime management')
|
||||
.showHelpAfterError();
|
||||
|
||||
dev.hook('preAction', (_thisCommand, actionCommand) => {
|
||||
|
|
@ -51,11 +47,4 @@ export function registerDevCommands(program: Command, context: KtxCliCommandCont
|
|||
);
|
||||
|
||||
registerRuntimeCommands(dev, context);
|
||||
registerScanCommands(dev, context);
|
||||
registerIngestCommands(dev, context, {
|
||||
runIngestWithProgress: async (ingestArgs, ingestIo, ingestDeps, defaultRunIngest) =>
|
||||
await (ingestDeps.ingest ?? defaultRunIngest)(ingestArgs, ingestIo),
|
||||
});
|
||||
registerConnectionMappingCommands(dev, context);
|
||||
registerCompletionCommands(dev, context, program);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -107,7 +107,6 @@ describe('standalone local warehouse example', () => {
|
|||
expect(parseJsonOutput<{ data: { yaml: string } }>(slRead.stdout).data.yaml).toContain('name: orders');
|
||||
|
||||
const ingest = await runBuiltCli([
|
||||
'dev',
|
||||
'ingest',
|
||||
'run',
|
||||
'--project-dir',
|
||||
|
|
@ -121,7 +120,7 @@ describe('standalone local warehouse example', () => {
|
|||
]);
|
||||
expect(ingest).toMatchObject({ code: 1, stdout: '' });
|
||||
expect(ingest.stderr).toContain(
|
||||
'ktx dev ingest run requires llm.provider.backend: anthropic, vertex, or gateway, or an injected agentRunner',
|
||||
'ktx ingest run requires llm.provider.backend: anthropic, vertex, or gateway, or an injected agentRunner',
|
||||
);
|
||||
}, 30_000);
|
||||
|
||||
|
|
|
|||
|
|
@ -123,10 +123,10 @@ describe('runKtxCli', () => {
|
|||
await expect(runKtxCli(['--help'], testIo.io)).resolves.toBe(0);
|
||||
|
||||
expect(testIo.stdout()).toContain('Usage: ktx [options] [command]');
|
||||
for (const command of ['setup', 'connection', 'ingest', 'wiki', 'sl', 'status']) {
|
||||
for (const command of ['setup', 'connection', 'ingest', 'wiki', 'sl', 'status', 'scan']) {
|
||||
expect(testIo.stdout()).toContain(`${command}`);
|
||||
}
|
||||
for (const removed of ['demo', 'init', 'connect', 'scan', 'ask', 'knowledge', 'agent', 'completion', 'runtime', 'serve']) {
|
||||
for (const removed of ['demo', 'init', 'connect', 'ask', 'knowledge', 'agent', 'completion', 'serve']) {
|
||||
expect(testIo.stdout()).not.toContain(`${removed} [`);
|
||||
expect(testIo.stdout()).not.toContain(`${removed} `);
|
||||
}
|
||||
|
|
@ -146,7 +146,6 @@ describe('runKtxCli', () => {
|
|||
const stopIo = makeIo();
|
||||
const stopAllIo = makeIo();
|
||||
const statusIo = makeIo();
|
||||
const doctorIo = makeIo();
|
||||
const pruneIo = makeIo();
|
||||
|
||||
await expect(
|
||||
|
|
@ -160,7 +159,6 @@ describe('runKtxCli', () => {
|
|||
await expect(runKtxCli(['dev', 'runtime', 'stop'], stopIo.io, { runtime })).resolves.toBe(0);
|
||||
await expect(runKtxCli(['dev', 'runtime', 'stop', '--all'], stopAllIo.io, { runtime })).resolves.toBe(0);
|
||||
await expect(runKtxCli(['dev', 'runtime', 'status', '--json'], statusIo.io, { runtime })).resolves.toBe(0);
|
||||
await expect(runKtxCli(['dev', 'runtime', 'doctor'], doctorIo.io, { runtime })).resolves.toBe(0);
|
||||
await expect(runKtxCli(['dev', 'runtime', 'prune', '--dry-run'], pruneIo.io, { runtime })).resolves.toBe(0);
|
||||
|
||||
expect(runtime).toHaveBeenNthCalledWith(
|
||||
|
|
@ -212,15 +210,6 @@ describe('runKtxCli', () => {
|
|||
);
|
||||
expect(runtime).toHaveBeenNthCalledWith(
|
||||
6,
|
||||
{
|
||||
command: 'doctor',
|
||||
cliVersion: '0.0.0-private',
|
||||
json: false,
|
||||
},
|
||||
doctorIo.io,
|
||||
);
|
||||
expect(runtime).toHaveBeenNthCalledWith(
|
||||
7,
|
||||
{
|
||||
command: 'prune',
|
||||
cliVersion: '0.0.0-private',
|
||||
|
|
@ -229,7 +218,7 @@ describe('runKtxCli', () => {
|
|||
},
|
||||
pruneIo.io,
|
||||
);
|
||||
for (const io of [installIo, startIo, stopIo, stopAllIo, statusIo, doctorIo, pruneIo]) {
|
||||
for (const io of [installIo, startIo, stopIo, stopAllIo, statusIo, pruneIo]) {
|
||||
expect(io.stderr()).toBe('');
|
||||
}
|
||||
});
|
||||
|
|
@ -247,16 +236,15 @@ describe('runKtxCli', () => {
|
|||
});
|
||||
|
||||
it('skips the project directory line for JSON and TUI output modes', async () => {
|
||||
const publicIngest = vi.fn(async () => 0);
|
||||
const ingest = vi.fn(async () => 0);
|
||||
const jsonIo = makeIo();
|
||||
const vizIo = makeIo({ stdoutIsTty: true });
|
||||
|
||||
await expect(runKtxCli(['--project-dir', tempDir, 'ingest', '--all', '--json'], jsonIo.io, { publicIngest }))
|
||||
await expect(runKtxCli(['--project-dir', tempDir, 'ingest', 'status', 'run-1', '--json'], jsonIo.io, { ingest }))
|
||||
.resolves.toBe(0);
|
||||
await expect(
|
||||
runKtxCli(
|
||||
['--project-dir', tempDir, 'dev', 'ingest', 'status', 'run-1', '--viz'],
|
||||
['--project-dir', tempDir, 'ingest', 'status', 'run-1', '--viz'],
|
||||
vizIo.io,
|
||||
{ ingest },
|
||||
),
|
||||
|
|
@ -503,158 +491,17 @@ describe('runKtxCli', () => {
|
|||
expect(testIo.stdout()).toBe('');
|
||||
});
|
||||
|
||||
it('prints a zsh completion function', async () => {
|
||||
const testIo = makeIo();
|
||||
const zshWords = '$' + '{words[@]}';
|
||||
|
||||
await expect(runKtxCli(['dev', 'completion', 'zsh'], testIo.io)).resolves.toBe(0);
|
||||
|
||||
expect(testIo.stdout()).toContain('#compdef ktx');
|
||||
expect(testIo.stdout()).toContain('KTX_COMPLETION_COMMAND:-ktx');
|
||||
expect(testIo.stdout()).toContain(`dev __complete --shell zsh --position "$CURRENT" -- "${zshWords}"`);
|
||||
expect(testIo.stdout()).toContain('compdef _ktx ktx');
|
||||
expect(testIo.stderr()).toBe('');
|
||||
});
|
||||
|
||||
it('installs zsh completions into the user zsh config directory', async () => {
|
||||
const testIo = makeIo();
|
||||
const previousHome = process.env.HOME;
|
||||
const previousZdotdir = process.env.ZDOTDIR;
|
||||
const tempHome = await mkdtemp(join(tmpdir(), 'ktx-completion-home-'));
|
||||
|
||||
try {
|
||||
process.env.HOME = tempHome;
|
||||
delete process.env.ZDOTDIR;
|
||||
|
||||
await expect(runKtxCli(['dev', 'completion', 'zsh', '--install'], testIo.io)).resolves.toBe(0);
|
||||
|
||||
const completionFile = await readFile(join(tempHome, '.zfunc', '_ktx'), 'utf-8');
|
||||
const zshrc = await readFile(join(tempHome, '.zshrc'), 'utf-8');
|
||||
expect(completionFile).toContain('#compdef ktx');
|
||||
expect(zshrc).toContain('# >>> ktx completion >>>');
|
||||
expect(zshrc).toContain('_ktx_completion_command()');
|
||||
expect(zshrc).toContain('"name": "ktx-workspace"');
|
||||
expect(zshrc).toContain('scripts/run-ktx.mjs');
|
||||
expect(zshrc).toContain("export KTX_COMPLETION_COMMAND='$(_ktx_completion_command)'");
|
||||
expect(zshrc).toContain('setopt complete_aliases');
|
||||
expect(zshrc).toContain('fpath=("$HOME/.zfunc" $fpath)');
|
||||
expect(zshrc).toContain('autoload -Uz compinit');
|
||||
expect(zshrc).toContain('compinit');
|
||||
expect(testIo.stdout()).toContain('Installed zsh completion:');
|
||||
expect(testIo.stdout()).toContain('Restart your shell or run: source ~/.zshrc');
|
||||
expect(testIo.stderr()).toBe('');
|
||||
} finally {
|
||||
if (previousHome === undefined) {
|
||||
delete process.env.HOME;
|
||||
} else {
|
||||
process.env.HOME = previousHome;
|
||||
}
|
||||
if (previousZdotdir === undefined) {
|
||||
delete process.env.ZDOTDIR;
|
||||
} else {
|
||||
process.env.ZDOTDIR = previousZdotdir;
|
||||
}
|
||||
await rm(tempHome, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('updates zsh completion install block idempotently before existing compinit', async () => {
|
||||
const firstIo = makeIo();
|
||||
const secondIo = makeIo();
|
||||
const previousHome = process.env.HOME;
|
||||
const previousZdotdir = process.env.ZDOTDIR;
|
||||
const tempHome = await mkdtemp(join(tmpdir(), 'ktx-completion-home-'));
|
||||
|
||||
try {
|
||||
process.env.HOME = tempHome;
|
||||
delete process.env.ZDOTDIR;
|
||||
await writeFile(join(tempHome, '.zshrc'), 'export EDITOR=vim\nautoload -Uz compinit\ncompinit\n', 'utf-8');
|
||||
|
||||
await expect(runKtxCli(['dev', 'completion', 'zsh', '--install'], firstIo.io)).resolves.toBe(0);
|
||||
await expect(runKtxCli(['dev', 'completion', 'zsh', '--install'], secondIo.io)).resolves.toBe(0);
|
||||
|
||||
const zshrc = await readFile(join(tempHome, '.zshrc'), 'utf-8');
|
||||
expect(zshrc.match(/# >>> ktx completion >>>/g)).toHaveLength(1);
|
||||
expect(zshrc.indexOf('fpath=("$HOME/.zfunc" $fpath)')).toBeLessThan(zshrc.indexOf('autoload -Uz compinit'));
|
||||
expect(zshrc.match(/_ktx_completion_command\(\)/g)).toHaveLength(1);
|
||||
expect(zshrc.match(/^compinit$/gm)).toHaveLength(1);
|
||||
expect(secondIo.stdout()).toContain('Updated zsh config:');
|
||||
expect(firstIo.stderr()).toBe('');
|
||||
expect(secondIo.stderr()).toBe('');
|
||||
} finally {
|
||||
if (previousHome === undefined) {
|
||||
delete process.env.HOME;
|
||||
} else {
|
||||
process.env.HOME = previousHome;
|
||||
}
|
||||
if (previousZdotdir === undefined) {
|
||||
delete process.env.ZDOTDIR;
|
||||
} else {
|
||||
process.env.ZDOTDIR = previousZdotdir;
|
||||
}
|
||||
await rm(tempHome, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('completes root and nested Commander command names', async () => {
|
||||
const rootIo = makeIo();
|
||||
const connectionIo = makeIo();
|
||||
it('rejects removed shell completion commands', async () => {
|
||||
const completionIo = makeIo();
|
||||
const hiddenIo = makeIo();
|
||||
|
||||
await expect(runKtxCli(['dev', 'completion', 'zsh'], completionIo.io)).resolves.toBe(1);
|
||||
await expect(
|
||||
runKtxCli(['dev', '__complete', '--shell', 'zsh', '--position', '2', '--', 'ktx', 'co'], rootIo.io),
|
||||
).resolves.toBe(0);
|
||||
await expect(
|
||||
runKtxCli(
|
||||
['dev', '__complete', '--shell', 'zsh', '--position', '3', '--', 'ktx', 'connection', 'm'],
|
||||
connectionIo.io,
|
||||
),
|
||||
).resolves.toBe(0);
|
||||
runKtxCli(['dev', '__complete', '--shell', 'zsh', '--position', '2', '--', 'ktx', 'co'], hiddenIo.io),
|
||||
).resolves.toBe(1);
|
||||
|
||||
expect(rootIo.stdout()).toContain('connection:Add, list, test, and map data sources');
|
||||
expect(rootIo.stdout()).not.toContain('__complete');
|
||||
expect(connectionIo.stdout()).toContain('map:Refresh and validate BI-to-warehouse mappings');
|
||||
expect(connectionIo.stdout()).toContain('mapping:Manage Metabase warehouse mappings');
|
||||
expect(rootIo.stderr()).toBe('');
|
||||
expect(connectionIo.stderr()).toBe('');
|
||||
});
|
||||
|
||||
it('completes options and Commander choices', async () => {
|
||||
const optionIo = makeIo();
|
||||
const choiceIo = makeIo();
|
||||
|
||||
await expect(
|
||||
runKtxCli(
|
||||
['dev', '__complete', '--shell', 'zsh', '--position', '4', '--', 'ktx', 'connection', 'add', '--cr'],
|
||||
optionIo.io,
|
||||
),
|
||||
).resolves.toBe(0);
|
||||
await expect(
|
||||
runKtxCli(
|
||||
[
|
||||
'dev',
|
||||
'__complete',
|
||||
'--shell',
|
||||
'zsh',
|
||||
'--position',
|
||||
'7',
|
||||
'--',
|
||||
'ktx',
|
||||
'connection',
|
||||
'add',
|
||||
'notion',
|
||||
'docs',
|
||||
'--crawl-mode',
|
||||
'',
|
||||
],
|
||||
choiceIo.io,
|
||||
),
|
||||
).resolves.toBe(0);
|
||||
|
||||
expect(optionIo.stdout()).toContain('--crawl-mode:Notion crawl mode');
|
||||
expect(choiceIo.stdout()).toContain('all_accessible');
|
||||
expect(choiceIo.stdout()).toContain('selected_roots');
|
||||
expect(optionIo.stderr()).toBe('');
|
||||
expect(choiceIo.stderr()).toBe('');
|
||||
expect(completionIo.stderr()).toMatch(/unknown command|error:/);
|
||||
expect(hiddenIo.stderr()).toMatch(/unknown command|error:/);
|
||||
});
|
||||
|
||||
it('rejects removed serve commands', async () => {
|
||||
|
|
@ -666,35 +513,22 @@ describe('runKtxCli', () => {
|
|||
expect(testIo.stderr()).toMatch(/unknown command|error:/);
|
||||
});
|
||||
|
||||
it('routes public ingest through the public ingest parser', async () => {
|
||||
it('rejects removed public ingest shorthand', async () => {
|
||||
const testIo = makeIo();
|
||||
const ingest = vi.fn().mockResolvedValue(0);
|
||||
|
||||
await expect(
|
||||
runKtxCli(['--project-dir', '/tmp/project', 'ingest', 'warehouse'], testIo.io, { publicIngest: ingest }),
|
||||
).resolves.toBe(0);
|
||||
await expect(runKtxCli(['--project-dir', '/tmp/project', 'ingest', 'warehouse'], testIo.io, { ingest }))
|
||||
.resolves.toBe(1);
|
||||
|
||||
expect(ingest).toHaveBeenCalledWith(
|
||||
{
|
||||
command: 'run',
|
||||
projectDir: '/tmp/project',
|
||||
targetConnectionId: 'warehouse',
|
||||
all: false,
|
||||
json: false,
|
||||
inputMode: 'auto',
|
||||
},
|
||||
testIo.io,
|
||||
);
|
||||
expect(ingest).not.toHaveBeenCalled();
|
||||
expect(testIo.stderr()).toMatch(/unknown command|error:/);
|
||||
});
|
||||
|
||||
it('prints public ingest watch help from Commander', async () => {
|
||||
it('prints ingest watch help from Commander', async () => {
|
||||
const testIo = makeIo();
|
||||
const publicIngest = vi.fn(async () => 0);
|
||||
const lowLevelIngest = vi.fn(async () => 0);
|
||||
const ingest = vi.fn(async () => 0);
|
||||
|
||||
await expect(
|
||||
runKtxCli(['ingest', 'watch', '--help'], testIo.io, { publicIngest, ingest: lowLevelIngest }),
|
||||
).resolves.toBe(0);
|
||||
await expect(runKtxCli(['ingest', 'watch', '--help'], testIo.io, { ingest })).resolves.toBe(0);
|
||||
|
||||
expect(testIo.stdout()).toContain('Usage: ktx ingest watch [options] [runId]');
|
||||
expect(testIo.stdout()).toContain('[runId]');
|
||||
|
|
@ -702,43 +536,42 @@ describe('runKtxCli', () => {
|
|||
expect(testIo.stdout()).toContain('--json');
|
||||
expect(testIo.stdout()).toContain('--no-input');
|
||||
expect(testIo.stderr()).toBe('');
|
||||
expect(publicIngest).not.toHaveBeenCalled();
|
||||
expect(lowLevelIngest).not.toHaveBeenCalled();
|
||||
expect(ingest).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('dispatches public ingest status and watch through Commander', async () => {
|
||||
it('dispatches ingest status and watch through Commander', async () => {
|
||||
const statusIo = makeIo();
|
||||
const watchIo = makeIo();
|
||||
const publicIngest = vi.fn(async () => 0);
|
||||
const ingest = vi.fn(async () => 0);
|
||||
|
||||
await expect(
|
||||
runKtxCli(['--project-dir', tempDir, 'ingest', 'status', 'run-1', '--json', '--no-input'], statusIo.io, {
|
||||
publicIngest,
|
||||
ingest,
|
||||
}),
|
||||
).resolves.toBe(0);
|
||||
await expect(
|
||||
runKtxCli(['--project-dir', tempDir, 'ingest', 'watch', '--no-input'], watchIo.io, {
|
||||
publicIngest,
|
||||
ingest,
|
||||
}),
|
||||
).resolves.toBe(0);
|
||||
|
||||
expect(publicIngest).toHaveBeenNthCalledWith(
|
||||
expect(ingest).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
{
|
||||
command: 'status',
|
||||
projectDir: tempDir,
|
||||
runId: 'run-1',
|
||||
json: true,
|
||||
outputMode: 'json',
|
||||
inputMode: 'disabled',
|
||||
},
|
||||
statusIo.io,
|
||||
);
|
||||
expect(publicIngest).toHaveBeenNthCalledWith(
|
||||
expect(ingest).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
{
|
||||
command: 'watch',
|
||||
projectDir: tempDir,
|
||||
json: false,
|
||||
outputMode: 'viz',
|
||||
inputMode: 'disabled',
|
||||
},
|
||||
watchIo.io,
|
||||
|
|
@ -778,60 +611,44 @@ describe('runKtxCli', () => {
|
|||
expect(setup).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('prints public ingest help without invoking ingest execution', async () => {
|
||||
it('prints ingest help without invoking ingest execution', async () => {
|
||||
const testIo = makeIo();
|
||||
const publicIngest = vi.fn();
|
||||
const lowLevelIngest = vi.fn();
|
||||
const ingest = vi.fn();
|
||||
|
||||
await expect(runKtxCli(['ingest', '--help'], testIo.io, { publicIngest, ingest: lowLevelIngest })).resolves.toBe(0);
|
||||
await expect(runKtxCli(['ingest', '--help'], testIo.io, { ingest })).resolves.toBe(0);
|
||||
|
||||
expect(testIo.stdout()).toContain('Usage: ktx ingest [options] [connectionId]');
|
||||
expect(testIo.stdout()).toContain('Build and refresh KTX context from configured sources');
|
||||
expect(testIo.stdout()).toContain('Usage: ktx ingest [options] [command]');
|
||||
expect(testIo.stdout()).toContain('Run or inspect local ingest memory-flow output');
|
||||
expect(testIo.stdout()).toContain('run');
|
||||
expect(testIo.stdout()).toContain('status');
|
||||
expect(testIo.stdout()).toContain('watch');
|
||||
expect(testIo.stdout()).toContain('ktx ingest --all [options]');
|
||||
expect(testIo.stdout()).toContain('ktx ingest status [runId] [options]');
|
||||
expect(testIo.stdout()).toContain('ktx ingest watch [runId] [options]');
|
||||
expect(testIo.stdout()).not.toContain('ktx ingest replay <runId> [options]');
|
||||
expect(testIo.stdout()).toContain('--no-input');
|
||||
expect(testIo.stdout()).not.toContain('--adapter');
|
||||
expect(testIo.stdout()).toContain('replay');
|
||||
expect(testIo.stdout()).not.toContain('--all');
|
||||
expect(testIo.stderr()).toBe('');
|
||||
expect(publicIngest).not.toHaveBeenCalled();
|
||||
expect(lowLevelIngest).not.toHaveBeenCalled();
|
||||
expect(ingest).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('reserves public ingest run while keeping dev ingest run available', async () => {
|
||||
const publicRunIo = makeIo();
|
||||
const publicHelpIo = makeIo();
|
||||
it('routes ingest run at the top level and rejects removed dev ingest', async () => {
|
||||
const runIo = makeIo();
|
||||
const devRunIo = makeIo();
|
||||
const publicIngest = vi.fn(async () => 0);
|
||||
const lowLevelIngest = vi.fn(async () => 0);
|
||||
|
||||
await expect(runKtxCli(['ingest', 'run'], publicRunIo.io, { publicIngest, ingest: lowLevelIngest })).resolves.toBe(
|
||||
1,
|
||||
);
|
||||
expect(publicRunIo.stderr()).toMatch(/invalid argument|reserved|run/i);
|
||||
expect(publicIngest).not.toHaveBeenCalled();
|
||||
const ingest = vi.fn(async () => 0);
|
||||
|
||||
await expect(
|
||||
runKtxCli(['ingest', 'run', '--help'], publicHelpIo.io, { publicIngest, ingest: lowLevelIngest }),
|
||||
runKtxCli(['ingest', 'run', '--connection-id', 'warehouse', '--adapter', 'metabase'], runIo.io, { ingest }),
|
||||
).resolves.toBe(0);
|
||||
expect(publicHelpIo.stdout()).toContain('Usage: ktx ingest [options] [connectionId]');
|
||||
expect(publicHelpIo.stdout()).not.toContain('Usage: ktx ingest ' + 'run');
|
||||
|
||||
await expect(
|
||||
runKtxCli(['dev', 'ingest', 'run', '--connection-id', 'warehouse', '--adapter', 'metabase'], devRunIo.io, {
|
||||
publicIngest,
|
||||
ingest: lowLevelIngest,
|
||||
ingest,
|
||||
}),
|
||||
).resolves.toBe(0);
|
||||
expect(lowLevelIngest).toHaveBeenCalledWith(
|
||||
).resolves.toBe(1);
|
||||
expect(ingest).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ command: 'run', connectionId: 'warehouse', adapter: 'metabase' }),
|
||||
expect.anything(),
|
||||
);
|
||||
expect(devRunIo.stderr()).toMatch(/unknown command|error:/);
|
||||
});
|
||||
|
||||
it('rejects removed dev doctor while keeping ingest parser cases under dev', async () => {
|
||||
it('rejects removed dev doctor while keeping ingest parser cases at the root', async () => {
|
||||
const doctor = vi.fn(async () => 0);
|
||||
const ingest = vi.fn(async () => 0);
|
||||
const doctorIo = makeIo();
|
||||
|
|
@ -842,7 +659,6 @@ describe('runKtxCli', () => {
|
|||
await expect(
|
||||
runKtxCli(
|
||||
[
|
||||
'dev',
|
||||
'ingest',
|
||||
'run',
|
||||
'--project-dir',
|
||||
|
|
@ -862,7 +678,7 @@ describe('runKtxCli', () => {
|
|||
{ ingest },
|
||||
),
|
||||
).resolves.toBe(0);
|
||||
await expect(runKtxCli(['dev', 'ingest', 'replay', '--help'], ingestReplayHelpIo.io, { ingest })).resolves.toBe(0);
|
||||
await expect(runKtxCli(['ingest', 'replay', '--help'], ingestReplayHelpIo.io, { ingest })).resolves.toBe(0);
|
||||
|
||||
expect(doctor).not.toHaveBeenCalled();
|
||||
expect(ingest).toHaveBeenCalledWith(
|
||||
|
|
@ -881,7 +697,7 @@ describe('runKtxCli', () => {
|
|||
},
|
||||
ingestRunIo.io,
|
||||
);
|
||||
expect(ingestReplayHelpIo.stdout()).toContain('Usage: ktx dev ingest replay [options] <runId>');
|
||||
expect(ingestReplayHelpIo.stdout()).toContain('Usage: ktx ingest replay [options] <runId>');
|
||||
expect(ingestReplayHelpIo.stdout()).toContain('<runId>');
|
||||
expect(doctorIo.stderr()).toMatch(/unknown command|error:/);
|
||||
expect(ingestRunIo.stderr()).toBe('');
|
||||
|
|
@ -896,7 +712,6 @@ describe('runKtxCli', () => {
|
|||
await expect(
|
||||
runKtxCli(
|
||||
[
|
||||
'dev',
|
||||
'ingest',
|
||||
'run',
|
||||
'--project-dir',
|
||||
|
|
@ -914,7 +729,6 @@ describe('runKtxCli', () => {
|
|||
await expect(
|
||||
runKtxCli(
|
||||
[
|
||||
'dev',
|
||||
'ingest',
|
||||
'run',
|
||||
'--project-dir',
|
||||
|
|
@ -1511,7 +1325,7 @@ describe('runKtxCli', () => {
|
|||
'ktx connection mapping refresh <connectionId> --auto-accept',
|
||||
'ktx connection mapping set <connectionId> databaseMappings <id>=<target>',
|
||||
'ktx connection mapping set-sync-enabled <connectionId> <id> --enabled true',
|
||||
'ktx ingest <connectionId>',
|
||||
'ktx ingest run --connection-id <connectionId> --adapter metabase',
|
||||
]) {
|
||||
expect(helpIo.stdout()).toContain(line);
|
||||
}
|
||||
|
|
@ -1652,7 +1466,6 @@ describe('runKtxCli', () => {
|
|||
for (const argv of [
|
||||
['init'],
|
||||
['connect', 'list'],
|
||||
['scan', 'warehouse'],
|
||||
['knowledge', 'list'],
|
||||
['ask', 'What sources are connected?'],
|
||||
]) {
|
||||
|
|
@ -1823,11 +1636,11 @@ describe('runKtxCli', () => {
|
|||
expect(testIo.stderr()).toContain('[debug] dispatch=connection');
|
||||
});
|
||||
|
||||
it('routes low-level scan through ktx dev with top-level project-dir', async () => {
|
||||
it('routes scan through the top-level command with top-level project-dir', async () => {
|
||||
const testIo = makeIo();
|
||||
const scan = vi.fn().mockResolvedValue(0);
|
||||
|
||||
await expect(runKtxCli(['--project-dir', tempDir, 'dev', 'scan', 'warehouse'], testIo.io, { scan })).resolves.toBe(
|
||||
await expect(runKtxCli(['--project-dir', tempDir, 'scan', 'warehouse'], testIo.io, { scan })).resolves.toBe(
|
||||
0,
|
||||
);
|
||||
|
||||
|
|
@ -1853,12 +1666,12 @@ describe('runKtxCli', () => {
|
|||
const conflictIo = makeIo();
|
||||
const scan = vi.fn().mockResolvedValue(0);
|
||||
|
||||
await expect(runKtxCli(['--project-dir', tempDir, 'dev', 'scan', 'warehouse', '--yes'], autoIo.io, { scan }))
|
||||
await expect(runKtxCli(['--project-dir', tempDir, 'scan', 'warehouse', '--yes'], autoIo.io, { scan }))
|
||||
.resolves.toBe(0);
|
||||
await expect(runKtxCli(['--project-dir', tempDir, 'dev', 'scan', 'warehouse', '--no-input'], neverIo.io, { scan }))
|
||||
await expect(runKtxCli(['--project-dir', tempDir, 'scan', 'warehouse', '--no-input'], neverIo.io, { scan }))
|
||||
.resolves.toBe(0);
|
||||
await expect(
|
||||
runKtxCli(['--project-dir', tempDir, 'dev', 'scan', 'warehouse', '--yes', '--no-input'], conflictIo.io, {
|
||||
runKtxCli(['--project-dir', tempDir, 'scan', 'warehouse', '--yes', '--no-input'], conflictIo.io, {
|
||||
scan,
|
||||
}),
|
||||
).resolves.toBe(1);
|
||||
|
|
@ -1913,44 +1726,38 @@ describe('runKtxCli', () => {
|
|||
await expect(runKtxCli(['dev'], testIo.io)).resolves.toBe(0);
|
||||
|
||||
expect(testIo.stdout()).toContain('Usage: ktx dev [options] [command]');
|
||||
expect(testIo.stdout()).toContain('Low-level diagnostics');
|
||||
expect(testIo.stdout()).toContain('scan');
|
||||
expect(testIo.stdout()).toContain('ingest');
|
||||
expect(testIo.stdout()).toContain('mapping');
|
||||
expect(testIo.stdout()).toContain('Low-level project initialization');
|
||||
expect(testIo.stdout()).toContain('init');
|
||||
expect(testIo.stdout()).toContain('runtime');
|
||||
expect(testIo.stdout()).not.toContain('scan');
|
||||
expect(testIo.stdout()).not.toContain('ingest');
|
||||
expect(testIo.stdout()).not.toContain('mapping');
|
||||
expect(testIo.stdout()).not.toContain('model');
|
||||
expect(testIo.stdout()).not.toContain('knowledge');
|
||||
expect(testIo.stderr()).toBe('');
|
||||
});
|
||||
|
||||
it('prints dev command help without invoking low-level execution', async () => {
|
||||
for (const [command, expected] of [
|
||||
['scan', ['Usage: ktx dev scan', '--dry-run', 'status', 'report']],
|
||||
['ingest', ['Usage: ktx dev ingest', 'run', 'replay']],
|
||||
['mapping', ['Usage: ktx dev mapping', 'sync-state', 'validate']],
|
||||
] as const) {
|
||||
it('rejects removed dev command groups without invoking execution', async () => {
|
||||
for (const command of ['scan', 'ingest', 'mapping']) {
|
||||
const testIo = makeIo();
|
||||
const scan = vi.fn().mockResolvedValue(0);
|
||||
const sl = vi.fn().mockResolvedValue(0);
|
||||
|
||||
await expect(runKtxCli(['dev', command, '--help'], testIo.io, { scan, sl })).resolves.toBe(0);
|
||||
await expect(runKtxCli(['dev', command], testIo.io, { scan, sl })).resolves.toBe(1);
|
||||
|
||||
for (const text of expected) {
|
||||
expect(testIo.stdout()).toContain(text);
|
||||
}
|
||||
expect(testIo.stderr()).toBe('');
|
||||
expect(testIo.stderr()).toMatch(/unknown command|error:/);
|
||||
expect(scan).not.toHaveBeenCalled();
|
||||
expect(sl).not.toHaveBeenCalled();
|
||||
}
|
||||
});
|
||||
|
||||
it('prints dev scan subcommand help without invoking scan execution', async () => {
|
||||
it('rejects removed scan subcommands without invoking scan execution', async () => {
|
||||
const testIo = makeIo();
|
||||
const scan = vi.fn().mockResolvedValue(0);
|
||||
|
||||
await expect(runKtxCli(['dev', 'scan', 'report', '--help'], testIo.io, { scan })).resolves.toBe(0);
|
||||
await expect(runKtxCli(['scan', 'report'], testIo.io, { scan })).resolves.toBe(1);
|
||||
|
||||
expect(testIo.stdout()).toContain('Usage: ktx dev scan report [options] <runId>');
|
||||
expect(testIo.stderr()).toBe('');
|
||||
expect(testIo.stderr()).toMatch(/too many arguments|unknown command|error:/);
|
||||
expect(scan).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
|
|
@ -1966,8 +1773,8 @@ describe('runKtxCli', () => {
|
|||
const ingest = vi.fn(async () => 0);
|
||||
|
||||
for (const argv of [
|
||||
['dev', 'ingest', 'run', '--connection-id', 'warehouse', '--adapter', 'fake', '--json', '--plain'],
|
||||
['dev', 'ingest', 'status', 'run-1', '--json', '--viz'],
|
||||
['ingest', 'run', '--connection-id', 'warehouse', '--adapter', 'fake', '--json', '--plain'],
|
||||
['ingest', 'status', 'run-1', '--json', '--viz'],
|
||||
]) {
|
||||
const testIo = makeIo();
|
||||
await expect(runKtxCli(argv, testIo.io, { ingest })).resolves.toBe(1);
|
||||
|
|
|
|||
|
|
@ -107,7 +107,7 @@ describe('runKtxIngest', () => {
|
|||
expect(statusIo.stderr()).toBe('');
|
||||
});
|
||||
|
||||
it('prints provider setup guidance when a skip-llm setup project runs dev ingest', async () => {
|
||||
it('prints provider setup guidance when a skip-llm setup project runs ingest', async () => {
|
||||
const projectDir = join(tempDir, 'project');
|
||||
const setupIo = makeIo();
|
||||
await expect(
|
||||
|
|
@ -168,7 +168,7 @@ describe('runKtxIngest', () => {
|
|||
|
||||
expect(runIo.stdout()).toBe('');
|
||||
expect(runIo.stderr()).toContain(
|
||||
'ktx dev ingest run requires llm.provider.backend: anthropic, vertex, or gateway, or an injected agentRunner.',
|
||||
'ktx ingest run requires llm.provider.backend: anthropic, vertex, or gateway, or an injected agentRunner.',
|
||||
);
|
||||
expect(runIo.stderr()).toContain(
|
||||
`ktx setup --project-dir ${projectDir} --anthropic-api-key-env ANTHROPIC_API_KEY --anthropic-model claude-sonnet-4-6 --no-input`,
|
||||
|
|
@ -663,7 +663,7 @@ describe('runKtxIngest', () => {
|
|||
).resolves.toBe(1);
|
||||
|
||||
expect(io.stderr()).toContain('source-dir uploads are not supported for the Metabase fan-out adapter');
|
||||
expect(io.stderr()).not.toContain('ktx dev ingest run requires llm.provider.backend');
|
||||
expect(io.stderr()).not.toContain('ktx ingest run requires llm.provider.backend');
|
||||
expect(io.stdout()).toBe('');
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -518,7 +518,9 @@ export async function runKtxIngest(
|
|||
const project = await loadKtxProject({ projectDir: args.projectDir });
|
||||
const env = deps.env ?? process.env;
|
||||
if (args.command === 'run') {
|
||||
const createAdapters = deps.createAdapters ?? createKtxCliLocalIngestAdapters;
|
||||
const createAdapters =
|
||||
deps.createAdapters ??
|
||||
(deps.runLocalIngest || deps.runLocalMetabaseIngest ? () => [] : createKtxCliLocalIngestAdapters);
|
||||
const executeLocalIngest = deps.runLocalIngest ?? runLocalIngest;
|
||||
const localIngestOptions = deps.localIngestOptions ?? {};
|
||||
const managedDaemon = managedDaemonOptionsForIngestRun(args, io);
|
||||
|
|
@ -645,7 +647,7 @@ export async function runKtxIngest(
|
|||
throw new Error(
|
||||
args.runId
|
||||
? `Local ingest run or report "${args.runId}" was not found`
|
||||
: 'No local ingest reports were found. Run `ktx ingest --all` first.',
|
||||
: 'No local ingest reports were found. Run `ktx ingest run --connection-id <id> --adapter <adapter>` first.',
|
||||
);
|
||||
}
|
||||
await writeReportRecord(report, args.outputMode, io, {
|
||||
|
|
|
|||
|
|
@ -33,10 +33,9 @@ describe('project directory defaults', () => {
|
|||
const connection = vi.fn(async () => 0);
|
||||
const doctor = vi.fn(async () => 0);
|
||||
const ingest = vi.fn(async () => 0);
|
||||
const publicIngest = vi.fn(async () => 0);
|
||||
const scan = vi.fn(async () => 0);
|
||||
const setup = vi.fn(async () => 0);
|
||||
const deps: KtxCliDeps = { connection, doctor, ingest, publicIngest, scan, setup };
|
||||
const deps: KtxCliDeps = { connection, doctor, ingest, scan, setup };
|
||||
|
||||
const cases: Array<{
|
||||
argv: string[];
|
||||
|
|
@ -58,8 +57,8 @@ describe('project directory defaults', () => {
|
|||
},
|
||||
{
|
||||
argv: ['ingest', 'status', 'run-1'],
|
||||
spy: publicIngest,
|
||||
expected: { command: 'status', projectDir: '/tmp/ktx-env-project', runId: 'run-1' },
|
||||
spy: ingest,
|
||||
expected: { command: 'status', projectDir: '/tmp/ktx-env-project', runId: 'run-1', outputMode: 'plain' },
|
||||
expectedStderr: 'Project: /tmp/ktx-env-project\n',
|
||||
},
|
||||
{
|
||||
|
|
@ -69,7 +68,7 @@ describe('project directory defaults', () => {
|
|||
expectedStderr: 'Project: /tmp/ktx-env-project\n',
|
||||
},
|
||||
{
|
||||
argv: ['dev', 'scan', 'warehouse'],
|
||||
argv: ['scan', 'warehouse'],
|
||||
spy: scan,
|
||||
expected: { command: 'run', projectDir: '/tmp/ktx-env-project', connectionId: 'warehouse' },
|
||||
expectedStderr: 'Project: /tmp/ktx-env-project\n',
|
||||
|
|
@ -88,16 +87,16 @@ describe('project directory defaults', () => {
|
|||
process.env.KTX_PROJECT_DIR = '/tmp/ktx-env-project';
|
||||
|
||||
const scan = vi.fn(async () => 0);
|
||||
const publicIngest = vi.fn(async () => 0);
|
||||
const ingest = vi.fn(async () => 0);
|
||||
const scanIo = makeIo();
|
||||
const ingestIo = makeIo();
|
||||
|
||||
await expect(
|
||||
runKtxCli(['--project-dir', '/tmp/ktx-explicit-project', 'dev', 'scan', 'warehouse'], scanIo.io, { scan }),
|
||||
runKtxCli(['--project-dir', '/tmp/ktx-explicit-project', 'scan', 'warehouse'], scanIo.io, { scan }),
|
||||
).resolves.toBe(0);
|
||||
await expect(
|
||||
runKtxCli(['ingest', 'status', 'run-1', '--project-dir=/tmp/ktx-explicit-project'], ingestIo.io, {
|
||||
publicIngest,
|
||||
ingest,
|
||||
}),
|
||||
).resolves.toBe(0);
|
||||
|
||||
|
|
@ -105,7 +104,7 @@ describe('project directory defaults', () => {
|
|||
expect.objectContaining({ command: 'run', projectDir: '/tmp/ktx-explicit-project' }),
|
||||
scanIo.io,
|
||||
);
|
||||
expect(publicIngest).toHaveBeenCalledWith(
|
||||
expect(ingest).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ command: 'status', projectDir: '/tmp/ktx-explicit-project' }),
|
||||
ingestIo.io,
|
||||
);
|
||||
|
|
@ -132,7 +131,7 @@ describe('project directory defaults', () => {
|
|||
|
||||
try {
|
||||
process.chdir(nestedDir);
|
||||
await expect(runKtxCli(['dev', 'scan', 'warehouse'], testIo.io, { scan })).resolves.toBe(0);
|
||||
await expect(runKtxCli(['scan', 'warehouse'], testIo.io, { scan })).resolves.toBe(0);
|
||||
} finally {
|
||||
process.chdir(originalCwd);
|
||||
await rm(root, { recursive: true, force: true });
|
||||
|
|
|
|||
|
|
@ -57,7 +57,7 @@ describe('buildPublicIngestPlan', () => {
|
|||
driver: 'notion',
|
||||
operation: 'source-ingest',
|
||||
adapter: 'notion',
|
||||
debugCommand: 'ktx dev ingest run --connection-id docs --adapter notion --debug',
|
||||
debugCommand: 'ktx ingest run --connection-id docs --adapter notion --debug',
|
||||
steps: ['source-ingest', 'memory-update'],
|
||||
},
|
||||
{
|
||||
|
|
@ -65,7 +65,7 @@ describe('buildPublicIngestPlan', () => {
|
|||
driver: 'metabase',
|
||||
operation: 'source-ingest',
|
||||
adapter: 'metabase',
|
||||
debugCommand: 'ktx dev ingest run --connection-id prod_metabase --adapter metabase --debug',
|
||||
debugCommand: 'ktx ingest run --connection-id prod_metabase --adapter metabase --debug',
|
||||
steps: ['source-ingest', 'memory-update'],
|
||||
},
|
||||
],
|
||||
|
|
@ -76,7 +76,7 @@ describe('buildPublicIngestPlan', () => {
|
|||
const project = projectWithConnections({ warehouse: { driver: 'postgres' } });
|
||||
|
||||
expect(() => buildPublicIngestPlan(project, { projectDir: '/tmp/project', all: false })).toThrow(
|
||||
'ktx ingest requires <connectionId> or --all in this release',
|
||||
'Context build requires a connection id or all targets',
|
||||
);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -107,7 +107,7 @@ function targetForConnection(connectionId: string, connection: KtxProjectConnect
|
|||
operation: 'source-ingest',
|
||||
adapter,
|
||||
...(sourceDir ? { sourceDir } : {}),
|
||||
debugCommand: `ktx dev ingest run --connection-id ${connectionId} --adapter ${adapter} --debug`,
|
||||
debugCommand: `ktx ingest run --connection-id ${connectionId} --adapter ${adapter} --debug`,
|
||||
steps: ['source-ingest', 'memory-update'],
|
||||
};
|
||||
}
|
||||
|
|
@ -130,7 +130,7 @@ export function buildPublicIngestPlan(
|
|||
args: { projectDir: string; targetConnectionId?: string; all: boolean },
|
||||
): KtxPublicIngestPlan {
|
||||
if (!args.all && !args.targetConnectionId) {
|
||||
throw new Error('ktx ingest requires <connectionId> or --all in this release');
|
||||
throw new Error('Context build requires a connection id or all targets');
|
||||
}
|
||||
|
||||
const entries = Object.entries(project.config.connections).sort(([a], [b]) => a.localeCompare(b));
|
||||
|
|
|
|||
|
|
@ -5,7 +5,6 @@ import type {
|
|||
ManagedPythonDaemonStopResult,
|
||||
} from './managed-python-daemon.js';
|
||||
import type {
|
||||
ManagedPythonRuntimeDoctorCheck,
|
||||
ManagedPythonRuntimeInstallResult,
|
||||
ManagedPythonRuntimeStatus,
|
||||
} from './managed-python-runtime.js';
|
||||
|
|
@ -290,28 +289,6 @@ describe('runKtxRuntime', () => {
|
|||
});
|
||||
});
|
||||
|
||||
it('returns failure for doctor when any check fails', async () => {
|
||||
const io = makeIo();
|
||||
const deps: KtxRuntimeDeps = {
|
||||
doctorRuntime: vi.fn(async (): Promise<ManagedPythonRuntimeDoctorCheck[]> => [
|
||||
{ id: 'uv', label: 'uv', status: 'pass', detail: 'uv 0.9.5' },
|
||||
{
|
||||
id: 'runtime',
|
||||
label: 'Managed Python runtime',
|
||||
status: 'fail',
|
||||
detail: 'No runtime manifest',
|
||||
fix: 'Run: ktx dev runtime install --yes',
|
||||
},
|
||||
]),
|
||||
};
|
||||
|
||||
await expect(runKtxRuntime({ command: 'doctor', cliVersion: '0.2.0', json: false }, io.io, deps)).resolves.toBe(1);
|
||||
|
||||
expect(io.stdout()).toContain('PASS uv: uv 0.9.5');
|
||||
expect(io.stdout()).toContain('FAIL Managed Python runtime: No runtime manifest');
|
||||
expect(io.stdout()).toContain('Fix: Run: ktx dev runtime install --yes');
|
||||
});
|
||||
|
||||
it('requires --yes before pruning stale runtime directories', async () => {
|
||||
const io = makeIo();
|
||||
const deps: KtxRuntimeDeps = {
|
||||
|
|
|
|||
|
|
@ -8,12 +8,10 @@ import {
|
|||
type ManagedPythonDaemonStopResult,
|
||||
} from './managed-python-daemon.js';
|
||||
import {
|
||||
doctorManagedPythonRuntime,
|
||||
installManagedPythonRuntime,
|
||||
pruneManagedPythonRuntimes,
|
||||
readManagedPythonRuntimeStatus,
|
||||
type KtxRuntimeFeature,
|
||||
type ManagedPythonRuntimeDoctorCheck,
|
||||
type ManagedPythonRuntimeInstallOptions,
|
||||
type ManagedPythonRuntimeInstallResult,
|
||||
type ManagedPythonRuntimeLayoutOptions,
|
||||
|
|
@ -26,7 +24,6 @@ export type KtxRuntimeArgs =
|
|||
| { command: 'start'; cliVersion: string; feature: KtxRuntimeFeature; force: boolean }
|
||||
| { command: 'stop'; cliVersion: string; all: boolean }
|
||||
| { command: 'status'; cliVersion: string; json: boolean }
|
||||
| { command: 'doctor'; cliVersion: string; json: boolean }
|
||||
| { command: 'prune'; cliVersion: string; dryRun: boolean; yes: boolean };
|
||||
|
||||
export interface KtxRuntimeDeps {
|
||||
|
|
@ -39,7 +36,6 @@ export interface KtxRuntimeDeps {
|
|||
stopDaemon?: (options: { cliVersion: string }) => Promise<ManagedPythonDaemonStopResult>;
|
||||
stopAllDaemons?: (options: { cliVersion: string }) => Promise<ManagedPythonDaemonStopAllResult>;
|
||||
readStatus?: (options: ManagedPythonRuntimeLayoutOptions) => Promise<ManagedPythonRuntimeStatus>;
|
||||
doctorRuntime?: (options: ManagedPythonRuntimeLayoutOptions) => Promise<ManagedPythonRuntimeDoctorCheck[]>;
|
||||
pruneRuntime?: (options: {
|
||||
cliVersion: string;
|
||||
runtimeRoot: string;
|
||||
|
|
@ -149,16 +145,6 @@ function writeStatus(io: KtxCliIo, status: ManagedPythonRuntimeStatus): void {
|
|||
}
|
||||
}
|
||||
|
||||
function writeDoctor(io: KtxCliIo, checks: ManagedPythonRuntimeDoctorCheck[]): void {
|
||||
io.stdout.write('KTX Python runtime doctor\n');
|
||||
for (const check of checks) {
|
||||
io.stdout.write(`${check.status.toUpperCase()} ${check.label}: ${check.detail}\n`);
|
||||
if (check.fix) {
|
||||
io.stdout.write(` Fix: ${check.fix}\n`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function writePrune(io: KtxCliIo, result: ManagedPythonRuntimePruneResult, dryRun: boolean): void {
|
||||
if (result.stale.length === 0) {
|
||||
io.stdout.write(`No stale KTX Python runtimes found under ${result.runtimeRoot}\n`);
|
||||
|
|
@ -218,16 +204,6 @@ export async function runKtxRuntime(
|
|||
}
|
||||
return 0;
|
||||
}
|
||||
if (args.command === 'doctor') {
|
||||
const doctorRuntime = deps.doctorRuntime ?? doctorManagedPythonRuntime;
|
||||
const checks = await doctorRuntime({ cliVersion: args.cliVersion });
|
||||
if (args.json) {
|
||||
writeJson(io, { checks });
|
||||
} else {
|
||||
writeDoctor(io, checks);
|
||||
}
|
||||
return checks.some((check) => check.status === 'fail') ? 1 : 0;
|
||||
}
|
||||
if (!args.dryRun && !args.yes) {
|
||||
io.stderr.write('Refusing to prune without --yes. Preview with: ktx dev runtime prune --dry-run\n');
|
||||
return 1;
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -1,34 +1,10 @@
|
|||
import { loadKtxProject } from '@ktx/context/project';
|
||||
import {
|
||||
type ApplyLocalScanRelationshipReviewDecisionsResult,
|
||||
adviseLocalRelationshipFeedbackThresholds,
|
||||
applyLocalScanRelationshipReviewDecisions,
|
||||
calibrateLocalRelationshipFeedbackLabels,
|
||||
type ExportLocalRelationshipFeedbackLabelsResult,
|
||||
exportLocalRelationshipFeedbackLabels,
|
||||
formatKtxRelationshipFeedbackCalibrationMarkdown,
|
||||
formatKtxRelationshipFeedbackLabelsJsonl,
|
||||
formatKtxRelationshipThresholdAdviceMarkdown,
|
||||
getLocalScanReport,
|
||||
getLocalScanStatus,
|
||||
type KtxProgressPort,
|
||||
type KtxRelationshipArtifact,
|
||||
type KtxRelationshipArtifactEdge,
|
||||
type KtxRelationshipArtifactStatus,
|
||||
type KtxRelationshipDiagnosticsArtifact,
|
||||
type KtxRelationshipFeedbackCalibrationReport,
|
||||
type KtxRelationshipFeedbackDecisionFilter,
|
||||
type KtxRelationshipFeedbackLabel,
|
||||
type KtxRelationshipReviewDecisionValue,
|
||||
type KtxRelationshipThresholdAdviceReport,
|
||||
type KtxScanMode,
|
||||
type KtxScanReport,
|
||||
type KtxScanWarning,
|
||||
type LocalScanStatusResponse,
|
||||
readLocalScanRelationshipArtifacts,
|
||||
runLocalScan,
|
||||
type WriteLocalScanRelationshipReviewDecisionResult,
|
||||
writeLocalScanRelationshipReviewDecision,
|
||||
} from '@ktx/context/scan';
|
||||
import type { KtxCliIo } from './index.js';
|
||||
import { createKtxCliLocalIngestAdapters } from './local-adapters.js';
|
||||
|
|
@ -38,88 +14,21 @@ import { profileMark } from './startup-profile.js';
|
|||
|
||||
profileMark('module:scan');
|
||||
|
||||
export type KtxScanArgs =
|
||||
| {
|
||||
command: 'run';
|
||||
projectDir: string;
|
||||
connectionId: string;
|
||||
mode: KtxScanMode;
|
||||
detectRelationships: boolean;
|
||||
dryRun: boolean;
|
||||
databaseIntrospectionUrl?: string;
|
||||
cliVersion?: string;
|
||||
runtimeInstallPolicy?: KtxManagedPythonInstallPolicy;
|
||||
}
|
||||
| { command: 'status'; projectDir: string; runId: string }
|
||||
| { command: 'report'; projectDir: string; runId: string; json: boolean }
|
||||
| {
|
||||
command: 'relationships';
|
||||
projectDir: string;
|
||||
runId: string;
|
||||
status: KtxRelationshipArtifactStatus;
|
||||
json: boolean;
|
||||
limit: number;
|
||||
}
|
||||
| {
|
||||
command: 'relationshipDecision';
|
||||
projectDir: string;
|
||||
runId: string;
|
||||
candidateId: string;
|
||||
decision: KtxRelationshipReviewDecisionValue;
|
||||
reviewer: string;
|
||||
note: string | null;
|
||||
json: boolean;
|
||||
}
|
||||
| {
|
||||
command: 'relationshipApply';
|
||||
projectDir: string;
|
||||
runId: string;
|
||||
applyAllAccepted: boolean;
|
||||
candidateIds: string[];
|
||||
dryRun: boolean;
|
||||
json: boolean;
|
||||
}
|
||||
| {
|
||||
command: 'relationshipFeedback';
|
||||
projectDir: string;
|
||||
connectionId: string | null;
|
||||
decision: KtxRelationshipFeedbackDecisionFilter;
|
||||
json: boolean;
|
||||
jsonl: boolean;
|
||||
}
|
||||
| {
|
||||
command: 'relationshipCalibration';
|
||||
projectDir: string;
|
||||
connectionId: string | null;
|
||||
decision: KtxRelationshipFeedbackDecisionFilter;
|
||||
acceptThreshold: number;
|
||||
reviewThreshold: number;
|
||||
json: boolean;
|
||||
}
|
||||
| {
|
||||
command: 'relationshipThresholds';
|
||||
projectDir: string;
|
||||
connectionId: string | null;
|
||||
minTotalLabels: number;
|
||||
minAcceptedLabels: number;
|
||||
minRejectedLabels: number;
|
||||
json: boolean;
|
||||
};
|
||||
export interface KtxScanArgs {
|
||||
command: 'run';
|
||||
projectDir: string;
|
||||
connectionId: string;
|
||||
mode: KtxScanMode;
|
||||
detectRelationships: boolean;
|
||||
dryRun: boolean;
|
||||
databaseIntrospectionUrl?: string;
|
||||
cliVersion?: string;
|
||||
runtimeInstallPolicy?: KtxManagedPythonInstallPolicy;
|
||||
}
|
||||
|
||||
interface KtxScanDeps {
|
||||
runLocalScan?: typeof runLocalScan;
|
||||
createLocalIngestAdapters?: typeof createKtxCliLocalIngestAdapters;
|
||||
getLocalScanStatus?: typeof getLocalScanStatus;
|
||||
getLocalScanReport?: typeof getLocalScanReport;
|
||||
readLocalScanRelationshipArtifacts?: typeof readLocalScanRelationshipArtifacts;
|
||||
writeLocalScanRelationshipReviewDecision?: typeof writeLocalScanRelationshipReviewDecision;
|
||||
applyLocalScanRelationshipReviewDecisions?: typeof applyLocalScanRelationshipReviewDecisions;
|
||||
exportLocalRelationshipFeedbackLabels?: typeof exportLocalRelationshipFeedbackLabels;
|
||||
formatKtxRelationshipFeedbackLabelsJsonl?: typeof formatKtxRelationshipFeedbackLabelsJsonl;
|
||||
calibrateLocalRelationshipFeedbackLabels?: typeof calibrateLocalRelationshipFeedbackLabels;
|
||||
formatKtxRelationshipFeedbackCalibrationMarkdown?: typeof formatKtxRelationshipFeedbackCalibrationMarkdown;
|
||||
adviseLocalRelationshipFeedbackThresholds?: typeof adviseLocalRelationshipFeedbackThresholds;
|
||||
formatKtxRelationshipThresholdAdviceMarkdown?: typeof formatKtxRelationshipThresholdAdviceMarkdown;
|
||||
}
|
||||
|
||||
function shouldUseStyledOutput(io: KtxCliIo): boolean {
|
||||
|
|
@ -284,208 +193,8 @@ function writeRunSummary(report: KtxScanReport, projectDir: string, io: KtxCliIo
|
|||
writeHumanReportBody(report, io);
|
||||
const projectDirArg = quoteCliArg(projectDir);
|
||||
io.stdout.write('\nNext:\n');
|
||||
const statusCommand = styled ? dim('ktx dev scan status') : 'ktx dev scan status';
|
||||
const reportCommand = styled ? dim('ktx dev scan report') : 'ktx dev scan report';
|
||||
io.stdout.write(` ${statusCommand} --project-dir ${projectDirArg} ${report.runId}\n`);
|
||||
io.stdout.write(` ${reportCommand} --project-dir ${projectDirArg} ${report.runId}\n`);
|
||||
}
|
||||
|
||||
function writeReport(report: KtxScanReport, io: KtxCliIo): void {
|
||||
io.stdout.write('KTX scan report\n');
|
||||
writeHumanReportBody(report, io);
|
||||
}
|
||||
|
||||
function formatRelationshipEndpoint(edge: KtxRelationshipArtifactEdge, side: 'from' | 'to'): string {
|
||||
const endpoint = edge[side];
|
||||
if (endpoint.columns.length === 1) {
|
||||
return `${endpoint.table.name}.${endpoint.columns[0]}`;
|
||||
}
|
||||
return `${endpoint.table.name}.(${endpoint.columns.join(',')})`;
|
||||
}
|
||||
|
||||
function formatRelationshipScore(value: number | null): string {
|
||||
return value === null ? 'n/a' : value.toFixed(2);
|
||||
}
|
||||
|
||||
function relationshipStatusTitle(status: Exclude<KtxRelationshipArtifactStatus, 'all'>): string {
|
||||
if (status === 'accepted') {
|
||||
return 'Accepted relationships';
|
||||
}
|
||||
if (status === 'review') {
|
||||
return 'Review relationships';
|
||||
}
|
||||
if (status === 'rejected') {
|
||||
return 'Rejected relationships';
|
||||
}
|
||||
return 'Skipped relationships';
|
||||
}
|
||||
|
||||
function filteredRelationshipArtifact(
|
||||
relationships: KtxRelationshipArtifact,
|
||||
status: KtxRelationshipArtifactStatus,
|
||||
): KtxRelationshipArtifact {
|
||||
if (status === 'all') {
|
||||
return relationships;
|
||||
}
|
||||
return {
|
||||
connectionId: relationships.connectionId,
|
||||
accepted: status === 'accepted' ? relationships.accepted : [],
|
||||
review: status === 'review' ? relationships.review : [],
|
||||
rejected: status === 'rejected' ? relationships.rejected : [],
|
||||
skipped: status === 'skipped' ? relationships.skipped : [],
|
||||
};
|
||||
}
|
||||
|
||||
function writeRelationshipEdge(edge: KtxRelationshipArtifactEdge, index: number, io: KtxCliIo): void {
|
||||
io.stdout.write(
|
||||
` ${index + 1}. ${formatRelationshipEndpoint(edge, 'from')} -> ${formatRelationshipEndpoint(edge, 'to')}\n`,
|
||||
);
|
||||
io.stdout.write(
|
||||
` type=${edge.relationshipType} source=${edge.source} confidence=${edge.confidence.toFixed(2)} pkScore=${formatRelationshipScore(edge.pkScore)} fkScore=${formatRelationshipScore(edge.fkScore)}\n`,
|
||||
);
|
||||
io.stdout.write(` reasons=${edge.reasons.length > 0 ? edge.reasons.join(', ') : 'none'}\n`);
|
||||
}
|
||||
|
||||
function writeRelationshipGroup(
|
||||
status: Exclude<KtxRelationshipArtifactStatus, 'all'>,
|
||||
relationships: KtxRelationshipArtifact,
|
||||
limit: number,
|
||||
io: KtxCliIo,
|
||||
): void {
|
||||
if (status === 'skipped') {
|
||||
io.stdout.write(`\n${relationshipStatusTitle(status)} (${relationships.skipped.length})\n`);
|
||||
relationships.skipped.slice(0, limit).forEach((item, index) => {
|
||||
io.stdout.write(` ${index + 1}. ${item.relationshipId}\n`);
|
||||
io.stdout.write(` reason=${item.reason}\n`);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const edges =
|
||||
status === 'accepted'
|
||||
? relationships.accepted
|
||||
: status === 'review'
|
||||
? relationships.review
|
||||
: relationships.rejected;
|
||||
io.stdout.write(`\n${relationshipStatusTitle(status)} (${edges.length})\n`);
|
||||
edges.slice(0, limit).forEach((edge, index) => {
|
||||
writeRelationshipEdge(edge, index, io);
|
||||
});
|
||||
if (edges.length > limit) {
|
||||
io.stdout.write(` ${edges.length - limit} more not shown; rerun with --limit ${edges.length}\n`);
|
||||
}
|
||||
}
|
||||
|
||||
function writeRelationshipArtifactSummary(input: {
|
||||
runId: string;
|
||||
connectionId: string;
|
||||
syncId: string;
|
||||
status: KtxRelationshipArtifactStatus;
|
||||
limit: number;
|
||||
summary: KtxRelationshipArtifact;
|
||||
relationships: KtxRelationshipArtifact;
|
||||
diagnostics: KtxRelationshipDiagnosticsArtifact | null;
|
||||
relationshipsPath: string;
|
||||
io: KtxCliIo;
|
||||
}): void {
|
||||
input.io.stdout.write('KTX relationship artifacts\n');
|
||||
input.io.stdout.write(`Run: ${input.runId}\n`);
|
||||
input.io.stdout.write(`Connection: ${input.connectionId}\n`);
|
||||
input.io.stdout.write(`Sync: ${input.syncId}\n`);
|
||||
input.io.stdout.write(
|
||||
`Summary: accepted=${input.summary.accepted.length} review=${input.summary.review.length} rejected=${input.summary.rejected.length} skipped=${input.summary.skipped.length}\n`,
|
||||
);
|
||||
if (input.diagnostics?.noAcceptedReason) {
|
||||
input.io.stdout.write(`Reason: ${input.diagnostics.noAcceptedReason}\n`);
|
||||
}
|
||||
input.io.stdout.write(`Artifacts: ${input.relationshipsPath}\n`);
|
||||
|
||||
const statuses: Array<Exclude<KtxRelationshipArtifactStatus, 'all'>> =
|
||||
input.status === 'all' ? ['accepted', 'review', 'rejected', 'skipped'] : [input.status];
|
||||
for (const status of statuses) {
|
||||
writeRelationshipGroup(status, input.relationships, input.limit, input.io);
|
||||
}
|
||||
}
|
||||
|
||||
function writeRelationshipDecisionResult(result: WriteLocalScanRelationshipReviewDecisionResult, io: KtxCliIo): void {
|
||||
io.stdout.write('Recorded relationship decision\n');
|
||||
io.stdout.write(`Decision: ${result.decision.decision}\n`);
|
||||
io.stdout.write(`Candidate: ${result.decision.candidateId}\n`);
|
||||
io.stdout.write(`Previous status: ${result.decision.previousStatus}\n`);
|
||||
io.stdout.write(`Reviewer: ${result.decision.reviewer}\n`);
|
||||
if (result.decision.note) {
|
||||
io.stdout.write(`Note: ${result.decision.note}\n`);
|
||||
}
|
||||
io.stdout.write(`Path: ${result.path}\n`);
|
||||
}
|
||||
|
||||
function writeRelationshipApplyResult(result: ApplyLocalScanRelationshipReviewDecisionsResult, io: KtxCliIo): void {
|
||||
io.stdout.write('Relationship review apply\n');
|
||||
io.stdout.write(`Run: ${result.runId}\n`);
|
||||
io.stdout.write(`Connection: ${result.connectionId}\n`);
|
||||
io.stdout.write(`Sync: ${result.syncId}\n`);
|
||||
io.stdout.write(`Mode: ${result.dryRun ? 'dry-run' : 'write'}\n`);
|
||||
io.stdout.write(`Decisions: ${result.selectedDecisions} ${plural(result.selectedDecisions, 'accepted decision')}\n`);
|
||||
io.stdout.write(
|
||||
`Applied: ${result.appliedRelationships} manual ${plural(result.appliedRelationships, 'relationship')}\n`,
|
||||
);
|
||||
io.stdout.write(`Schema shards written: ${result.manifestShardsWritten}\n`);
|
||||
if (result.manifestShards.length > 0) {
|
||||
io.stdout.write('Schema shards:\n');
|
||||
for (const shard of result.manifestShards) {
|
||||
io.stdout.write(` - ${shard}\n`);
|
||||
}
|
||||
}
|
||||
io.stdout.write(`Decisions: ${result.decisionsPath}\n`);
|
||||
}
|
||||
|
||||
function formatFeedbackColumns(columns: readonly string[]): string {
|
||||
return columns.length === 1 ? (columns[0] ?? 'unknown') : `(${columns.join(',')})`;
|
||||
}
|
||||
|
||||
function feedbackTableShortName(value: string): string {
|
||||
return value.split('.').at(-1) ?? value;
|
||||
}
|
||||
|
||||
function feedbackEndpoint(label: KtxRelationshipFeedbackLabel, side: 'from' | 'to'): string {
|
||||
if (side === 'from') {
|
||||
return `${feedbackTableShortName(label.fromTable)}.${formatFeedbackColumns(label.fromColumns)}`;
|
||||
}
|
||||
return `${feedbackTableShortName(label.toTable)}.${formatFeedbackColumns(label.toColumns)}`;
|
||||
}
|
||||
|
||||
function writeRelationshipFeedbackSummary(result: ExportLocalRelationshipFeedbackLabelsResult, io: KtxCliIo): void {
|
||||
io.stdout.write('KTX relationship feedback labels\n');
|
||||
io.stdout.write(`Generated: ${result.generatedAt}\n`);
|
||||
io.stdout.write(`Filter connection: ${result.filters.connectionId ?? 'all'}\n`);
|
||||
io.stdout.write(`Filter decision: ${result.filters.decision}\n`);
|
||||
io.stdout.write(`Total: ${result.summary.total}\n`);
|
||||
io.stdout.write(`Accepted: ${result.summary.accepted}\n`);
|
||||
io.stdout.write(`Rejected: ${result.summary.rejected}\n`);
|
||||
io.stdout.write(`Connections: ${result.summary.connections}\n`);
|
||||
io.stdout.write(`Runs: ${result.summary.runs}\n`);
|
||||
|
||||
if (result.warnings.length > 0) {
|
||||
io.stdout.write('\nWarnings\n');
|
||||
for (const warning of result.warnings.slice(0, 5)) {
|
||||
io.stdout.write(` - ${warning.path}: ${warning.message}\n`);
|
||||
}
|
||||
}
|
||||
|
||||
if (result.labels.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
io.stdout.write('\nLabels\n');
|
||||
for (const label of result.labels.slice(0, 25)) {
|
||||
io.stdout.write(` - ${feedbackEndpoint(label, 'from')} -> ${feedbackEndpoint(label, 'to')}\n`);
|
||||
io.stdout.write(
|
||||
` decision=${label.decision} previous=${label.previousStatus} score=${formatRelationshipScore(label.score)} reviewer=${label.reviewer}\n`,
|
||||
);
|
||||
}
|
||||
if (result.labels.length > 25) {
|
||||
io.stdout.write(` ${result.labels.length - 25} more labels not shown; rerun with --jsonl for the full dataset\n`);
|
||||
}
|
||||
const statusCommand = styled ? dim('ktx status') : 'ktx status';
|
||||
io.stdout.write(` ${statusCommand} --project-dir ${projectDirArg}\n`);
|
||||
}
|
||||
|
||||
interface KtxCliScanProgressState {
|
||||
|
|
@ -540,184 +249,9 @@ export function createCliScanProgress(
|
|||
return progress;
|
||||
}
|
||||
|
||||
function writeStatus(status: LocalScanStatusResponse, io: KtxCliIo): void {
|
||||
io.stdout.write(`Run: ${status.runId}\n`);
|
||||
io.stdout.write(`Status: ${status.status}\n`);
|
||||
io.stdout.write(`Connection: ${status.connectionId}\n`);
|
||||
io.stdout.write(`Mode: ${status.mode}\n`);
|
||||
io.stdout.write(`Sync: ${status.syncId}\n`);
|
||||
io.stdout.write(`Progress: ${status.progress}\n`);
|
||||
io.stdout.write(`Report: ${status.reportPath ?? 'none'}\n`);
|
||||
}
|
||||
|
||||
export async function runKtxScan(args: KtxScanArgs, io: KtxCliIo = process, deps: KtxScanDeps = {}): Promise<number> {
|
||||
try {
|
||||
const project = await loadKtxProject({ projectDir: args.projectDir });
|
||||
if (args.command === 'status') {
|
||||
const status = await (deps.getLocalScanStatus ?? getLocalScanStatus)(project, args.runId);
|
||||
if (!status) {
|
||||
throw new Error(`Scan run "${args.runId}" was not found`);
|
||||
}
|
||||
writeStatus(status, io);
|
||||
return 0;
|
||||
}
|
||||
if (args.command === 'report') {
|
||||
const report = await (deps.getLocalScanReport ?? getLocalScanReport)(project, args.runId);
|
||||
if (!report) {
|
||||
throw new Error(`Scan report "${args.runId}" was not found`);
|
||||
}
|
||||
if (args.json) {
|
||||
io.stdout.write(`${JSON.stringify(report, null, 2)}\n`);
|
||||
} else {
|
||||
writeReport(report, io);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
if (args.command === 'relationships') {
|
||||
const result = await (deps.readLocalScanRelationshipArtifacts ?? readLocalScanRelationshipArtifacts)(
|
||||
project,
|
||||
args.runId,
|
||||
);
|
||||
if (!result) {
|
||||
throw new Error(`Scan run "${args.runId}" was not found`);
|
||||
}
|
||||
const filtered = filteredRelationshipArtifact(result.relationships, args.status);
|
||||
if (args.json) {
|
||||
io.stdout.write(
|
||||
`${JSON.stringify(
|
||||
{
|
||||
runId: result.runId,
|
||||
connectionId: result.connectionId,
|
||||
syncId: result.syncId,
|
||||
status: args.status,
|
||||
paths: result.paths,
|
||||
diagnostics: result.diagnostics,
|
||||
summary: {
|
||||
accepted: result.relationships.accepted.length,
|
||||
review: result.relationships.review.length,
|
||||
rejected: result.relationships.rejected.length,
|
||||
skipped: result.relationships.skipped.length,
|
||||
},
|
||||
relationships: filtered,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
)}\n`,
|
||||
);
|
||||
} else {
|
||||
writeRelationshipArtifactSummary({
|
||||
runId: result.runId,
|
||||
connectionId: result.connectionId,
|
||||
syncId: result.syncId,
|
||||
status: args.status,
|
||||
limit: args.limit,
|
||||
summary: result.relationships,
|
||||
relationships: filtered,
|
||||
diagnostics: result.diagnostics,
|
||||
relationshipsPath: result.paths.relationships,
|
||||
io,
|
||||
});
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
if (args.command === 'relationshipDecision') {
|
||||
const result = await (deps.writeLocalScanRelationshipReviewDecision ?? writeLocalScanRelationshipReviewDecision)(
|
||||
project,
|
||||
{
|
||||
runId: args.runId,
|
||||
candidateId: args.candidateId,
|
||||
decision: args.decision,
|
||||
reviewer: args.reviewer,
|
||||
note: args.note,
|
||||
},
|
||||
);
|
||||
if (!result) {
|
||||
throw new Error(`Scan run "${args.runId}" was not found`);
|
||||
}
|
||||
if (args.json) {
|
||||
io.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
|
||||
} else {
|
||||
writeRelationshipDecisionResult(result, io);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
if (args.command === 'relationshipApply') {
|
||||
const result = await (
|
||||
deps.applyLocalScanRelationshipReviewDecisions ?? applyLocalScanRelationshipReviewDecisions
|
||||
)(project, {
|
||||
runId: args.runId,
|
||||
applyAllAccepted: args.applyAllAccepted,
|
||||
candidateIds: args.candidateIds,
|
||||
dryRun: args.dryRun,
|
||||
});
|
||||
if (args.json) {
|
||||
io.stdout.write(
|
||||
`${JSON.stringify(result satisfies ApplyLocalScanRelationshipReviewDecisionsResult, null, 2)}\n`,
|
||||
);
|
||||
} else {
|
||||
writeRelationshipApplyResult(result, io);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
if (args.command === 'relationshipFeedback') {
|
||||
const result = await (deps.exportLocalRelationshipFeedbackLabels ?? exportLocalRelationshipFeedbackLabels)(
|
||||
project,
|
||||
{
|
||||
connectionId: args.connectionId,
|
||||
decision: args.decision,
|
||||
},
|
||||
);
|
||||
if (args.jsonl) {
|
||||
io.stdout.write(
|
||||
(deps.formatKtxRelationshipFeedbackLabelsJsonl ?? formatKtxRelationshipFeedbackLabelsJsonl)(result),
|
||||
);
|
||||
} else if (args.json) {
|
||||
io.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
|
||||
} else {
|
||||
writeRelationshipFeedbackSummary(result, io);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
if (args.command === 'relationshipCalibration') {
|
||||
const result = await (deps.calibrateLocalRelationshipFeedbackLabels ?? calibrateLocalRelationshipFeedbackLabels)(
|
||||
project,
|
||||
{
|
||||
connectionId: args.connectionId,
|
||||
decision: args.decision,
|
||||
acceptThreshold: args.acceptThreshold,
|
||||
reviewThreshold: args.reviewThreshold,
|
||||
},
|
||||
);
|
||||
if (args.json) {
|
||||
io.stdout.write(`${JSON.stringify(result satisfies KtxRelationshipFeedbackCalibrationReport, null, 2)}\n`);
|
||||
} else {
|
||||
io.stdout.write(
|
||||
(deps.formatKtxRelationshipFeedbackCalibrationMarkdown ?? formatKtxRelationshipFeedbackCalibrationMarkdown)(
|
||||
result,
|
||||
),
|
||||
);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
if (args.command === 'relationshipThresholds') {
|
||||
const result = await (
|
||||
deps.adviseLocalRelationshipFeedbackThresholds ?? adviseLocalRelationshipFeedbackThresholds
|
||||
)(project, {
|
||||
connectionId: args.connectionId,
|
||||
minTotalLabels: args.minTotalLabels,
|
||||
minAcceptedLabels: args.minAcceptedLabels,
|
||||
minRejectedLabels: args.minRejectedLabels,
|
||||
});
|
||||
if (args.json) {
|
||||
io.stdout.write(`${JSON.stringify(result satisfies KtxRelationshipThresholdAdviceReport, null, 2)}\n`);
|
||||
} else {
|
||||
io.stdout.write(
|
||||
(deps.formatKtxRelationshipThresholdAdviceMarkdown ?? formatKtxRelationshipThresholdAdviceMarkdown)(result),
|
||||
);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
const managedDaemon = managedDaemonOptionsForScanRun(args, io);
|
||||
const connector =
|
||||
args.mode !== 'structural' || args.detectRelationships
|
||||
|
|
|
|||
|
|
@ -204,7 +204,8 @@ describe('setup context build state', () => {
|
|||
expect.objectContaining({ onDetach: expect.any(Function) }),
|
||||
);
|
||||
expect(verifyContextReady).toHaveBeenCalledWith(tempDir);
|
||||
expect(await readKtxSetupState(tempDir)).toEqual({ completed_steps: ['context'] });
|
||||
expect(await readFile(join(tempDir, 'ktx.yaml'), 'utf-8')).not.toContain('completed_steps:');
|
||||
expect((await readKtxSetupState(tempDir)).completed_steps).toContain('context');
|
||||
await expect(readKtxSetupContextState(tempDir)).resolves.toMatchObject({
|
||||
runId: 'setup-context-local-abc123',
|
||||
status: 'completed',
|
||||
|
|
@ -285,7 +286,8 @@ describe('setup context build state', () => {
|
|||
).resolves.toEqual({ status: 'ready', projectDir: tempDir, runId: 'setup-context-local-existing' });
|
||||
|
||||
expect(runContextBuildMock).not.toHaveBeenCalled();
|
||||
expect(await readKtxSetupState(tempDir)).toEqual({ completed_steps: ['context'] });
|
||||
expect(await readFile(join(tempDir, 'ktx.yaml'), 'utf-8')).not.toContain('completed_steps:');
|
||||
expect((await readKtxSetupState(tempDir)).completed_steps).toContain('context');
|
||||
await expect(readKtxSetupContextState(tempDir)).resolves.toMatchObject({
|
||||
runId: 'setup-context-local-existing',
|
||||
status: 'completed',
|
||||
|
|
|
|||
|
|
@ -929,7 +929,7 @@ describe('setup databases step', () => {
|
|||
commandIo.stdout.write(' Raw sources: raw-sources/postgres-warehouse/live-database/2026-05-09-221301-local-moywh3ky\n');
|
||||
commandIo.stdout.write(' Schema shards: 1\n\n');
|
||||
commandIo.stdout.write('Next:\n');
|
||||
commandIo.stdout.write(` ktx dev scan status --project-dir ${tempDir} local-moywh3ky\n`);
|
||||
commandIo.stdout.write(` ktx status --project-dir ${tempDir} local-moywh3ky\n`);
|
||||
return 0;
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -1448,7 +1448,7 @@ async function validateAndScanConnection(input: {
|
|||
if (scanCode !== 0) {
|
||||
flushBufferedCommandOutput(input.io, scanIo);
|
||||
input.io.stderr.write(`Structural scan failed for ${input.connectionId}.\n`);
|
||||
input.io.stderr.write(`Debug command: ktx dev scan --project-dir ${input.projectDir} ${input.connectionId}\n`);
|
||||
input.io.stderr.write(`Debug command: ktx scan --project-dir ${input.projectDir} ${input.connectionId}\n`);
|
||||
return false;
|
||||
}
|
||||
const scanOutput = scanIo.stdoutText();
|
||||
|
|
|
|||
|
|
@ -664,7 +664,7 @@ describe('setup sources step', () => {
|
|||
expect(runInitialIngest).toHaveBeenCalledTimes(1);
|
||||
expect((await readConfig()).connections['dbt-main']).toMatchObject({ driver: 'dbt', source_dir: '/repo/dbt' });
|
||||
expect(io.stdout()).toContain('Context source saved without a completed context build for dbt-main.');
|
||||
expect(io.stdout()).toContain('Run later: ktx ingest dbt-main');
|
||||
expect(io.stdout()).toContain('Run later: ktx ingest run --connection-id dbt-main --adapter <adapter>');
|
||||
});
|
||||
|
||||
it('retries initial source ingest from the failure menu', async () => {
|
||||
|
|
|
|||
|
|
@ -739,7 +739,7 @@ async function runInitialSourceIngestWithRecovery(input: {
|
|||
}
|
||||
if (action === 'continue') {
|
||||
input.io.stdout.write(`│ Context source saved without a completed context build for ${input.connectionId}.\n`);
|
||||
input.io.stdout.write(`│ Run later: ktx ingest ${input.connectionId}\n`);
|
||||
input.io.stdout.write(`│ Run later: ktx ingest run --connection-id ${input.connectionId} --adapter <adapter>\n`);
|
||||
return 'continue';
|
||||
}
|
||||
return 'back';
|
||||
|
|
|
|||
|
|
@ -584,13 +584,13 @@ describe('setup status', () => {
|
|||
|
||||
expect(projectPrompts.select).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
message: 'Which KTX project should setup use?',
|
||||
message: 'Where should KTX create the project?',
|
||||
options: expect.arrayContaining([expect.objectContaining({ value: 'back', label: 'Back' })]),
|
||||
}),
|
||||
);
|
||||
expect(projectPrompts.select).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
message: 'Which KTX project should setup use?',
|
||||
message: 'Where should KTX create the project?',
|
||||
options: expect.not.arrayContaining([expect.objectContaining({ value: 'exit', label: 'Exit' })]),
|
||||
}),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -50,14 +50,6 @@ async function runBuiltCli(args: string[], options: { env?: NodeJS.ProcessEnv }
|
|||
}
|
||||
}
|
||||
|
||||
function getRunId(stdout: string): string {
|
||||
const match = stdout.match(/^Run: (.+)$/m);
|
||||
if (!match) {
|
||||
throw new Error(`Could not find run id in output:\n${stdout}`);
|
||||
}
|
||||
return match[1];
|
||||
}
|
||||
|
||||
async function writeWarehouseConfig(projectDir: string): Promise<void> {
|
||||
await writeFile(
|
||||
join(projectDir, 'ktx.yaml'),
|
||||
|
|
@ -181,7 +173,6 @@ describe('standalone built ktx CLI smoke', () => {
|
|||
await writeSourceFixture(sourceDir);
|
||||
|
||||
const run = await runBuiltCli([
|
||||
'dev',
|
||||
'ingest',
|
||||
'run',
|
||||
'--project-dir',
|
||||
|
|
@ -195,7 +186,7 @@ describe('standalone built ktx CLI smoke', () => {
|
|||
]);
|
||||
expect(run).toMatchObject({ code: 1, stdout: '' });
|
||||
expect(run.stderr).toContain(
|
||||
'ktx dev ingest run requires llm.provider.backend: anthropic, vertex, or gateway, or an injected agentRunner',
|
||||
'ktx ingest run requires llm.provider.backend: anthropic, vertex, or gateway, or an injected agentRunner',
|
||||
);
|
||||
});
|
||||
|
||||
|
|
@ -232,31 +223,11 @@ describe('standalone built ktx CLI smoke', () => {
|
|||
expect(connectionTest.stdout).toContain('Driver: sqlite');
|
||||
expect(connectionTest.stdout).toContain('Tables: 2');
|
||||
|
||||
const structural = await runBuiltCli(['dev', 'scan', 'warehouse', '--project-dir', projectDir]);
|
||||
const structural = await runBuiltCli(['scan', 'warehouse', '--project-dir', projectDir]);
|
||||
expectProjectStderr(structural, projectDir);
|
||||
expect(structural.stdout).toContain('Status: done');
|
||||
expect(structural.stdout).toContain('Mode: structural');
|
||||
const structuralRunId = getRunId(structural.stdout);
|
||||
|
||||
const structuralReportResult = await runBuiltCli([
|
||||
'dev',
|
||||
'scan',
|
||||
'report',
|
||||
'--json',
|
||||
'--project-dir',
|
||||
projectDir,
|
||||
structuralRunId,
|
||||
]);
|
||||
expect(structuralReportResult).toMatchObject({ code: 0, stderr: '' });
|
||||
const structuralReport = parseJsonOutput<{
|
||||
mode: string;
|
||||
artifactPaths: { manifestShards: string[]; enrichmentArtifacts: string[] };
|
||||
manifestShardsWritten: number;
|
||||
}>(structuralReportResult.stdout);
|
||||
expect(structuralReport.mode).toBe('structural');
|
||||
expect(structuralReport.artifactPaths.manifestShards).toEqual(['semantic-layer/warehouse/_schema/public.yaml']);
|
||||
expect(structuralReport.artifactPaths.enrichmentArtifacts).toEqual([]);
|
||||
expect(structuralReport.manifestShardsWritten).toBe(1);
|
||||
expect(structural.stdout).toContain('Schema shards: 1');
|
||||
|
||||
const structuralManifest = await readFile(
|
||||
join(projectDir, 'semantic-layer/warehouse/_schema/public.yaml'),
|
||||
|
|
@ -268,7 +239,6 @@ describe('standalone built ktx CLI smoke', () => {
|
|||
expect(structuralManifest).not.toContain('ai:');
|
||||
|
||||
const providerlessEnriched = await runBuiltCli([
|
||||
'dev',
|
||||
'scan',
|
||||
'warehouse',
|
||||
'--project-dir',
|
||||
|
|
@ -282,89 +252,11 @@ describe('standalone built ktx CLI smoke', () => {
|
|||
expect(providerlessEnriched.stdout).toContain('Accepted: 1');
|
||||
expect(providerlessEnriched.stdout).toContain('scan_enrichment_backend_not_configured');
|
||||
expect(providerlessEnriched.stdout).toContain('Enrichment artifacts: 3');
|
||||
const providerlessRunId = getRunId(providerlessEnriched.stdout);
|
||||
|
||||
const providerlessReportResult = await runBuiltCli([
|
||||
'dev',
|
||||
'scan',
|
||||
'report',
|
||||
'--json',
|
||||
'--project-dir',
|
||||
projectDir,
|
||||
providerlessRunId,
|
||||
]);
|
||||
expect(providerlessReportResult).toMatchObject({ code: 0, stderr: '' });
|
||||
const providerlessReport = parseJsonOutput<{
|
||||
mode: string;
|
||||
enrichment: {
|
||||
tableDescriptions: string;
|
||||
columnDescriptions: string;
|
||||
embeddings: string;
|
||||
deterministicRelationships: string;
|
||||
statisticalValidation: string;
|
||||
};
|
||||
relationships: { accepted: number; review: number; rejected: number; skipped: number };
|
||||
warnings: Array<{ code: string }>;
|
||||
artifactPaths: { enrichmentArtifacts: string[]; manifestShards: string[] };
|
||||
}>(providerlessReportResult.stdout);
|
||||
expect(providerlessReport.mode).toBe('enriched');
|
||||
expect(providerlessReport.enrichment).toMatchObject({
|
||||
tableDescriptions: 'skipped',
|
||||
columnDescriptions: 'skipped',
|
||||
embeddings: 'skipped',
|
||||
deterministicRelationships: 'completed',
|
||||
statisticalValidation: 'completed',
|
||||
});
|
||||
expect(providerlessReport.relationships).toEqual({ accepted: 1, review: 0, rejected: 0, skipped: 0 });
|
||||
expect(providerlessReport.warnings).toEqual(
|
||||
expect.arrayContaining([expect.objectContaining({ code: 'scan_enrichment_backend_not_configured' })]),
|
||||
);
|
||||
expect(providerlessReport.artifactPaths.enrichmentArtifacts).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.stringContaining('/enrichment/relationships.json'),
|
||||
expect.stringContaining('/enrichment/relationship-profile.json'),
|
||||
expect.stringContaining('/enrichment/relationship-diagnostics.json'),
|
||||
]),
|
||||
);
|
||||
expect(providerlessReport.artifactPaths.manifestShards).toEqual(['semantic-layer/warehouse/_schema/public.yaml']);
|
||||
|
||||
await writeSqliteScanConfig(projectDir, dbPath, true);
|
||||
const enriched = await runBuiltCli(['dev', 'scan', 'warehouse', '--project-dir', projectDir, '--mode', 'enriched']);
|
||||
const enriched = await runBuiltCli(['scan', 'warehouse', '--project-dir', projectDir, '--mode', 'enriched']);
|
||||
expectProjectStderr(enriched, projectDir);
|
||||
expect(enriched.stdout).toContain('Mode: enriched');
|
||||
const enrichedRunId = getRunId(enriched.stdout);
|
||||
|
||||
const enrichedReportResult = await runBuiltCli([
|
||||
'dev',
|
||||
'scan',
|
||||
'report',
|
||||
'--json',
|
||||
'--project-dir',
|
||||
projectDir,
|
||||
enrichedRunId,
|
||||
]);
|
||||
expect(enrichedReportResult).toMatchObject({ code: 0, stderr: '' });
|
||||
const enrichedReport = parseJsonOutput<{
|
||||
mode: string;
|
||||
enrichment: { tableDescriptions: string; columnDescriptions: string; embeddings: string };
|
||||
artifactPaths: { enrichmentArtifacts: string[]; manifestShards: string[] };
|
||||
}>(enrichedReportResult.stdout);
|
||||
expect(enrichedReport.mode).toBe('enriched');
|
||||
expect(enrichedReport.enrichment).toMatchObject({
|
||||
tableDescriptions: 'completed',
|
||||
columnDescriptions: 'completed',
|
||||
embeddings: 'completed',
|
||||
});
|
||||
expect(enrichedReport.artifactPaths.enrichmentArtifacts).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.stringContaining('/enrichment/descriptions.json'),
|
||||
expect.stringContaining('/enrichment/embeddings.json'),
|
||||
expect.stringContaining('/enrichment/relationships.json'),
|
||||
expect.stringContaining('/enrichment/relationship-profile.json'),
|
||||
expect.stringContaining('/enrichment/relationship-diagnostics.json'),
|
||||
]),
|
||||
);
|
||||
expect(enrichedReport.artifactPaths.manifestShards).toEqual(['semantic-layer/warehouse/_schema/public.yaml']);
|
||||
expect(enriched.stdout).toContain('Enrichment artifacts:');
|
||||
|
||||
const enrichedManifest = await readFile(join(projectDir, 'semantic-layer/warehouse/_schema/public.yaml'), 'utf-8');
|
||||
expect(enrichedManifest).toContain('Deterministic description');
|
||||
|
|
|
|||
|
|
@ -55,7 +55,7 @@ describe('createLocalBundleIngestRuntime', () => {
|
|||
}),
|
||||
).toThrow(
|
||||
[
|
||||
'ktx dev ingest run requires llm.provider.backend: anthropic, vertex, or gateway, or an injected agentRunner.',
|
||||
'ktx ingest run requires llm.provider.backend: anthropic, vertex, or gateway, or an injected agentRunner.',
|
||||
`Configure an Anthropic provider, then rerun ingest:`,
|
||||
` ktx setup --project-dir ${project.projectDir} --anthropic-api-key-env ANTHROPIC_API_KEY --anthropic-model claude-sonnet-4-6 --no-input`,
|
||||
].join('\n'),
|
||||
|
|
|
|||
|
|
@ -559,7 +559,7 @@ function nextLocalJobId(): string {
|
|||
|
||||
function localIngestLlmProviderGuardMessage(projectDir: string): string {
|
||||
return [
|
||||
'ktx dev ingest run requires llm.provider.backend: anthropic, vertex, or gateway, or an injected agentRunner.',
|
||||
'ktx ingest run requires llm.provider.backend: anthropic, vertex, or gateway, or an injected agentRunner.',
|
||||
'Configure an Anthropic provider, then rerun ingest:',
|
||||
` ktx setup --project-dir ${projectDir} --anthropic-api-key-env ANTHROPIC_API_KEY --anthropic-model claude-sonnet-4-6 --no-input`,
|
||||
].join('\n');
|
||||
|
|
|
|||
|
|
@ -55,6 +55,7 @@ describe('createKtxLlmProvider', () => {
|
|||
},
|
||||
{
|
||||
createAnthropic: vi.fn(() => vi.fn(() => anthropicModel)),
|
||||
devtoolsEnabled: false,
|
||||
wrapLanguageModel,
|
||||
devToolsMiddleware,
|
||||
} satisfies KtxLlmProviderFactoryDeps,
|
||||
|
|
@ -145,7 +146,7 @@ describe('createKtxLlmProvider', () => {
|
|||
modelSlots: { default: 'claude-sonnet-4-6' },
|
||||
promptCaching: { enabled: false },
|
||||
},
|
||||
{ createAnthropic },
|
||||
{ createAnthropic, devtoolsEnabled: false },
|
||||
);
|
||||
|
||||
expect(provider.getModel('default')).toBe(anthropicModel);
|
||||
|
|
@ -171,7 +172,7 @@ describe('createKtxLlmProvider', () => {
|
|||
modelSlots: { default: 'claude-sonnet-4-6' },
|
||||
promptCaching: { enabled: false },
|
||||
},
|
||||
{ createVertexAnthropic },
|
||||
{ createVertexAnthropic, devtoolsEnabled: false },
|
||||
);
|
||||
|
||||
expect(provider.getModel('default')).toBe(vertexModel);
|
||||
|
|
@ -191,7 +192,7 @@ describe('createKtxLlmProvider', () => {
|
|||
modelSlots: { default: 'anthropic/claude-sonnet-4-6' },
|
||||
promptCaching: { enabled: false },
|
||||
},
|
||||
{ createGateway },
|
||||
{ createGateway, devtoolsEnabled: false },
|
||||
);
|
||||
|
||||
expect(provider.getModel('curator')).toBe(gatewayModel);
|
||||
|
|
|
|||
|
|
@ -186,10 +186,10 @@ describe('standalone example docs', () => {
|
|||
assert.match(quickstart, publicPackagePattern('npm install -g {package}'));
|
||||
assert.match(quickstart, /ktx dev runtime install --feature local-embeddings --yes/);
|
||||
assert.match(quickstart, /ktx dev runtime start --feature local-embeddings/);
|
||||
assert.match(quickstart, /Install `uv`, run `ktx dev runtime doctor`/);
|
||||
assert.match(quickstart, /Install `uv`, run `ktx dev runtime status`/);
|
||||
assert.match(packageArtifacts, /requires `uv` on `PATH`/);
|
||||
assert.match(packageArtifacts, /ktx dev runtime status/);
|
||||
assert.match(packageArtifacts, /ktx dev runtime doctor/);
|
||||
assert.match(packageArtifacts, /ktx dev runtime status/);
|
||||
assert.match(packageArtifacts, /ktx dev runtime prune --dry-run/);
|
||||
assert.match(packageArtifacts, /ktx dev runtime prune --yes/);
|
||||
assert.match(
|
||||
|
|
@ -223,7 +223,7 @@ describe('standalone example docs', () => {
|
|||
assert.doesNotMatch(readme, /installs the Python artifacts directly/);
|
||||
assert.match(readme, /requires `uv` on `PATH`/);
|
||||
assert.match(readme, /ktx dev runtime status/);
|
||||
assert.match(readme, /ktx dev runtime doctor/);
|
||||
assert.match(readme, /ktx dev runtime status/);
|
||||
assert.match(readme, /ktx dev runtime prune --dry-run/);
|
||||
assert.match(readme, /ktx dev runtime prune --yes/);
|
||||
assert.doesNotMatch(readme, /@ktx\/context/);
|
||||
|
|
@ -236,14 +236,15 @@ describe('standalone example docs', () => {
|
|||
const buildingContext = await readText('docs-site/content/docs/guides/building-context.mdx');
|
||||
const scanReference = await readText('docs-site/content/docs/cli-reference/ktx-scan.mdx');
|
||||
|
||||
assert.match(buildingContext, /ktx dev scan <connection-id>/);
|
||||
assert.match(buildingContext, /ktx dev scan status <run-id>/);
|
||||
assert.match(buildingContext, /ktx dev scan report <run-id>/);
|
||||
assert.match(scanReference, /ktx dev scan <connectionId> \[options\]/);
|
||||
assert.match(buildingContext, /ktx scan <connection-id>/);
|
||||
assert.match(buildingContext, /ktx status/);
|
||||
assert.doesNotMatch(buildingContext, /ktx scan status <run-id>/);
|
||||
assert.doesNotMatch(buildingContext, /ktx scan report <run-id>/);
|
||||
assert.match(scanReference, /ktx scan <connectionId> \[options\]/);
|
||||
assert.match(rootReadme, /raw-sources\//);
|
||||
assert.match(rootReadme, /live-database\//);
|
||||
assert.doesNotMatch(rootReadme, /Run a local ingest smoke test/);
|
||||
assert.doesNotMatch(rootReadme, /ktx dev ingest run --project-dir/);
|
||||
assert.doesNotMatch(rootReadme, /ktx ingest run --project-dir/);
|
||||
assert.doesNotMatch(rootReadme, /ktx ingest status --project-dir/);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -107,7 +107,6 @@ export function buildLiveDatabaseIngestArgs(projectDir, databaseIntrospectionUrl
|
|||
return [
|
||||
'exec',
|
||||
'ktx',
|
||||
'dev',
|
||||
'ingest',
|
||||
'run',
|
||||
'--project-dir',
|
||||
|
|
@ -325,12 +324,12 @@ async function main() {
|
|||
env: managedRuntimeEnv(cleanInstallDir),
|
||||
timeout: 120_000,
|
||||
});
|
||||
requireSuccess('ktx dev ingest run live-database', ingestRun);
|
||||
requireOutput('ktx dev ingest run live-database', ingestRun, /Status: done/);
|
||||
requireOutput('ktx dev ingest run live-database', ingestRun, /Adapter: live-database/);
|
||||
requireOutput('ktx dev ingest run live-database', ingestRun, /Diff: \+4\/~0\/-0\/=0/);
|
||||
requireOutput('ktx dev ingest run live-database', ingestRun, /Raw files: 4/);
|
||||
requireOutput('ktx dev ingest run live-database', ingestRun, /Work units: 2/);
|
||||
requireSuccess('ktx ingest run live-database', ingestRun);
|
||||
requireOutput('ktx ingest run live-database', ingestRun, /Status: done/);
|
||||
requireOutput('ktx ingest run live-database', ingestRun, /Adapter: live-database/);
|
||||
requireOutput('ktx ingest run live-database', ingestRun, /Diff: \+4\/~0\/-0\/=0/);
|
||||
requireOutput('ktx ingest run live-database', ingestRun, /Raw files: 4/);
|
||||
requireOutput('ktx ingest run live-database', ingestRun, /Work units: 2/);
|
||||
|
||||
const runId = getRunId(ingestRun.stdout);
|
||||
const ingestStatus = await run('pnpm', buildLiveDatabaseStatusArgs(projectDir, runId), {
|
||||
|
|
|
|||
|
|
@ -102,7 +102,6 @@ describe('installed live-database artifact smoke helpers', () => {
|
|||
assert.deepEqual(buildLiveDatabaseIngestArgs('/tmp/project', 'http://127.0.0.1:8765'), [
|
||||
'exec',
|
||||
'ktx',
|
||||
'dev',
|
||||
'ingest',
|
||||
'run',
|
||||
'--project-dir',
|
||||
|
|
|
|||
|
|
@ -789,12 +789,11 @@ 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', 'dev', 'runtime', 'doctor']);
|
||||
requireSuccess('ktx dev runtime doctor', runtimeDoctor);
|
||||
requireOutput('ktx dev runtime doctor', runtimeDoctor, /PASS uv/);
|
||||
requireOutput('ktx dev runtime doctor', runtimeDoctor, /PASS Bundled Python wheel/);
|
||||
requireOutput('ktx dev runtime doctor', runtimeDoctor, /PASS Managed Python runtime/);
|
||||
process.stdout.write('ktx dev runtime doctor verified\\n');
|
||||
const runtimeDoctor = await run('pnpm', ['exec', 'ktx', 'dev', 'runtime', 'status']);
|
||||
requireSuccess('ktx dev runtime status', runtimeDoctor);
|
||||
requireOutput('ktx dev runtime status', runtimeDoctor, /KTX Python runtime/);
|
||||
requireOutput('ktx dev runtime status', runtimeDoctor, /status: ready/);
|
||||
process.stdout.write('ktx dev runtime status verified\\n');
|
||||
|
||||
const runtimeStart = await run('pnpm', ['exec', 'ktx', 'dev', 'runtime', 'start']);
|
||||
requireSuccess('ktx dev runtime start', runtimeStart);
|
||||
|
|
@ -835,7 +834,7 @@ try {
|
|||
await assert.rejects(() => access(staleRuntimeDir));
|
||||
process.stdout.write('ktx dev runtime prune verified\\n');
|
||||
|
||||
const structuralScan = await run('pnpm', ['exec', 'ktx', 'dev', 'scan', 'warehouse',
|
||||
const structuralScan = await run('pnpm', ['exec', 'ktx', 'scan', 'warehouse',
|
||||
'--project-dir',
|
||||
projectDir,
|
||||
]);
|
||||
|
|
@ -844,34 +843,10 @@ try {
|
|||
requireOutput('ktx scan structural', structuralScan, /Mode: structural/);
|
||||
requireOutput('ktx scan structural', structuralScan, /Needs attention\\s+None/);
|
||||
const structuralScanRunId = getRunId(structuralScan.stdout);
|
||||
|
||||
const scanStatus = await run('pnpm', ['exec', 'ktx', 'dev', 'scan', 'status',
|
||||
'--project-dir',
|
||||
projectDir,
|
||||
structuralScanRunId,
|
||||
]);
|
||||
requireProjectStderr('ktx scan status', scanStatus, projectDir);
|
||||
requireOutput('ktx scan status', scanStatus, new RegExp('Run: ' + structuralScanRunId));
|
||||
requireOutput('ktx scan status', scanStatus, /Status: done/);
|
||||
requireOutput('ktx scan status', scanStatus, /Mode: structural/);
|
||||
|
||||
const scanReport = await run('pnpm', ['exec', 'ktx', 'dev', 'scan', 'report',
|
||||
'--project-dir',
|
||||
projectDir,
|
||||
'--json',
|
||||
structuralScanRunId,
|
||||
]);
|
||||
requireSuccess('ktx scan report', scanReport);
|
||||
const scanReportJson = JSON.parse(scanReport.stdout);
|
||||
assert.equal(scanReportJson.mode, 'structural');
|
||||
assert.equal(scanReportJson.connectionId, 'warehouse');
|
||||
assert.equal(scanReportJson.manifestShardsWritten, 1);
|
||||
assert.deepEqual(scanReportJson.artifactPaths.enrichmentArtifacts, []);
|
||||
assert.deepEqual(scanReportJson.artifactPaths.manifestShards, ['semantic-layer/warehouse/_schema/public.yaml']);
|
||||
await access(join(projectDir, 'semantic-layer', 'warehouse', '_schema', 'public.yaml'));
|
||||
process.stdout.write('ktx scan structural verified: ' + structuralScanRunId + '\\n');
|
||||
|
||||
const enrichedScan = await run('pnpm', ['exec', 'ktx', 'dev', 'scan', 'warehouse',
|
||||
const enrichedScan = await run('pnpm', ['exec', 'ktx', 'scan', 'warehouse',
|
||||
'--project-dir',
|
||||
projectDir,
|
||||
'--mode',
|
||||
|
|
@ -880,24 +855,14 @@ try {
|
|||
requireProjectStderr('ktx scan enriched', enrichedScan, projectDir);
|
||||
requireOutput('ktx scan enriched', enrichedScan, /Status: done/);
|
||||
requireOutput('ktx scan enriched', enrichedScan, /Mode: enriched/);
|
||||
requireOutput('ktx scan enriched', enrichedScan, /Enrichment artifacts:/);
|
||||
const enrichedScanRunId = getRunId(enrichedScan.stdout);
|
||||
const enrichedScanReport = await run('pnpm', ['exec', 'ktx', 'dev', 'scan', 'report',
|
||||
'--project-dir',
|
||||
projectDir,
|
||||
'--json',
|
||||
enrichedScanRunId,
|
||||
]);
|
||||
requireSuccess('ktx scan enriched report', enrichedScanReport);
|
||||
const enrichedScanReportJson = JSON.parse(enrichedScanReport.stdout);
|
||||
assert.equal(enrichedScanReportJson.mode, 'enriched');
|
||||
assert.ok(enrichedScanReportJson.artifactPaths.enrichmentArtifacts.length > 0);
|
||||
assert.deepEqual(enrichedScanReportJson.artifactPaths.manifestShards, ['semantic-layer/warehouse/_schema/public.yaml']);
|
||||
process.stdout.write('ktx scan enriched verified: ' + enrichedScanRunId + '\\n');
|
||||
|
||||
await mkdir(join(sourceDir, 'orders'), { recursive: true });
|
||||
await writeFile(join(sourceDir, 'orders', 'orders.json'), '{"name":"orders"}\\n', 'utf-8');
|
||||
|
||||
const ingestRun = await run('pnpm', ['exec', 'ktx', 'dev', 'ingest', 'run',
|
||||
const ingestRun = await run('pnpm', ['exec', 'ktx', 'ingest', 'run',
|
||||
'--project-dir',
|
||||
projectDir,
|
||||
'--connection-id',
|
||||
|
|
@ -907,14 +872,14 @@ try {
|
|||
'--source-dir',
|
||||
sourceDir,
|
||||
]);
|
||||
assert.equal(ingestRun.code, 1, 'ktx dev ingest run without an LLM provider must fail');
|
||||
assert.equal(ingestRun.code, 1, 'ktx ingest run without an LLM provider must fail');
|
||||
assert.match(
|
||||
ingestRun.stderr,
|
||||
/ktx dev ingest run requires llm\\.provider\\.backend: anthropic, vertex, or gateway, or an injected agentRunner/,
|
||||
/ktx ingest run requires llm\\.provider\\.backend: anthropic, vertex, or gateway, or an injected agentRunner/,
|
||||
);
|
||||
|
||||
await access(join(projectDir, '.ktx', 'db.sqlite'));
|
||||
process.stdout.write('ktx dev ingest provider guard verified\\n');
|
||||
process.stdout.write('ktx ingest provider guard verified\\n');
|
||||
} finally {
|
||||
if (daemonStarted) {
|
||||
await run('pnpm', ['exec', 'ktx', 'dev', 'runtime', 'stop']);
|
||||
|
|
|
|||
|
|
@ -484,8 +484,8 @@ describe('verification snippets', () => {
|
|||
assert.match(source, /ktx dev runtime status ready/);
|
||||
assert.match(source, /runtimeStatusAfter\.kind, 'ready'/);
|
||||
assert.match(source, /runtimeStatusAfter\.manifest\.features/);
|
||||
assert.match(source, /ktx dev runtime doctor/);
|
||||
assert.match(source, /PASS Managed Python runtime/);
|
||||
assert.match(source, /ktx dev runtime status/);
|
||||
assert.match(source, /status: ready/);
|
||||
assert.match(source, /ktx dev runtime start/);
|
||||
assert.match(source, /ktx dev runtime start reuse/);
|
||||
assert.match(source, /Using existing KTX Python daemon/);
|
||||
|
|
@ -497,20 +497,18 @@ describe('verification snippets', () => {
|
|||
assert.match(source, /ktx dev runtime prune confirmed/);
|
||||
assert.match(source, /Removed stale KTX Python runtimes/);
|
||||
assert.match(source, /assert\.rejects\(\(\) => access\(staleRuntimeDir\)\)/);
|
||||
assert.match(source, /run\('pnpm', \[\s*'exec',\s*'ktx',\s*'dev',\s*'scan',\s*'warehouse'/);
|
||||
assert.match(source, /run\('pnpm', \[\s*'exec',\s*'ktx',\s*'scan',\s*'warehouse'/);
|
||||
assert.match(source, /'--mode',\s*'enriched'/);
|
||||
assert.doesNotMatch(source, /'--enrich'/);
|
||||
assert.match(source, /ktx scan structural verified/);
|
||||
assert.match(source, /ktx scan enriched verified/);
|
||||
assert.match(source, /scanReportJson\.artifactPaths\.manifestShards/);
|
||||
assert.match(source, /scanReportJson\.artifactPaths\.enrichmentArtifacts/);
|
||||
assert.match(source, /enrichment:/);
|
||||
assert.match(source, /mode: deterministic/);
|
||||
assert.match(source, /run\('pnpm', \['exec', 'ktx', 'dev', 'ingest', 'run'/);
|
||||
assert.match(source, /run\('pnpm', \['exec', 'ktx', 'ingest', 'run'/);
|
||||
assert.match(source, /access\(join\(projectDir, '\.ktx', 'db\.sqlite'\)\)/);
|
||||
assert.match(source, /SQLite knowledge index/);
|
||||
assert.match(source, /ktx dev ingest run requires llm\\.provider\\.backend: anthropic, vertex, or gateway/);
|
||||
assert.match(source, /ktx dev ingest provider guard verified/);
|
||||
assert.match(source, /ktx ingest run requires llm\\.provider\\.backend: anthropic, vertex, or gateway/);
|
||||
assert.match(source, /ktx ingest provider guard verified/);
|
||||
});
|
||||
|
||||
describe('npmCliSmokeSource', () => {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
#!/usr/bin/env node
|
||||
|
||||
import { mkdir as fsMkdir, writeFile as fsWriteFile } from 'node:fs/promises';
|
||||
import { mkdir as fsMkdir, readFile as fsReadFile, writeFile as fsWriteFile } from 'node:fs/promises';
|
||||
import { execFile as childExecFile } from 'node:child_process';
|
||||
import { dirname, resolve } from 'node:path';
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url';
|
||||
|
|
@ -90,11 +90,7 @@ function parseArgs(argv) {
|
|||
}
|
||||
|
||||
export function buildOrbitScanArgv(input) {
|
||||
return ['dev', 'scan', input.connectionId, '--enrich', '--project-dir', input.projectDir];
|
||||
}
|
||||
|
||||
export function buildOrbitReportArgv(input) {
|
||||
return ['dev', 'scan', 'report', '--json', '--project-dir', input.projectDir, input.runId];
|
||||
return ['scan', input.connectionId, '--mode', 'relationships', '--project-dir', input.projectDir];
|
||||
}
|
||||
|
||||
export function extractRunId(stdout) {
|
||||
|
|
@ -102,6 +98,11 @@ export function extractRunId(stdout) {
|
|||
return match?.[1] ?? null;
|
||||
}
|
||||
|
||||
export function extractReportPath(stdout) {
|
||||
const match = stdout.match(/^\s*Report:\s*(\S+)/m);
|
||||
return match?.[1] ?? null;
|
||||
}
|
||||
|
||||
function listLines(values) {
|
||||
if (!values || values.length === 0) {
|
||||
return ['- none'];
|
||||
|
|
@ -204,11 +205,9 @@ export function formatOrbitVerificationMarkdown(result) {
|
|||
|
||||
if (result.status === 'success') {
|
||||
lines.push(
|
||||
'## JSON Report Command',
|
||||
'## Scan Report Artifact',
|
||||
'',
|
||||
'```bash',
|
||||
result.reportCommand,
|
||||
'```',
|
||||
`- ${result.reportPath}`,
|
||||
'',
|
||||
...formatSuccess(result),
|
||||
);
|
||||
|
|
@ -250,6 +249,7 @@ export async function runOrbitVerification(options = {}) {
|
|||
const now = options.now ?? (() => new Date());
|
||||
const mkdir = options.mkdir ?? fsMkdir;
|
||||
const writeFile = options.writeFile ?? fsWriteFile;
|
||||
const readFile = options.readFile ?? fsReadFile;
|
||||
const date = dateOnly(now());
|
||||
const env = options.env ?? orbitVerificationEnv(projectDir);
|
||||
const runWithEnv = (argv, runnerOptions) => runner(argv, { ...runnerOptions, env });
|
||||
|
|
@ -285,33 +285,32 @@ export async function runOrbitVerification(options = {}) {
|
|||
scanStderr: scan.stderr,
|
||||
};
|
||||
} else {
|
||||
const reportArgv = buildOrbitReportArgv({ projectDir, runId });
|
||||
const reportOutput = await runBufferedWorkspaceKtx(runWithEnv, reportArgv, rootDir, execFile);
|
||||
if (reportOutput.exitCode !== 0) {
|
||||
const scanReportPath = extractReportPath(scan.stdout);
|
||||
if (!scanReportPath) {
|
||||
result = {
|
||||
status: 'blocked',
|
||||
date,
|
||||
connectionId,
|
||||
projectDir,
|
||||
scanCommand: shellCommand(scanArgv),
|
||||
scanExitCode: reportOutput.exitCode,
|
||||
blocker: firstNonEmptyLine(reportOutput.stderr, reportOutput.stdout),
|
||||
scanStdout: `${scan.stdout}\n${reportOutput.stdout}`.trim(),
|
||||
scanStderr: `${scan.stderr}\n${reportOutput.stderr}`.trim(),
|
||||
scanExitCode: scan.exitCode,
|
||||
blocker: 'KTX scan completed without printing a report artifact path',
|
||||
scanStdout: scan.stdout,
|
||||
scanStderr: scan.stderr,
|
||||
};
|
||||
} else {
|
||||
const fullScanReportPath = resolve(projectDir, scanReportPath);
|
||||
result = {
|
||||
status: 'success',
|
||||
date,
|
||||
connectionId,
|
||||
projectDir,
|
||||
scanCommand: shellCommand(scanArgv),
|
||||
reportCommand: shellCommand(reportArgv),
|
||||
reportPath: fullScanReportPath,
|
||||
scanExitCode: scan.exitCode,
|
||||
reportExitCode: reportOutput.exitCode,
|
||||
scanStdout: scan.stdout,
|
||||
scanStderr: scan.stderr,
|
||||
report: JSON.parse(reportOutput.stdout),
|
||||
report: JSON.parse(await readFile(fullScanReportPath, 'utf8')),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,9 +3,9 @@ import { readFile } from 'node:fs/promises';
|
|||
import { dirname } from 'node:path';
|
||||
import { describe, it } from 'node:test';
|
||||
import {
|
||||
buildOrbitReportArgv,
|
||||
buildOrbitScanArgv,
|
||||
defaultOrbitVerificationProjectDir,
|
||||
extractReportPath,
|
||||
extractRunId,
|
||||
formatOrbitVerificationMarkdown,
|
||||
runOrbitVerification,
|
||||
|
|
@ -59,24 +59,15 @@ describe('relationship Orbit verification helper', () => {
|
|||
);
|
||||
});
|
||||
|
||||
it('builds the current KTX launcher arguments for scan and JSON report commands', () => {
|
||||
it('builds the current KTX launcher arguments for scan commands', () => {
|
||||
assert.deepEqual(buildOrbitScanArgv({ connectionId: 'orbit', projectDir: '/tmp/orbit-project' }), [
|
||||
'dev',
|
||||
'scan',
|
||||
'orbit',
|
||||
'--enrich',
|
||||
'--mode',
|
||||
'relationships',
|
||||
'--project-dir',
|
||||
'/tmp/orbit-project',
|
||||
]);
|
||||
assert.deepEqual(buildOrbitReportArgv({ projectDir: '/tmp/orbit-project', runId: 'scan-orbit-1' }), [
|
||||
'dev',
|
||||
'scan',
|
||||
'report',
|
||||
'--json',
|
||||
'--project-dir',
|
||||
'/tmp/orbit-project',
|
||||
'scan-orbit-1',
|
||||
]);
|
||||
});
|
||||
|
||||
it('uses the checked-in Orbit verification project by default', async () => {
|
||||
|
|
@ -95,22 +86,17 @@ describe('relationship Orbit verification helper', () => {
|
|||
runWorkspaceKtx: async (argv, options) => {
|
||||
calls.push(argv);
|
||||
envs.push(options.env);
|
||||
if (argv[2] === 'report') {
|
||||
options.stdout.write(successReportJson());
|
||||
return 0;
|
||||
}
|
||||
options.stdout.write('KTX scan completed\nRun: scan-orbit-1\nConnection: orbit\n');
|
||||
options.stdout.write('KTX scan completed\nRun: scan-orbit-1\nConnection: orbit\n Report: reports/scan-report.json\n');
|
||||
return 0;
|
||||
},
|
||||
readFile: async () => successReportJson(),
|
||||
});
|
||||
|
||||
assert.equal(result.status, 'success');
|
||||
assert.deepEqual(calls, [
|
||||
['dev', 'scan', 'orbit', '--enrich', '--project-dir', defaultProjectDir],
|
||||
['dev', 'scan', 'report', '--json', '--project-dir', defaultProjectDir, 'scan-orbit-1'],
|
||||
['scan', 'orbit', '--mode', 'relationships', '--project-dir', defaultProjectDir],
|
||||
]);
|
||||
assert.equal(envs[0].GIT_CEILING_DIRECTORIES, dirname(defaultProjectDir));
|
||||
assert.equal(envs[1].GIT_CEILING_DIRECTORIES, dirname(defaultProjectDir));
|
||||
assert.equal(writes.length, 1);
|
||||
assert.match(writes[0].content, new RegExp(defaultProjectDir.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')));
|
||||
});
|
||||
|
|
@ -129,19 +115,15 @@ describe('relationship Orbit verification helper', () => {
|
|||
writeFile: async () => {},
|
||||
runWorkspaceKtx: async (argv, options) => {
|
||||
calls.push(argv);
|
||||
if (argv[2] === 'report') {
|
||||
options.stdout.write(successReportJson());
|
||||
return 0;
|
||||
}
|
||||
options.stdout.write('KTX scan completed\nRun: scan-orbit-1\nConnection: orbit\n');
|
||||
options.stdout.write('KTX scan completed\nRun: scan-orbit-1\nConnection: orbit\n Report: reports/scan-report.json\n');
|
||||
return 0;
|
||||
},
|
||||
readFile: async () => successReportJson(),
|
||||
});
|
||||
|
||||
assert.equal(result.projectDir, '/tmp/orbit-project-from-env');
|
||||
assert.deepEqual(calls, [
|
||||
['dev', 'scan', 'orbit', '--enrich', '--project-dir', '/tmp/orbit-project-from-env'],
|
||||
['dev', 'scan', 'report', '--json', '--project-dir', '/tmp/orbit-project-from-env', 'scan-orbit-1'],
|
||||
['scan', 'orbit', '--mode', 'relationships', '--project-dir', '/tmp/orbit-project-from-env'],
|
||||
]);
|
||||
} finally {
|
||||
if (previousProjectDir === undefined) {
|
||||
|
|
@ -155,6 +137,7 @@ describe('relationship Orbit verification helper', () => {
|
|||
it('extracts the run id from human scan output', () => {
|
||||
assert.equal(extractRunId(`KTX scan completed\nStatus: done\nRun: scan-orbit-1\nConnection: orbit\n`), 'scan-orbit-1');
|
||||
assert.equal(extractRunId('KTX scan completed without a run line\n'), null);
|
||||
assert.equal(extractReportPath('Artifacts\n Report: reports/scan-report.json\n'), 'reports/scan-report.json');
|
||||
});
|
||||
|
||||
it('formats successful Orbit verification evidence from the JSON report', () => {
|
||||
|
|
@ -163,10 +146,9 @@ describe('relationship Orbit verification helper', () => {
|
|||
date: '2026-05-07',
|
||||
connectionId: 'orbit',
|
||||
projectDir: '/tmp/orbit-project',
|
||||
scanCommand: 'pnpm run ktx -- dev scan orbit --enrich --project-dir /tmp/orbit-project',
|
||||
reportCommand: 'pnpm run ktx -- dev scan report --json --project-dir /tmp/orbit-project scan-orbit-1',
|
||||
scanCommand: 'pnpm run ktx -- scan orbit --mode relationships --project-dir /tmp/orbit-project',
|
||||
reportPath: '/tmp/orbit-project/reports/scan-report.json',
|
||||
scanExitCode: 0,
|
||||
reportExitCode: 0,
|
||||
scanStdout: 'KTX scan completed\nRun: scan-orbit-1\n',
|
||||
scanStderr: '',
|
||||
report: JSON.parse(successReportJson()),
|
||||
|
|
@ -189,7 +171,7 @@ describe('relationship Orbit verification helper', () => {
|
|||
date: '2026-05-07',
|
||||
connectionId: 'orbit',
|
||||
projectDir: '/tmp/orbit-project',
|
||||
scanCommand: 'pnpm run ktx -- dev scan orbit --enrich --project-dir /tmp/orbit-project',
|
||||
scanCommand: 'pnpm run ktx -- scan orbit --mode relationships --project-dir /tmp/orbit-project',
|
||||
scanExitCode: 1,
|
||||
blocker: 'Connection "orbit" was not found',
|
||||
scanStdout: '',
|
||||
|
|
@ -202,7 +184,7 @@ describe('relationship Orbit verification helper', () => {
|
|||
assert.doesNotMatch(markdown, /scan\.enrichment\.mode is required/);
|
||||
});
|
||||
|
||||
it('runs scan then JSON report and writes success Markdown', async () => {
|
||||
it('runs scan then reads the report artifact and writes success Markdown', async () => {
|
||||
const calls = [];
|
||||
const writes = [];
|
||||
const result = await runOrbitVerification({
|
||||
|
|
@ -216,19 +198,15 @@ describe('relationship Orbit verification helper', () => {
|
|||
},
|
||||
runWorkspaceKtx: async (argv, options) => {
|
||||
calls.push(argv);
|
||||
if (argv[2] === 'report') {
|
||||
options.stdout.write(successReportJson());
|
||||
return 0;
|
||||
}
|
||||
options.stdout.write('KTX scan completed\nRun: scan-orbit-1\nConnection: orbit\n');
|
||||
options.stdout.write('KTX scan completed\nRun: scan-orbit-1\nConnection: orbit\n Report: reports/scan-report.json\n');
|
||||
return 0;
|
||||
},
|
||||
readFile: async () => successReportJson(),
|
||||
});
|
||||
|
||||
assert.equal(result.status, 'success');
|
||||
assert.deepEqual(calls, [
|
||||
['dev', 'scan', 'orbit', '--enrich', '--project-dir', '/tmp/orbit-project'],
|
||||
['dev', 'scan', 'report', '--json', '--project-dir', '/tmp/orbit-project', 'scan-orbit-1'],
|
||||
['scan', 'orbit', '--mode', 'relationships', '--project-dir', '/tmp/orbit-project'],
|
||||
]);
|
||||
assert.equal(writes.length, 1);
|
||||
assert.equal(writes[0].path, '/tmp/orbit-report.md');
|
||||
|
|
|
|||
|
|
@ -83,10 +83,6 @@ async function isBuildStale(rootDir, binPath, fs) {
|
|||
return false;
|
||||
}
|
||||
|
||||
function isShellCompletionRequest(argv) {
|
||||
return argv[0] === '__complete' || (argv[0] === 'dev' && argv[1] === '__complete');
|
||||
}
|
||||
|
||||
async function runBuffered(execFile, stdout, stderr, command, args, options) {
|
||||
try {
|
||||
const result = await execFile(command, args, { cwd: options.cwd, env: options.env, maxBuffer: 1024 * 1024 * 16 });
|
||||
|
|
@ -150,8 +146,7 @@ export async function runWorkspaceKtx(argv, options = {}) {
|
|||
const commandEnv = options.env;
|
||||
|
||||
const binExists = await fileExists(binPath, access);
|
||||
const skipStaleBuildCheck = binExists && isShellCompletionRequest(cliArgv);
|
||||
const needsBuild = !binExists || (!skipStaleBuildCheck && (await isBuildStale(rootDir, binPath, fs)));
|
||||
const needsBuild = !binExists || (await isBuildStale(rootDir, binPath, fs));
|
||||
if (needsBuild) {
|
||||
stderr.write(
|
||||
binExists
|
||||
|
|
|
|||
|
|
@ -105,53 +105,6 @@ test('runWorkspaceKtx drops a leading npm argument separator', async () => {
|
|||
]);
|
||||
});
|
||||
|
||||
test('runWorkspaceKtx skips stale-build checks for shell completion when dist exists', async () => {
|
||||
const calls = [];
|
||||
let statCalls = 0;
|
||||
|
||||
const exitCode = await runWorkspaceKtx(['dev', '__complete', '--shell', 'zsh', '--position', '2', '--', 'ktx', ''], {
|
||||
rootDir: '/workspace/ktx',
|
||||
access: async () => undefined,
|
||||
stat: async (path) => {
|
||||
statCalls += 1;
|
||||
return {
|
||||
mtimeMs: path.endsWith('/packages/cli/dist/bin.js') ? 2000 : 3000,
|
||||
isDirectory: () => path.endsWith('/src') || path.endsWith('/packages'),
|
||||
};
|
||||
},
|
||||
readdir: async () => {
|
||||
throw new Error('completion should not scan source directories');
|
||||
},
|
||||
execFile: async (command, args, options) => {
|
||||
calls.push({ command, args, cwd: options.cwd });
|
||||
return { stdout: 'connect:Add, list, test, and map data sources\n', stderr: '' };
|
||||
},
|
||||
stdout: { write: () => undefined },
|
||||
stderr: { write: () => undefined },
|
||||
});
|
||||
|
||||
assert.equal(exitCode, 0);
|
||||
assert.equal(statCalls, 0);
|
||||
assert.deepEqual(calls, [
|
||||
{
|
||||
command: process.execPath,
|
||||
args: [
|
||||
'/workspace/ktx/packages/cli/dist/bin.js',
|
||||
'dev',
|
||||
'__complete',
|
||||
'--shell',
|
||||
'zsh',
|
||||
'--position',
|
||||
'2',
|
||||
'--',
|
||||
'ktx',
|
||||
'',
|
||||
],
|
||||
cwd: '/workspace/ktx',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test('runWorkspaceKtx builds the workspace CLI before running it when dist is missing', async () => {
|
||||
const calls = [];
|
||||
const logs = [];
|
||||
|
|
@ -199,7 +152,7 @@ test('runWorkspaceKtx rebuilds before running when workspace sources are newer t
|
|||
const logs = [];
|
||||
let sourceMtimeMs = 3000;
|
||||
|
||||
const exitCode = await runWorkspaceKtx(['dev', 'scan', 'orbit', '--enrich'], {
|
||||
const exitCode = await runWorkspaceKtx(['scan', 'orbit', '--mode', 'relationships'], {
|
||||
rootDir: '/workspace/ktx',
|
||||
access: async () => undefined,
|
||||
stat: async (path) => ({
|
||||
|
|
@ -232,7 +185,7 @@ test('runWorkspaceKtx rebuilds before running when workspace sources are newer t
|
|||
calls.map((call) => [call.command, call.args]),
|
||||
[
|
||||
['pnpm', ['run', 'build']],
|
||||
[process.execPath, ['/workspace/ktx/packages/cli/dist/bin.js', 'dev', 'scan', 'orbit', '--enrich']],
|
||||
[process.execPath, ['/workspace/ktx/packages/cli/dist/bin.js', 'scan', 'orbit', '--mode', 'relationships']],
|
||||
],
|
||||
);
|
||||
assert.deepEqual(logs, [
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue