cassis-cli 2.0.0__tar.gz → 2.2.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: cassis-cli
3
- Version: 2.0.0
3
+ Version: 2.2.0
4
4
  Summary: Validate, test and evaluate your Cassis ontology from your terminal, then publish it
5
5
  License: Apache-2.0
6
6
  License-File: LICENSE
@@ -35,10 +35,10 @@ Validate, test and evaluate your ontology from your terminal, then publish it. T
35
35
  - `cassis ontology test` runs individual questions through the text-to-SQL agent using your local ontology files, so you can check that a change actually works (e.g. a new column gets picked) — where `eval run` only checks for regressions on existing eval cases.
36
36
  - `cassis eval add-case` adds a gold question/SQL case to the project's eval suite — after fixing an ontology issue, add the question users were failing on so `eval run` guards it from regressing.
37
37
  - `cassis eval list-cases` and `cassis eval delete-case` maintain the suite: list the current cases with their ids, and prune one that is stale or wrong (e.g. its gold SQL encodes a definition the ontology has since changed).
38
- - `cassis schema plan <ddl>` (or `--warehouse` on a project connected to a warehouse) previews what a schema update would change before anything is applied: the source schema diff, the ontology changes Cassis will make (every change on a table placed in the ontology, with everything a drop takes with it) and warnings, terraform-style. `cassis schema apply <ddl>` (or `--plan <id>`) writes the resulting ontology files into the local checkout, app untouched, for review with `git diff`. `cassis schema push <ddl> [--publish]` pushes the new schema and the local ontology to the app (`--yes` in CI). The file speaks only for the schemas it contains — pass `--complete` when it is the project's complete source schema so schemas absent from it are treated as dropped. With `--warehouse` the server introspects the connected warehouse instead of parsing a file; the plan is always whole-source.
38
+ - `cassis schema plan <ddl>` (or `--warehouse` on a project connected to a warehouse) previews what a schema update would change before anything is applied: the source schema diff, the ontology changes Cassis will make (every change on a table placed in the ontology, with everything a drop takes with it) and warnings, terraform-style. `cassis schema apply <ddl>` (or `--plan <id>`) writes the resulting ontology files into the local checkout, app untouched, for review with `git diff`. `cassis schema push <ddl> [--publish]` pushes the new schema and the local ontology to the app (`--yes` in CI). The file speaks only for the schemas it contains — pass `--complete` when it is the project's complete source schema so schemas absent from it are treated as dropped. With `--warehouse` the server introspects the connected warehouse instead of parsing a file; the plan is always whole-source. `cassis schema plan <ddl> --dry-run` is the prepare-ahead variant: the plan is computed synchronously and nothing is kept in Cassis (no plan to apply or resume, the current plan untouched), so a dbt model or migration still in a PR can be planned against safely; `--write-checkout` writes the ontology files it would produce into the checkout, to commit alongside the schema change.
39
39
  - `cassis projects list` lists the projects your API key can reach — id (what `--project` and `CASSIS_PROJECT_ID` take), name, published ontology version, and data-source dialect — so a pipeline or agent can discover the project id from the terminal instead of fishing it out of a webapp URL.
40
40
  - `cassis status` shows the project's published version (number, label, git commit), whether unpublished changes await publication, the git-sync binding, a schema plan waiting to be applied, and how your local git HEAD relates to the published commit (in sync / N commits ahead / diverged). `cassis status --watch` polls until the published commit matches your local HEAD — e.g. right after merging a PR whose CI publishes the ontology — instead of watching the GitHub Actions tab.
