From b274023c094a90387f0a261e0b87254a56863bd1 Mon Sep 17 00:00:00 2001 From: John Morrissey <544926+tachyon-beep@users.noreply.github.com> Date: Thu, 18 Jun 2026 01:24:41 +1000 Subject: [PATCH 1/2] fix(doctor): resolve route checks by matching, not flat app.routes scan MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `filigree doctor`'s dashboard route-registration checks ("Scan results routes", "Entity association routes") reported registered routes as missing on any install that resolved FastAPI >= 0.137, failing doctor (exit 1) even though the routes are served correctly at runtime. FastAPI 0.137 made `include_router` lazy: child routes are mounted behind a `_IncludedRouter` wrapper and compose their `/api` (and `/weft`) prefix only at match time, so the doctor's flat `app.routes` path scan saw the wrapper's empty path and concluded the routes were missing. CI never caught it because `uv.lock` pinned FastAPI 0.136.3 while field installs resolved 0.137.x. `_route_supports` now resolves routes via Starlette matching (`route.matches(scope)`) instead of path-string scanning — version- agnostic, method-aware, prefix-composing. The lock moves forward to FastAPI 0.137.1 / Starlette 1.3.1 so CI exercises the version field installs actually receive (no upper cap added). The lock bump's newer TestClient shifted GC timing and unmasked two pre-existing test-hygiene leaks: tests that opened a DB via `_attempt_startup` (process-lifetime on `mcp_server.db`, closed at shutdown in production) but abandoned the handle without closing it, so the connection was finalized mid-session and tripped the suite's `error::ResourceWarning` filter. Closed both, matching the close-after- startup pattern the other `_attempt_startup` tests already use. Bumps to 3.0.1 so fixed installs are distinguishable from the 3.0.0 builds carrying the false-positive. Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 16 ++++++++ pyproject.toml | 2 +- src/filigree/install_support/doctor.py | 52 +++++++++++++++++++------- tests/cli/test_mcp_status_command.py | 8 ++++ tests/test_doctor.py | 40 ++++++++++++++++++++ tests/test_schema_mismatch.py | 20 +++++++--- uv.lock | 14 +++---- 7 files changed, 125 insertions(+), 27 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3aeefe6c..e33f4609 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,22 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [3.0.1] - 2026-06-18 + +### Fixed + +- **`filigree doctor` false-positive on FastAPI ≥ 0.137** — the dashboard + route-registration checks ("Scan results routes", "Entity association routes") + reported registered routes as *missing* on any install that resolved + FastAPI 0.137 or newer, failing `doctor` (exit 1) even though the routes are + served correctly at runtime. FastAPI 0.137 made `include_router` lazy: child + routes are mounted behind a `_IncludedRouter` wrapper and only compose their + `/api` (and `/weft`) prefix at match time, so the doctor's flat `app.routes` + path scan could no longer see them. The check now resolves routes by Starlette + *matching* instead of path-string scanning, which is correct across FastAPI + versions. The dependency lock now resolves FastAPI 0.137.1 so CI exercises the + version field installs actually receive. + ## [3.0.0] - 2026-06-17 3.0.0 is a **major release** — the SemVer-major boundary that lands the diff --git a/pyproject.toml b/pyproject.toml index 31b61d01..4fa0eaa5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "filigree" -version = "3.0.0" +version = "3.0.1" description = "Agent-native issue tracker with convention-based project discovery" requires-python = ">=3.11" license = "MIT" diff --git a/src/filigree/install_support/doctor.py b/src/filigree/install_support/doctor.py index 9401cd41..31b00de0 100644 --- a/src/filigree/install_support/doctor.py +++ b/src/filigree/install_support/doctor.py @@ -396,8 +396,41 @@ def _doctor_bundled_scanner_checks(filigree_dir: Path) -> list[CheckResult]: ] -def _route_supports(route_table: dict[str, set[str]], path: str, method: str) -> bool: - return method.upper() in route_table.get(path, set()) +def _route_supports(app: Any, path: str, method: str) -> bool: + """Return ``True`` iff *app* fully serves ``(path, method)``. + + Uses Starlette route *matching* rather than a flat ``app.routes`` path scan. + FastAPI >=0.137 mounts ``include_router`` results behind a lazy + ``fastapi.routing._IncludedRouter`` whose child routes keep their unprefixed + paths and only compose the ``/api`` (and ``/weft``) prefix at match time. A + flat scan of ``app.routes`` therefore sees the wrapper's empty path and + reports every included route as missing — a false-positive that fails + ``filigree doctor`` on any install resolving the newer FastAPI, even though + the routes are served correctly at runtime. Matching composes the prefixes + and enforces the HTTP method natively, so it is correct across FastAPI + versions without inspecting version-specific internals. + """ + from starlette.routing import Match + + # Substitute path-template params ({issue_id}) with a concrete segment so + # the matcher resolves an actual scope. + concrete_path = re.sub(r"\{[^/}]+\}", "_", path) + scope = { + "type": "http", + "method": method.upper(), + "path": concrete_path, + "headers": [], + "query_string": b"", + } + for route in getattr(app, "routes", []): + try: + match, _ = route.matches(scope) + except Exception: + logger.debug("route.matches failed for %r", getattr(route, "path", route), exc_info=True) + continue + if match == Match.FULL: + return True + return False def _doctor_dashboard_contract_checks(project_root: Path | None = None) -> list[CheckResult]: @@ -412,15 +445,6 @@ def _doctor_dashboard_contract_checks(project_root: Path | None = None) -> list[ from filigree.dashboard import FEDERATION_TOKEN_ENV_VARS, create_app app = create_app(server_mode=False) - route_table: dict[str, set[str]] = {} - for route in getattr(app, "routes", []): - path = getattr(route, "path", "") - if not isinstance(path, str) or not path: - continue - methods = getattr(route, "methods", None) - if methods is None: - continue - route_table.setdefault(path, set()).update(str(method).upper() for method in methods) except Exception as exc: message = f"Could not inspect dashboard route table: {exc}" return [ @@ -432,7 +456,7 @@ def _doctor_dashboard_contract_checks(project_root: Path | None = None) -> list[ results: list[CheckResult] = [] - if _route_supports(route_table, "/api/health", "GET"): + if _route_supports(app, "/api/health", "GET"): results.append(CheckResult("API routes", True, "GET /api/health registered")) else: results.append( @@ -449,7 +473,7 @@ def _doctor_dashboard_contract_checks(project_root: Path | None = None) -> list[ ("/api/scan-results", "POST"), ("/api/files/_schema", "GET"), ) - missing_scanner = [f"{method} {path}" for path, method in scanner_routes if not _route_supports(route_table, path, method)] + missing_scanner = [f"{method} {path}" for path, method in scanner_routes if not _route_supports(app, path, method)] if missing_scanner: results.append( CheckResult( @@ -468,7 +492,7 @@ def _doctor_dashboard_contract_checks(project_root: Path | None = None) -> list[ ("/api/issue/{issue_id}/entity-associations", "DELETE"), ("/api/entity-associations", "GET"), ) - missing_entity = [f"{method} {path}" for path, method in entity_routes if not _route_supports(route_table, path, method)] + missing_entity = [f"{method} {path}" for path, method in entity_routes if not _route_supports(app, path, method)] if missing_entity: results.append( CheckResult( diff --git a/tests/cli/test_mcp_status_command.py b/tests/cli/test_mcp_status_command.py index bd11b3c5..d324f315 100644 --- a/tests/cli/test_mcp_status_command.py +++ b/tests/cli/test_mcp_status_command.py @@ -51,6 +51,14 @@ def _restore_mcp_globals() -> Generator[None, None, None]: try: yield finally: + # The command opens a DB onto ``mcp_server.db`` (process-lifetime in + # production; closed when the CLI process exits). Under in-process + # CliRunner the handle would be dropped unclosed when we restore the + # globals below, leaking the connection — finalized mid-session it trips + # the suite's ``error::ResourceWarning`` filter. Close it first. + opened = getattr(mcp_server, "db", None) + if opened is not None and opened is not saved["db"]: + opened.close() for name, value in saved.items(): setattr(mcp_server, name, value) diff --git a/tests/test_doctor.py b/tests/test_doctor.py index e70a43b1..e3080c42 100644 --- a/tests/test_doctor.py +++ b/tests/test_doctor.py @@ -38,6 +38,7 @@ _find_all_filigree_binaries, _is_absolute_command_path, _is_venv_binary, + _route_supports, _unresolved_env_refs, doctor_check_id, run_doctor, @@ -1069,6 +1070,45 @@ def test_failed_results_have_fix_hint(self, tmp_path: Path) -> None: assert r.fix_hint.strip(), f"Failed check '{r.name}' has no fix_hint" +class TestRouteSupports: + """Route detection must see ``include_router``-mounted routes regardless of + FastAPI version. FastAPI >=0.137 wraps included routers in a lazy + ``_IncludedRouter`` whose child paths are stored unprefixed and composed + into the full ``/api/...`` path only at match time, so a flat ``app.routes`` + path scan reports every included route as missing — a false-positive doctor + failure on field installs that resolve the newer FastAPI. ``_route_supports`` + must match through that wrapper and enforce the HTTP method.""" + + def test_detects_included_router_routes_across_fastapi_versions(self) -> None: + from filigree.dashboard import create_app + + app = create_app(server_mode=False) + + # ``include_router``-mounted routes — present at runtime on every + # install, but invisible to a flat path scan under FastAPI >=0.137. + assert _route_supports(app, "/api/entity-associations", "GET") is True + assert _route_supports(app, "/api/scan-results", "POST") is True + assert _route_supports(app, "/api/weft/scan-results", "POST") is True + assert _route_supports(app, "/api/files/_schema", "GET") is True + assert _route_supports(app, "/api/issue/{issue_id}/entity-associations", "DELETE") is True + # Directly-registered route (not via include_router) still works. + assert _route_supports(app, "/api/health", "GET") is True + + def test_enforces_http_method(self) -> None: + from filigree.dashboard import create_app + + app = create_app(server_mode=False) + # ``/api/entity-associations`` is GET-only; a different verb on the same + # path must not register as supported. + assert _route_supports(app, "/api/entity-associations", "POST") is False + + def test_absent_route_stays_absent(self) -> None: + from filigree.dashboard import create_app + + app = create_app(server_mode=False) + assert _route_supports(app, "/api/this-route-does-not-exist", "GET") is False + + class TestDoctorSharedContractChecks: def test_run_doctor_emits_real_route_and_auth_checks(self, tmp_path: Path) -> None: project = _make_project(tmp_path) diff --git a/tests/test_schema_mismatch.py b/tests/test_schema_mismatch.py index 4005a48f..48012316 100644 --- a/tests/test_schema_mismatch.py +++ b/tests/test_schema_mismatch.py @@ -258,13 +258,23 @@ def test_mcp_server_log_startup_status_silent_on_clean_open( monkeypatch.setattr(mcp_mod, "_schema_mismatch", None) mcp_mod._attempt_startup(filigree_dir) - assert mcp_mod._schema_mismatch is None + try: + assert mcp_mod._schema_mismatch is None - logger = _logging.getLogger("filigree.mcp_server.test_clean") - with caplog.at_level(_logging.WARNING, logger=logger.name): - mcp_mod._log_startup_status(logger) + logger = _logging.getLogger("filigree.mcp_server.test_clean") + with caplog.at_level(_logging.WARNING, logger=logger.name): + mcp_mod._log_startup_status(logger) - assert not [r for r in caplog.records if r.message == "mcp_server_degraded"] + assert not [r for r in caplog.records if r.message == "mcp_server_degraded"] + finally: + # Clean open: _attempt_startup stores the opened DB on the module-level + # ``mcp_server.db`` (process-lifetime in production, closed at _run + # shutdown). A direct caller must close it — otherwise the connection is + # finalized mid-session during a later test's GC and trips the suite's + # ``error::ResourceWarning`` filter. monkeypatch only rebinds the global, + # dropping the handle without closing it. + if mcp_mod.db is not None: + mcp_mod.db.close() def test_dashboard_server_mode_returns_409_for_v_plus_one_project( diff --git a/uv.lock b/uv.lock index 1dc4994e..ea743d12 100644 --- a/uv.lock +++ b/uv.lock @@ -325,7 +325,7 @@ wheels = [ [[package]] name = "fastapi" -version = "0.136.3" +version = "0.137.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "annotated-doc" }, @@ -334,9 +334,9 @@ dependencies = [ { name = "typing-extensions" }, { name = "typing-inspection" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/81/2d/ff8d91d7b564d464629a0fd50a4489c97fcb836ac230bf3a7269232a9b1f/fastapi-0.136.3.tar.gz", hash = "sha256:e487fae93ad408e6f47641ee4dfe389864fd7bec92e547ea8498fc13f43e83ab", size = 396410, upload-time = "2026-05-23T18:53:15.192Z" } +sdist = { url = "https://files.pythonhosted.org/packages/d5/b1/e5b92c59d2c37817e77c1a8c2fc1f79cdcc04c68253e5406b43e3204cba7/fastapi-0.137.1.tar.gz", hash = "sha256:822360704230d9533d8d9475399613525968aa2f0b5bd2a3ccc9f18c88fd541c", size = 408293, upload-time = "2026-06-15T11:28:20.79Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e0/82/45359b62a067409bd929ae8a56b8ed13e5a8c8a61194b3c236920999ab83/fastapi-0.136.3-py3-none-any.whl", hash = "sha256:3d2a69bdf04b7e9f3afa292c3bc7a98816bbfafa10bc9b45f3f3700d2f761620", size = 117481, upload-time = "2026-05-23T18:53:16.924Z" }, + { url = "https://files.pythonhosted.org/packages/da/35/380b9a5922f4340e51c309cde09e5bd32e62f02302971bee30dc15aa0624/fastapi-0.137.1-py3-none-any.whl", hash = "sha256:64f6983c59e45c4b9fdc44e57cb8035c2451ee91ea8e8ec042aca37de7cf6b69", size = 121877, upload-time = "2026-06-15T11:28:19.523Z" }, ] [[package]] @@ -350,7 +350,7 @@ wheels = [ [[package]] name = "filigree" -version = "3.0.0" +version = "3.0.1" source = { editable = "." } dependencies = [ { name = "click" }, @@ -1159,15 +1159,15 @@ wheels = [ [[package]] name = "starlette" -version = "1.0.1" +version = "1.3.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, { name = "typing-extensions", marker = "python_full_version < '3.13'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/08/a3/84e821cc54b4ab50ae6dbc6ac3800a651b65ec35f045cc73785380654057/starlette-1.0.1.tar.gz", hash = "sha256:512399c5f1de7fac99c88572212ded9ddeddef2fb32afa82d724000e88b38f4f", size = 2659596, upload-time = "2026-05-21T21:58:58.433Z" } +sdist = { url = "https://files.pythonhosted.org/packages/eb/e3/7c1dc7381d9f8ab7d854328ebfa884e62cb3f3d8549ddfd37c7814f42afa/starlette-1.3.1.tar.gz", hash = "sha256:05d0213193f2fbaae60e2ecb593b4add4262ad4e46536b54abe36f11a71724e0", size = 2703240, upload-time = "2026-06-12T09:23:11.602Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ec/e1/b2df4bc09a1e51ff664c1e17018a4274b42e5e9352e4a478ea540512dc88/starlette-1.0.1-py3-none-any.whl", hash = "sha256:7c0e69b2ee1c848bd54669d908500117a3ee13de603a21427e5c6fc1adf98dcd", size = 72802, upload-time = "2026-05-21T21:58:56.551Z" }, + { url = "https://files.pythonhosted.org/packages/ec/bb/2799cc2ede3ed41131f8975621e7213dfc7ef4acbbaadfa440f32500c370/starlette-1.3.1-py3-none-any.whl", hash = "sha256:c7372aae11c3c3f26a42df7bd626cec2f47d03483d261d369516a615a53714c6", size = 73632, upload-time = "2026-06-12T09:23:10.017Z" }, ] [[package]] From 1cbaadfab41c36a5954826ab3a961137ff98dfb7 Mon Sep 17 00:00:00 2001 From: John Morrissey <544926+tachyon-beep@users.noreply.github.com> Date: Thu, 18 Jun 2026 06:38:31 +1000 Subject: [PATCH 2/2] docs(changelog): add 3.0.1 compare-link reference Button up the [3.0.1] entry with its compare link, matching the reference-link convention used for every prior release. Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index e33f4609..da931213 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4233,6 +4233,7 @@ identified through systematic static analysis and verified against HEAD. - Issue validation against workflow templates (`validate`) - PEP 561 `py.typed` marker for downstream type checking +[3.0.1]: https://github.com/foundryside-dev/filigree/compare/v3.0.0...v3.0.1 [3.0.0]: https://github.com/foundryside-dev/filigree/compare/v2.3.0...v3.0.0 [2.3.0]: https://github.com/foundryside-dev/filigree/compare/v2.2.0...v2.3.0 [2.2.0]: https://github.com/foundryside-dev/filigree/compare/v2.1.1...v2.2.0