Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -4217,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
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
52 changes: 38 additions & 14 deletions src/filigree/install_support/doctor.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]:
Expand All @@ -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 [
Expand All @@ -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(
Expand All @@ -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(
Expand All @@ -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(
Expand Down
8 changes: 8 additions & 0 deletions tests/cli/test_mcp_status_command.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
40 changes: 40 additions & 0 deletions tests/test_doctor.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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)
Expand Down
20 changes: 15 additions & 5 deletions tests/test_schema_mismatch.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
14 changes: 7 additions & 7 deletions uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.