From 4b090d9212ef541323e9069154ee57879b11806a Mon Sep 17 00:00:00 2001 From: soustruh Date: Thu, 27 Aug 2026 15:41:24 +0200 Subject: [PATCH 1/3] fix(kai,docs,component): honor the "project use" pin when --project is omitted The kai group, docs query, component detail, component search, config examples, and config new ignored config.default_project when --project was omitted. They used the first registered project instead. They now resolve the project through the shared resolve_pinned_alias cascade. The cascade moved from ProjectService to BaseService, so every service shares one copy. Fixes #684. --- CLAUDE.md | 6 ++ .../kbagent/references/commands-reference.md | 12 ++-- .../skills/kbagent/references/gotchas.md | 27 +++++++ src/keboola_agent_cli/commands/component.py | 2 +- src/keboola_agent_cli/commands/config.py | 2 +- src/keboola_agent_cli/commands/docs.py | 2 +- src/keboola_agent_cli/services/base.py | 72 ++++++++++++++++++- .../services/component_service.py | 67 ++++++++--------- .../services/docs_service.py | 14 ++-- src/keboola_agent_cli/services/kai_service.py | 19 +++-- .../services/project_service.py | 65 ----------------- tests/test_component_service.py | 56 ++++++++++++++- tests/test_docs_cli.py | 16 ++++- tests/test_kai_service.py | 21 +++++- 14 files changed, 248 insertions(+), 133 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 928454d0..20a78d89 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -961,6 +961,12 @@ kbagent kai ask --message "question" [--project NAME] kbagent kai chat --message "msg" [--chat-id ID] [--project NAME] kbagent kai chat-detail --chat-id ID [--project NAME] kbagent kai history [--project NAME] [--limit N] +# Default-project resolution (since vNEXT, #684): the `kai` group, `docs query`, `component detail`, +# `component list --query`, `config examples`, and `config new` (scaffold mode) resolve an omitted +# --project through the shared cascade (--project > KBAGENT_PROJECT env > `project use` pin > sole +# project). Before vNEXT these paths used the FIRST REGISTERED project and ignored the pin. +# Several projects with no pin now exit 5 (CONFIG_ERROR) instead of silently picking the first. +# Same fix over `kbagent serve`: /kai/*, POST /documentation/query, GET /components?query=... kbagent transformation create --project NAME --name NAME (--sql 'SELECT ...' | --sql-file PATH) [--created-table NAME ...] [--component-id ID] [--description D] [--branch ID] [--dry-run] kbagent transformation show --project NAME --config-id ID [--component-id ID] [--branch ID] diff --git a/plugins/kbagent/skills/kbagent/references/commands-reference.md b/plugins/kbagent/skills/kbagent/references/commands-reference.md index 71784fe2..0ad6ed64 100644 --- a/plugins/kbagent/skills/kbagent/references/commands-reference.md +++ b/plugins/kbagent/skills/kbagent/references/commands-reference.md @@ -117,9 +117,9 @@ Requires a **super-admin** Manage API token (same kind as `org setup`). Same def ## Component Discovery - `component sync-action ACTION_NAME --component-id ID --project ALIAS (--config-id ID [--row-id ID] | --config-data JSON|@file|-) [--branch ID] [--timeout N]` -- run a synchronous component action (`testConnection`, `getTables`, ...) on the `sync-actions.{stack}` service. `ACTION_NAME` is freeform (component-defined; discover via `component detail` `synchronous_actions`). `--row-id` shallow-merges the row over the root config at TOP level only (row `parameters`/`storage` replace root wholesale -- NOT deep merge; MCP `run_sync_action` parity). `--config-data` sends explicit `configData` verbatim. Response is action-specific pass-through. Ports the `run_sync_action` MCP tool. **Since 0.89.0 (#620)** the ROOT configuration's `authorization` and `runtime` blocks are forwarded into `configData` too -- root only (a `--row-id` never overrides them), and only when non-empty. `authorization.oauth_api.id` is the OAuth broker reference the sync-actions service resolves and decrypts, so on 0.88.0 and earlier every sync action on an OAuth / Service-Account component (`keboola.ex-linkedin-ads`, ...) failed with an opaque empty-body 400. -- `config examples --component-id ID [--project NAME] [--row]` -- sample root/row configurations from the AI-service component detail. `--json` emits `{component_id, root_examples, row_examples}`; `--row` limits to row examples. Ports the `get_config_examples` MCP tool. -- `component list [--project NAME] [--type TYPE] [--query "text"]` -- list/search components (AI-powered with `--query`) -- `component detail --component-id ID [--project NAME]` -- show component schema, docs URL, examples. **Since 0.90.0** a component the AI Service does not index (private/deprecated: `keboola.mcp-server-tool`, `keboola.data-apps`) no longer errors -- it falls back to the project's Storage component catalog. `documentation_source` (`"ai_service"` vs `"storage_catalog"`) is on BOTH paths and tells them apart; the fallback has NO configuration examples (`examples_count`/`row_examples_count` always 0), so read `documentation_source` before treating 0 as "this component ships none". `NOT_FOUND` is still raised when both sources miss. **Since 0.90.0** omitting `--project` really does use the first configured project -- on <= 0.89.x it failed with `CONFIG_ERROR: Project 'None' not found` despite the help text, so pass `--project` explicitly there. See `gotchas.md`. +- `config examples --component-id ID [--project NAME] [--row]` -- sample root/row configurations from the AI-service component detail. `--json` emits `{component_id, root_examples, row_examples}`; `--row` limits to row examples. Ports the `get_config_examples` MCP tool. **Since vNEXT (#684)** an omitted `--project` resolves the `project use` pin, not the first registered project. See `gotchas.md`. +- `component list [--project NAME] [--type TYPE] [--query "text"]` -- list/search components (AI-powered with `--query`). **Since vNEXT (#684)** the `--query` path resolves an omitted `--project` through the `project use` pin, not the first registered project. The no-`--query` path still fans out to all projects. See `gotchas.md`. +- `component detail --component-id ID [--project NAME]` -- show component schema, docs URL, examples. **Since 0.90.0** a component the AI Service does not index (private/deprecated: `keboola.mcp-server-tool`, `keboola.data-apps`) no longer errors -- it falls back to the project's Storage component catalog. `documentation_source` (`"ai_service"` vs `"storage_catalog"`) is on BOTH paths and tells them apart; the fallback has NO configuration examples (`examples_count`/`row_examples_count` always 0), so read `documentation_source` before treating 0 as "this component ships none". `NOT_FOUND` is still raised when both sources miss. **Since 0.90.0** omitting `--project` really does fall back to a default project -- on <= 0.89.x it failed with `CONFIG_ERROR: Project 'None' not found` despite the help text, so pass `--project` explicitly there. **Since vNEXT (#684)** that fallback is the `project use` pin (explicit > `KBAGENT_PROJECT` env > pin > sole project), not the first registered project. Several projects with no pin now fail with `CONFIG_ERROR` (exit 5). On 0.90.0-0.90.1 the fallback was the first registered project. See `gotchas.md`. ## Configuration Browsing - `config list [--project NAME] [--component-type TYPE] [--component-id ID] [--branch ID] [--include-rows]` -- list configs across projects (branch-aware). With `--include-rows` each row extends to include the full `configuration` and `rows` body (noticeably larger payload -- use only when the bodies are needed; the summary default covers name/description/component/last_modified/folder) @@ -131,7 +131,7 @@ Requires a **super-admin** Manage API token (same kind as `org setup`). Same def - `config delete --project NAME --component-id ID --config-id ID [--branch ID] [--dry-run]` -- SOFT-delete a configuration into the Storage trash (restorable). Since 0.89.0 it locates the config first and a config already in the trash is NOT deleted again -- the raw API purges permanently on a second DELETE (the retry-after-timeout trap); kbagent reports `already_in_trash` and exits 0 instead. `--dry-run` reports the located state without writing. Undo with `config restore`. Permission class `destructive` - `config restore --project NAME --component-id ID --config-id ID [--branch ID]` *(since v0.89.0)* -- restore a trashed configuration (versions, rows and metadata come back). Only works on a config currently in the trash. Permission class `write` - `config trash-list --project NAME [--component-id ID] [--branch ID]` *(since v0.89.0)* -- list configurations in the trash; each row carries `component_id`, `config_id`, `name`, `version` and `deleted_at`, which is exactly what `config restore` needs. Permission class `read` -- `config new --component-id ID [--project NAME] [--name NAME] [--output-dir DIR] [--push --no-files --description D --configuration JSON|@file|- --configuration-file PATH --no-validate --branch ID --dry-run --allow-plaintext-on-encrypt-failure]` -- **two modes**. **Default (no `--push`)**: scaffold new config from component schema; writes files to `--output-dir` or prints to stdout. **No Storage write calls** -- one AI Service read fetches the component schema/examples (that is what `--project` authenticates even without `--push`). **With `--push`** (0.33.0+, requires `--project` + non-empty `--name`): also POSTs to `/v2/storage/components/{cid}/configs` for a one-shot remote create. `#`-prefixed secrets in the pushed body auto-encrypt via the Encryption API first (fail-closed; since 0.54.0, #378; `--allow-plaintext-on-encrypt-failure` overrides). `--no-files` skips the filesystem step entirely (FIIA-style empty-shell pattern). `--configuration` / `--configuration-file` override the POSTed body (default is `{}`, with validation auto-skipped for the default empty shell). `--dry-run` previews the planned POST + validation result without creating. Since 0.89.0, `--push` + `--output-dir` writes the scaffold WITH `_keboola.config_id` into the subtree of the branch the config was created in (registering the branch in the manifest when missing), so the next `sync push` adopts it instead of duplicating (issue #644); with an explicit body the local file mirrors the pushed encrypted configuration. Schema validation runs by default when an explicit body is given (fail-closed: `ConfigError` exit 5 on mismatch) but skips silently if the AI Service has no schema for the component or returns an error; `--no-validate` opts out. Works for ALL component types including `keboola.snowflake-transformation`. **Since 0.90.0** the scaffold mode's optional `--project` really does fall back to the first configured project when omitted -- on <= 0.89.x it failed with `CONFIG_ERROR: Project 'None' not found` (see `gotchas.md`). +- `config new --component-id ID [--project NAME] [--name NAME] [--output-dir DIR] [--push --no-files --description D --configuration JSON|@file|- --configuration-file PATH --no-validate --branch ID --dry-run --allow-plaintext-on-encrypt-failure]` -- **two modes**. **Default (no `--push`)**: scaffold new config from component schema; writes files to `--output-dir` or prints to stdout. **No Storage write calls** -- one AI Service read fetches the component schema/examples (that is what `--project` authenticates even without `--push`). **With `--push`** (0.33.0+, requires `--project` + non-empty `--name`): also POSTs to `/v2/storage/components/{cid}/configs` for a one-shot remote create. `#`-prefixed secrets in the pushed body auto-encrypt via the Encryption API first (fail-closed; since 0.54.0, #378; `--allow-plaintext-on-encrypt-failure` overrides). `--no-files` skips the filesystem step entirely (FIIA-style empty-shell pattern). `--configuration` / `--configuration-file` override the POSTed body (default is `{}`, with validation auto-skipped for the default empty shell). `--dry-run` previews the planned POST + validation result without creating. Since 0.89.0, `--push` + `--output-dir` writes the scaffold WITH `_keboola.config_id` into the subtree of the branch the config was created in (registering the branch in the manifest when missing), so the next `sync push` adopts it instead of duplicating (issue #644); with an explicit body the local file mirrors the pushed encrypted configuration. Schema validation runs by default when an explicit body is given (fail-closed: `ConfigError` exit 5 on mismatch) but skips silently if the AI Service has no schema for the component or returns an error; `--no-validate` opts out. Works for ALL component types including `keboola.snowflake-transformation`. **Since 0.90.0** the scaffold mode's optional `--project` really does fall back to a default project when omitted -- on <= 0.89.x it failed with `CONFIG_ERROR: Project 'None' not found` (see `gotchas.md`). **Since vNEXT (#684)** that fallback is the `project use` pin, not the first registered project. - `config clone --project P --component-id ID --config-id ID --name NAME [--target-project P2] [--description D] [--set PATH=VALUE ...] [--secret PATH=VALUE ...] [--branch ID] [--target-branch ID] [--dry-run] [--allow-plaintext-on-encrypt-failure]` (0.84.2+, #587) -- duplicate a configuration **whole**. Reach for this instead of reading `config detail` and rebuilding a body: copying only `configuration["parameters"]` silently drops its siblings (`runtime`, `storage`, `authorization`), and a lost `runtime.parallelism` makes Keboola fall back to `parallelism: 1` -- the reporter's 65-row writer went sequential, 140 min instead of ~60-90, with nothing in any output pointing at it. **Same project** (default): server-side copy via `POST .../configs/{id}/versions/{v}/create`; rows and `KBC::` encrypted values travel with it (verified live). `--set PATH=VALUE` is applied as a follow-up update on the copy, so an override can never be the reason a key went missing. **Cross project** (`--target-project`): reassembled client-side and rows recreated one by one, because encrypted values **cannot** travel -- a Keboola ciphertext is scoped to the project it was encrypted in. Any `KBC::` value makes the clone **fail with exit 5**, listing every path, until re-supplied via `--secret PATH=VALUE` (encrypted in the TARGET project on write). `--dry-run` reports those paths instead of refusing -- run it first to learn what to gather. Storage bucket/table IDs are copied **verbatim, never remapped**; `sync clone` is the command that remaps. - `config variables-set --project NAME --component-id ID --config-id ID --var KEY=VALUE [--var ...] [--replace] [--variables-id ID] [--values-id ID] [--branch ID] [--dry-run] [--allow-plaintext-on-encrypt-failure] [--yes]` -- attach variable values to a config. Auto-creates a sibling `keboola.variables` config + default row on first use and links it via the parent's `runtime.variables_id` / `variables_values_id`. Defaults to merge; `--replace` drops keys not in `--var`. `#`-prefixed values encrypt via the Encryption API (fail-closed; exit non-zero on `ENCRYPTION_FAILED`). See `variables-workflow.md` - `config variables-get --project NAME --component-id ID --config-id ID [--branch ID]` -- resolve `variables_id` + `values_id` from the parent config and fetch the current KEY=VALUE map. Returns `{linked: bool, variables_id, values_id, values}`; `linked=false` means the parent has no variables attached @@ -279,10 +279,10 @@ Ports the `create_sql_transformation` / `update_sql_transformation` tools (#396) - `transformation edit --project NAME --config-id ID --change-description TEXT (--op JSON ... | --op-file ops.json) [--storage JSON|@file|-] [--component-id ID] [--branch ID] [--dry-run]` -- 9 ops (`add_block`, `remove_block`, `rename_block`, `add_code`, `remove_code`, `rename_code`, `set_code`, `add_script`, `str_replace`) applied sequentially against batch-start ids. `--storage` REPLACES `configuration.storage` wholesale (include ALL mappings you want to keep). Unknown ids error with the list of valid ids. ## Documentation Q&A -- `docs query "QUESTION" [--project NAME]` -- natural-language answer from the Keboola documentation via the AI Service (server-side RAG, no local corpus). Returns answer text + source URLs; `--json` emits `{query, text, source_urls}`. Unlike `kai ask` it does NOT see project data, works with any token (no master-token / feature-flag requirement), and is the right tool for "how do I ..." questions. Ports the `docs_query` MCP tool. +- `docs query "QUESTION" [--project NAME]` -- natural-language answer from the Keboola documentation via the AI Service (server-side RAG, no local corpus). Returns answer text + source URLs; `--json` emits `{query, text, source_urls}`. Unlike `kai ask` it does NOT see project data, works with any token (no master-token / feature-flag requirement), and is the right tool for "how do I ..." questions. Ports the `docs_query` MCP tool. **Since vNEXT (#684)** an omitted `--project` resolves the `project use` pin, not the first registered project. See `gotchas.md`. ## Kai (Keboola AI Assistant) -Requires the project to be added with its **master ('owner') Storage API token** -- the auto-generated owner token, not a custom one. Custom tokens cannot access Kai. Also requires the `agent-chat` feature flag on the project. Use `kai preflight` to verify both conditions without raising. +Requires the project to be added with its **master ('owner') Storage API token** -- the auto-generated owner token, not a custom one. Custom tokens cannot access Kai. Also requires the `agent-chat` feature flag on the project. Use `kai preflight` to verify both conditions without raising. **Since vNEXT (#684)** every `kai` subcommand resolves an omitted `--project` through the `project use` pin (explicit > `KBAGENT_PROJECT` env > pin > sole project). On 0.90.1 and older the fallback was the first registered project and the pin was ignored -- pass `--project` explicitly there. See `gotchas.md`. - `kai ping [--project NAME]` -- check Kai server health and MCP connection status. Fails with KAI_NOT_ENABLED if the agent-chat feature is missing or the token is not a master token - `kai preflight [--project NAME]` -- inspect token readiness WITHOUT raising. Returns `{ok, is_master_token, has_agent_chat_feature, token_description, error}`. Use this in UIs and automation pre-flight checks instead of `ping` - `kai ask --message "question" [--project NAME]` -- one-shot question to Kai, collects full response diff --git a/plugins/kbagent/skills/kbagent/references/gotchas.md b/plugins/kbagent/skills/kbagent/references/gotchas.md index 48891fa6..b23e102b 100644 --- a/plugins/kbagent/skills/kbagent/references/gotchas.md +++ b/plugins/kbagent/skills/kbagent/references/gotchas.md @@ -4559,6 +4559,8 @@ fallback (`config examples` already resolved it correctly). `component detail`'s `project_alias` reports the alias actually used (never `None`). With NO projects configured at all, the failure is an actionable `CONFIG_ERROR: No projects configured. Use 'kbagent project add' ...`. + Since vNEXT the fallback is the `project use` pin, not the first project -- + see the vNEXT gotcha on the `project use` pin (issue #684). - **<= 0.89.x**: pass `--project` explicitly to these two commands -- the help text's "first available" promise does not work there. - `component sync-action` is unaffected: its `--project` is genuinely required @@ -4811,3 +4813,28 @@ volatile components without waiting for an upstream kbagent release. and pushing DELETED the config in production. If you are stuck on an older version, do not delete-dir-then-push a `keboola.mcp-server-tool` (or any MCP-workspace) directory -- upgrade instead. + +## `kai` / `docs query` / `component` / `config new` now honor the `project use` pin + +*(since vNEXT, closes #684)* These commands take an optional `--project`: the whole `kai` group, +`docs query`, `component detail`, `component list --query`, `config examples`, +and `config new` (scaffold mode). Before vNEXT, they resolved an omitted +`--project` to the **first registered project**. They ignored the pin from +`kbagent project use`. With two or more projects registered, the command acted +on the wrong project and reported no warning. + +An omitted `--project` now resolves through the same cascade as every other +single-project command: + +1. explicit `--project` +2. `KBAGENT_PROJECT` env var +3. the `project use` pin +4. the sole registered project + +- **New failure mode**: several projects and no pin now fail with + `CONFIG_ERROR` (exit 5). The message names the three fixes. Before, the + command silently used the first registered project. +- **On 0.90.1 and older**: pass `--project` explicitly whenever the pinned + project is not the first row of `project list`. +- The fix also covers `kbagent serve`: the `/kai/*` routes, `POST /documentation/query`, + and `GET /components?query=...` resolved the project the same wrong way. diff --git a/src/keboola_agent_cli/commands/component.py b/src/keboola_agent_cli/commands/component.py index 20582482..da4e54e5 100644 --- a/src/keboola_agent_cli/commands/component.py +++ b/src/keboola_agent_cli/commands/component.py @@ -206,7 +206,7 @@ def component_detail( project: str | None = typer.Option( None, "--project", - help="Project alias (uses first available if not set)", + help="Project alias (defaults to the pinned default project)", ), ) -> None: """Show detailed information about a specific component.""" diff --git a/src/keboola_agent_cli/commands/config.py b/src/keboola_agent_cli/commands/config.py index cd36e7a2..b6b4da8b 100644 --- a/src/keboola_agent_cli/commands/config.py +++ b/src/keboola_agent_cli/commands/config.py @@ -415,7 +415,7 @@ def config_examples( project: str | None = typer.Option( None, "--project", - help="Project alias (uses first available if not set)", + help="Project alias (defaults to the pinned default project)", ), row: bool = typer.Option( False, diff --git a/src/keboola_agent_cli/commands/docs.py b/src/keboola_agent_cli/commands/docs.py index 18a6a675..32c48967 100644 --- a/src/keboola_agent_cli/commands/docs.py +++ b/src/keboola_agent_cli/commands/docs.py @@ -57,7 +57,7 @@ def docs_query( project: str | None = typer.Option( None, "--project", - help="Project alias (uses first available if not set)", + help="Project alias (defaults to the pinned default project)", ), ) -> None: """Ask the Keboola documentation a natural language question.""" diff --git a/src/keboola_agent_cli/services/base.py b/src/keboola_agent_cli/services/base.py index b8e4e5af..eb70c1a6 100644 --- a/src/keboola_agent_cli/services/base.py +++ b/src/keboola_agent_cli/services/base.py @@ -14,7 +14,11 @@ from ..auth.sentinel import is_session_token, parse_session_project_id, require_static_token from ..client import KeboolaClient from ..config_store import ConfigError, ConfigStore, project_not_found_error -from ..constants import ENV_MAX_PARALLEL_WORKERS, UNEXPECTED_ERROR_MAX_MESSAGE_LEN +from ..constants import ( + ENV_KBAGENT_PROJECT, + ENV_MAX_PARALLEL_WORKERS, + UNEXPECTED_ERROR_MAX_MESSAGE_LEN, +) from ..errors import ErrorCode from ..models import ProjectConfig @@ -217,6 +221,72 @@ def resolve_projects(self, aliases: list[str] | None = None) -> dict[str, Projec return resolved + def resolve_pinned_alias(self, explicit: str | None = None) -> tuple[str, str]: + """Resolve the effective project alias for a single-project operation. + + Precedence (first match wins): + 1. ``explicit`` argument (typically the CLI ``--project`` flag) + 2. ``KBAGENT_PROJECT`` env var + 3. Persisted ``default_project`` pin + 4. If exactly one project is registered, fall back to it (source=sole) + 5. Fail hard with ConfigError + + This is the single-project analog of ``resolve_projects()`` (which + fans out to all projects). Every service that needs one implicit + project must go through this cascade -- never a "first registered + project" shortcut, which ignores the ``project use`` pin (issue #684). + + Args: + explicit: Explicit alias from a CLI flag, or None. + + Returns: + Tuple of (alias, source). + + Raises: + ConfigError: If the resolved alias is not registered, or if none + can be resolved. + """ + config = self._config_store.load() + + if explicit: + if explicit not in config.projects: + raise project_not_found_error( + explicit, self._config_store.config_path, self._config_store.source + ) + return explicit, "explicit" + + env_value = os.environ.get(ENV_KBAGENT_PROJECT) + if env_value: + if env_value not in config.projects: + raise ConfigError( + f"{ENV_KBAGENT_PROJECT}='{env_value}' points to a project " + "that is not registered. Use 'kbagent project add' or " + "unset the env var." + ) + return env_value, "env" + + pinned = config.default_project + if pinned: + if pinned not in config.projects: + raise ConfigError( + f"Pinned default project '{pinned}' is not registered. " + "Run 'kbagent project use ' to repair." + ) + return pinned, "pin" + + if len(config.projects) == 1: + (sole,) = config.projects.keys() + return sole, "sole" + + if not config.projects: + raise ConfigError("No projects configured. Run 'kbagent project add' first.") + + raise ConfigError( + "Multiple projects configured and no default pinned. " + "Pass --project , set KBAGENT_PROJECT, or run " + "'kbagent project use '." + ) + def _resolve_max_workers(self) -> int: """Resolve max parallel workers: env var > config.json > default (10). diff --git a/src/keboola_agent_cli/services/component_service.py b/src/keboola_agent_cli/services/component_service.py index c7387c85..1b4ef2e1 100644 --- a/src/keboola_agent_cli/services/component_service.py +++ b/src/keboola_agent_cli/services/component_service.py @@ -32,9 +32,9 @@ class ResolvedProject: """A project resolved together with the alias it was resolved under. - ``alias`` is the caller's alias when one was given, otherwise the first - configured project's -- the value response payloads report as - ``project_alias``. A dataclass rather than a bare 2-tuple per + ``alias`` is the caller's alias when one was given, otherwise the one + the default-project cascade resolved -- the value response payloads + report as ``project_alias``. A dataclass rather than a bare 2-tuple per CONTRIBUTING.md's multi-value-return rule. """ @@ -518,7 +518,7 @@ def list_components( Two modes of operation: - With ``query``: Uses AI Service to suggest components matching a natural language description. Enriches each suggestion with detail - from get_component_detail(). Runs against first/default project. + from get_component_detail(). Runs against the default project. - Without ``query``: Uses Storage API list_components() across all resolved projects in parallel, returning unique components. @@ -537,26 +537,22 @@ def list_components( return self._list_via_ai(aliases, component_type, query) return self._list_via_storage(aliases, component_type) - def _resolve_alias_or_first(self, alias: str | None) -> ResolvedProject: - """Resolve *alias* to a project, defaulting to the first configured one. + def _resolve_alias_or_default(self, alias: str | None) -> ResolvedProject: + """Resolve *alias* to a project, defaulting via the shared cascade. - :meth:`BaseService.resolve_projects` falls back to "all projects" only - when the alias LIST itself is empty/None -- a ``[None]`` element goes - down its strict lookup and raises "Project 'None' not found". Commands - that document ``--project`` as optional (``component detail``, - ``config examples``, ``config new`` without ``--push``) must therefore - normalise an omitted alias here instead of passing it through. + Commands that document ``--project`` as optional (``component + detail``, ``config examples``, ``config new`` without ``--push``) + normalise an omitted alias here. The fallback goes through + :meth:`BaseService.resolve_pinned_alias` (KBAGENT_PROJECT env > + ``project use`` pin > sole project), never "first registered + project" -- that shortcut ignored the pin (issue #684). Raises: - ConfigError: If *alias* is given but unknown, or when no projects - are configured at all. + ConfigError: If *alias* is given but unknown, or when no default + project can be resolved. """ - projects = self.resolve_projects([alias] if alias else None) - if not projects: - raise ConfigError( - "No projects configured. Use 'kbagent project add' to connect a project first." - ) - resolved_alias = alias or next(iter(projects)) + resolved_alias, _source = self.resolve_pinned_alias(explicit=alias) + projects = self.resolve_projects([resolved_alias]) return ResolvedProject(alias=resolved_alias, project=projects[resolved_alias]) def get_component_detail(self, alias: str | None, component_id: str) -> dict[str, Any]: @@ -584,7 +580,7 @@ def get_component_detail(self, alias: str | None, component_id: str) -> dict[str Args: alias: Project alias (used to derive stack URL and token). When - None, the first available project is used. + None, the default-project cascade resolves it. component_id: The component identifier (e.g. 'keboola.ex-aws-s3'). Returns: @@ -598,7 +594,7 @@ def get_component_detail(self, alias: str | None, component_id: str) -> dict[str re-raised unchanged only when the Storage catalog does not know the component either (i.e. the id really is wrong). """ - resolved = self._resolve_alias_or_first(alias) + resolved = self._resolve_alias_or_default(alias) project = resolved.project ai_client = self._ai_client_factory(project.stack_url, project.token) @@ -710,8 +706,8 @@ def get_config_examples(self, alias: str | None, component_id: str) -> dict[str, contract is a summary and stays unchanged). Args: - alias: Project alias. When None, the first available project is - used (only the stack URL and token are needed). + alias: Project alias. When None, the default-project cascade + resolves it (only the stack URL and token are needed). component_id: The component identifier (e.g. 'keboola.ex-google-drive'). Returns: @@ -722,7 +718,7 @@ def get_config_examples(self, alias: str | None, component_id: str) -> dict[str, ConfigError: If the alias is not found or no projects are configured. KeboolaApiError: If the AI Service call fails. """ - project = self._resolve_alias_or_first(alias).project + project = self._resolve_alias_or_default(alias).project ai_client = self._ai_client_factory(project.stack_url, project.token) try: @@ -856,7 +852,7 @@ def generate_scaffold( Args: alias: Project alias (used to derive stack URL and token). When - None, the first available project is used (`config new` + None, the default-project cascade resolves it (`config new` without --push documents --project as optional). component_id: The component identifier. name: Configuration name. If None, defaults to @@ -870,7 +866,7 @@ def generate_scaffold( configured. KeboolaApiError: If the AI Service call fails. """ - project = self._resolve_alias_or_first(alias).project + project = self._resolve_alias_or_default(alias).project ai_client = self._ai_client_factory(project.stack_url, project.token) try: @@ -910,13 +906,18 @@ def _list_via_ai( ) -> dict[str, Any]: """Search components via AI Service suggestions. - Uses first/default project for AI queries, then enriches each - suggestion with component detail. + Runs the AI query against one project's stack: the first explicitly + requested alias, or the default-project cascade when none was given + (never "first registered" -- issue #684). Enriches each suggestion + with component detail. """ - projects = self.resolve_projects(aliases) - # Use first project for AI queries - first_alias = next(iter(projects)) - project = projects[first_alias] + if aliases: + projects = self.resolve_projects(aliases) + first_alias = next(iter(projects)) + project = projects[first_alias] + else: + resolved = self._resolve_alias_or_default(None) + first_alias, project = resolved.alias, resolved.project ai_client = self._ai_client_factory(project.stack_url, project.token) components: list[dict[str, Any]] = [] diff --git a/src/keboola_agent_cli/services/docs_service.py b/src/keboola_agent_cli/services/docs_service.py index f5b127ce..5a8c2483 100644 --- a/src/keboola_agent_cli/services/docs_service.py +++ b/src/keboola_agent_cli/services/docs_service.py @@ -10,7 +10,6 @@ from typing import Any from ..config_store import ConfigStore -from ..errors import ConfigError from ..models import DocsAnswer from .base import BaseService, ClientFactory from .component_service import AiClientFactory, default_ai_client_factory @@ -39,7 +38,8 @@ def ask_docs(self, alias: str | None, query: str) -> dict[str, Any]: Args: alias: Project alias used to derive the stack URL and token. - None means the first configured project. + None resolves via the shared default-project cascade + (KBAGENT_PROJECT env > ``project use`` pin > sole project). query: Natural language question about the Keboola platform. Returns: @@ -50,14 +50,12 @@ def ask_docs(self, alias: str | None, query: str) -> dict[str, Any]: is grounded in Raises: - ConfigError: If no projects are configured or the alias is unknown. + ConfigError: If the alias is unknown, or no default project can + be resolved. KeboolaApiError: If the AI Service call fails. """ - projects = self.resolve_projects([alias] if alias else None) - if not projects: - raise ConfigError("No projects configured. Run 'kbagent project add' first.") - first_alias = next(iter(projects)) - project = projects[first_alias] + resolved_alias, _source = self.resolve_pinned_alias(explicit=alias) + project = self.resolve_projects([resolved_alias])[resolved_alias] ai_client = self._ai_client_factory(project.stack_url, project.token) try: diff --git a/src/keboola_agent_cli/services/kai_service.py b/src/keboola_agent_cli/services/kai_service.py index 567d4bee..66e63360 100644 --- a/src/keboola_agent_cli/services/kai_service.py +++ b/src/keboola_agent_cli/services/kai_service.py @@ -12,7 +12,7 @@ from ..auth.sentinel import require_static_token from ..constants import KAI_FEATURE_FLAG, KAI_REQUEST_TIMEOUT, KAI_STREAM_TIMEOUT -from ..errors import ConfigError, ErrorCode, KeboolaApiError +from ..errors import ErrorCode, KeboolaApiError from .base import BaseService logger = logging.getLogger(__name__) @@ -32,6 +32,10 @@ class KaiService(BaseService): def resolve_alias(self, alias: str | None) -> str: """Resolve a project alias, falling back to the default project. + Delegates to the shared cascade (explicit > KBAGENT_PROJECT env > + ``project use`` pin > sole project), so an omitted alias honors the + pinned default instead of the first registered project (issue #684). + Args: alias: Explicit alias, or None for default. @@ -39,17 +43,10 @@ def resolve_alias(self, alias: str | None) -> str: Resolved alias string. Raises: - ConfigError: If no projects configured or alias not found. + ConfigError: If the alias is unknown, or none can be resolved. """ - if alias: - # Validate it exists - self.resolve_projects([alias]) - return alias - # Fall back to default (first project) - projects = self.resolve_projects() - if not projects: - raise ConfigError("No projects configured. Run 'kbagent project add' first.") - return next(iter(projects)) + resolved, _source = self.resolve_pinned_alias(explicit=alias) + return resolved # ------------------------------------------------------------------ # Feature detection diff --git a/src/keboola_agent_cli/services/project_service.py b/src/keboola_agent_cli/services/project_service.py index c57638a5..1939979b 100644 --- a/src/keboola_agent_cli/services/project_service.py +++ b/src/keboola_agent_cli/services/project_service.py @@ -980,68 +980,3 @@ def get_info(self, alias: str) -> dict[str, Any]: "is_master_token": raw.get("isMasterToken", False), "token_expires": raw.get("expires"), } - - def resolve_pinned_alias(self, explicit: str | None = None) -> tuple[str, str]: - """Resolve the effective project alias for a single-project operation. - - Precedence (first match wins): - 1. ``explicit`` argument (typically the CLI ``--project`` flag) - 2. ``KBAGENT_PROJECT`` env var - 3. Persisted ``default_project`` pin - 4. If exactly one project is registered, fall back to it (source=sole) - 5. Fail hard with ConfigError - - This is the single-project analog of ``resolve_projects()`` (which - fans out to all projects). Use this from write/destructive command - paths where fan-out would be surprising or unsafe. - - Args: - explicit: Explicit alias from a CLI flag, or None. - - Returns: - Tuple of (alias, source). - - Raises: - ConfigError: If the resolved alias is not registered, or if none - can be resolved. - """ - config = self._config_store.load() - - if explicit: - if explicit not in config.projects: - raise project_not_found_error( - explicit, self._config_store.config_path, self._config_store.source - ) - return explicit, "explicit" - - env_value = os.environ.get(ENV_KBAGENT_PROJECT) - if env_value: - if env_value not in config.projects: - raise ConfigError( - f"{ENV_KBAGENT_PROJECT}='{env_value}' points to a project " - "that is not registered. Use 'kbagent project add' or " - "unset the env var." - ) - return env_value, "env" - - pinned = config.default_project - if pinned: - if pinned not in config.projects: - raise ConfigError( - f"Pinned default project '{pinned}' is not registered. " - "Run 'kbagent project use ' to repair." - ) - return pinned, "pin" - - if len(config.projects) == 1: - (sole,) = config.projects.keys() - return sole, "sole" - - if not config.projects: - raise ConfigError("No projects configured. Run 'kbagent project add' first.") - - raise ConfigError( - "Multiple projects configured and no default pinned. " - "Pass --project , set KBAGENT_PROJECT, or run " - "'kbagent project use '." - ) diff --git a/tests/test_component_service.py b/tests/test_component_service.py index e955f5d9..3b9cbfe4 100644 --- a/tests/test_component_service.py +++ b/tests/test_component_service.py @@ -7,7 +7,7 @@ import pytest import yaml -from helpers import setup_single_project +from helpers import setup_single_project, setup_two_projects from keboola_agent_cli.config_store import ConfigStore from keboola_agent_cli.constants import SECRET_PLACEHOLDER from keboola_agent_cli.errors import ConfigError, ErrorCode, KeboolaApiError @@ -334,6 +334,32 @@ def test_list_components_via_ai_query(self, tmp_config_dir: Path) -> None: mock_ai.suggest_components.assert_called_once_with("download data from http") mock_ai.close.assert_called_once() + def test_list_via_ai_no_alias_honors_pin(self, tmp_config_dir: Path, monkeypatch) -> None: + """A query without aliases resolves the pin, not the first project. + + Regression test for issue #684: `component list --query` without + --project ran the AI query against the first registered project. + """ + monkeypatch.delenv("KBAGENT_PROJECT", raising=False) + store = setup_two_projects(tmp_config_dir) + # add_project() pinned "prod" (first added); repoint like `project use dev`. + cfg = store.load() + cfg.default_project = "dev" + store.save(cfg) + dev_token = store.load().projects["dev"].token + + mock_ai = _make_ai_client(suggest_response=[]) + seen_tokens: list[str] = [] + + def factory(url: str, token: str) -> MagicMock: + seen_tokens.append(token) + return mock_ai + + service = ComponentService(config_store=store, ai_client_factory=factory) + service.list_components(query="anything") + + assert seen_tokens == [dev_token] + # =========================================================================== # get_component_detail @@ -343,6 +369,34 @@ def test_list_components_via_ai_query(self, tmp_config_dir: Path) -> None: class TestGetComponentDetail: """Tests for get_component_detail.""" + def test_get_component_detail_no_alias_honors_pin( + self, tmp_config_dir: Path, monkeypatch + ) -> None: + """alias=None resolves the pin, not the first project. + + Regression test for issue #684: `component detail` without --project + used the first registered project. + """ + monkeypatch.delenv("KBAGENT_PROJECT", raising=False) + store = setup_two_projects(tmp_config_dir) + # add_project() pinned "prod" (first added); repoint like `project use dev`. + cfg = store.load() + cfg.default_project = "dev" + store.save(cfg) + dev_token = store.load().projects["dev"].token + + mock_ai = _make_ai_client(detail_response=EXTRACTOR_RESPONSE) + seen_tokens: list[str] = [] + + def factory(url: str, token: str) -> MagicMock: + seen_tokens.append(token) + return mock_ai + + service = ComponentService(config_store=store, ai_client_factory=factory) + service.get_component_detail(alias=None, component_id="keboola.ex-http") + + assert seen_tokens == [dev_token] + def test_get_component_detail_success(self, tmp_config_dir: Path) -> None: """Returns parsed detail with schema_summary for a valid component.""" mock_ai = _make_ai_client(detail_response=EXTRACTOR_RESPONSE) diff --git a/tests/test_docs_cli.py b/tests/test_docs_cli.py index bfffb0fc..91a20bf0 100644 --- a/tests/test_docs_cli.py +++ b/tests/test_docs_cli.py @@ -214,16 +214,26 @@ def test_ask_docs_explicit_alias(self, tmp_path: Path) -> None: "source_urls": ["https://help.keboola.com/x"], } - def test_ask_docs_default_alias_uses_first_project(self, tmp_path: Path) -> None: - """alias=None falls back to the first configured project.""" + def test_ask_docs_default_alias_uses_pin(self, tmp_path: Path, monkeypatch) -> None: + """alias=None resolves the ``project use`` pin, not the first project. + + Regression test for issue #684: with two projects and the pin moved + to the second one, `docs query` without --project used the first + registered project. + """ + monkeypatch.delenv("KBAGENT_PROJECT", raising=False) store = self._make_store(tmp_path, ["prod", "dev"]) + # add_project() pinned "prod" (first added); repoint like `project use dev`. + cfg = store.load() + cfg.default_project = "dev" + store.save(cfg) raw = {"text": "Answer.", "sourceUrls": []} service, _mock_client, mock_factory = self._make_service(store, raw) result = service.ask_docs(alias=None, query="q") mock_factory.assert_called_once_with( - "https://connection.prod.keboola.com", + "https://connection.dev.keboola.com", TEST_TOKEN, ) assert result["source_urls"] == [] diff --git a/tests/test_kai_service.py b/tests/test_kai_service.py index c8f16b6f..9b98cc7b 100644 --- a/tests/test_kai_service.py +++ b/tests/test_kai_service.py @@ -10,7 +10,7 @@ import pytest -from helpers import setup_single_project +from helpers import setup_single_project, setup_two_projects from keboola_agent_cli.errors import ConfigError, KeboolaApiError from keboola_agent_cli.models import TokenVerifyResponse from keboola_agent_cli.services.kai_service import KaiService @@ -524,10 +524,27 @@ def test_resolve_explicit_alias(self, tmp_config_dir: Path) -> None: assert service.resolve_alias("prod") == "prod" def test_resolve_default_alias(self, tmp_config_dir: Path) -> None: - """resolve_alias with None returns the first (default) project.""" + """resolve_alias with None returns the sole configured project.""" service, _ = _make_kai_service(tmp_config_dir) assert service.resolve_alias(None) == "prod" + def test_resolve_default_honors_pin(self, tmp_config_dir: Path, monkeypatch) -> None: + """resolve_alias(None) returns the pin, not the first project. + + Regression test for issue #684: with two projects and the pin moved + to the second one, `kai ping/preflight/ask/chat` without --project + acted on the first registered project. + """ + monkeypatch.delenv("KBAGENT_PROJECT", raising=False) + store = setup_two_projects(tmp_config_dir) + # add_project() pinned "prod" (first added); repoint like `project use dev`. + cfg = store.load() + cfg.default_project = "dev" + store.save(cfg) + + service = KaiService(config_store=store, client_factory=lambda url, token: MagicMock()) + assert service.resolve_alias(None) == "dev" + def test_resolve_unknown_alias(self, tmp_config_dir: Path) -> None: """resolve_alias raises ConfigError for unknown alias.""" service, _ = _make_kai_service(tmp_config_dir) From 1eb961c13013188285ee890e18de502ed1fde874 Mon Sep 17 00:00:00 2001 From: soustruh Date: Thu, 27 Aug 2026 15:41:45 +0200 Subject: [PATCH 2/3] chore: address the PR #700 review findings Add a keboola-expert.md gotcha for the new CONFIG_ERROR exit. Correct the stale docstring in server/routers/docs.py. Assert that the two new component tests close the AI client. Rename first_alias to query_alias in _list_via_ai. --- plugins/kbagent/agents/keboola-expert.md | 9 +++++++++ src/keboola_agent_cli/server/routers/docs.py | 6 ++++-- src/keboola_agent_cli/services/component_service.py | 10 +++++----- tests/test_component_service.py | 2 ++ 4 files changed, 20 insertions(+), 7 deletions(-) diff --git a/plugins/kbagent/agents/keboola-expert.md b/plugins/kbagent/agents/keboola-expert.md index fd9380bc..232ae0ac 100644 --- a/plugins/kbagent/agents/keboola-expert.md +++ b/plugins/kbagent/agents/keboola-expert.md @@ -188,6 +188,15 @@ its absence is NOT a promise the entry is version-independent (see ยง1 Rule 6). because the component ships none. Reach for `config examples` or an existing config in the project instead. +**A command without `--project` refused with CONFIG_ERROR (or picked a project)** +- On **vNEXT+** the `kai` group, `docs query`, `component detail`, `component + list --query`, `config examples`, and `config new` (scaffold mode) resolve an + omitted `--project` through the pin cascade: `--project` > `KBAGENT_PROJECT` + env > `project use` pin > sole project. Several projects and no pin -> exit 5 + (`CONFIG_ERROR`). That exit is the fix working, not a bug -- pass `--project` + or run `project use`. On <= 0.90.1 the same commands silently used the FIRST + registered project and ignored the pin (issue #684). gotchas.md. + **Reading job logs / table usage / narrow config search (0.88.0+)** - `job detail --log-tail-lines N` -- the ONLY route to an already-finished job's logs (`job run` tails only the run it started). `job list --offset/--sort-by/ diff --git a/src/keboola_agent_cli/server/routers/docs.py b/src/keboola_agent_cli/server/routers/docs.py index 838cf1c4..b864ceb2 100644 --- a/src/keboola_agent_cli/server/routers/docs.py +++ b/src/keboola_agent_cli/server/routers/docs.py @@ -28,7 +28,9 @@ def query(body: DocsQuery, registry: ServiceRegistry = Depends(get_registry)) -> """Natural-language question answered from the official Keboola docs. Mirrors `kbagent docs query`. ``project`` selects which project's stack - URL + token reach the AI Service; omitted means the first configured - project (the answer itself is project-independent). + URL + token reach the AI Service. Omitted resolves through the + default-project cascade (``--project`` > ``KBAGENT_PROJECT`` env > + ``project use`` pin > sole project); the answer itself is + project-independent. """ return registry.docs.ask_docs(alias=body.project, query=body.query) diff --git a/src/keboola_agent_cli/services/component_service.py b/src/keboola_agent_cli/services/component_service.py index 1b4ef2e1..5baaabc2 100644 --- a/src/keboola_agent_cli/services/component_service.py +++ b/src/keboola_agent_cli/services/component_service.py @@ -913,11 +913,11 @@ def _list_via_ai( """ if aliases: projects = self.resolve_projects(aliases) - first_alias = next(iter(projects)) - project = projects[first_alias] + query_alias = next(iter(projects)) + project = projects[query_alias] else: resolved = self._resolve_alias_or_default(None) - first_alias, project = resolved.alias, resolved.project + query_alias, project = resolved.alias, resolved.project ai_client = self._ai_client_factory(project.stack_url, project.token) components: list[dict[str, Any]] = [] @@ -975,7 +975,7 @@ def _list_via_ai( except KeboolaApiError as exc: errors.append( { - "project_alias": first_alias, + "project_alias": query_alias, "error_code": exc.error_code, "message": exc.message, } @@ -983,7 +983,7 @@ def _list_via_ai( except Exception as exc: errors.append( { - "project_alias": first_alias, + "project_alias": query_alias, "error_code": "UNEXPECTED_ERROR", "message": str(exc), } diff --git a/tests/test_component_service.py b/tests/test_component_service.py index 3b9cbfe4..60ea3060 100644 --- a/tests/test_component_service.py +++ b/tests/test_component_service.py @@ -359,6 +359,7 @@ def factory(url: str, token: str) -> MagicMock: service.list_components(query="anything") assert seen_tokens == [dev_token] + mock_ai.close.assert_called_once() # =========================================================================== @@ -396,6 +397,7 @@ def factory(url: str, token: str) -> MagicMock: service.get_component_detail(alias=None, component_id="keboola.ex-http") assert seen_tokens == [dev_token] + mock_ai.close.assert_called_once() def test_get_component_detail_success(self, tmp_config_dir: Path) -> None: """Returns parsed detail with schema_summary for a valid component.""" From 2332c911def8f25ce293d44ba6c8ae847309c145 Mon Sep 17 00:00:00 2001 From: soustruh Date: Thu, 27 Aug 2026 15:42:17 +0200 Subject: [PATCH 3/3] fix(ci): skip fenced lines in the vNEXT heading check A "#" comment line inside a markdown code fence renders as content. It has no anchor slug, so the slug-breakage rationale behind the heading check (#702) does not apply to it. CLAUDE.md's command section is one giant fence full of such lines. Convention #17 requires feature PRs to tag new notes there with vNEXT. The check therefore failed every PR that follows the documented process. This PR was the first to fail on it. The residue scan is untouched: a fenced gate is still a live gate the release PR must resolve. --- scripts/check_version_gates.py | 27 +++++++++++++++++++++++ tests/test_check_version_gates.py | 36 +++++++++++++++++++++++++++++++ 2 files changed, 63 insertions(+) diff --git a/scripts/check_version_gates.py b/scripts/check_version_gates.py index df80f0fa..8501d4e8 100644 --- a/scripts/check_version_gates.py +++ b/scripts/check_version_gates.py @@ -161,6 +161,17 @@ def find_vnext_residue(paths: list[Path]) -> list[VnextResidue]: # which has no anchor slug to break. HEADING_RE = re.compile(r"^ {0,3}#{1,6} ") +# A code-fence delimiter: 3+ backticks or 3+ tildes, at most 3 spaces indented +# (CommonMark). The heading check needs it because a ``#`` line INSIDE a fence +# is content, not a heading -- it renders no anchor slug, so the slug-breakage +# rationale does not apply there. CLAUDE.md's ``## All CLI Commands`` section +# is one giant fence full of ``#`` comment lines, and feature PRs are REQUIRED +# to tag new notes there with ``vNEXT`` (CONTRIBUTING.md, coding convention +# #17) -- so flagging them would fail every PR that follows the process. This +# is the same argument that already exempts ``#`` comments in ``.py`` files. +# The residue scan is untouched: a fenced gate is still a live gate. +FENCE_RE = re.compile(r"^ {0,3}(`{3,}|~{3,})") + def find_heading_placeholders(paths: list[Path]) -> list[VnextResidue]: """Return every markdown heading carrying a live ``vNEXT`` placeholder. @@ -176,6 +187,11 @@ def find_heading_placeholders(paths: list[Path]) -> list[VnextResidue]: Numeric versions in headings are deliberately NOT flagged: an already resolved tag never changes again, so its slug is stable, and flagging the dozen historical ones would be noise with no inbound link at risk. + + Lines inside a code fence are skipped -- they render as content, never as + headings, so no anchor slug is at risk (see ``FENCE_RE``). A closing fence + must repeat the opening character (CommonMark): a ``~~~`` inside a backtick + fence is content and does not close it. """ flagged: list[VnextResidue] = [] for path in paths: @@ -185,9 +201,20 @@ def find_heading_placeholders(paths: list[Path]) -> list[VnextResidue]: rel = path.relative_to(REPO_ROOT).as_posix() except ValueError: rel = path.as_posix() + fence_open: str | None = None for lineno, line in enumerate( path.read_text(encoding="utf-8", errors="replace").splitlines(), start=1 ): + fence = FENCE_RE.match(line) + if fence is not None: + marker = fence.group(1) + if fence_open is None: + fence_open = marker + elif marker[0] == fence_open[0] and len(marker) >= len(fence_open): + fence_open = None + continue + if fence_open is not None: + continue if VNEXT_TOKEN not in line or not HEADING_RE.match(line): continue # Same quotation rule as the residue scan: a heading that merely diff --git a/tests/test_check_version_gates.py b/tests/test_check_version_gates.py index 412601e4..342d5c25 100644 --- a/tests/test_check_version_gates.py +++ b/tests/test_check_version_gates.py @@ -414,6 +414,42 @@ def test_hash_without_a_space_is_not_a_heading(self, tmp_path: Path) -> None: f = _write(tmp_path, "g.md", "#vNEXT (since vNEXT)\n") assert headings([f]) == [] + def test_fenced_comment_line_is_not_a_heading(self, tmp_path: Path) -> None: + """The CLAUDE.md shape: a ``#`` gate comment inside the command fence. + + A fenced ``#`` line renders as content and has no anchor slug, so the + slug-breakage rationale does not apply -- and feature PRs are REQUIRED + to write exactly this shape (coding convention #17). The residue scan + must still see it: a fenced gate is a live gate. + """ + f = _write( + tmp_path, + "commands.md", + "```\n# component detail (since vNEXT, #684): resolves the pin\n```\n", + ) + assert headings([f]) == [] + assert len(residue([f])) == 1 + + def test_heading_after_a_closed_fence_is_flagged(self, tmp_path: Path) -> None: + """Closing the fence re-arms the check -- the toggle must not stick.""" + f = _write( + tmp_path, + "g.md", + "```\n# fenced (since vNEXT)\n```\n\n## Real heading (since vNEXT)\n", + ) + found = headings([f]) + assert len(found) == 1 + assert found[0].line == 5 + + def test_tilde_line_does_not_close_a_backtick_fence(self, tmp_path: Path) -> None: + """CommonMark: a closing fence must repeat the opening character.""" + f = _write( + tmp_path, + "g.md", + "```\n~~~\n# still fenced (since vNEXT)\n```\n", + ) + assert headings([f]) == [] + def test_live_repository_has_no_placeholder_headings(self) -> None: """The real tree must stay clean -- this is the check's whole point.""" assert headings(check_version_gates.resolve_paths()) == []