mirror of
https://github.com/github/spec-kit.git
synced 2026-08-03 06:26:30 +08:00
fix(extensions): tolerate non-string tags in catalog search (#3746)
* fix(extensions): tolerate non-string tags in catalog search ExtensionCatalog.search() assumed catalog `tags` were always strings: the tag filter called `t.lower()` and the query path did `" ".join([...] + tags)`. Extension catalog JSON is user-editable, so a hand-authored `tags: [1, 2]` crashed search with AttributeError (tag filter) or TypeError (query join). Coerce defensively by filtering to `isinstance(t, str)` and guarding the tags value as a list, matching the reference-correct sibling in integrations/catalog.py. Non-string tags are now skipped rather than raising. Adds a regression test driving search(tag=...) and search(query=...) against a catalog with mixed string/int tags; both fail pre-fix. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(extensions): also coerce non-string author/name in catalog search The same ExtensionCatalog.search() method had two more string assumptions on user-editable catalog fields: the author filter called `ext_data.get("author", "").lower()` (AttributeError on a numeric author) and the query searchable-text joined `name`/`description` uncoerced (TypeError on a numeric name). Coerce both defensively, matching the reference-correct integrations/catalog.py::search. Extends the regression test with non-string author/name coverage; fails pre-fix with AttributeError at the author filter. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -3154,22 +3154,35 @@ class ExtensionCatalog(CatalogStackBase):
|
||||
if verified_only and not ext_data.get("verified", False):
|
||||
continue
|
||||
|
||||
if author and ext_data.get("author", "").lower() != author.lower():
|
||||
continue
|
||||
if author:
|
||||
author_val = ext_data.get("author", "")
|
||||
if not isinstance(author_val, str):
|
||||
author_val = str(author_val) if author_val is not None else ""
|
||||
if author_val.lower() != author.lower():
|
||||
continue
|
||||
|
||||
if tag and tag.lower() not in [t.lower() for t in ext_data.get("tags", [])]:
|
||||
continue
|
||||
if tag:
|
||||
raw_tags = ext_data.get("tags", [])
|
||||
tags_list = raw_tags if isinstance(raw_tags, list) else []
|
||||
if tag.lower() not in [
|
||||
t.lower() for t in tags_list if isinstance(t, str)
|
||||
]:
|
||||
continue
|
||||
|
||||
if query:
|
||||
# Search in name, description, and tags
|
||||
query_lower = query.lower()
|
||||
raw_tags = ext_data.get("tags", [])
|
||||
tags_list = raw_tags if isinstance(raw_tags, list) else []
|
||||
name_val = ext_data.get("name", "")
|
||||
desc_val = ext_data.get("description", "")
|
||||
searchable_text = " ".join(
|
||||
[
|
||||
ext_data.get("name", ""),
|
||||
ext_data.get("description", ""),
|
||||
str(name_val) if name_val else "",
|
||||
str(desc_val) if desc_val else "",
|
||||
ext_id,
|
||||
]
|
||||
+ ext_data.get("tags", [])
|
||||
+ [t for t in tags_list if isinstance(t, str)]
|
||||
).lower()
|
||||
|
||||
if query_lower not in searchable_text:
|
||||
|
||||
@@ -4300,6 +4300,136 @@ class TestExtensionCatalog:
|
||||
assert len(results) == 2
|
||||
assert {r["id"] for r in results} == {"jira", "linear"}
|
||||
|
||||
def test_search_tolerates_non_string_tags(self, temp_dir):
|
||||
"""Non-string catalog tags must not crash tag/query search.
|
||||
|
||||
Catalog JSON is user-editable, so ``tags`` may contain non-strings
|
||||
(e.g. ``tags: [1, 2]``). The tag filter (``t.lower()``) and the query
|
||||
searchable-text ``" ".join(...)`` both assume strings; a numeric tag
|
||||
must be skipped rather than raising AttributeError/TypeError.
|
||||
"""
|
||||
import yaml as yaml_module
|
||||
|
||||
project_dir = temp_dir / "project"
|
||||
project_dir.mkdir()
|
||||
(project_dir / ".specify").mkdir()
|
||||
|
||||
config_path = project_dir / ".specify" / "extension-catalogs.yml"
|
||||
with open(config_path, "w") as f:
|
||||
yaml_module.dump(
|
||||
{
|
||||
"catalogs": [
|
||||
{
|
||||
"name": "test-catalog",
|
||||
"url": ExtensionCatalog.DEFAULT_CATALOG_URL,
|
||||
"priority": 1,
|
||||
"install_allowed": True,
|
||||
}
|
||||
]
|
||||
},
|
||||
f,
|
||||
)
|
||||
|
||||
catalog = ExtensionCatalog(project_dir)
|
||||
|
||||
# Mixed string / non-string tags, mirroring hand-edited catalog JSON.
|
||||
catalog_data = {
|
||||
"schema_version": "1.0",
|
||||
"extensions": {
|
||||
"jira": {
|
||||
"name": "Jira",
|
||||
"id": "jira",
|
||||
"version": "1.0.0",
|
||||
"description": "Jira",
|
||||
"tags": ["issue-tracking", 1, 2],
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
catalog.cache_dir.mkdir(parents=True, exist_ok=True)
|
||||
catalog.cache_file.write_text(json.dumps(catalog_data))
|
||||
catalog.cache_metadata_file.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"cached_at": datetime.now(timezone.utc).isoformat(),
|
||||
"catalog_url": "http://test.com",
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
# Tag filter: numeric tags skipped, string tag still matches.
|
||||
results = catalog.search(tag="issue-tracking")
|
||||
assert {r["id"] for r in results} == {"jira"}
|
||||
|
||||
# Query search: numeric tags skipped, no crash, string fields match.
|
||||
results = catalog.search(query="jira")
|
||||
assert {r["id"] for r in results} == {"jira"}
|
||||
|
||||
def test_search_tolerates_non_string_author_and_name(self, temp_dir):
|
||||
"""Non-string catalog author/name must not crash author/query search.
|
||||
|
||||
Catalog JSON is user-editable, so ``author`` and ``name`` may be
|
||||
non-strings. The author filter (``.lower()``) and the query
|
||||
searchable-text ``" ".join(...)`` both assume strings; a numeric
|
||||
value must be coerced rather than raising AttributeError/TypeError.
|
||||
"""
|
||||
import yaml as yaml_module
|
||||
|
||||
project_dir = temp_dir / "project"
|
||||
project_dir.mkdir()
|
||||
(project_dir / ".specify").mkdir()
|
||||
|
||||
config_path = project_dir / ".specify" / "extension-catalogs.yml"
|
||||
with open(config_path, "w") as f:
|
||||
yaml_module.dump(
|
||||
{
|
||||
"catalogs": [
|
||||
{
|
||||
"name": "test-catalog",
|
||||
"url": ExtensionCatalog.DEFAULT_CATALOG_URL,
|
||||
"priority": 1,
|
||||
"install_allowed": True,
|
||||
}
|
||||
]
|
||||
},
|
||||
f,
|
||||
)
|
||||
|
||||
catalog = ExtensionCatalog(project_dir)
|
||||
|
||||
# Numeric author/name, mirroring hand-edited catalog JSON.
|
||||
catalog_data = {
|
||||
"schema_version": "1.0",
|
||||
"extensions": {
|
||||
"jira": {
|
||||
"name": 123,
|
||||
"id": "jira",
|
||||
"version": "1.0.0",
|
||||
"description": "Jira",
|
||||
"author": 456,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
catalog.cache_dir.mkdir(parents=True, exist_ok=True)
|
||||
catalog.cache_file.write_text(json.dumps(catalog_data))
|
||||
catalog.cache_metadata_file.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"cached_at": datetime.now(timezone.utc).isoformat(),
|
||||
"catalog_url": "http://test.com",
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
# Author filter: non-string author coerced, no AttributeError.
|
||||
results = catalog.search(author="456")
|
||||
assert {r["id"] for r in results} == {"jira"}
|
||||
|
||||
# Query search: non-string name coerced into searchable text.
|
||||
results = catalog.search(query="123")
|
||||
assert {r["id"] for r in results} == {"jira"}
|
||||
|
||||
def test_search_verified_only(self, temp_dir):
|
||||
"""Test searching verified extensions only."""
|
||||
import yaml as yaml_module
|
||||
|
||||
Reference in New Issue
Block a user