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:
Noor ul ain
2026-07-27 21:58:23 +05:00
committed by GitHub
parent e6a3ccfb27
commit 42c7230aa9
2 changed files with 150 additions and 7 deletions

View File

@@ -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:

View File

@@ -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