From fad466bd9cf6b0404d99c9610d8df9c912374c3b Mon Sep 17 00:00:00 2001 From: caixuehe <1786459296@qq.com> Date: Mon, 10 Aug 2026 14:40:48 +0800 Subject: [PATCH 1/2] test(bdd): cover standalone first-use search and reindex skips Add pytest-bdd scenarios for #46 (empty-index / unset-embedding search) and #200 (skip empty descriptions during rebuild). Rebuild now warns and skips empty description skills so one bad frontmatter cannot 500 reindex. Co-authored-by: Cursor --- pyproject.toml | 4 + src/xskill/skill/repo.py | 8 + tests/bdd/README.md | 15 + .../standalone/first_use_local_search.feature | 46 +++ .../reindex_empty_description.feature | 35 ++ tests/bdd/test_standalone_resilience.py | 381 ++++++++++++++++++ 6 files changed, 489 insertions(+) create mode 100644 tests/bdd/features/standalone/first_use_local_search.feature create mode 100644 tests/bdd/features/standalone/reindex_empty_description.feature create mode 100644 tests/bdd/test_standalone_resilience.py diff --git a/pyproject.toml b/pyproject.toml index e437a612..706d39e3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -113,6 +113,10 @@ markers = [ "bdd: executable product behavior specifications", "http_llm: real OpenAI-compatible HTTP boundary backed by local aimock", "state_machine: deterministic SkillEdit state-machine coverage for mutation testing", + "first_use: uninitialized / first-install local search and status behavior", + "reindex: skill vector index rebuild resilience", + "issue46: regression coverage for SkillNerds/xskill#46", + "issue200: regression coverage for SkillNerds/xskill#200", "primary: primary user success journey", "golden_path: end-to-end happy-path behavior", "recovery: retry and restart recovery behavior", diff --git a/src/xskill/skill/repo.py b/src/xskill/skill/repo.py index b81348eb..a2ba9d35 100644 --- a/src/xskill/skill/repo.py +++ b/src/xskill/skill/repo.py @@ -209,6 +209,14 @@ def rebuild_skill_index( if not frontmatter: continue description = (frontmatter.get("description") or "").strip() + # 空 description(含宽松 frontmatter 恢复失败后的空串)发给 embedding + # 端点会 400 并拖死整轮 reindex(#200)。跳过并告警,局部降级。 + if not description: + logger.warning( + "rebuild_skill_index: skip skill %s with empty description", + skill_path.name, + ) + continue entries.append((skill_path.name, description)) if not entries: diff --git a/tests/bdd/README.md b/tests/bdd/README.md index f72166a7..0ccc04a4 100644 --- a/tests/bdd/README.md +++ b/tests/bdd/README.md @@ -89,3 +89,18 @@ mutmut run --max-children 8 \ - 不会开 LLM 的 git skill(main 无 ux、瘦 baby)不得 `submit` 进 edit 池; - 单 skill 的 actionable 检查失败只 skip,不得中断整轮扫描; - 无 `.git` 目录不崩扫描;排序 empty-last,避免空目录饿死 READY baby。 + +## standalone 韧性(本地 search / reindex) + +`features/standalone/` + `test_standalone_resilience.py` 是进程内 `@state_machine` +场景,不启 aimock: + +- `first_use_local_search.feature`:Issue #46 / 缺索引或未配 embedding 时 + `/skills/search`、`/skills/resolve`、SDK `search_skills` 返回空且不 500; + 未 git init 的 skill 目录 `/status` 仍 200。 +- `reindex_empty_description.feature`:Issue #200 / 空或非法 description 时 + `rebuild_skill_index` 局部跳过,不把空串发给 embedding、不拖死整轮。 + +```bash +pytest tests/bdd/test_standalone_resilience.py -v +``` diff --git a/tests/bdd/features/standalone/first_use_local_search.feature b/tests/bdd/features/standalone/first_use_local_search.feature new file mode 100644 index 00000000..108e105e --- /dev/null +++ b/tests/bdd/features/standalone/first_use_local_search.feature @@ -0,0 +1,46 @@ +Feature: 首次安装时本地 skill 搜索不因缺索引或未配 embedding 而 500 + 未初始化安装(无 .skill_index.pkl,或 embedding 未配置)时,本地 + /api/v1/skills/search、/skills/resolve 与 SDK search_skills 应返回空结果, + 不得为了建 embed client 而 500。status 在 skill 目录尚未 git init 时也应 200。 + + Background: + Given xskill API 使用隔离的空 skill 目录 + And embedding 配置为空 + + @state_machine @first_use @issue46 + Scenario: 缺 .skill_index.pkl 时 skills/search 返回空列表且不建 embed client + When 客户端 POST /api/v1/skills/search 查询 "heartbeat" + Then 响应状态码是 200 + And 响应 JSON 是空列表 + And 不应创建 embedding 客户端 + And 日志应包含 "skill search skipped" + + @state_machine @first_use @issue46 + Scenario: 缺索引时 skills/resolve 返回空结果且不建 embed client + When 客户端 POST /api/v1/skills/resolve 查询 "heartbeat" + Then 响应状态码是 200 + And resolve 结果为空 + And 不应创建 embedding 客户端 + And 日志应包含 "skill resolve skipped" + + @state_machine @first_use @issue46 + Scenario: 有占位索引但未配 embedding 时 skills/search 返回空列表 + Given skill 目录存在占位 .skill_index.pkl + When 客户端 POST /api/v1/skills/search 查询 "heartbeat" + Then 响应状态码是 200 + And 响应 JSON 是空列表 + And 不应创建 embedding 客户端 + And 日志应包含 "embedding.base_url/model unset" + + @state_machine @first_use @issue46 + Scenario: SDK search_skills 在缺索引时返回空列表 + When SDK 调用 search_skills 查询 "heartbeat" + Then SDK 搜索结果是空列表 + And 不应创建 embedding 客户端 + And 日志应包含 "skill search skipped" + + @state_machine @first_use @issue46 + Scenario: skill 目录尚未 git init 时 status 返回 200 且 git_branch 为空 + When 客户端 GET /api/v1/status + Then 响应状态码是 200 + And status 的 git_branch 为空 diff --git a/tests/bdd/features/standalone/reindex_empty_description.feature b/tests/bdd/features/standalone/reindex_empty_description.feature new file mode 100644 index 00000000..e75c89d2 --- /dev/null +++ b/tests/bdd/features/standalone/reindex_empty_description.feature @@ -0,0 +1,35 @@ +Feature: reindex 遇到空或非法 description 时局部降级而不是整轮 500 + description-only 索引重构后,宽松 frontmatter 可能恢复出空 description; + 若把空串发给 embedding 端点,单条 400 会拖死整轮 reindex(issue #200)。 + 系统应跳过空 description 的 skill,仍为合法 skill 写出索引。 + + Background: + Given xskill 使用隔离 skill 目录与可记录的假 embedding 客户端 + + @state_machine @reindex @issue200 + Scenario: 仓内同时有合法 skill 与空 description skill 时 reindex 成功并写出索引 + Given skill 仓中有合法 skill "good-skill" 描述为 "Manage docker containers" + And skill 仓中有空 description 的 skill "empty-skill" + When 重建 skill 向量索引 + Then 索引重建不抛错 + And skill 目录存在 .skill_index.pkl + And 索引包含 skill "good-skill" + And 索引不包含 skill "empty-skill" + + @state_machine @reindex @issue200 + Scenario: 仓内存在非法裸多行 description 的 baby SKILL.md 时 reindex 不抛错 + Given skill 仓中有合法 skill "good-skill" 描述为 "Manage docker containers" + And skill 仓中有非法裸多行 description 的 skill "login-v4-i18n" + When 重建 skill 向量索引 + Then 索引重建不抛错 + And skill 目录存在 .skill_index.pkl + And 索引包含 skill "good-skill" + + @state_machine @reindex @issue200 + Scenario: 被跳过的空 description 不会发给 embedding 客户端 + Given skill 仓中有合法 skill "good-skill" 描述为 "Manage docker containers" + And skill 仓中有空 description 的 skill "empty-skill" + When 重建 skill 向量索引 + Then 索引重建不抛错 + And 假 embedding 客户端收到的文本不含空串 + And 假 embedding 客户端收到过 "Manage docker containers" diff --git a/tests/bdd/test_standalone_resilience.py b/tests/bdd/test_standalone_resilience.py new file mode 100644 index 00000000..046b1b7e --- /dev/null +++ b/tests/bdd/test_standalone_resilience.py @@ -0,0 +1,381 @@ +"""Executable BDD for standalone first-use search (#46) and reindex (#200). + +Process-in FastAPI TestClient + deterministic fake embed client. No aimock, +no real network. Intended to run under ordinary ``pytest tests/bdd``. +""" +from __future__ import annotations + +import logging +import pickle +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any + +import numpy as np +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient +from pytest_bdd import given, parsers, scenario, then, when + +import xskill.api.app as api_app +from xskill import core +from xskill.skill.repo import rebuild_skill_index +from xskill.utils import llm as llm_mod + + +pytestmark = [ + pytest.mark.bdd, + pytest.mark.state_machine, +] + + +# ── scenarios: #46 first-use local search ───────────────────────────── + +@scenario( + "features/standalone/first_use_local_search.feature", + "缺 .skill_index.pkl 时 skills/search 返回空列表且不建 embed client", +) +def test_search_missing_index_skips_embed() -> None: + """Missing index must not construct an embedding client.""" + + +@scenario( + "features/standalone/first_use_local_search.feature", + "缺索引时 skills/resolve 返回空结果且不建 embed client", +) +def test_resolve_missing_index_skips_embed() -> None: + """Missing index resolve path stays empty without embed.""" + + +@scenario( + "features/standalone/first_use_local_search.feature", + "有占位索引但未配 embedding 时 skills/search 返回空列表", +) +def test_search_unset_embedding_skips_embed() -> None: + """Placeholder index still skips when embedding is unset.""" + + +@scenario( + "features/standalone/first_use_local_search.feature", + "SDK search_skills 在缺索引时返回空列表", +) +def test_sdk_search_missing_index() -> None: + """SDK search_skills mirrors the API empty-index guard.""" + + +@scenario( + "features/standalone/first_use_local_search.feature", + "skill 目录尚未 git init 时 status 返回 200 且 git_branch 为空", +) +def test_status_without_git_repo() -> None: + """Uninitialized skill dir status returns 200 with null branch.""" + + +# ── scenarios: #200 reindex empty description ───────────────────────── + +@scenario( + "features/standalone/reindex_empty_description.feature", + "仓内同时有合法 skill 与空 description skill 时 reindex 成功并写出索引", +) +def test_reindex_skips_empty_description_skill() -> None: + """Empty-description skills are skipped; good skills are indexed.""" + + +@scenario( + "features/standalone/reindex_empty_description.feature", + "仓内存在非法裸多行 description 的 baby SKILL.md 时 reindex 不抛错", +) +def test_reindex_tolerates_illegal_multiline_description() -> None: + """Illegal bare multiline YAML must not abort the whole reindex.""" + + +@scenario( + "features/standalone/reindex_empty_description.feature", + "被跳过的空 description 不会发给 embedding 客户端", +) +def test_reindex_does_not_send_empty_strings_to_embed() -> None: + """Embed client must never see empty description texts.""" + + +# ── fakes / world ───────────────────────────────────────────────────── + +class _RecordingEmbed: + """Deterministic embed that records texts and rejects empty strings.""" + + dim = 4 + model = "fake-bdd" + base_url = "test://" + + def __init__(self) -> None: + self.texts: list[str] = [] + + def encode(self, text: str) -> np.ndarray: + if not str(text).strip(): + raise ValueError("empty description must not be embedded") + self.texts.append(text) + digest = abs(hash(text)) % (10 ** 8) + rng = np.random.default_rng(digest) + vector = rng.random(self.dim, dtype=np.float32) + return vector / (np.linalg.norm(vector) or 1.0) + + def encode_batch(self, texts: list[str]) -> np.ndarray: + return np.stack([self.encode(text) for text in texts]) + + +@dataclass +class _World: + skill_dir: Path + config: dict = field(default_factory=dict) + client: TestClient | None = None + embed: _RecordingEmbed | None = None + embed_created: bool = False + last_status: int | None = None + last_body: Any = None + sdk_hits: list | None = None + reindex_error: BaseException | None = None + caplog: pytest.LogCaptureFixture | None = None + + +@pytest.fixture +def world(tmp_path: Path, monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture): + skill_dir = tmp_path / "skill" + skill_dir.mkdir() + state = _World( + skill_dir=skill_dir, + config={"llm": {}, "embedding": {}, "watcher": {"poll_interval": 30}}, + caplog=caplog, + ) + + def _create_embed(_config): + state.embed_created = True + raise AssertionError("unexpected embed client") + + monkeypatch.setattr(api_app, "_skill_dir", skill_dir) + monkeypatch.setattr(api_app, "_config", state.config) + monkeypatch.setattr(api_app, "create_embed_client", _create_embed) + + app = FastAPI() + app.include_router(api_app.router) + state.client = TestClient(app) + + with caplog.at_level(logging.WARNING): + yield state + + +def _write_skill(skill_dir: Path, name: str, body: str) -> Path: + path = skill_dir / name + path.mkdir(parents=True, exist_ok=True) + (path / "SKILL.md").write_text(body, encoding="utf-8") + return path + + +# ── given: first-use ────────────────────────────────────────────────── + +@given("xskill API 使用隔离的空 skill 目录", target_fixture="world") +def given_isolated_empty_skill_dir(world: _World) -> _World: + assert world.skill_dir.is_dir() + assert not (world.skill_dir / ".skill_index.pkl").exists() + return world + + +@given("embedding 配置为空") +def given_embedding_unset(world: _World) -> None: + world.config["embedding"] = {} + + +@given("skill 目录存在占位 .skill_index.pkl") +def given_placeholder_index(world: _World) -> None: + (world.skill_dir / ".skill_index.pkl").write_bytes(b"placeholder") + + +# ── given: reindex ──────────────────────────────────────────────────── + +@given( + "xskill 使用隔离 skill 目录与可记录的假 embedding 客户端", + target_fixture="world", +) +def given_reindex_world(world: _World) -> _World: + world.embed = _RecordingEmbed() + return world + + +@given(parsers.parse('skill 仓中有合法 skill "{name}" 描述为 "{description}"')) +def given_good_skill(world: _World, name: str, description: str) -> None: + _write_skill( + world.skill_dir, + name, + ( + f"---\nname: {name}\n" + f"description: {description}\n" + f"---\n# {name}\nbody\n" + ), + ) + + +@given(parsers.parse('skill 仓中有空 description 的 skill "{name}"')) +def given_empty_description_skill(world: _World, name: str) -> None: + _write_skill( + world.skill_dir, + name, + f"---\nname: {name}\ndescription: \"\"\n---\n# {name}\nbody\n", + ) + + +@given(parsers.parse('skill 仓中有非法裸多行 description 的 skill "{name}"')) +def given_illegal_multiline_skill(world: _World, name: str) -> None: + # Mirrors SkillNerds/xskill#200: bare multiline description breaks YAML. + _write_skill( + world.skill_dir, + name, + ( + f"---\nname: {name}\n" + "description: 服务于登录页 V4 版本的 UI 对齐与全英文(i18n)适配任务。\n" + "典型操作包括 Apple 登录按钮置顶、文案本地化替换、CSS 样式对齐、locale 配置验证等。\n" + "常在 worktree 并行开发模式下执行,需跨组件/语言包协同修改。\n" + "metadata:\n" + " version: 0\n" + " state: baby\n" + f"---\n# {name}\nbody\n" + ), + ) + + +# ── when ────────────────────────────────────────────────────────────── + +@when(parsers.parse('客户端 POST /api/v1/skills/search 查询 "{query}"')) +def when_post_search(world: _World, query: str) -> None: + assert world.client is not None + response = world.client.post( + "/api/v1/skills/search", json={"query": query, "top_k": 2}, + ) + world.last_status = response.status_code + world.last_body = response.json() + + +@when(parsers.parse('客户端 POST /api/v1/skills/resolve 查询 "{query}"')) +def when_post_resolve(world: _World, query: str) -> None: + assert world.client is not None + response = world.client.post( + "/api/v1/skills/resolve", json={"query": query}, + ) + world.last_status = response.status_code + world.last_body = response.json() + + +@when("客户端 GET /api/v1/status") +def when_get_status(world: _World) -> None: + assert world.client is not None + response = world.client.get("/api/v1/status") + world.last_status = response.status_code + world.last_body = response.json() + + +@when(parsers.parse('SDK 调用 search_skills 查询 "{query}"')) +def when_sdk_search( + world: _World, query: str, monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr( + core, "load_config", lambda config_path=None: {"embedding": {}}, + ) + monkeypatch.setattr(core, "get_skill_dir", lambda: world.skill_dir) + + def _boom(_config): + world.embed_created = True + raise AssertionError("unexpected embed client") + + monkeypatch.setattr(llm_mod, "create_embed_client", _boom) + world.sdk_hits = core.XSkill().search_skills(query, top_k=2) + + +@when("重建 skill 向量索引") +def when_rebuild_index(world: _World) -> None: + assert world.embed is not None + try: + rebuild_skill_index( + skill_dir=world.skill_dir, + embed_client=world.embed, + scope="search", + ) + world.reindex_error = None + except BaseException as exc: # noqa: BLE001 — BDD captures failure + world.reindex_error = exc + + +# ── then: first-use ─────────────────────────────────────────────────── + +@then(parsers.parse("响应状态码是 {code:d}")) +def then_status_code(world: _World, code: int) -> None: + assert world.last_status == code + + +@then("响应 JSON 是空列表") +def then_body_empty_list(world: _World) -> None: + assert world.last_body == [] + + +@then("resolve 结果为空") +def then_resolve_empty(world: _World) -> None: + assert world.last_body == { + "skill_name": None, "path": None, "side": "none", "sha": "", + } + + +@then("不应创建 embedding 客户端") +def then_embed_not_created(world: _World) -> None: + assert world.embed_created is False + + +@then(parsers.parse('日志应包含 "{snippet}"')) +def then_log_contains(world: _World, snippet: str) -> None: + assert world.caplog is not None + messages = [record.getMessage() for record in world.caplog.records] + assert any(snippet in message for message in messages), messages + + +@then("SDK 搜索结果是空列表") +def then_sdk_empty(world: _World) -> None: + assert world.sdk_hits == [] + + +@then("status 的 git_branch 为空") +def then_git_branch_null(world: _World) -> None: + assert isinstance(world.last_body, dict) + assert world.last_body.get("git_branch") is None + + +# ── then: reindex ───────────────────────────────────────────────────── + +@then("索引重建不抛错") +def then_reindex_ok(world: _World) -> None: + assert world.reindex_error is None, world.reindex_error + + +@then("skill 目录存在 .skill_index.pkl") +def then_index_file_exists(world: _World) -> None: + assert (world.skill_dir / ".skill_index.pkl").is_file() + + +@then(parsers.parse('索引包含 skill "{name}"')) +def then_index_contains(world: _World, name: str) -> None: + with open(world.skill_dir / ".skill_index.pkl", "rb") as handle: + data = pickle.load(handle) + assert name in data["skill_names"] + + +@then(parsers.parse('索引不包含 skill "{name}"')) +def then_index_excludes(world: _World, name: str) -> None: + with open(world.skill_dir / ".skill_index.pkl", "rb") as handle: + data = pickle.load(handle) + assert name not in data["skill_names"] + + +@then("假 embedding 客户端收到的文本不含空串") +def then_embed_no_empty(world: _World) -> None: + assert world.embed is not None + assert all(str(text).strip() for text in world.embed.texts) + + +@then(parsers.parse('假 embedding 客户端收到过 "{text}"')) +def then_embed_saw_text(world: _World, text: str) -> None: + assert world.embed is not None + assert text in world.embed.texts From 238bff447b2ef94362f7450580b21a4ca09d3792 Mon Sep 17 00:00:00 2001 From: somewhere1 <1786459296@qq.com> Date: Mon, 10 Aug 2026 20:00:24 +0800 Subject: [PATCH 2/2] fix(skill): YAML-safe serialize baby stub frontmatter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Root cure for #200: init_skill_repo_on_baby interpolated the cluster/LLM description into the stub SKILL.md with a raw f-string, so a multiline or YAML-special description made the baby frontmatter invalid from birth — the lenient loader then recovered an empty description, which is what the index-side skip (previous commit) has to guard against. Build the stub frontmatter as a dict and serialize via frontmatter.serialize (yaml.safe_dump), so the baby SKILL.md is valid YAML from the first commit and the description survives verbatim. BDD: baby_stub_frontmatter.feature covers multiline and colon/quote/hash descriptions — strict parse, verbatim round-trip, and indexability without triggering the skip path. --- src/xskill/skill/git.py | 35 +++++----- tests/bdd/README.md | 4 ++ .../standalone/baby_stub_frontmatter.feature | 28 ++++++++ tests/bdd/test_standalone_resilience.py | 69 +++++++++++++++++++ 4 files changed, 120 insertions(+), 16 deletions(-) create mode 100644 tests/bdd/features/standalone/baby_stub_frontmatter.feature diff --git a/src/xskill/skill/git.py b/src/xskill/skill/git.py index a423f697..37b605bc 100644 --- a/src/xskill/skill/git.py +++ b/src/xskill/skill/git.py @@ -1788,23 +1788,26 @@ def init_skill_repo_on_baby(skill_dir: str, name: str, description: str) -> None (p / ".gitignore").write_text(SKILL_GITIGNORE, encoding="utf-8") today = _date.today().isoformat() - stub_md = ( - f"---\n" - f"name: {name}\n" - f"description: {description}\n" - f"metadata:\n" - f" version: 0\n" - f" state: baby\n" - f" created: \"{today}\"\n" - f" last_updated: \"{today}\"\n" - f" source_atoms: []\n" - f"---\n" - f"\n" - f"# {name}\n" - f"\n" - f"{BABY_STUB_BODY_MARKER}\n" + # frontmatter 必须走 yaml.safe_dump(fm_serialize)而非 f-string + # 裸拼:cluster/LLM 给的 description 可能多行或含 YAML 特殊字符, + # 裸拼会让 baby 初版从出生就是非法 YAML——宽松 loader 恢复出空 + # description,进而在 reindex 时被 embedding 端点 400 拒掉(#200)。 + from xskill.skill.frontmatter import serialize as _fm_serialize + stub_fm = { + "name": name, + "description": description, + "metadata": { + "version": 0, + "state": "baby", + "created": today, + "last_updated": today, + "source_atoms": [], + }, + } + stub_body = f"\n# {name}\n\n{BABY_STUB_BODY_MARKER}\n" + (p / "SKILL.md").write_text( + _fm_serialize(stub_fm, stub_body), encoding="utf-8", ) - (p / "SKILL.md").write_text(stub_md, encoding="utf-8") (p / "scripts").mkdir(exist_ok=True) (p / "references").mkdir(exist_ok=True) diff --git a/tests/bdd/README.md b/tests/bdd/README.md index 0ccc04a4..bf13d0e5 100644 --- a/tests/bdd/README.md +++ b/tests/bdd/README.md @@ -100,6 +100,10 @@ mutmut run --max-children 8 \ 未 git init 的 skill 目录 `/status` 仍 200。 - `reindex_empty_description.feature`:Issue #200 / 空或非法 description 时 `rebuild_skill_index` 局部跳过,不把空串发给 embedding、不拖死整轮。 +- `baby_stub_frontmatter.feature`:Issue #200 根治侧 / + `init_skill_repo_on_baby` 用 `yaml.safe_dump`(`frontmatter.serialize`)写 + stub frontmatter;多行、含冒号/引号/井号的敌意 description 产出的 baby + 初版仍是合法 YAML、description 逐字保留、无需止血跳过即可进索引。 ```bash pytest tests/bdd/test_standalone_resilience.py -v diff --git a/tests/bdd/features/standalone/baby_stub_frontmatter.feature b/tests/bdd/features/standalone/baby_stub_frontmatter.feature new file mode 100644 index 00000000..d8c58970 --- /dev/null +++ b/tests/bdd/features/standalone/baby_stub_frontmatter.feature @@ -0,0 +1,28 @@ +Feature: baby stub frontmatter 安全序列化(#200 根治) + 蒸馏器创建 baby skill 初版时,description 来自 cluster/LLM,可能是多行 + 或含 YAML 特殊字符。stub 写入必须走 yaml.safe_dump 序列化而非 f-string + 裸拼,保证 baby 初版从出生就是合法 YAML——宽松 loader 不会恢复出空 + description,reindex 也无需触发止血跳过。 + + Background: + Given xskill 使用隔离 skill 目录与可记录的假 embedding 客户端 + + @state_machine @stub @issue200 + Scenario: 多行 description 创建 baby stub 后 frontmatter 仍严格可解析 + Given 用敌意 description 创建 baby skill "hostile-multiline" + Then skill "hostile-multiline" 的 SKILL.md frontmatter 严格可解析 + And skill "hostile-multiline" 的 description 与输入逐字一致 + + @state_machine @stub @issue200 + Scenario: 含冒号引号井号的 description 创建 baby stub 后 frontmatter 仍严格可解析 + Given 用敌意 description 创建 baby skill "hostile-specials" + Then skill "hostile-specials" 的 SKILL.md frontmatter 严格可解析 + And skill "hostile-specials" 的 description 与输入逐字一致 + + @state_machine @stub @reindex @issue200 + Scenario: 敌意 description 的 baby stub 无需止血即可进入索引 + Given 用敌意 description 创建 baby skill "hostile-multiline" + When 重建 skill 向量索引 + Then 索引重建不抛错 + And skill 目录存在 .skill_index.pkl + And 索引包含 skill "hostile-multiline" diff --git a/tests/bdd/test_standalone_resilience.py b/tests/bdd/test_standalone_resilience.py index 046b1b7e..ceb3c952 100644 --- a/tests/bdd/test_standalone_resilience.py +++ b/tests/bdd/test_standalone_resilience.py @@ -97,6 +97,32 @@ def test_reindex_does_not_send_empty_strings_to_embed() -> None: """Embed client must never see empty description texts.""" +# ── scenarios: #200 root cure — baby stub YAML-safe frontmatter ─────── + +@scenario( + "features/standalone/baby_stub_frontmatter.feature", + "多行 description 创建 baby stub 后 frontmatter 仍严格可解析", +) +def test_baby_stub_multiline_description_is_valid_yaml() -> None: + """Multiline descriptions must serialize into strictly parseable YAML.""" + + +@scenario( + "features/standalone/baby_stub_frontmatter.feature", + "含冒号引号井号的 description 创建 baby stub 后 frontmatter 仍严格可解析", +) +def test_baby_stub_special_chars_description_is_valid_yaml() -> None: + """YAML-special characters must not corrupt the stub frontmatter.""" + + +@scenario( + "features/standalone/baby_stub_frontmatter.feature", + "敌意 description 的 baby stub 无需止血即可进入索引", +) +def test_baby_stub_hostile_description_is_indexable() -> None: + """A freshly created hostile-description stub indexes without the skip path.""" + + # ── fakes / world ───────────────────────────────────────────────────── class _RecordingEmbed: @@ -379,3 +405,46 @@ def then_embed_no_empty(world: _World) -> None: def then_embed_saw_text(world: _World, text: str) -> None: assert world.embed is not None assert text in world.embed.texts + + +# ── given/then: #200 root cure — baby stub YAML-safe frontmatter ────── + +_HOSTILE_DESCRIPTIONS = { + "hostile-multiline": ( + "服务于登录页 V4 版本的 UI 对齐与全英文(i18n)适配任务。\n" + "典型操作包括 Apple 登录按钮置顶、文案本地化替换、CSS 样式对齐。\n" + "常在 worktree 并行开发模式下执行,需跨组件/语言包协同修改。" + ), + "hostile-specials": ( + 'contains: a colon, "double quotes", #hash, [brackets] and {braces}' + ), +} + + +@given(parsers.parse('用敌意 description 创建 baby skill "{name}"')) +def given_hostile_baby_stub(world: _World, name: str) -> None: + from xskill.skill.git import init_skill_repo_on_baby + + init_skill_repo_on_baby( + str(world.skill_dir / name), name, _HOSTILE_DESCRIPTIONS[name], + ) + + +@then(parsers.parse('skill "{name}" 的 SKILL.md frontmatter 严格可解析')) +def then_stub_frontmatter_strict(world: _World, name: str) -> None: + from xskill.skill.frontmatter import parse_strict + + fm, _body = parse_strict( + (world.skill_dir / name / "SKILL.md").read_text(encoding="utf-8"), + ) + assert fm["name"] == name + + +@then(parsers.parse('skill "{name}" 的 description 与输入逐字一致')) +def then_stub_description_verbatim(world: _World, name: str) -> None: + from xskill.skill.frontmatter import parse_strict + + fm, _body = parse_strict( + (world.skill_dir / name / "SKILL.md").read_text(encoding="utf-8"), + ) + assert fm["description"] == _HOSTILE_DESCRIPTIONS[name]