diff --git a/misterdev/core/economics/free_models.py b/misterdev/core/economics/free_models.py index 1f51a9c..0214d45 100644 --- a/misterdev/core/economics/free_models.py +++ b/misterdev/core/economics/free_models.py @@ -21,6 +21,11 @@ _MODELS_URL = "https://openrouter.ai/api/v1/models" _DAY_SECONDS = 24 * 3600 +# Bound the body materialized from the (semi-trusted) OpenRouter endpoint. Real +# model lists are well under this; the cap only rejects a compromised/misbehaving +# endpoint slow-dripping an oversized body. Callers already treat a fetch that +# raises as a transient failure and fall back to the cached list. +_MAX_RESPONSE_BYTES = 16 * 1024 * 1024 # Per-process cache of fetched list endpoints, keyed by URL. Free-model # harvesting, the model catalog, and each failover client's catalog all hit the @@ -41,7 +46,10 @@ def _http_fetch(url: str = _MODELS_URL) -> list: req = urllib.request.Request(url, headers={"User-Agent": "misterdev"}) with urllib.request.urlopen(req, timeout=10) as resp: - payload = json.loads(resp.read().decode("utf-8")) + raw = resp.read(_MAX_RESPONSE_BYTES + 1) + if len(raw) > _MAX_RESPONSE_BYTES: + raise ValueError(f"model-list response exceeded {_MAX_RESPONSE_BYTES} bytes") + payload = json.loads(raw.decode("utf-8")) data = payload.get("data", []) if isinstance(payload, dict) else [] _FETCH_CACHE[url] = data return data diff --git a/misterdev/core/economics/llm_cache.py b/misterdev/core/economics/llm_cache.py index 89bf34a..b23440d 100644 --- a/misterdev/core/economics/llm_cache.py +++ b/misterdev/core/economics/llm_cache.py @@ -33,6 +33,11 @@ class LLMCache: def __init__(self, dir_path: Path, max_entries: int = DEFAULT_MAX_ENTRIES): self.dir = Path(dir_path) self.max_entries = max_entries + # Running count of stored entries, seeded by one real scan on the first + # new put and re-synced whenever an eviction scan runs. Lets a put below + # the cap skip the O(entries) scandir+stat that would otherwise run on + # every write (O(entries^2) filesystem work across a build). + self._count_estimate: Optional[int] = None @staticmethod def _key(system_prompt: str, prompt: str) -> str: @@ -71,21 +76,41 @@ def put( key = self._key(system_prompt, prompt) path = self._path(key) + is_new = not path.exists() atomic_write_json( path, {"output": output, "model": model, "created_at": timestamp, "key": key}, indent=2, ) - self._evict_if_needed() + if is_new: + self._note_new_entry() - def _evict_if_needed(self) -> None: - """Drop the oldest entries (by mtime) once the cap is exceeded. + def _note_new_entry(self) -> None: + """Account for a newly-stored entry, scanning only when eviction is due. - Best-effort: any filesystem error degrades to leaving the cache as-is - rather than raising into the build, like the rest of this module. + The full scandir+stat sweep runs only when the running count first needs + seeding or crosses the cap — not on every put — so a long build's writes + cost O(entries) filesystem work total rather than O(entries^2). """ if self.max_entries <= 0: return + if self._count_estimate is None: + self._count_estimate = self._evict_if_needed() + return + self._count_estimate += 1 + if self._count_estimate > self.max_entries: + self._count_estimate = self._evict_if_needed() + + def _evict_if_needed(self) -> int: + """Drop the oldest entries (by mtime) once the cap is exceeded. + + Returns the entry count after any eviction, so the caller can re-sync its + running estimate. Best-effort: any filesystem error degrades to leaving + the cache as-is rather than raising into the build, like the rest of this + module. + """ + if self.max_entries <= 0: + return 0 try: entries = [ (e.stat().st_mtime, e.path) @@ -93,13 +118,16 @@ def _evict_if_needed(self) -> None: if e.name.endswith(".json") and e.is_file() ] except OSError: - return + return 0 excess = len(entries) - self.max_entries if excess <= 0: - return + return len(entries) entries.sort(key=lambda t: t[0]) + removed = 0 for _mtime, victim in entries[:excess]: try: os.remove(victim) + removed += 1 except OSError: continue + return len(entries) - removed diff --git a/misterdev/core/evolution/partial_credit.py b/misterdev/core/evolution/partial_credit.py index 472995b..9157e3d 100644 --- a/misterdev/core/evolution/partial_credit.py +++ b/misterdev/core/evolution/partial_credit.py @@ -20,6 +20,7 @@ regressions==0 + resolved_rate (see fitness.py). Pure and deterministic. """ +import math from dataclasses import dataclass from typing import Iterable, Optional, Sequence, Tuple @@ -99,8 +100,8 @@ def variance(self) -> float: """Sample variance of the per-task credits (population form).""" if self.total < 1: return 0.0 - m = self.mean_credit - return sum((c - m) ** 2 for c in self.credits) / self.total + m = math.fsum(self.credits) / self.total + return math.fsum((c - m) ** 2 for c in self.credits) / self.total @property def stderr(self) -> float: diff --git a/misterdev/core/execution/parallel.py b/misterdev/core/execution/parallel.py index 7a9f6e2..0c5332e 100644 --- a/misterdev/core/execution/parallel.py +++ b/misterdev/core/execution/parallel.py @@ -429,7 +429,9 @@ def run_one(item): # to delete a branch still checked out in a worktree, which otherwise # leaks even merged branches. The task's commits live on the branch # ref, so the merge below still sees them after the dir is gone. - git.worktree_remove(project, str(wt_path)) + ok, msg = git.worktree_remove(project, str(wt_path)) + if not ok: + logger.warning(f"Failed to remove worktree {wt_path}: {msg}") merged = False if ( result is not None diff --git a/misterdev/core/integration/mcp_gather.py b/misterdev/core/integration/mcp_gather.py index 99874fd..777fc6c 100644 --- a/misterdev/core/integration/mcp_gather.py +++ b/misterdev/core/integration/mcp_gather.py @@ -275,7 +275,10 @@ def _emit(kind: str, **details: Any) -> None: gathered.append(f"### {server}.{tool} -> (no result / error)") continue tag = _UNVETTED_TAG if server in provisioned else "" - gathered.append(f"### {server}.{tool}{tag}\n{result}") + gathered.append( + f"### {server}.{tool}{tag}\n" + f'\n{result}\n' + ) if not gathered: return "" diff --git a/misterdev/core/integration/mcp_registry.py b/misterdev/core/integration/mcp_registry.py index 2dbdcb9..8f088a2 100644 --- a/misterdev/core/integration/mcp_registry.py +++ b/misterdev/core/integration/mcp_registry.py @@ -60,6 +60,13 @@ "https://raw.githubusercontent.com/Himanshu507/cgcone/main/public/registry.json" ) _DEFAULT_TIMEOUT = 10.0 +# Bound the body materialized from a (semi-trusted) registry/GitHub/npm/PyPI +# endpoint. Real registry and model-list payloads are well under this; the cap +# only rejects a compromised/misbehaving endpoint slow-dripping an oversized body. +_MAX_RESPONSE_BYTES = 16 * 1024 * 1024 +# PEP 503 normalized distribution name; anything else (path separators, spaces) +# is not a real PyPI package and must not be interpolated raw into a request path. +_PYPI_NAME_RE = re.compile(r"^[A-Za-z0-9]([A-Za-z0-9._-]*[A-Za-z0-9])?$") _DEFAULT_MAX_SERVERS = 3 _DEFAULT_MIN_TRUST = 0.5 # Cap network scoring per build so a wide search cannot hammer the GitHub API @@ -172,7 +179,11 @@ def _work() -> Optional[Any]: with urllib.request.urlopen(req, timeout=timeout) as resp: # noqa: S310 if resp.status != 200: return None - return json.loads(resp.read().decode("utf-8")) + raw = resp.read(_MAX_RESPONSE_BYTES + 1) + if len(raw) > _MAX_RESPONSE_BYTES: + logger.debug(f"MCP registry GET oversized (> cap): {url}") + return None + return json.loads(raw.decode("utf-8")) except (urllib.error.URLError, ValueError, OSError) as e: logger.debug(f"MCP registry GET failed ({url}): {e}") return None @@ -205,6 +216,10 @@ def __init__(self, path: Path, ttl: float = _CACHE_TTL_SECONDS): with RegistryCache._locks_guard: self._lock = RegistryCache._locks.setdefault(key, threading.Lock()) self._data: Dict[str, Dict[str, Any]] = self._load() + try: + self._mtime: float = self.path.stat().st_mtime + except OSError: + self._mtime = 0.0 def _load(self) -> Dict[str, Dict[str, Any]]: try: @@ -224,13 +239,31 @@ def _flush(self) -> None: merged = {**self._load(), **self._data} self._data = merged tmp = self.path.with_suffix(f".{os.getpid()}.{id(self)}.tmp") - tmp.write_text(json.dumps(merged), encoding="utf-8") - os.replace(tmp, self.path) + try: + tmp.write_text(json.dumps(merged), encoding="utf-8") + os.replace(tmp, self.path) + try: + self._mtime = self.path.stat().st_mtime + except OSError: + pass + except Exception: + try: + tmp.unlink(missing_ok=True) + except OSError: + pass + raise except (OSError, ValueError) as e: logger.debug(f"MCP registry cache flush failed: {e}") def get(self, key: str) -> Optional[Any]: with self._lock: + try: + mtime = self.path.stat().st_mtime + except OSError: + mtime = 0.0 + if mtime != self._mtime: + self._data = self._load() + self._mtime = mtime entry = self._data.get(key) if not entry: return None @@ -681,8 +714,11 @@ def _resolve_version(runtime: str, identifier: str, timeout: float, cache) -> st quoted = urllib.parse.quote(identifier, safe="@") data = _http_get_json(f"https://registry.npmjs.org/{quoted}/latest", timeout) version = data.get("version", "") if isinstance(data, dict) else "" + elif not _PYPI_NAME_RE.match(identifier): + version = "" else: - data = _http_get_json(f"https://pypi.org/pypi/{identifier}/json", timeout) + quoted = urllib.parse.quote(identifier, safe="") + data = _http_get_json(f"https://pypi.org/pypi/{quoted}/json", timeout) version = ( (data.get("info") or {}).get("version", "") if isinstance(data, dict) diff --git a/misterdev/core/planning/decomposer.py b/misterdev/core/planning/decomposer.py index bf3186d..ffe94bc 100644 --- a/misterdev/core/planning/decomposer.py +++ b/misterdev/core/planning/decomposer.py @@ -4,7 +4,7 @@ Uses topological sort to determine execution order. """ -from collections import deque +import heapq from pathlib import Path from misterdev.core.models import Task @@ -494,7 +494,8 @@ def _add_implicit_dependencies(tasks: list[Task]) -> None: def topological_sort(tasks: list[Task]) -> list[Task]: """Sort tasks respecting dependencies. Falls back to category order on ties. - Returns tasks in execution order. Raises ValueError on cycles. + Returns tasks in execution order. On a dependency cycle it does not raise: + the cyclic remainder is appended in category order with a warning. """ task_map = {t.id: t for t in tasks} in_degree: dict[str, int] = {t.id: 0 for t in tasks} @@ -506,20 +507,28 @@ def topological_sort(tasks: list[Task]) -> list[Task]: in_degree[t.id] += 1 dependents[dep_id].append(t.id) - # Seed queue with zero-dependency tasks, sorted by category order - queue: deque[str] = deque() - ready = [tid for tid, deg in in_degree.items() if deg == 0] - ready.sort(key=lambda tid: CATEGORY_ORDER.get(task_map[tid].category, 99)) - queue.extend(ready) + def _rank(tid: str) -> int: + return CATEGORY_ORDER.get(task_map[tid].category, 99) + + # Min-heap keyed on (category_rank, insertion_index) so every ready task — + # the initial seed and tasks unblocked mid-traversal alike — is dequeued in + # category order, with declaration order as a stable tie-break. + counter = 0 + heap: list[tuple[int, int, str]] = [] + for tid, deg in in_degree.items(): + if deg == 0: + heapq.heappush(heap, (_rank(tid), counter, tid)) + counter += 1 result = [] - while queue: - tid = queue.popleft() + while heap: + _, _, tid = heapq.heappop(heap) result.append(task_map[tid]) for dep_tid in dependents[tid]: in_degree[dep_tid] -= 1 if in_degree[dep_tid] == 0: - queue.append(dep_tid) + heapq.heappush(heap, (_rank(dep_tid), counter, dep_tid)) + counter += 1 if len(result) != len(tasks): # Cycle detected; append remaining tasks anyway with a warning diff --git a/misterdev/core/planning/tasklist_parser.py b/misterdev/core/planning/tasklist_parser.py index d5a0796..cf86dad 100644 --- a/misterdev/core/planning/tasklist_parser.py +++ b/misterdev/core/planning/tasklist_parser.py @@ -205,9 +205,25 @@ def _extract_dependency_table(text: str) -> Dict[str, List[str]]: ), None, ) - task_col = next((i for i, h in enumerate(header) if "task" in h or "id" in h), 0) + task_col = next( + ( + i + for i, h in enumerate(header) + if any( + k in h for k in ("task", "id", "step", "feature", "item", "name", "#") + ) + ), + None, + ) if dep_col is None: return deps + if task_col is None or task_col == dep_col: + # No recognizable task header (or it collided with the dep column): + # take the first column that is not the dependency column rather than + # blindly keying on index 0 and manufacturing a self-edge. + task_col = next((i for i in range(len(header)) if i != dep_col), None) + if task_col is None: + return deps for row in rows[1:]: cells = [c.strip() for c in row.strip("|").split("|")] if len(cells) <= max(task_col, dep_col) or set(cells[task_col]) <= { diff --git a/misterdev/core/verification/gatekeeper/__init__.py b/misterdev/core/verification/gatekeeper/__init__.py index 7319ce3..9d6be4a 100644 --- a/misterdev/core/verification/gatekeeper/__init__.py +++ b/misterdev/core/verification/gatekeeper/__init__.py @@ -290,6 +290,12 @@ def run_gates( if mutation.evidence: health.test_output = mutation.evidence return False, issues, health + elif mutation.status == "skip" and (mutation.reason or "").startswith( + "error:" + ): + logger.warning( + f"G3.6: Mutation gate errored and was skipped: {mutation.reason}" + ) # G4: Type check (optional). Blocking like G1/G3: a configured # typecheck command that fails short-circuits the gate so broken types diff --git a/misterdev/core/verification/web_verify.py b/misterdev/core/verification/web_verify.py index 97ee818..fa9d110 100644 --- a/misterdev/core/verification/web_verify.py +++ b/misterdev/core/verification/web_verify.py @@ -213,7 +213,7 @@ def _drive_browser( deadline: float, ) -> WebResult: """Load ``url`` headless, run ``checks``, always capture a screenshot.""" - nav_timeout_ms = max(1, int((deadline - time.monotonic()) * 1000)) + nav_timeout_ms = max(1000, int((deadline - time.monotonic()) * 1000)) console_errors: List[str] = [] results: List[Tuple[str, str]] = [] failures: List[str] = [] diff --git a/misterdev/llm/client/providers.py b/misterdev/llm/client/providers.py index 93debb8..6185943 100644 --- a/misterdev/llm/client/providers.py +++ b/misterdev/llm/client/providers.py @@ -158,7 +158,7 @@ class OpenRouterLLMClient(BaseLLMClient): def __init__(self, config: dict): super().__init__(config) llm_config = config.get("llm", {}) - self.client, self.api_key = _openrouter_sdk(llm_config) + self.client, _ = _openrouter_sdk(llm_config) self.model = get_section_setting("llm", llm_config, "model") self.temperature = get_section_setting("llm", llm_config, "temperature") self.sampling = dict(get_section_setting("llm", llm_config, "sampling") or {}) diff --git a/misterdev/task_executors/markdown_plan_executor/execute_mixin.py b/misterdev/task_executors/markdown_plan_executor/execute_mixin.py index bcf0442..ec5b5df 100644 --- a/misterdev/task_executors/markdown_plan_executor/execute_mixin.py +++ b/misterdev/task_executors/markdown_plan_executor/execute_mixin.py @@ -861,7 +861,7 @@ def execute( locations = resolver.resolve_errors(output) attributed_error = resolver.format_for_llm(locations, output) classified = format_classified_error(output) - error_logs = self._build_error_context( + error_logs = "[BUILD] " + self._build_error_context( prior_errors, attempt, output, @@ -886,7 +886,7 @@ def execute( locations = resolver.resolve_errors(output) attributed_error = resolver.format_for_llm(locations, output) classified = format_classified_error(output) - error_logs = self._build_error_context( + error_logs = "[TYPECHECK] " + self._build_error_context( prior_errors, attempt, output, @@ -1016,7 +1016,7 @@ def execute( locations = resolver.resolve_errors(output) attributed_error = resolver.format_for_llm(locations, output) classified = format_classified_error(output) - error_logs = self._build_error_context( + error_logs = "[TEST] " + self._build_error_context( prior_errors, attempt, output, diff --git a/misterdev/task_executors/markdown_plan_executor/gates_mixin.py b/misterdev/task_executors/markdown_plan_executor/gates_mixin.py index 1295f7e..81e352e 100644 --- a/misterdev/task_executors/markdown_plan_executor/gates_mixin.py +++ b/misterdev/task_executors/markdown_plan_executor/gates_mixin.py @@ -112,10 +112,12 @@ def _apply_reflection( reflections.append(new_reflection) if not reflections: return error_logs + window = reflections[-3:] header = "### Reflection on why previous attempts failed\n" + "\n\n".join( - reflections + window ) - return f"{header}\n\n{error_logs}" + trimmed = error_logs[:20000] if len(error_logs) > 20000 else error_logs + return f"{header}\n\n{trimmed}" def _build_error_context( self, diff --git a/tests/test_decomposer.py b/tests/test_decomposer.py index 03836df..b8ee964 100644 --- a/tests/test_decomposer.py +++ b/tests/test_decomposer.py @@ -49,6 +49,20 @@ def test_topological_sort_cycle(): assert len(result) == 2 +def test_topological_sort_tiebreak_applies_mid_traversal(): + # A single root A unblocks both a `test` and a `feature` task in the same + # wave. Category order (feature=2 before test=4) must win regardless of the + # input order in which the dependents were declared. + tasks = [ + _task("A", category="infrastructure"), + _task("T-test", deps=["A"], category="test"), + _task("T-feat", deps=["A"], category="feature"), + ] + result = topological_sort(tasks) + order = [t.id for t in result] + assert order.index("T-feat") < order.index("T-test"), order + + def test_add_implicit_dependencies(): tasks = [ _task("T-1", files_create=["src/new.py"]), @@ -150,10 +164,12 @@ def test_decompose_includes_file_map_and_grounding_rule(): class _Client: def generate_code(self, prompt, system=""): captured["prompt"] = prompt - return '[{"id": "T-001", "title": "x", "description": "x", '\ - '"acceptance_criteria": "x", "files_to_create": [], '\ - '"files_to_modify": ["lib/allowlist.js"], "context_files": [], '\ - '"dependencies": [], "complexity": "small", "category": "fix"}]' + return ( + '[{"id": "T-001", "title": "x", "description": "x", ' + '"acceptance_criteria": "x", "files_to_create": [], ' + '"files_to_modify": ["lib/allowlist.js"], "context_files": [], ' + '"dependencies": [], "complexity": "small", "category": "fix"}]' + ) assessment = ProjectAssessment( structure=ProjectStructure(project_type="web-app", languages=["javascript"]), @@ -163,7 +179,11 @@ def generate_code(self, prompt, system=""): ) file_map = "lib/allowlist.js\n function parseAllowlistCsv\n" tasks = decompose_spec( - "fix parseAllowlistCsv", assessment, BuildMode.DEBUG, _Client(), ".", + "fix parseAllowlistCsv", + assessment, + BuildMode.DEBUG, + _Client(), + ".", file_map=file_map, ) assert "lib/allowlist.js" in captured["prompt"] @@ -200,12 +220,17 @@ def generate_code(self, prompt, system=""): def test_targets_prompt_helper(): from misterdev.core.planning.decomposer import _targets_prompt + section, rule = _targets_prompt(None) assert section == "" and rule == "" section, rule = _targets_prompt( [ {"name": "core", "path": "emathy-core", "build_command": "cargo build"}, - {"name": "web", "path": "clients/web", "build_command": "npm run typecheck"}, + { + "name": "web", + "path": "clients/web", + "build_command": "npm run typecheck", + }, ] ) assert "emathy-core/" in section and "clients/web/" in section @@ -237,8 +262,14 @@ def generate_code(self, prompt, system=""): risk=RiskAssessment(level="low"), ) decompose_spec( - "x", assessment, BuildMode.SMART, _Client(), ".", - targets=[{"name": "web", "path": "clients/web", "build_command": "npm run typecheck"}], + "x", + assessment, + BuildMode.SMART, + _Client(), + ".", + targets=[ + {"name": "web", "path": "clients/web", "build_command": "npm run typecheck"} + ], ) assert "clients/web/" in captured["prompt"] assert "ONE target" in captured["prompt"] @@ -249,7 +280,9 @@ def test_cap_prunes_dangling_dependency_on_dropped_task(): from misterdev.core.planning.decomposer import _cap_tasks def _t(tid, deps=None): - return Task(id=tid, description="x", project_ref=".", dependencies=list(deps or [])) + return Task( + id=tid, description="x", project_ref=".", dependencies=list(deps or []) + ) # C is the 3rd task -> dropped at cap 2; A's dependency on it must be pruned. tasks = [_t("A", deps=["C"]), _t("B"), _t("C")] diff --git a/tests/test_free_models.py b/tests/test_free_models.py index bc91faa..e93562e 100644 --- a/tests/test_free_models.py +++ b/tests/test_free_models.py @@ -110,7 +110,7 @@ def __enter__(self): def __exit__(self, *a): return False - def read(self): + def read(self, n=-1): return b'{"data": [{"id": "m"}]}' def fake_urlopen(req, timeout=10): @@ -125,3 +125,30 @@ def fake_urlopen(req, timeout=10): assert len(calls) == 1 # second call served from the per-process memo finally: fm._FETCH_CACHE.clear() + + +def test_http_fetch_rejects_oversized_body(monkeypatch): + import misterdev.core.economics.free_models as fm + + fm._FETCH_CACHE.clear() + # Valid JSON but larger than the cap — only the size guard can reject it. + body = b'{"data":[' + b"0," * (fm._MAX_RESPONSE_BYTES // 2 + 1) + b"0]}" + assert len(body) > fm._MAX_RESPONSE_BYTES + + class FakeResp: + def __enter__(self): + return self + + def __exit__(self, *a): + return False + + def read(self, n=-1): + return body if n < 0 else body[:n] + + monkeypatch.setattr("urllib.request.urlopen", lambda req, timeout=10: FakeResp()) + try: + with pytest.raises(ValueError): + fm._http_fetch("https://example/big") + assert "https://example/big" not in fm._FETCH_CACHE + finally: + fm._FETCH_CACHE.clear() diff --git a/tests/test_llm_cache.py b/tests/test_llm_cache.py index 9e4921e..8f6dacc 100644 --- a/tests/test_llm_cache.py +++ b/tests/test_llm_cache.py @@ -96,3 +96,22 @@ def test_eviction_ignores_tmp_files(): stray.write_text("{}", encoding="utf-8") cache.put("sys", "p2", "o2") assert stray.exists() + + +def test_put_below_cap_does_not_rescan(monkeypatch): + import misterdev.core.economics.llm_cache as mod + + with tempfile.TemporaryDirectory() as d: + cache = LLMCache(Path(d) / "c", max_entries=1000) + cache.put("sys", "warm", "o") # first put seeds the count estimate + calls = [] + real = os.scandir + + def spy(path): + calls.append(path) + return real(path) + + monkeypatch.setattr(mod.os, "scandir", spy) + for i in range(5): + cache.put("sys", f"p{i}", f"o{i}") + assert calls == [] # no directory scan while far below the cap diff --git a/tests/test_mcp_registry.py b/tests/test_mcp_registry.py index e18a308..d3289c4 100644 --- a/tests/test_mcp_registry.py +++ b/tests/test_mcp_registry.py @@ -315,19 +315,32 @@ def _cgcone_entry( "stars": stars, "isArchived": archived, "lastCommit": last, - "installConfig": {"command": command, "args": args, "env": env or {}, "type": "npm"}, + "installConfig": { + "command": command, + "args": args, + "env": env or {}, + "type": "npm", + }, } def test_cgcone_config_admits_free_stdio_rejects_keyed_and_remote(): assert reg._cgcone_config(_cgcone_entry("x/a", "a"))["command"] == "npx" assert reg._cgcone_config(_cgcone_entry("x/a", "a", env={"API_KEY": ""})) is None - assert reg._cgcone_config(_cgcone_entry("x/a", "a", server_type="streamable-http")) is None - assert reg._cgcone_config({"name": "x", "serverType": "stdio", "installConfig": {}}) is None + assert ( + reg._cgcone_config(_cgcone_entry("x/a", "a", server_type="streamable-http")) + is None + ) + assert ( + reg._cgcone_config({"name": "x", "serverType": "stdio", "installConfig": {}}) + is None + ) def test_cgcone_signals_map_from_index(): - sig = reg._cgcone_signals(_cgcone_entry("x/a", "a", stars=3000, days=5, archived=True)) + sig = reg._cgcone_signals( + _cgcone_entry("x/a", "a", stars=3000, days=5, archived=True) + ) assert sig.github_stars == 3000 and sig.archived is True assert 0 <= sig.github_pushed_days <= 7 @@ -337,10 +350,10 @@ def test_discover_via_cgcone_admits_pins_and_ranks(monkeypatch): _cgcone_entry("io.x/fetcher", "server-fetch", stars=8000), _cgcone_entry("io.x/obscure", "obscure-pkg", stars=1, description="unrelated"), ] - monkeypatch.setattr(reg, "fetch_cgcone_registry", lambda cache=None, timeout=10.0: entries) monkeypatch.setattr( - reg, "_resolve_version", lambda rt, ident, t, c: "1.2.3" + reg, "fetch_cgcone_registry", lambda cache=None, timeout=10.0: entries ) + monkeypatch.setattr(reg, "_resolve_version", lambda rt, ident, t, c: "1.2.3") out = reg.discover_via_cgcone(["fetch web pages"], max_servers=2) assert out and out[0]["name"] == "io.x.fetcher" assert out[0]["args"][-1] == "server-fetch@1.2.3" # version-pinned @@ -349,21 +362,29 @@ def test_discover_via_cgcone_admits_pins_and_ranks(monkeypatch): def test_discover_via_cgcone_drops_bogus_unresolvable_spec(monkeypatch): entries = [_cgcone_entry("io.x/bogus", "n8n-monorepo", stars=9000)] - monkeypatch.setattr(reg, "fetch_cgcone_registry", lambda cache=None, timeout=10.0: entries) - monkeypatch.setattr(reg, "_resolve_version", lambda rt, ident, t, c: "") # doesn't resolve + monkeypatch.setattr( + reg, "fetch_cgcone_registry", lambda cache=None, timeout=10.0: entries + ) + monkeypatch.setattr( + reg, "_resolve_version", lambda rt, ident, t, c: "" + ) # doesn't resolve assert reg.discover_via_cgcone(["fetch web pages"], max_servers=2) == [] def test_discover_via_cgcone_skips_archived_by_score(monkeypatch): entries = [_cgcone_entry("io.x/dead", "dead-pkg", stars=9000, archived=True)] - monkeypatch.setattr(reg, "fetch_cgcone_registry", lambda cache=None, timeout=10.0: entries) + monkeypatch.setattr( + reg, "fetch_cgcone_registry", lambda cache=None, timeout=10.0: entries + ) monkeypatch.setattr(reg, "_resolve_version", lambda rt, ident, t, c: "1.0.0") assert reg.discover_via_cgcone(["fetch web pages"], max_servers=2) == [] def test_discover_via_cgcone_dedups_known_identity(monkeypatch): entries = [_cgcone_entry("io.x/fetcher", "server-fetch", stars=8000)] - monkeypatch.setattr(reg, "fetch_cgcone_registry", lambda cache=None, timeout=10.0: entries) + monkeypatch.setattr( + reg, "fetch_cgcone_registry", lambda cache=None, timeout=10.0: entries + ) monkeypatch.setattr(reg, "_resolve_version", lambda rt, ident, t, c: "1.2.3") known = {("stdio", "npx", "-y", "server-fetch@1.2.3")} assert reg.discover_via_cgcone(["fetch"], known_identities=known) == [] @@ -372,11 +393,13 @@ def test_discover_via_cgcone_dedups_known_identity(monkeypatch): def test_discover_servers_routes_source_cgcone(monkeypatch): called = {"cgcone": 0, "official": 0} monkeypatch.setattr( - reg, "discover_via_cgcone", + reg, + "discover_via_cgcone", lambda *a, **k: called.__setitem__("cgcone", called["cgcone"] + 1) or [], ) monkeypatch.setattr( - reg, "search_registry", + reg, + "search_registry", lambda *a, **k: called.__setitem__("official", called["official"] + 1) or [], ) discover_servers(["fetch"], source="cgcone") @@ -415,6 +438,66 @@ def test_absent_runtime_hint_uses_registry_runtime(): from misterdev.core.integration.mcp_registry import _to_config assert ( - _to_config({"name": "p"}, {"registryType": "pypi", "identifier": "p"})["command"] + _to_config({"name": "p"}, {"registryType": "pypi", "identifier": "p"})[ + "command" + ] == "uvx" ) + + +class _FakeResp: + status = 200 + + def __init__(self, body: bytes): + self._body = body + + def __enter__(self): + return self + + def __exit__(self, *a): + return False + + def read(self, n=-1): + return self._body if n < 0 else self._body[:n] + + +def test_http_get_json_rejects_oversized_body(monkeypatch): + import urllib.request + + # Valid JSON, but larger than the cap — only the size guard can reject it. + body = b"[" + b"0," * (reg._MAX_RESPONSE_BYTES // 2 + 1) + b"0]" + assert len(body) > reg._MAX_RESPONSE_BYTES + monkeypatch.setattr( + urllib.request, "urlopen", lambda req, timeout=None: _FakeResp(body) + ) + assert reg._http_get_json("https://example.test/x", 1.0) is None + + +def test_http_get_json_parses_normal_body(monkeypatch): + import urllib.request + + monkeypatch.setattr( + urllib.request, "urlopen", lambda req, timeout=None: _FakeResp(b'{"ok": true}') + ) + assert reg._http_get_json("https://example.test/x", 1.0) == {"ok": True} + + +def test_resolve_version_pypi_rejects_traversal_identifier(monkeypatch): + calls = [] + + def _fake(url, timeout, headers=None): + calls.append(url) + return {"info": {"version": "9.9.9"}} + + monkeypatch.setattr(reg, "_http_get_json", _fake) + assert reg._resolve_version("uvx", "foo/../../bar", 1.0, None) == "" + assert calls == [] # non-conforming identifier dropped before any request + + +def test_resolve_version_pypi_resolves_valid_name(monkeypatch): + def _fake(url, timeout, headers=None): + assert url == "https://pypi.org/pypi/mcp-server-foo/json" + return {"info": {"version": "1.2.3"}} + + monkeypatch.setattr(reg, "_http_get_json", _fake) + assert reg._resolve_version("uvx", "mcp-server-foo", 1.0, None) == "1.2.3" diff --git a/tests/test_tasklist_parser.py b/tests/test_tasklist_parser.py index a53e80e..734168e 100644 --- a/tests/test_tasklist_parser.py +++ b/tests/test_tasklist_parser.py @@ -4,6 +4,7 @@ from misterdev.core.planning.tasklist_parser import ( _clean_path, + _extract_dependency_table, detect_format, parse_task_list, ) @@ -216,6 +217,13 @@ def test_markdown_dependency_table_unlocks_graph(): assert core.dependencies == [] # independent -> parallelizable +def test_dependency_table_dep_column_first_no_self_edge(): + # Dependency column at index 0 with an unrecognized task header ('Step'). + # task_col must not blindly fall back to 0 (== dep_col) and key a self-edge. + md = "| Blocked By | Step |\n|-----|-----|\n| T001 | T002 |\n" + assert _extract_dependency_table(md) == {"T002": ["T001"]} + + # --- plain text ------------------------------------------------------------- def test_plain_text_lines(): txt = "Fix the parser bug\nAdd a regression test\nUpdate the changelog\n"