mirror of
https://github.com/github/spec-kit.git
synced 2026-08-03 06:26:30 +08:00
fix(presets): tolerate non-string and non-list catalog fields in preset search/info (#3769)
* fix(presets): tolerate non-string and non-list catalog fields in preset search/info `preset search` and `preset info` crashed with a raw traceback on catalog payloads that are valid YAML/JSON but not string-typed. Catalog files are user-editable, so these shapes reach the code unvalidated. `PresetCatalog.search` had three unguarded assumptions: - `--author` called `.lower()` on the raw value → `AttributeError: 'int' object has no attribute 'lower'` for `author: 789`. - the query searchable-text join passed raw `name`/`description` through → `TypeError: sequence item 0: expected str instance, int found`. - the `--tag` filter iterated `tags` without a list check, so a scalar `tags: 5` (truthy, not iterable) raised `TypeError: 'int' object is not iterable`. PR #3743 fixed only the non-string *elements* of `tags` here; a non-list `tags` and the `author`/`name`/`description` fields were still unguarded. The sibling catalogs already handle all of these — `extensions/__init__.py` and `integrations/catalog.py` coerce with `str(...)` and gate on `isinstance(raw_tags, list)`. This aligns presets with them. The same scalar-`tags` crash reached the four display sites in `presets/_commands.py`, so those now gate on `isinstance(tags, list)`, matching `integrations/_query_commands.py`. Note `PresetManifest.tags` returns `self.data.get("tags", [])` and manifest validation does not enforce list-ness, so a local `preset.yaml` with `tags: 5` validates successfully and then crashed `preset info` — hence the guard on the local-manifest branch too. While here, `preset search` printed tags unescaped, so a tag containing `[bold]` was silently swallowed as a Rich style tag; it now routes through `_escape_markup` like the `preset list` line directly above it. Regression tests in `TestPresetTagsNonString` drive the full CLI path via CliRunner. All five fail before the fix, each with the exact exception it targets. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Assisted-by: Claude Opus 5 (1M context) <noreply@anthropic.com> * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * chore: regenerate security audit requirements (annotated-doc 0.0.5) The Security Audit workflow's "Check committed audit requirements are current" step regenerates requirements with `uv pip compile --upgrade`, which now resolves annotated-doc==0.0.5. Re-sync the committed snapshot so the check passes. No pyproject dependency changes; upgrade drift only. Assisted-by: GitHub Copilot (model: Claude Opus 4.8, autonomous) Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: b3d0333b-4a36-4f69-9273-3ac0c3f46481 --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> Co-authored-by: Manfred Riem <15701806+mnriem@users.noreply.github.com> Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: b3d0333b-4a36-4f69-9273-3ac0c3f46481
This commit is contained in:
@@ -4513,23 +4513,34 @@ class PresetCatalog:
|
||||
results = []
|
||||
|
||||
for pack_id, pack_data in packs.items():
|
||||
if author and pack_data.get("author", "").lower() != author.lower():
|
||||
continue
|
||||
if author:
|
||||
author_val = pack_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 [
|
||||
str(t).lower() for t in pack_data.get("tags", [])
|
||||
]:
|
||||
continue
|
||||
if tag:
|
||||
raw_tags = pack_data.get("tags", [])
|
||||
tags_list = raw_tags if isinstance(raw_tags, list) else []
|
||||
if tag.lower() not in [
|
||||
str(t).lower() for t in tags_list
|
||||
]:
|
||||
continue
|
||||
|
||||
if query:
|
||||
query_lower = query.lower()
|
||||
raw_tags = pack_data.get("tags", [])
|
||||
tags_list = raw_tags if isinstance(raw_tags, list) else []
|
||||
name_val = pack_data.get("name", "")
|
||||
desc_val = pack_data.get("description", "")
|
||||
searchable_text = " ".join(
|
||||
[
|
||||
pack_data.get("name", ""),
|
||||
pack_data.get("description", ""),
|
||||
str(name_val) if name_val is not None else "",
|
||||
str(desc_val) if desc_val is not None else "",
|
||||
pack_id,
|
||||
]
|
||||
+ [str(t) for t in pack_data.get("tags", [])]
|
||||
+ [str(t) for t in tags_list]
|
||||
).lower()
|
||||
|
||||
if query_lower not in searchable_text:
|
||||
|
||||
@@ -61,8 +61,9 @@ def preset_list():
|
||||
pri = pack.get('priority', 10)
|
||||
console.print(f" [bold]{pack['name']}[/bold] ({pack['id']}) v{pack['version']} — {status} — priority {pri}")
|
||||
console.print(f" {pack['description']}")
|
||||
if pack.get("tags"):
|
||||
tags_str = _escape_markup(", ".join(str(t) for t in pack["tags"]))
|
||||
tags = pack.get("tags", [])
|
||||
if isinstance(tags, list) and tags:
|
||||
tags_str = _escape_markup(", ".join(str(t) for t in tags))
|
||||
console.print(f" [dim]Tags: {tags_str}[/dim]")
|
||||
console.print(f" [dim]Templates: {pack['template_count']}[/dim]")
|
||||
console.print()
|
||||
@@ -293,8 +294,9 @@ def preset_search(
|
||||
for pack in results:
|
||||
console.print(f" [bold]{pack.get('name', pack['id'])}[/bold] ({pack['id']}) v{pack.get('version', '?')}")
|
||||
console.print(f" {pack.get('description', '')}")
|
||||
if pack.get("tags"):
|
||||
tags_str = ", ".join(str(t) for t in pack["tags"])
|
||||
tags = pack.get("tags", [])
|
||||
if isinstance(tags, list) and tags:
|
||||
tags_str = _escape_markup(", ".join(str(t) for t in tags))
|
||||
console.print(f" [dim]Tags: {tags_str}[/dim]")
|
||||
console.print()
|
||||
|
||||
@@ -384,8 +386,9 @@ def preset_info(
|
||||
console.print(f" Description: {local_pack.description}")
|
||||
if local_pack.author:
|
||||
console.print(f" Author: {local_pack.author}")
|
||||
if local_pack.tags:
|
||||
console.print(f" Tags: {', '.join(str(t) for t in local_pack.tags)}")
|
||||
local_tags = local_pack.tags
|
||||
if isinstance(local_tags, list) and local_tags:
|
||||
console.print(f" Tags: {', '.join(str(t) for t in local_tags)}")
|
||||
console.print(f" Templates: {len(local_pack.templates)}")
|
||||
for tmpl in local_pack.templates:
|
||||
console.print(f" - {tmpl['name']} ({tmpl['type']}): {tmpl.get('description', '')}")
|
||||
@@ -420,8 +423,9 @@ def preset_info(
|
||||
console.print(f" Description: {pack_info.get('description', '')}")
|
||||
if pack_info.get("author"):
|
||||
console.print(f" Author: {pack_info['author']}")
|
||||
if pack_info.get("tags"):
|
||||
console.print(f" Tags: {', '.join(str(t) for t in pack_info['tags'])}")
|
||||
catalog_tags = pack_info.get("tags", [])
|
||||
if isinstance(catalog_tags, list) and catalog_tags:
|
||||
console.print(f" Tags: {', '.join(str(t) for t in catalog_tags)}")
|
||||
if pack_info.get("repository"):
|
||||
console.print(f" Repository: {pack_info['repository']}")
|
||||
if pack_info.get("license"):
|
||||
|
||||
Reference in New Issue
Block a user