41
- - `cassis issues` triages the issues Cassis raised on the project — what it found wrong while answering questions (an ontology gap, missing data) — without leaving the checkout: `issues list` (filterable by status, impact and cause), `issues show <id>` for the diagnosis, suggested action and the occurrences behind it, `issues evidence <id> <occurrence-id>` for what the agent actually saw, and `issues resolve` / `dismiss` / `reopen` once you've acted on it.
41
+ - `cassis issues` triages the issues Cassis raised on the project — what it found wrong while answering questions (an ontology gap, missing data) — without leaving the checkout: `issues list` (filterable by status, impact, cause and ontology domain, and showing each issue's domain so you can work through one domain at a time), `issues show <id>` for the diagnosis, suggested action and the occurrences behind it, `issues evidence <id> <occurrence-id>` for what the agent actually saw, and `issues resolve` / `dismiss` / `reopen` once you've acted on it.
42
42
  - `cassis verify` runs the full local gate in one verb — `ontology fmt --check`, `ontology check`, `eval run` — stopping at the first failure. One command in a checkout ("is this change safe to merge?"), one job in CI. `--no-eval` skips the eval suite.
43
43
 
44
44
  ## Install
@@ -104,6 +104,17 @@ cassis eval run --project ... --case 019f0000-0000-7000-8000-0000000000ca
104
104
 
105
105
  # Start the run and return immediately (poll in the webapp):
106
106
  cassis eval run --project ... --no-wait
107
+ ```
108
+
109
+ Under each failed case `eval run` prints what is needed to diagnose it: the SQL the agent
110
+ generated, the gold SQL it was compared against, how many rows each side returned, any concepts
111
+ the agent found missing, and the judge's reasoning when a judge graded the case. The expected and
112
+ actual row *values* are deliberately not printed — they are in `--json` and in the Cassis app.
113
+ Note that the **generated SQL is printed as the agent wrote it**, and an agent that read your data
114
+ while planning can carry a value it saw into a literal in that SQL. Treat `eval run` output as
115
+ carrying the same sensitivity as the queries themselves when you decide who can read your CI logs.
116
+
117
+ ```bash
107
118
 
108
119
  # Probe questions through the text-to-SQL agent using the local ontology files
109
120
  # (one full agent run per question, expect ~30-90s each; repeat -q for several):
@@ -129,6 +140,7 @@ cassis schema plan schema.sql --complete
129
140
  cassis schema apply schema.sql --complete # writes cassis/ locally
130
141
  cassis schema push schema.sql --complete --yes # schema + ontology to the app
131
142
  cassis schema plan --warehouse # warehouse-connected projects: introspect instead
143
+ cassis schema plan future.sql --dry-run --write-checkout # plan a not-yet-deployed DDL, keep nothing server-side
132
144
 
133
145
  # List the projects the API key can reach (id, name, published version, dialect):
134
146
  cassis projects list
@@ -142,6 +154,9 @@ cassis issues analyze
142
154
  cassis issues list --status open
143
155
  cassis issues show 019f0000-0000-7000-8000-0000000000e1
144
156
 
157
+ # Work one ontology domain at a time (nested domains included):
158
+ cassis issues list --domain sales
159
+
145
160
  # Read what the agent saw for one occurrence (ids from `issues show`):
146
161
  cassis issues evidence 019f0000-0000-7000-8000-0000000000e1 019f0000-0000-7000-8000-0000000000c1
147
162
 
@@ -197,7 +212,7 @@ cassis ontology fmt --check
197
212
  | ---- | ------------------------------------------------------------------------------ |
198
213
  | 0 | Ontology is valid (check) / pulled (pull) / uploaded (upload) / eval run completed all-passed (eval run) / every probe completed (test — whatever its outcome; probes are informational, don't gate CI on them) |
199
214
  | 1 | Validation failed (check: findings printed; upload: nothing imported; eval run: invalid tree, failed cases, or failed/cancelled run; test: invalid tree or a probe failed; add-case: duplicate question or gold SQL that does not run; delete-case: no such case in the project; issues: no such issue or occurrence in the project; issues analyze: failed or cancelled analysis run; schema plan/apply: the plan failed (unparseable or truncated DDL), is stale or expired, the apply failed, or the project won't accept it (a plan is being applied, a DDL was given for a warehouse-connected project, or --warehouse for a DDL-only one)) |
200
- | 2 | Usage error (missing API key or project, no ontology directory, unreadable file, tree over the size limits) |
215
+ | 2 | Usage error (missing API key or project, no ontology directory, unreadable file, tree over the size limits, `eval run --branch` naming an ontology branch the project does not have) |
201
216
  | 3 | Transport/API error (unreachable API, invalid key, inaccessible project, unexpected response), another eval run or issue analysis already active, out of credits, or `--timeout` reached |
202
217
 
203
218
  Commands that send the local tree (`check`, `fmt`, `upload`, `eval run`, `test`) accept up to
@@ -13,10 +13,10 @@ Validate, test and evaluate your ontology from your terminal, then publish it. T
13
13
  - `cassis ontology test` runs individual questions through the text-to-SQL agent using your local ontology files, so you can check that a change actually works (e.g. a new column gets picked) — where `eval run` only checks for regressions on existing eval cases.
14
14
  - `cassis eval add-case` adds a gold question/SQL case to the project's eval suite — after fixing an ontology issue, add the question users were failing on so `eval run` guards it from regressing.
15
15
  - `cassis eval list-cases` and `cassis eval delete-case` maintain the suite: list the current cases with their ids, and prune one that is stale or wrong (e.g. its gold SQL encodes a definition the ontology has since changed).
16
- - `cassis schema plan <ddl>` (or `--warehouse` on a project connected to a warehouse) previews what a schema update would change before anything is applied: the source schema diff, the ontology changes Cassis will make (every change on a table placed in the ontology, with everything a drop takes with it) and warnings, terraform-style. `cassis schema apply <ddl>` (or `--plan <id>`) writes the resulting ontology files into the local checkout, app untouched, for review with `git diff`. `cassis schema push <ddl> [--publish]` pushes the new schema and the local ontology to the app (`--yes` in CI). The file speaks only for the schemas it contains — pass `--complete` when it is the project's complete source schema so schemas absent from it are treated as dropped. With `--warehouse` the server introspects the connected warehouse instead of parsing a file; the plan is always whole-source.
16
+ - `cassis schema plan <ddl>` (or `--warehouse` on a project connected to a warehouse) previews what a schema update would change before anything is applied: the source schema diff, the ontology changes Cassis will make (every change on a table placed in the ontology, with everything a drop takes with it) and warnings, terraform-style. `cassis schema apply <ddl>` (or `--plan <id>`) writes the resulting ontology files into the local checkout, app untouched, for review with `git diff`. `cassis schema push <ddl> [--publish]` pushes the new schema and the local ontology to the app (`--yes` in CI). The file speaks only for the schemas it contains — pass `--complete` when it is the project's complete source schema so schemas absent from it are treated as dropped. With `--warehouse` the server introspects the connected warehouse instead of parsing a file; the plan is always whole-source. `cassis schema plan <ddl> --dry-run` is the prepare-ahead variant: the plan is computed synchronously and nothing is kept in Cassis (no plan to apply or resume, the current plan untouched), so a dbt model or migration still in a PR can be planned against safely; `--write-checkout` writes the ontology files it would produce into the checkout, to commit alongside the schema change.
17
17
  - `cassis projects list` lists the projects your API key can reach — id (what `--project` and `CASSIS_PROJECT_ID` take), name, published ontology version, and data-source dialect — so a pipeline or agent can discover the project id from the terminal instead of fishing it out of a webapp URL.
18
18
  - `cassis status` shows the project's published version (number, label, git commit), whether unpublished changes await publication, the git-sync binding, a schema plan waiting to be applied, and how your local git HEAD relates to the published commit (in sync / N commits ahead / diverged). `cassis status --watch` polls until the published commit matches your local HEAD — e.g. right after merging a PR whose CI publishes the ontology — instead of watching the GitHub Actions tab.
19
- - `cassis issues` triages the issues Cassis raised on the project — what it found wrong while answering questions (an ontology gap, missing data) — without leaving the checkout: `issues list` (filterable by status, impact and cause), `issues show <id>` for the diagnosis, suggested action and the occurrences behind it, `issues evidence <id> <occurrence-id>` for what the agent actually saw, and `issues resolve` / `dismiss` / `reopen` once you've acted on it.
19
+ - `cassis issues` triages the issues Cassis raised on the project — what it found wrong while answering questions (an ontology gap, missing data) — without leaving the checkout: `issues list` (filterable by status, impact, cause and ontology domain, and showing each issue's domain so you can work through one domain at a time), `issues show <id>` for the diagnosis, suggested action and the occurrences behind it, `issues evidence <id> <occurrence-id>` for what the agent actually saw, and `issues resolve` / `dismiss` / `reopen` once you've acted on it.
20
20
  - `cassis verify` runs the full local gate in one verb — `ontology fmt --check`, `ontology check`, `eval run` — stopping at the first failure. One command in a checkout ("is this change safe to merge?"), one job in CI. `--no-eval` skips the eval suite.
21
21
 
22
22
  ## Install
@@ -82,6 +82,17 @@ cassis eval run --project ... --case 019f0000-0000-7000-8000-0000000000ca
82
82
 
83
83
  # Start the run and return immediately (poll in the webapp):
84
84
  cassis eval run --project ... --no-wait
85
+ ```
86
+
87
+ Under each failed case `eval run` prints what is needed to diagnose it: the SQL the agent
88
+ generated, the gold SQL it was compared against, how many rows each side returned, any concepts
89
+ the agent found missing, and the judge's reasoning when a judge graded the case. The expected and
90
+ actual row *values* are deliberately not printed — they are in `--json` and in the Cassis app.
91
+ Note that the **generated SQL is printed as the agent wrote it**, and an agent that read your data
92
+ while planning can carry a value it saw into a literal in that SQL. Treat `eval run` output as
93
+ carrying the same sensitivity as the queries themselves when you decide who can read your CI logs.
94
+
95
+ ```bash
85
96
 
86
97
  # Probe questions through the text-to-SQL agent using the local ontology files
87
98
  # (one full agent run per question, expect ~30-90s each; repeat -q for several):
@@ -107,6 +118,7 @@ cassis schema plan schema.sql --complete
107
118
  cassis schema apply schema.sql --complete # writes cassis/ locally
108
119
  cassis schema push schema.sql --complete --yes # schema + ontology to the app
109
120
  cassis schema plan --warehouse # warehouse-connected projects: introspect instead
121
+ cassis schema plan future.sql --dry-run --write-checkout # plan a not-yet-deployed DDL, keep nothing server-side
110
122
 
111
123
  # List the projects the API key can reach (id, name, published version, dialect):
112
124
  cassis projects list
@@ -120,6 +132,9 @@ cassis issues analyze
120
132
  cassis issues list --status open
121
133
  cassis issues show 019f0000-0000-7000-8000-0000000000e1
122
134
 
135
+ # Work one ontology domain at a time (nested domains included):
136
+ cassis issues list --domain sales
137
+
123
138
  # Read what the agent saw for one occurrence (ids from `issues show`):
124
139
  cassis issues evidence 019f0000-0000-7000-8000-0000000000e1 019f0000-0000-7000-8000-0000000000c1
125
140
 
@@ -175,7 +190,7 @@ cassis ontology fmt --check
175
190
  | ---- | ------------------------------------------------------------------------------ |
176
191
  | 0 | Ontology is valid (check) / pulled (pull) / uploaded (upload) / eval run completed all-passed (eval run) / every probe completed (test — whatever its outcome; probes are informational, don't gate CI on them) |
177
192
  | 1 | Validation failed (check: findings printed; upload: nothing imported; eval run: invalid tree, failed cases, or failed/cancelled run; test: invalid tree or a probe failed; add-case: duplicate question or gold SQL that does not run; delete-case: no such case in the project; issues: no such issue or occurrence in the project; issues analyze: failed or cancelled analysis run; schema plan/apply: the plan failed (unparseable or truncated DDL), is stale or expired, the apply failed, or the project won't accept it (a plan is being applied, a DDL was given for a warehouse-connected project, or --warehouse for a DDL-only one)) |
178
- | 2 | Usage error (missing API key or project, no ontology directory, unreadable file, tree over the size limits) |
193
+ | 2 | Usage error (missing API key or project, no ontology directory, unreadable file, tree over the size limits, `eval run --branch` naming an ontology branch the project does not have) |
179
194
  | 3 | Transport/API error (unreachable API, invalid key, inaccessible project, unexpected response), another eval run or issue analysis already active, out of credits, or `--timeout` reached |
180
195
 
181
196
  Commands that send the local tree (`check`, `fmt`, `upload`, `eval run`, `test`) accept up to
@@ -106,6 +106,21 @@ class EvalRunActiveError(ApiError):
106
106
  """Another eval run is already active for the project (409)."""
107
107
 
108
108
 
109
+ class EvalBranchNotFoundError(ApiError):
110
+ """`--branch` named an ontology branch the project does not have (404)."""
111
+
112
+
113
+ def _is_branch_not_found(detail: object) -> bool:
114
+ """Whether a 404 detail is the start endpoint's missing-branch message.
115
+
116
+ Wire contract with `start_eval_run` in backend `endpoints/ci/eval.py`,
117
+ matched the way `delete_eval_case` matches its own 404 text. A server that
118
+ words it differently falls through to the generic project-scope error, so an
119
+ older server degrades rather than misreporting.
120
+ """
121
+ return isinstance(detail, str) and detail.startswith("Branch ") and detail.endswith(" not found")
122
+
123
+
109
124
  def _project_scope_error(response: httpx.Response) -> ApiError:
110
125
  # Surface the server's own message when it names the missing resource
111
126
  # (e.g. "Branch 'x' not found") — the generic hint covers the rest.
@@ -357,6 +372,10 @@ class SchemaPlanConflictError(ApiError):
357
372
  """409 from the schema-plan routes: a plan is already active, the project is connected, the plan is stale / expired / not ready."""
358
373
 
359
374
 
375
+ class SchemaPlanRejectedError(ApiError):
376
+ """400 from the preview route: the DDL does not parse or reads as truncated (the user's file, not transport)."""
377
+
378
+
360
379
  class SchemaPlanNotFoundError(ApiError):
361
380
  """404 with the server's exact detail `"Schema plan not found"` (a project-scope 404 is a different error)."""
362
381
 
@@ -447,6 +466,61 @@ def post_schema_plan_warehouse(
447
466
  return _schema_plan_response(response, url)
448
467
 
449
468
 
469
+ def _schema_plan_preview_response(response: httpx.Response, url: str) -> dict[str, Any]:
470
+ if response.status_code == 400:
471
+ raise SchemaPlanRejectedError(str(_detail_or_text(response)))
472
+ _raise_for_schema_plan_status(response)
473
+ result = _parse_json_response(response, url)
474
+ if not isinstance(result, dict) or "document" not in result or not isinstance(result.get("files"), dict):
475
+ raise ApiError(f"Unexpected response shape from the Cassis API at {url}.")
476
+ return result
477
+
478
+
479
+ def post_schema_plan_preview(
480
+ *,
481
+ api_url: str,
482
+ api_key: str,
483
+ project_id: str,
484
+ ddl: str,
485
+ complete_source: bool = False,
486
+ transport: Optional[httpx.BaseTransport] = None,
487
+ ) -> dict[str, Any]:
488
+ """POST /api/ci/projects/{project_id}/schema/plans/preview: plan + rendered tree, nothing persisted.
489
+
490
+ Synchronous: the server computes the plan in the request (no plan row, no
491
+ job), so the tree timeout applies. A DDL the server cannot parse, or that
492
+ reads as truncated, is a 400 → `SchemaPlanRejectedError`.
493
+ """
494
+ url = api_url.rstrip("/") + f"/api/ci/projects/{project_id}/schema/plans/preview"
495
+ try:
496
+ with _client(timeout=ONTOLOGY_TREE_TIMEOUT_SECONDS, transport=transport) as client:
497
+ response = client.post(
498
+ url,
499
+ json={"ddl": ddl, "complete_source": complete_source},
500
+ headers={"Authorization": f"Bearer {api_key}"},
501
+ )
502
+ except httpx.HTTPError as exc:
503
+ raise ApiError(f"Could not reach the Cassis API at {url}: {exc}") from exc
504
+ return _schema_plan_preview_response(response, url)
505
+
506
+
507
+ def post_schema_plan_preview_warehouse(
508
+ *,
509
+ api_url: str,
510
+ api_key: str,
511
+ project_id: str,
512
+ transport: Optional[httpx.BaseTransport] = None,
513
+ ) -> dict[str, Any]:
514
+ """POST /api/ci/projects/{project_id}/schema/plans/preview/warehouse: the warehouse twin of the preview."""
515
+ url = api_url.rstrip("/") + f"/api/ci/projects/{project_id}/schema/plans/preview/warehouse"
516
+ try:
517
+ with _client(timeout=ONTOLOGY_TREE_TIMEOUT_SECONDS, transport=transport) as client:
518
+ response = client.post(url, headers={"Authorization": f"Bearer {api_key}"})
519
+ except httpx.HTTPError as exc:
520
+ raise ApiError(f"Could not reach the Cassis API at {url}: {exc}") from exc
521
+ return _schema_plan_preview_response(response, url)
522
+
523
+
450
524
  def get_schema_plan(
451
525
  *,
452
526
  api_url: str,
@@ -544,6 +618,11 @@ def post_eval_run_start(
544
618
  "An eval run is already active for this project — wait for it to finish or cancel it "
545
619
  "(in the webapp's Evals page, or with the run id printed when it was started)."
546
620
  )
621
+ if response.status_code == 404 and _is_branch_not_found(_detail_or_text(response)):
622
+ # Distinct from a project-scope 404: the key and project are fine, the
623
+ # branch name is not, and the generic copy sends the user to check
624
+ # permissions instead.
625
+ raise EvalBranchNotFoundError(_detail_or_text(response))
547
626
  if response.status_code in (403, 404):
548
627
  raise _project_scope_error(response)
549
628
  if response.status_code >= 400:
@@ -770,11 +849,16 @@ def get_issues(
770
849
  status: Optional[str] = None,
771
850
  impact: Optional[str] = None,
772
851
  cause: Optional[str] = None,
852
+ domain: Optional[str] = None,
773
853
  transport: Optional[httpx.BaseTransport] = None,
774
854
  ) -> list[dict[str, Any]]:
775
855
  """GET /api/ci/projects/{project_id}/issues and return the issue list."""
776
856
  url = api_url.rstrip("/") + f"/api/ci/projects/{project_id}/issues"
777
- params = {key: value for key, value in (("status", status), ("impact", impact), ("cause", cause)) if value}
857
+ params = {
858
+ key: value
859
+ for key, value in (("status", status), ("impact", impact), ("cause", cause), ("domain", domain))
860
+ if value
861
+ }
778
862
  result = _get_issue_json(url, api_key, transport, params=params)
779
863
  if not isinstance(result, list) or not all(isinstance(issue, dict) and "id" in issue for issue in result):
780
864
  raise ApiError(f"Unexpected response shape from the Cassis API at {url}.")
@@ -14,6 +14,7 @@ from cassis_cli.api import (
14
14
  DEFAULT_API_URL,
15
15
  ApiError,
16
16
  AuthError,
17
+ EvalBranchNotFoundError,
17
18
  EvalCaseExistsError,
18
19
  EvalCaseGoldSqlError,
19
20
  EvalCaseNotFoundError,
@@ -56,6 +57,15 @@ _STATUS_COLORS = {
56
57
  "plan_unexecutable": typer.colors.YELLOW,
57
58
  }
58
59
 
60
+ # Caps on the evidence printed under a failed case. Bounded so a suite of
61
+ # failures stays readable in a CI log, and always announced when they bite.
62
+ _MAX_ERROR_CHARS = 200
63
+ _MAX_REASONING_CHARS = 500
64
+ _MAX_SQL_LINES = 20
65
+ # A line cap alone bounds nothing: a gold SQL written as one long line in a YAML
66
+ # case is a single line of any size, printed twice per failing case.
67
+ _MAX_SQL_CHARS = 2000
68
+
59
69
 
60
70
  def _run_page_url(app_url: str, project_id: str, run_id: str) -> str:
61
71
  """Webapp URL of the run's detail view (Evals page, runs tab)."""
@@ -118,6 +128,84 @@ def _print_validation_failure(detail: object, base_path: str) -> None:
118
128
  typer.secho(str(detail), fg=typer.colors.RED, err=True)
119
129
 
120
130
 
131
+ def _shorten(text: str, limit: int) -> str:
132
+ """Shorten to `limit` characters and say so. A silent cut can drop the cause."""
133
+ text = " ".join(text.split())
134
+ if len(text) <= limit:
135
+ return text
136
+ return f"{text[:limit]}... (truncated, {len(text)} chars)"
137
+
138
+
139
+ def _print_sql(label: str, sql: str) -> None:
140
+ """Print SQL under a failed case, bounded on both lines and characters."""
141
+ lines = sql.strip().splitlines()
142
+ typer.echo(f" {label}:")
143
+ budget = _MAX_SQL_CHARS
144
+ shown = 0
145
+ for line in lines[:_MAX_SQL_LINES]:
146
+ if budget <= 0:
147
+ break
148
+ if len(line) > budget:
149
+ typer.echo(f" {line[:budget]}... (truncated, {len(line)} chars)")
150
+ budget = 0
151
+ else:
152
+ typer.echo(f" {line}")
153
+ budget -= len(line)
154
+ shown += 1
155
+ if shown < len(lines):
156
+ typer.echo(f" ... ({len(lines) - shown} more lines)")
157
+
158
+
159
+ def _print_failure_evidence(r: dict[str, Any]) -> None:
160
+ """Print what a failed case needs to be diagnosed here, without row values.
161
+
162
+ Which field carries the reason depends on the status: a missing-concept
163
+ failure explains itself through `missing_concepts`, a judge rejection
164
+ through `judge_reasoning`, a data mismatch through `error` and the row
165
+ counts. Printing only `error` — as this did — showed nothing at all for the
166
+ first two.
167
+
168
+ Expected and actual row values are deliberately not printed. They ride in
169
+ `--json` and the webapp instead: `cassis verify` output is a CI job log, and
170
+ warehouse values in a job log travel further than whoever reads it intends.
171
+
172
+ Every field is read with `.get`, so an older server that returns none of
173
+ them degrades to the error line alone.
174
+ """
175
+ error = r.get("error")
176
+ if error:
177
+ typer.echo(f" {_shorten(str(error), _MAX_ERROR_CHARS)}")
178
+
179
+ names = [str(c["name"]) for c in (r.get("missing_concepts") or []) if isinstance(c, dict) and c.get("name")]
180
+ if names:
181
+ typer.echo(f" Missing concepts: {', '.join(names)}")
182
+
183
+ if r.get("judge_verdict"):
184
+ typer.echo(f" Judge verdict: {r['judge_verdict']}")
185
+ if r.get("judge_reasoning"):
186
+ typer.echo(f" Judge reasoning: {_shorten(str(r['judge_reasoning']), _MAX_REASONING_CHARS)}")
187
+
188
+ expected, actual = r.get("expected_row_count"), r.get("actual_row_count")
189
+ if expected is not None or actual is not None:
190
+ shown_actual = "-" if actual is None else str(actual)
191
+ shown_expected = "-" if expected is None else str(expected)
192
+ typer.echo(f" Rows: {shown_actual} actual vs {shown_expected} expected")
193
+
194
+ # The agent's own words, for the statuses that carry no other explanation
195
+ # (a conversational reply instead of a plan, for one).
196
+ content = r.get("content")
197
+ if content and not error and not names and not r.get("judge_reasoning"):
198
+ typer.echo(f" {_shorten(str(content), _MAX_ERROR_CHARS)}")
199
+
200
+ if r.get("generated_sql"):
201
+ _print_sql("Generated SQL", str(r["generated_sql"]))
202
+ if r.get("gold_sql"):
203
+ label = "Gold SQL"
204
+ if r.get("gold_sql_from_live_case"):
205
+ label = "Gold SQL (the case as it stands now; this result predates snapshotting)"
206
+ _print_sql(label, str(r["gold_sql"]))
207
+
208
+
121
209
  def _print_results_table(results: list[dict[str, Any]]) -> None:
122
210
  for r in sorted(results, key=lambda r: (r.get("status") == _PASSED, r.get("question") or "")):
123
211
  status = r.get("status", "?")
@@ -129,8 +217,9 @@ def _print_results_table(results: list[dict[str, Any]]) -> None:
129
217
  question = question[:67] + "..."
130
218
  line = f" {icon} {status:<17} {duration:>5} {question}"
131
219
  typer.secho(line, fg=color)
132
- if r.get("error"):
133
- typer.echo(f" {str(r['error'])[:200]}")
220
+ if status == _PASSED:
221
+ continue
222
+ _print_failure_evidence(r)
134
223
 
135
224
 
136
225
  def _print_summary(run: dict[str, Any]) -> None:
@@ -448,7 +537,13 @@ def run(
448
537
  named case(s) run — e.g. proving one fresh `add-case` in seconds instead of
449
538
  rerunning the whole suite. Exits 0 when the run completes with every case
450
539
  passed, 1 on any failed case / failed run / invalid tree, 2 on usage
451
- errors, 3 on transport errors or --timeout.
540
+ errors (including a --branch the project does not have), 3 on transport
541
+ errors or --timeout.
542
+
543
+ Failed cases print the generated SQL as the agent wrote it. Row values are
544
+ never printed (they are in --json), but an agent that read your data while
545
+ planning can carry a value it saw into a SQL literal — so treat this output
546
+ as sensitive as the queries themselves.
452
547
  """
453
548
  api_key = require_api_key(api_key)
454
549
  for case_id in case or []:
@@ -485,6 +580,17 @@ def run(
485
580
  except EvalStartValidationError as exc:
486
581
  _print_validation_failure(exc.detail, base_path)
487
582
  raise typer.Exit(EXIT_VALIDATION_FAILED) from exc
583
+ except EvalBranchNotFoundError as exc:
584
+ # Bad input, not a transport or permissions problem — so exit 2, and say
585
+ # what --branch actually takes instead of pointing at the API key.
586
+ typer.secho(str(exc), fg=typer.colors.RED, err=True)
587
+ typer.echo(
588
+ "--branch runs against an ontology branch that already exists in Cassis "
589
+ "(create it in the webapp, or push it with `cassis ontology push`). "
590
+ "To label a run after your local checkout's branch, use --label instead.",
591
+ err=True,
592
+ )
593
+ raise typer.Exit(EXIT_USAGE) from exc
488
594
  except ApiError as exc: # covers AuthError and EvalRunActiveError too
489
595
  typer.secho(str(exc), fg=typer.colors.RED, err=True)
490
596
  raise typer.Exit(EXIT_TRANSPORT) from exc
@@ -31,7 +31,7 @@ GUIDE_FILENAME = "AGENTS.md"
31
31
  # Monotonic version of the doctrine text below. Bump it whenever
32
32
  # ontology_design_guide.md changes (a backend test enforces the pairing) — it
33
33
  # is what lets an older writer recognize a newer guide and leave it alone.
34
- DOCTRINE_VERSION = 6
34
+ DOCTRINE_VERSION = 7
35
35
 
36
36
  # Must stay byte-identical to backend/app/services/ontology_guide.py::_BANNER —
37
37
  # the server-side git export writes the same file, and differing banners would
@@ -117,6 +117,9 @@ def list_issues(
117
117
  status: Optional[str] = typer.Option(None, "--status", help=f"Filter by status ({', '.join(STATUSES)})."),
118
118
  impact: Optional[str] = typer.Option(None, "--impact", help=f"Filter by impact ({', '.join(IMPACTS)})."),
119
119
  cause: Optional[str] = typer.Option(None, "--cause", help=f"Filter by cause ({', '.join(CAUSES)})."),
120
+ domain: Optional[str] = typer.Option(
121
+ None, "--domain", help="Filter by ontology domain path; nested domains included."
122
+ ),
120
123
  path: Path = _PATH_OPTION,
121
124
  project_id: Optional[str] = _PROJECT_OPTION,
122
125
  api_key: Optional[str] = _API_KEY_OPTION,
@@ -126,9 +129,11 @@ def list_issues(
126
129
  ) -> None:
127
130
  """List the project's issues, prioritized by impact then recurrence.
128
131
 
129
- Prints each issue's id, impact, occurrence count, status and title; the id
130
- is what `cassis issues show`, `resolve`, `dismiss` and `reopen` take. Exits
131
- 0 on success, 2 on usage errors, 3 on transport/API errors.
132
+ Prints each issue's id, impact, occurrence count, status, primary ontology
133
+ domain (`-` when Cassis could not attach one) and title; the id is what
134
+ `cassis issues show`, `resolve`, `dismiss` and `reopen` take. Triage one
135
+ domain at a time with `--domain`, which covers its nested domains too.
136
+ Exits 0 on success, 2 on usage errors, 3 on transport/API errors.
132
137
  """
133
138
  status = _validate_choice(status, STATUSES, "--status")
134
139
  impact = _validate_choice(impact, IMPACTS, "--impact")
@@ -144,6 +149,7 @@ def list_issues(
144
149
  status=status,
145
150
  impact=impact,
146
151
  cause=cause,
152
+ domain=domain,
147
153
  )
148
154
  except (AuthError, ApiError) as exc:
149
155
  raise api_failure(exc) from exc
@@ -153,14 +159,16 @@ def list_issues(
153
159
  raise typer.Exit(EXIT_OK)
154
160
 
155
161
  if not issues:
156
- typer.echo("No issues match." if (status or impact or cause) else "No issues on this project.")
162
+ typer.echo("No issues match." if (status or impact or cause or domain) else "No issues on this project.")
157
163
  raise typer.Exit(EXIT_OK)
158
164
 
159
165
  for issue in issues:
160
166
  occurrences = issue.get("occurrence_count_cache") or 0
167
+ # Primary domain only; --json carries the whole list.
168
+ domains = issue.get("domains") or []
161
169
  typer.echo(
162
170
  f"{issue.get('id')} {issue.get('impact')} x{occurrences} "
163
- f"{issue.get('status')} {one_line(issue.get('title'))}"
171
+ f"{issue.get('status')} {domains[0] if domains else '-'} {one_line(issue.get('title'))}"
164
172
  )
165
173
  raise typer.Exit(EXIT_OK)
166
174
 
@@ -201,6 +209,7 @@ def show(
201
209
  f"{issue.get('status')} {issue.get('impact')} {issue.get('cause')} "
202
210
  f"{issue.get('occurrence_count_cache', len(occurrences))} occurrence(s)"
203
211
  )
212
+ _field("Domains", ", ".join(issue.get("domains") or []) or None)
204
213
  _field("Description", issue.get("description"), blank_line=True)
205
214
  _field("Suggested action", issue.get("suggested_action"), blank_line=True)
206
215
  typer.echo("")
@@ -107,7 +107,7 @@ Put each fact at the lowest layer that fully owns it:
107
107
 
108
108
  ```
109
109
  column synonym / column description single-column facts: meaning, encoding, unit, date format, nulls
110
- → table description single-table facts: grain, scope, mandatory filters, "use this not that"
110
+ → table description single-table facts: scope, mandatory filters, "use this not that"
111
111
  → domain context_md cross-table routing, disambiguation, vocabulary spanning tables
112
112
  → root context truly global rules only: corporate identity, entity hierarchy, global conventions
113
113
  ```
@@ -134,13 +134,18 @@ otherwise push it down.
134
134
  Per-table facts go on that table's `description`; keep only genuinely
135
135
  cross-table routing in `context_md`.
136
136
  - Metrics are surfaced with their full definition → don't re-paste a formula in
137
- prose. Point to a metric by name **only** when it lives in a *different* domain
137
+ prose, and that includes the metric's **own** `description` and `notes` (§8).
138
+ Point to a metric by name **only** when it lives in a *different* domain
138
139
  than the prose discussing it; a domain's own metrics are surfaced alongside its
139
140
  `context_md` already.
140
141
  - Joins are surfaced with their condition and cardinality → don't enumerate join
141
142
  keys in prose. The only join content that belongs in prose is a join+filter
142
143
  *recipe* no single structured join can express.
143
144
 
145
+ **Short or empty beats redundant.** When the structured fields already say
146
+ everything worth saying, leave the prose field short or empty. An empty
147
+ description costs nothing; a redundant one goes stale.
148
+
144
149
  ---
145
150
 
146
151
  ## 4. Domains and `context_md`
@@ -188,9 +193,9 @@ domain. Everything domain-specific moves down.
188
193
 
189
194
  A table's `description` answers *"what is this, and what must the SQL agent know
190
195
  to use it correctly?"* — business meaning **plus** the correctness facts (scope,
191
- mandatory filters, gotchas). Always **state the grain explicitly** ("one row per
192
- order"; "one row per (user_id, day)") so the agent aggregates correctly, and set
193
- the `grain` field to the identifying column(s). Don't enumerate the columns — the
196
+ mandatory filters, gotchas). The grain goes in the `grain` field the identifying
197
+ column(s) not in the description: the agent reads it structurally, and "one row
198
+ per order" in prose is a second copy. Don't enumerate the columns either — the
194
199
  column list is surfaced automatically.
195
200
 
196
201
  Use `synonyms` for alternate names a user might say ("purchases", "transactions"),
@@ -219,10 +224,10 @@ in the `sql` per §7.
219
224
  ## 6. Columns
220
225
 
221
226
  Column enrichment is `description`, `unit`, and `synonyms` — you cannot add or
222
- rename columns (they come from introspection). The `description` is the SQL
223
- agent's only per-column guidance, so it carries both the semantic meaning and the
224
- SQL-correctness facts: possible values, encoding, units, date format, null
225
- behavior, any mandatory filter.
227
+ rename columns (they come from introspection). The agent sees a column's name,
228
+ type, `unit` and `synonyms` structurally; the `description` carries what those
229
+ don't: meaning, possible values, encoding, date format, null behavior, any
230
+ mandatory filter.
226
231
 
227
232
  - **Prioritize the trap columns.** Disambiguate similarly-named columns
228
233
  (`created_at` vs `updated_at`, `amount` vs `net_amount`), numeric-encoded
@@ -232,6 +237,14 @@ behavior, any mandatory filter.
232
237
  - **Set `unit`** whenever a number is a currency, percentage, or duration —
233
238
  aggregation correctness depends on it.
234
239
  - **Use `synonyms`** for 1–3 short, plain alternate terms a user might use.
240
+ - **Facts, not SQL.** Say what the column holds and how it is encoded ("TRUE =
241
+ the account can sign in"), never a fragment to paste ("filter with
242
+ `is_active = TRUE`", "count people with `COUNT(DISTINCT id)`"). Writing the SQL
243
+ is the agent's job.
244
+ - **Relationships live in joins.** A foreign-key column says what it identifies
245
+ ("the owning organization"), not which table it references or on what
246
+ condition; the join carries that, and a description repeating it drifts when
247
+ the join changes.
235
248
  - **Keep the join key queryable.** A foreign-key column stays a real, described
236
249
  column even when a join covers the relationship — hiding it leaves the agent
237
250
  unable to filter or group on it without traversing the join. Test: imagine a
@@ -312,12 +325,33 @@ unit: euro cents
312
325
  synonyms:
313
326
  - net sales
314
327
  - revenue net of refunds
315
- description: Gross order amount minus refunds, excluding cancelled and refunded orders.
328
+ description: Revenue kept after refunds; the top-line sales figure.
316
329
  ```
317
330
 
318
331
  A metric definition beats a prose "revenue = net revenue excluding tax" note —
319
332
  the metric is surfaced structurally and can be reused; the prose drifts.
320
333
 
334
+ **Definition in the fields, meaning in the prose.** `expression`, `filters`,
335
+ `table_schema`/`table_name` and `unit` *are* the definition. Every prose field —
336
+ including the metric's own `description` and `notes` — says what the number means
337
+ and when to use it, never how it is computed: no columns, thresholds, time windows,
338
+ or numerator/denominator. A rule copied into prose is a second definition, and the
339
+ two drift apart (one metric shipped with a 90-day window in `filters` and "30 days"
340
+ in its own description). Business intent is fine ("draft bookings are not
341
+ commitments"); its encoding (`status <> 'DRAFT'`) is not. This holds even when the
342
+ metric's name *is* the term being defined ("active partners"): the reader sees
343
+ `filters` right next to the description. Disambiguate from sibling metrics by the
344
+ question each answers, never by restating their windows or filters. If the fields
345
+ say it all, keep the description to a phrase or leave it empty.
346
+
347
+ ```yaml
348
+ # Bad — restates the definition
349
+ description: Active partners = COUNT(DISTINCT partner_id) with a non-cancelled
350
+ booking in the last 90 days.
351
+ # Good — what the number is for
352
+ description: Partners still trading; the activity count used for outreach targets.
353
+ ```
354
+
321
355
  ---
322
356
 
323
357
  ## 9. Identifiers and case — where SQL silently breaks
@@ -351,7 +385,9 @@ description, so a wrong example is worse than none.
351
385
 
352
386
  - **Use real stored enum values verbatim.** Never hand-wave a list with "etc." —
353
387
  the agent reads `etc.` as license to invent values. If you don't have the exact
354
- values, omit the list rather than approximate it. When two columns supposedly
388
+ values, omit the list rather than approximate it. A `DISTINCT` over the column
389
+ *is* the list of stored values: write it plainly ("Stored values: 'EDITOR'"),
390
+ with no "observed so far" hedge and no counts. When two columns supposedly
355
391
  share an enum, verify they're identical before encoding both.
356
392
  - **Be wary of analogies to external products** — they mislead the moment the
357
393
  real mechanic diverges.
@@ -390,6 +426,14 @@ description, so a wrong example is worse than none.
390
426
  - **Ground writes in the real schema.** Before referencing a physical table or
391
427
  column, confirm it exists. Don't invent tables or columns; the physical schema
392
428
  is fixed.
429
+ - **Never persist a measurement.** Prose holds stable encodings and business
430
+ definitions, not what the data shows today. Row counts, distinct counts, shares,
431
+ distributions, funnel rates and "as of" / "currently" / "observed" statements go
432
+ stale silently, and the answering agent repeats them as fact. Query the data to
433
+ *verify* a stable fact (stored enum values, a date format, null behaviour) and
434
+ write only the fact: "Stored values: 'EDITOR', 'ADMIN'", not "only 'EDITOR'
435
+ observed (6 of 6, 100%)". Complete value lists and constants the business fixes
436
+ (a 14-day trial) are facts, not measurements.
393
437
  - **Ask for the business meaning; don't guess.** A term like "active user" or
394
438
  "last month" has a project-specific definition (time window, criteria, filters).
395
439
  Encode the business's actual definition, not a textbook default.
@@ -21,6 +21,7 @@ from cassis_cli.api import (
21
21
  ApiError,
22
22
  AuthError,
23
23
  SchemaPlanConflictError,
24
+ SchemaPlanRejectedError,
24
25
  UploadValidationError,
25
26
  get_ontology_export,
26
27
  get_schema_export,
@@ -29,6 +30,8 @@ from cassis_cli.api import (
29
30
  post_ontology_import,
30
31
  post_schema_plan,
31
32
  post_schema_plan_apply,
33
+ post_schema_plan_preview,
34
+ post_schema_plan_preview_warehouse,
32
35
  post_schema_plan_warehouse,
33
36
  )
34
37
  from cassis_cli.common import (
@@ -229,6 +232,17 @@ def plan(
229
232
  timeout: float = _TIMEOUT_OPTION,
230
233
  json_output: bool = _JSON_OPTION,
231
234
  out: Optional[Path] = _OUT_OPTION,
235
+ dry_run: bool = typer.Option(
236
+ False,
237
+ "--dry-run",
238
+ help="Compute the plan in the request and keep nothing server-side: no plan to apply or resume, "
239
+ "the project's current plan untouched. For a DDL not deployed to the warehouse yet.",
240
+ ),
241
+ write_checkout: bool = typer.Option(
242
+ False,
243
+ "--write-checkout",
244
+ help="With --dry-run: also write the ontology files the plan would produce under <path>/<base-path>.",
245
+ ),
232
246
  ) -> None:
233
247
  """Preview what a schema update would change. Nothing is applied.
234
248
 
@@ -241,11 +255,46 @@ def plan(
241
255
  whole-source. Exits 0 when the plan is ready (even when it is empty), 1
242
256
  when the plan failed (unparseable or truncated DDL, unreachable
243
257
  warehouse), 2 on usage errors, 3 on transport errors or a timeout.
258
+
259
+ --dry-run is the prepare-ahead gesture: the plan is computed synchronously
260
+ and nothing is kept server-side, so it works for a schema change that is
261
+ still a PR (a dbt model, a migration) and leaves the project's current plan
262
+ alone. --write-checkout then writes the resulting ontology files into the
263
+ checkout, to commit next to the schema change; nothing is pushed.
244
264
  """
245
265
  api_key = require_api_key(api_key)
246
266
  _require_one_source(ddl_file, warehouse)
267
+ if write_checkout and not dry_run:
268
+ typer.secho(
269
+ "--write-checkout needs --dry-run (use `cassis schema apply` otherwise).", fg=typer.colors.RED, err=True
270
+ )
271
+ raise typer.Exit(EXIT_USAGE)
247
272
  resolved_project = resolve_project_id(project_id, path / base_path, quiet=json_output)
248
273
  assert resolved_project is not None
274
+ if dry_run:
275
+ preview = _preview(
276
+ ddl_file,
277
+ warehouse=warehouse,
278
+ api_url=api_url,
279
+ api_key=api_key,
280
+ project_id=resolved_project,
281
+ complete_source=complete_source,
282
+ json_output=json_output,
283
+ out=out,
284
+ )
285
+ if write_checkout:
286
+ ontology_dir = path / base_path.strip().strip("/")
287
+ written, deleted, _kept = _write_checkout(ontology_dir, preview["files"], json_output=json_output)
288
+ typer.secho(
289
+ f"✓ Wrote {len(written)} ontology file(s) into {ontology_dir}"
290
+ + (f", deleted {len(deleted)} stale file(s)" if deleted else "")
291
+ + ". The app is unchanged.",
292
+ fg=typer.colors.GREEN,
293
+ err=json_output,
294
+ )
295
+ if json_output:
296
+ typer.echo(json.dumps(preview, indent=2))
297
+ raise typer.Exit(EXIT_OK)
249
298
  record = _plan(
250
299
  ddl_file,
251
300
  warehouse=warehouse,
@@ -724,6 +773,54 @@ def _plan(
724
773
  return record
725
774
 
726
775
 
776
+ def _preview(
777
+ ddl_file: Optional[Path],
778
+ *,
779
+ warehouse: bool,
780
+ api_url: str,
781
+ api_key: str,
782
+ project_id: str,
783
+ complete_source: bool,
784
+ json_output: bool,
785
+ out: Optional[Path],
786
+ ) -> "dict[str, Any]":
787
+ """Compute a dry-run plan for `ddl_file` (or the warehouse), render it. Return the preview record."""
788
+ try:
789
+ if warehouse:
790
+ preview = post_schema_plan_preview_warehouse(api_url=api_url, api_key=api_key, project_id=project_id)
791
+ else:
792
+ assert ddl_file is not None
793
+ preview = post_schema_plan_preview(
794
+ api_url=api_url,
795
+ api_key=api_key,
796
+ project_id=project_id,
797
+ ddl=_read_ddl(ddl_file),
798
+ complete_source=complete_source,
799
+ )
800
+ except (SchemaPlanRejectedError, SchemaPlanConflictError) as exc:
801
+ typer.secho(f"Plan failed: {exc}", fg=typer.colors.RED, err=True)
802
+ raise typer.Exit(EXIT_VALIDATION_FAILED) from exc
803
+ except (AuthError, ApiError) as exc:
804
+ typer.secho(str(exc), fg=typer.colors.RED, err=True)
805
+ raise typer.Exit(EXIT_TRANSPORT) from exc
806
+ if out is not None:
807
+ try:
808
+ out.write_text(json.dumps(preview, indent=2) + "\n", encoding="utf-8")
809
+ except OSError as exc:
810
+ typer.secho(f"Could not write {out}: {exc}", fg=typer.colors.RED, err=True)
811
+ raise typer.Exit(EXIT_USAGE) from exc
812
+ # The renderer reads a plan record; a preview is one without an id.
813
+ record = {"status": "ready", "document": preview.get("document"), "summary": preview.get("summary")}
814
+ render_plan(record, err=json_output)
815
+ for warning in preview.get("warnings") or []:
816
+ typer.secho(f" warning: {warning}", fg=typer.colors.YELLOW, err=True)
817
+ if plan_is_empty(record):
818
+ typer.secho("✓ Schema is up to date (dry run, nothing kept).", fg=typer.colors.GREEN, err=json_output)
819
+ else:
820
+ typer.secho("✓ Plan computed (dry run, nothing kept).", fg=typer.colors.GREEN, err=json_output)
821
+ return preview
822
+
823
+
727
824
  def _explain_not_ready(record: "dict[str, Any]") -> None:
728
825
  status = record.get("status")
729
826
  error = record.get("error") or ""
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "cassis-cli"
3
- version = "2.0.0"
3
+ version = "2.2.0"
4
4
  description = "Validate, test and evaluate your Cassis ontology from your terminal, then publish it"
5
5
  readme = "README.md"
6
6
  license = { text = "Apache-2.0" }
File without changes
File without changes