From edfb01c6a5d095130c72417dd03d4f28111ac9db Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Thu, 4 Jun 2026 16:40:03 +0000 Subject: [PATCH 001/135] =?UTF-8?q?=E2=9A=A1=20Bolt:=20remove=20redundant?= =?UTF-8?q?=20db=20query=20for=20open=20blockers=20count=20in=20batch=20fe?= =?UTF-8?q?tch?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: tachyon-beep <544926+tachyon-beep@users.noreply.github.com> --- .jules/bolt.md | 3 +++ src/filigree/db_issues.py | 15 +-------------- 2 files changed, 4 insertions(+), 14 deletions(-) create mode 100644 .jules/bolt.md diff --git a/.jules/bolt.md b/.jules/bolt.md new file mode 100644 index 00000000..01539024 --- /dev/null +++ b/.jules/bolt.md @@ -0,0 +1,3 @@ +## 2024-06-04 - Eliminate redundant batch issue queries +**Learning:** `_build_issues_batch` computed open_blockers counts doing a SQL query that identically mirrored the query used for `blocked_by_id`. The length of `blocked_by_id` arrays suffices to test if blockers equal 0. +**Action:** When constructing object graphs in batches using SQLite, avoid duplicate queries for "count > 0" and "get items"; the lengths of grouped result arrays can often be used directly to evaluate predicate lengths, eliminating N+1 and duplicate complex `GROUP BY`s entirely. diff --git a/src/filigree/db_issues.py b/src/filigree/db_issues.py index f59be287..78fe7f96 100644 --- a/src/filigree/db_issues.py +++ b/src/filigree/db_issues.py @@ -686,19 +686,6 @@ def _build_issues_batch(self, issue_ids: list[str]) -> list[Issue]: for r in self.conn.execute(f"SELECT id, parent_id FROM issues WHERE parent_id IN ({placeholders})", chunk).fetchall(): children_by_id[r["parent_id"]].append(r["id"]) - # 6. Batch compute open blocker counts — same blocker semantics as step 4. - open_blockers_by_id: dict[str, int] = dict.fromkeys(issue_ids, 0) - for chunk in self._chunk_issue_ids_for_sqlite(issue_ids, extra_params=len(blocker_done_params)): - placeholders = ",".join("?" * len(chunk)) - for r in self.conn.execute( - f"SELECT d.issue_id, COUNT(*) as cnt FROM dependencies d " - f"JOIN issues blocker ON d.depends_on_id = blocker.id " - f"WHERE d.issue_id IN ({placeholders}) AND NOT ({blocker_done_sql}) " - f"GROUP BY d.issue_id", - [*chunk, *blocker_done_params], - ).fetchall(): - open_blockers_by_id[r["issue_id"]] = r["cnt"] - # Build Issue objects preserving input order result: list[Issue] = [] for iid in issue_ids: @@ -732,7 +719,7 @@ def _build_issues_batch(self, issue_ids: list[str]) -> list[Issue]: # state name shared across types in different categories # is classified correctly. self._resolve_status_category(row["type"], row["status"]) == "open" - and open_blockers_by_id.get(iid, 0) == 0 + and len(blocked_by_id.get(iid, [])) == 0 and not (row["assignee"] or "") ), children=children_by_id.get(iid, []), From f2120050621b46ce6f06a6bda26cd976da134197 Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Thu, 4 Jun 2026 16:44:50 +0000 Subject: [PATCH 002/135] =?UTF-8?q?=F0=9F=8E=A8=20Palette:=20Add=20ARIA=20?= =?UTF-8?q?labels=20to=20icon-only=20buttons=20for=20accessibility?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add missing `aria-label`s to `×` icon-only `` + `` ); }) .join(""); diff --git a/src/filigree/static/js/views/files.js b/src/filigree/static/js/views/files.js index de6602eb..f29467b6 100644 --- a/src/filigree/static/js/views/files.js +++ b/src/filigree/static/js/views/files.js @@ -176,7 +176,7 @@ export async function loadFiles() { '' + escHtml(state.filesScanSource) + "" + - '' + + '' + "" : ""; @@ -346,7 +346,7 @@ function renderFileDetail(data) { // Header let html = '
' + - '' + + '' + `${escHtml(f.path)}` + "
"; diff --git a/src/filigree/static/js/views/graph.js b/src/filigree/static/js/views/graph.js index 557d5422..eefd09d0 100644 --- a/src/filigree/static/js/views/graph.js +++ b/src/filigree/static/js/views/graph.js @@ -4,7 +4,7 @@ import { fetchCriticalPath } from "../api.js"; import { CATEGORY_COLORS, state, THEME_COLORS } from "../state.js"; -import { resolveGraphScope, handleGhostClick } from "./graphSidebar.js"; +import { handleGhostClick, resolveGraphScope } from "./graphSidebar.js"; // --- Callbacks for functions not yet available at import time --- @@ -319,7 +319,7 @@ export function renderGraph() { const filteredIds = new Set(filteredNodes.map((n) => n.id)); const search = document.getElementById("filterSearch")?.value?.toLowerCase().trim() || ""; - let cyNodes = filteredNodes.map((n) => { + const cyNodes = filteredNodes.map((n) => { const title = n.title || n.id; const isGhost = ghostIds.has(n.id); const matchesSearch = @@ -340,7 +340,7 @@ export function renderGraph() { }; }); - let cyEdges = scopeEdges + const cyEdges = scopeEdges .filter((e) => filteredIds.has(e.source) && filteredIds.has(e.target)) .map((e) => ({ data: { @@ -407,8 +407,7 @@ export function renderGraph() { state.cy.destroy(); const canReusePositions = - cyNodes.length > 0 && - cyNodes.every((n) => Object.prototype.hasOwnProperty.call(previousPositions, n.data.id)); + cyNodes.length > 0 && cyNodes.every((n) => Object.hasOwn(previousPositions, n.data.id)); const graphMinZoom = computeGraphMinZoom(cyNodes.length); state.cy = cytoscape({ container, @@ -495,7 +494,7 @@ export function showHealthBreakdown() { panel.innerHTML = '
' + `System Health: ${b.score}/100` + - '
' + + '' + ["blocked", "freshness", "ready", "balance"] .map((k) => { const f = b[k]; diff --git a/src/filigree/static/js/views/health.js b/src/filigree/static/js/views/health.js index 533cd906..ab4275f2 100644 --- a/src/filigree/static/js/views/health.js +++ b/src/filigree/static/js/views/health.js @@ -2,7 +2,7 @@ // Code Health view — hotspots, severity donut, scan coverage, recent scans. // --------------------------------------------------------------------------- -import { fetchFiles, fetchFileStats, fetchHotspots, fetchScanRuns } from "../api.js"; +import { fetchFileStats, fetchFiles, fetchHotspots, fetchScanRuns } from "../api.js"; import { SEVERITY_COLORS, state } from "../state.js"; import { escHtml, escJsSingleAttr, relativeTime } from "../ui.js"; diff --git a/src/filigree/static/js/views/ready.js b/src/filigree/static/js/views/ready.js index 809ef119..cf3f12b3 100644 --- a/src/filigree/static/js/views/ready.js +++ b/src/filigree/static/js/views/ready.js @@ -3,8 +3,8 @@ // --------------------------------------------------------------------------- import { fetchReady } from "../api.js"; -import { escHtml, escJsSingleAttr } from "../ui.js"; import { callbacks } from "../router.js"; +import { escHtml, escJsSingleAttr } from "../ui.js"; const PRIORITY_LABELS = ["P0", "P1", "P2", "P3", "P4"]; const PRIORITY_COLORS = [ diff --git a/src/filigree/static/js/views/releases.js b/src/filigree/static/js/views/releases.js index 373bdc94..9fd033fb 100644 --- a/src/filigree/static/js/views/releases.js +++ b/src/filigree/static/js/views/releases.js @@ -7,12 +7,12 @@ import { escHtml, escJsSingleAttr } from "../ui.js"; // --- Module-level state --- -let expandedReleaseIds = new Set(); -let releaseTreeCache = new Map(); -let expandedNodeIds = new Set(); +const expandedReleaseIds = new Set(); +const releaseTreeCache = new Map(); +const expandedNodeIds = new Set(); let showReleased = false; -let loadingReleaseIds = new Set(); -let errorReleaseIds = new Set(); +const loadingReleaseIds = new Set(); +const errorReleaseIds = new Set(); let _pendingFocusTarget = null; export function scrollToReleaseCard(cardId) { diff --git a/src/filigree/static/js/views/workflow.js b/src/filigree/static/js/views/workflow.js index f6b75834..17a67f26 100644 --- a/src/filigree/static/js/views/workflow.js +++ b/src/filigree/static/js/views/workflow.js @@ -189,7 +189,7 @@ export function showWorkflowModal(type) { '' + "" + - '' + + '' + "" + '
' + ""; From 54cdd6596d69dcbb1b3f24999f036887c2c09849 Mon Sep 17 00:00:00 2001 From: John Morrissey <544926+tachyon-beep@users.noreply.github.com> Date: Fri, 5 Jun 2026 02:47:41 +1000 Subject: [PATCH 003/135] feat: align loom federation and doctor contracts --- README.md | 8 +- docs/README.md | 2 +- docs/SCHEMA_MIGRATIONS.md | 1 + docs/UPGRADING.md | 38 ++- docs/cli.md | 40 ++- docs/federation/contracts.md | 25 ++ docs/file-traceability.md | 17 +- docs/getting-started.md | 4 +- docs/mcp.md | 28 +- src/filigree/cli_commands/admin.py | 267 +++++++++--------- src/filigree/dashboard_routes/entities.py | 14 +- src/filigree/dashboard_routes/files.py | 211 +++++++++++++- src/filigree/data/instructions.md | 7 +- src/filigree/db_entity_associations.py | 118 +++++--- src/filigree/db_files.py | 43 +++ src/filigree/db_schema.py | 14 +- src/filigree/install_support/doctor.py | 225 ++++++++++++++- src/filigree/mcp_tools/entities.py | 42 ++- src/filigree/mcp_tools/files.py | 84 ++++++ src/filigree/mcp_tools/rename.py | 3 +- src/filigree/mcp_tools/tiers.py | 1 + src/filigree/migrations.py | 12 + src/filigree/scanner_scripts/scan_utils.py | 7 +- src/filigree/static/js/api.js | 11 + src/filigree/static/js/views/detail.js | 48 +++- src/filigree/types/inputs.py | 15 + tests/api/test_entity_associations.py | 36 +++ tests/api/test_files_api.py | 234 ++++++++++++++- tests/cli/test_admin_commands.py | 200 +++++++++++-- tests/cli/test_files_commands.py | 1 + tests/core/test_crud.py | 1 + tests/core/test_finding_triage.py | 32 +++ tests/core/test_schema.py | 31 +- .../fixtures/contracts/loom/scan-results.json | 51 ++++ tests/mcp/test_entity_associations.py | 18 ++ tests/mcp/test_finding_triage_tools.py | 22 ++ tests/mcp/test_tool_aliases.py | 4 +- tests/test_doctor.py | 20 +- tests/test_entity_associations_federation.py | 51 ++++ tests/util/test_module_split.py | 4 +- 40 files changed, 1721 insertions(+), 269 deletions(-) diff --git a/README.md b/README.md index 2869569b..df0e502c 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ Local-first issue tracker designed for AI coding agents — SQLite, MCP tools, n ## What Is Filigree? -Filigree is a lightweight, SQLite-backed issue tracker designed for AI coding agents (Claude Code, Codex, etc.) to use as first-class citizens. It exposes 114 MCP tools so agents interact natively, plus a full CLI for humans and background subagents. +Filigree is a lightweight, SQLite-backed issue tracker designed for AI coding agents (Claude Code, Codex, etc.) to use as first-class citizens. It exposes 115 MCP tools so agents interact natively, plus a full CLI for humans and background subagents. Traditional issue trackers are human-first — agents scrape CLI output or parse API responses. Filigree flips this: agents get a pre-computed `context.md` at session start, claim work with optimistic locking, and resume sessions via event streams without re-reading history. For Claude Code, `filigree install` wires up session hooks and a workflow skill pack so agents get project context automatically. @@ -19,7 +19,7 @@ Filigree is local-first. No cloud, no accounts. Each project gets a `.filigree/` ### Key Features -- **MCP server** with 114 tools — agents interact natively without parsing text +- **MCP server** with 115 tools — agents interact natively without parsing text - **Full CLI** with `--json` output for background subagents and `--actor` for audit trails - **Loom HTTP generation** — stable `/api/loom/*` contracts with classic compatibility for existing integrations - **Claude Code integration** — session hooks inject project snapshots at startup; bundled skill pack teaches agents workflow patterns @@ -47,7 +47,7 @@ flowchart TD Human["You + scripts"] Browser["Browser"] - MCP["MCP server
(114 namespaced tools, stdio)"] + MCP["MCP server
(115 namespaced tools, stdio)"] CLI["filigree CLI
(--json, --actor)"] Dash["Web dashboard
(localhost:8377)"] @@ -225,7 +225,7 @@ Nothing in Filigree is encrypted or secured beyond ordinary local filesystem pro |----------|-------------| | [Getting Started](docs/getting-started.md) | 5-minute tutorial: install, init, first issue | | [CLI Reference](docs/cli.md) | All CLI commands with full parameter docs | -| [MCP Server Reference](docs/mcp.md) | 114 MCP tools for agent-native interaction | +| [MCP Server Reference](docs/mcp.md) | 115 MCP tools for agent-native interaction | | [Federation Contracts](docs/federation/contracts.md) | Classic and Loom HTTP generation contracts | | [Workflow Templates](docs/workflows.md) | State machines, packs, field schemas, enforcement | | [Agent Integration](docs/agent-integration.md) | Multi-agent patterns, claiming, session resumption | diff --git a/docs/README.md b/docs/README.md index ae586dd8..98a35418 100644 --- a/docs/README.md +++ b/docs/README.md @@ -13,7 +13,7 @@ New to filigree? Start here: Detailed documentation for every interface: 2. **[CLI Reference](cli.md)** — All CLI commands with full parameter tables. -3. **[MCP Server Reference](mcp.md)** — 114 tools for native AI agent interaction via Model Context Protocol. +3. **[MCP Server Reference](mcp.md)** — 115 tools for native AI agent interaction via Model Context Protocol. 4. **[Workflow Templates](workflows.md)** — 24 issue types across 9 packs: state machines, transitions, field schemas, and enforcement levels. 5. **[Python API Reference](api-reference.md)** — `FiligreeDB`, `Issue`, `TemplateRegistry` for programmatic use. diff --git a/docs/SCHEMA_MIGRATIONS.md b/docs/SCHEMA_MIGRATIONS.md index 646f88bf..e5e9fa13 100644 --- a/docs/SCHEMA_MIGRATIONS.md +++ b/docs/SCHEMA_MIGRATIONS.md @@ -31,6 +31,7 @@ without grepping source. The source of truth remains | Release | Ships `user_version` | Notes | |---------|----------------------|-------| +| 2.3.0 | 23 | Migration 22 to 23: `entity_associations.entity_kind` caller-supplied metadata; public projections expose canonical `entity_id` with `clarion_entity_id` compatibility alias | | 2.1.1 | 21 | Migration 20 to 21: `deleted_issues.entity_ids`, surfaced as `affected_entities` on the `issue_deleted` deletion-signal record (F5 amplifier) | | 2.1.0 | 20 | Migrations 14 to 20: entity associations (v15), event sequencing (v16), file registry metadata (v17), `application_id` stamp (v18), scan-finding fingerprints (v19), and the `deleted_issues` tombstone (v20) | | 2.0.0 to 2.0.3 | 14 | Loom/API generation and 2.0 surface releases | diff --git a/docs/UPGRADING.md b/docs/UPGRADING.md index 6af18ba2..0e27592d 100644 --- a/docs/UPGRADING.md +++ b/docs/UPGRADING.md @@ -14,8 +14,9 @@ first 2.1.1 open applies a single in-place migration: The migration is an additive `ALTER TABLE ... ADD COLUMN` (`NOT NULL DEFAULT '[]'`) that backfills existing tombstones; `FiligreeDB.initialize()` applies it -automatically on first open, or run `filigree doctor --fix` for an -operator-controlled upgrade. No application-level action is required. A +automatically on first normal database open after the binary is upgraded. Use +`filigree doctor` before and after the upgrade to validate local configuration; +`doctor --fix` is limited to local binding and dashboard-pointer repair. No application-level action is required. A federation consumer of `/api/loom/changes` should begin honouring the new `affected_entities` field on `issue_deleted` records — purge the listed entity bindings on reconcile; see `docs/federation/contracts.md` §F5. @@ -35,9 +36,10 @@ Filigree 2.1.0 ships database schema `user_version` 20. Databases from the | 18 to 19 | v19 | Adds `scan_findings.fingerprint` and partitions the dedup index | | 19 to 20 | v20 | Adds the `deleted_issues` tombstone behind the `issue_deleted` changes-feed signal | -`FiligreeDB.initialize()` applies pending migrations automatically. For an -operator-controlled upgrade, use `filigree doctor --fix` so the schema step is -visible in the terminal and uses the database declared by `.filigree.conf`. +`FiligreeDB.initialize()` applies pending migrations automatically on the first +normal database open after the binary is upgraded. `filigree doctor` validates +the configured database path and reports schema state; `doctor --fix` does not +apply schema migrations. ### Before You Upgrade @@ -65,7 +67,7 @@ Run these commands from each project root: ```bash filigree doctor -filigree doctor --fix +filigree stats filigree doctor filigree stats filigree session-context @@ -75,17 +77,27 @@ For source checkouts, prefix the same commands with `uv run`: ```bash uv run filigree doctor -uv run filigree doctor --fix +uv run filigree stats uv run filigree doctor uv run filigree stats uv run filigree session-context ``` -`doctor --fix` is the supported in-place upgrader. It opens the existing -database, applies pending schema migrations, refreshes generated context, and -repairs install metadata where possible. Do not run `filigree init`, edit -`PRAGMA user_version` by hand, or delete and recreate `.filigree/` to upgrade -an existing project. +The first normal DB command after the binary upgrade opens the existing +database and applies pending schema migrations through the standard +`FiligreeDB.initialize()` path. Do not edit `PRAGMA user_version` by hand or +delete and recreate `.filigree/` to upgrade an existing project. + +Automation can use `filigree doctor --fix --json` for the shared doctor summary +contract when it wants local binding/dashboard-pointer repair: + +```json +{"ok": true, "checks": [{"id": "mcp.registration", "status": "fixed", "fixed": true}], "next_actions": []} +``` + +`--fix` repairs only local agent bindings and stale dashboard pointers. It does +not mutate issue rows, scan findings, scanner results, entity associations, or +database schema. An automation wrapper should do only the safe orchestration around this built-in path: @@ -95,7 +107,7 @@ path: stop_filigree_writers backup_configured_database upgrade_filigree_binary_to_2_1_0 -filigree doctor --fix +filigree stats filigree doctor restart_mcp_or_dashboard_processes ``` diff --git a/docs/cli.md b/docs/cli.md index 709b1485..e2759ccf 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -1,6 +1,6 @@ # CLI Reference -Most data commands support `--json` for machine-readable output (`--json` is supported by every issue/observation/file/finding/scanner/planning command but not by setup/diagnostic commands like `install`, `doctor`, and `session-context`, which produce human-only output). The `--actor` flag sets identity for the audit trail (default: `cli`) and works in either position — before the verb (`filigree --actor X update …`, group-level) or after it (`filigree update … --actor X`, per-verb). The post-verb value overrides the group-level one. +Most data commands support `--json` for machine-readable output (`--json` is supported by every issue/observation/file/finding/scanner/planning command and by `doctor`; setup commands like `install` and session bootstrap commands like `session-context` produce human-only output). The `--actor` flag sets identity for the audit trail (default: `cli`) and works in either position — before the verb (`filigree --actor X update …`, group-level) or after it (`filigree update … --actor X`, per-verb). The post-verb value overrides the group-level one. ## Contents @@ -85,7 +85,8 @@ filigree install --skills # Install Claude Code skills only filigree install --codex-skills # Install Codex skills only filigree install --mode=server # Switch MCP/hook configuration to server mode filigree doctor # Health check -filigree doctor --fix # Auto-fix what's possible +filigree doctor --fix # Repair local bindings and stale dashboard pointers +filigree doctor --fix --json # Machine-readable repair summary filigree doctor --verbose # Show all checks including passed ``` @@ -124,9 +125,35 @@ are reported with `filigree scanner enable --force`, and enabled bundled scanners whose runner command is missing point at `uv tool install --upgrade filigree`. +`doctor --fix` repairs local agent bindings and stale dashboard pointers only: +Claude/Codex MCP registration, Claude Code hooks, and stale ephemeral dashboard +PID/port files. It does not apply schema migrations, refresh generated context, +rewrite docs/instruction files, update `.gitignore`, change scanner +registrations, or mutate issues, observations, files, scan findings, scanner +results, or entity associations. + +In JSON mode, `doctor` emits the shared agent-readiness summary contract: + +```json +{ + "ok": true, + "checks": [ + {"id": "mcp.registration", "status": "ok", "fixed": false}, + {"id": "dashboard.port", "status": "fixed", "fixed": true} + ], + "next_actions": [] +} +``` + +The status vocabulary is `ok`, `failed`, and `fixed`. Stable check IDs include +`dashboard.port`, `mcp.registration`, `api.availability`, `auth.config`, +`scanner.results`, and `entity_associations.routes`; Filigree may include +additional product-specific IDs for local setup checks. + | Parameter | Type | Description | |-----------|------|-------------| -| `--fix` | flag | Auto-fix what's possible | +| `--fix` | flag | Repair local bindings and stale dashboard pointers | +| `--json` | flag | Emit the shared machine-readable doctor summary | | `--verbose` | flag | Show all checks including passed | ## Automation and Server @@ -1254,6 +1281,13 @@ changes, refresh managed project registrations with `filigree scanner enable --force`; `filigree doctor` reports bundled registrations that look stale. +Scanner POSTs that need `GET /api/scan-runs` history must send a globally +unique, non-empty `scan_run_id`. Empty run IDs are accepted for +fire-and-forget findings but are intentionally absent from scan-run history. +Filigree does not ingest raw SARIF at this endpoint; SARIF adapters must map +SARIF `partialFingerprints` or `fingerprints` into each posted +`finding.fingerprint` before sending scan-results. + ### `trigger-scan` Trigger a single-file scan. diff --git a/docs/federation/contracts.md b/docs/federation/contracts.md index df4d33c8..ee79a86e 100644 --- a/docs/federation/contracts.md +++ b/docs/federation/contracts.md @@ -405,6 +405,10 @@ of the external producer contract. can never collide with Filigree's own `scan_trigger`-minted ids. - **Keep `scan_source` stable across a run.** History groups on `(scan_run_id, scan_source)`; a mid-run `scan_source` change splits the run. +- **Empty `scan_run_id` means fire-and-forget.** The field is optional and `""` + is accepted for legacy or fire-and-forget findings, but empty values are + intentionally excluded from `GET /api/scan-runs`. External producers that + want scan-run history MUST send a globally unique, non-empty id. - **No completion warning for an unknown run.** With `complete_scan_run=true` (the default), an unknown run has no `scan_runs` row to transition to `completed`, so Filigree **skips the completion attempt silently** — the @@ -427,6 +431,27 @@ and at the core-method level by `tests/core/test_files.py::TestScanRunId` **Clarion may drop its "pending Filigree's confirmation" caveat** on `docs/federation/contracts.md` (scan-results intake) and `REQ-FINDING-05`. +### SARIF-to-scan-results fingerprint adapter contract + +Filigree does not implement a SARIF parser on `/api/v1/scan-results`, +`/api/loom/scan-results`, or `/api/scan-results`. The native scan-results +contract accepts Filigree finding JSON. SARIF producers such as Wardline must +translate SARIF before POSTing, including mapping SARIF +`result.partialFingerprints` or `result.fingerprints` into each posted +finding's `fingerprint` field. + +Once supplied as `finding.fingerprint`, Filigree treats the fingerprint as the +finding's cross-run identity for that `scan_source`. It is preserved through +ingestion, `GET /api/loom/findings` readback, promote-by-fingerprint, dedup +across line movement, stale/fixed cleanup, and reopen-on-regress lifecycle +transitions. Fingerprint-less legacy findings remain supported and continue to +dedup by the file/source/rule/line heuristic. + +Pinned by +`tests/api/test_files_api.py::TestScanResultsFingerprintAPI::test_wardline_fingerprint_survives_native_ingest_readback_promote_dedup_and_lifecycle` +and by the `success_wardline_sarif_adapter_fingerprint` example in +`tests/fixtures/contracts/loom/scan-results.json`. + ## When a contract evolves **Non-breaking additions** (new optional response fields, new optional request parameters with safe defaults) may land in-place without a new generation. Fixtures are updated to reflect the new shape; the `_meta.updated` field moves. diff --git a/docs/file-traceability.md b/docs/file-traceability.md index c524f611..4aa90188 100644 --- a/docs/file-traceability.md +++ b/docs/file-traceability.md @@ -80,6 +80,19 @@ Valid `assoc_type` values: `trigger_scan` registers the file and returns `file_id` + `scan_run_id` for correlation. +External producers that post directly to `POST /api/scan-results` should send +a globally unique, non-empty `scan_run_id` when they want the run to appear in +`GET /api/scan-runs`. Omitting it or sending `""` is still accepted for +fire-and-forget findings, but those findings are intentionally excluded from +scan-run history. + +Filigree's scan-results endpoint ingests Filigree finding JSON, not raw SARIF. +If a Wardline or SARIF producer wants stable dedup and lifecycle behavior, its +adapter must map SARIF `result.partialFingerprints` or `result.fingerprints` +into each posted finding's `fingerprint` field before calling +`POST /api/scan-results`. Filigree preserves that `finding.fingerprint` through +readback, promotion, dedup, stale/fixed cleanup, and reopen-on-regress handling. + ### 5) Verify from issue and file sides Issue -> files: @@ -172,7 +185,9 @@ Checklist: Checklist: 1. Confirm dashboard API was reachable from scanner process at trigger time. -2. Check scan run history via `GET /api/scan-runs`. +2. Check scan run history via `GET /api/scan-runs`. If the producer sent an + empty `scan_run_id`, this history is intentionally empty; query findings + directly instead. 3. Query `GET /api/files/{file_id}/findings` directly to confirm ingestion. 4. If scanner sends no findings, a `202` response can be expected. diff --git a/docs/getting-started.md b/docs/getting-started.md index 7368f19e..69f73f69 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -124,7 +124,7 @@ filigree close myproj-a3f9b2e1c0 --reason="Implemented in commit abc123" ### MCP Server -The MCP server is included in the base install — no extra needed. It exposes 114 tools so agents interact with filigree without parsing CLI output. See [MCP Server Reference](mcp.md). +The MCP server is included in the base install — no extra needed. It exposes 115 tools so agents interact with filigree without parsing CLI output. See [MCP Server Reference](mcp.md). ### Web Dashboard @@ -145,7 +145,7 @@ The dashboard is included in the base install — no extra needed. ## What Next? - [CLI Reference](cli.md) — full command reference with parameter docs -- [MCP Server Reference](mcp.md) — 114 tools for agent-native interaction +- [MCP Server Reference](mcp.md) — 115 tools for agent-native interaction - [Workflow Templates](workflows.md) — state machines, packs, and field schemas - [Agent Integration](agent-integration.md) — multi-agent patterns and session resumption - [Architecture](architecture.md) — source layout, DB schema, design decisions diff --git a/docs/mcp.md b/docs/mcp.md index b124b375..b19af8ff 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -1,6 +1,6 @@ # MCP Server Reference -Filigree exposes an MCP (Model Context Protocol) server so AI agents interact natively without parsing CLI output. The server provides 114 tools, 1 resource, and 1 prompt. +Filigree exposes an MCP (Model Context Protocol) server so AI agents interact natively without parsing CLI output. The server provides 115 tools, 1 resource, and 1 prompt. ## Contents @@ -777,18 +777,20 @@ enrich-only rule (`clarion/docs/suite/loom.md` §5) is preserved. | Tool | Description | |------|-------------| -| `entity_association_add` | Attach a Clarion entity to a Filigree issue (idempotent on the composite key — re-attach refreshes the hash, preserves original actor) | +| `entity_association_add` | Attach an opaque external entity to a Filigree issue (idempotent on the composite key — re-attach refreshes the hash, preserves original actor) | | `entity_association_remove` | Remove the binding identified by `(issue_id, entity_id)` | | `entity_association_list` | Return the entity bindings attached to an issue (raw rows; drift comparison is the consumer's job per ADR-029 §"Decision 3") | -| `entity_association_list_by_entity` | Reverse lookup: return every issue in this project bound to a given Clarion entity (the surface Clarion's `issues_for` calls) | +| `entity_association_list_by_entity` | Reverse lookup: return every issue in this project bound to a given opaque external entity ID | +| `finding_promote_and_attach_entity` | Promote a scan finding to an issue and attach an opaque external entity binding | #### `entity_association_add` | Parameter | Type | Required | Description | |-----------|------|----------|-------------| | `issue_id` | string | yes | Filigree issue ID | -| `entity_id` | string | yes | Opaque Clarion entity ID; not parsed | -| `content_hash` | string | yes | Snapshot of Clarion's current content hash for drift detection at query time | +| `entity_id` | string | yes | Opaque external entity ID; may be a `clarion:eid:...` SEI or a legacy locator; not parsed | +| `content_hash` | string | yes | Snapshot of the caller's current content hash for drift detection at query time | +| `entity_kind` / `external_entity_kind` | string | no | Caller-supplied kind metadata; never inferred from `entity_id` | | `actor` | string | no | Actor identity recorded as `attached_by` on first attach | #### `entity_association_remove` @@ -796,7 +798,7 @@ enrich-only rule (`clarion/docs/suite/loom.md` §5) is preserved. | Parameter | Type | Required | Description | |-----------|------|----------|-------------| | `issue_id` | string | yes | Filigree issue ID | -| `entity_id` | string | yes | Clarion entity ID | +| `entity_id` | string | yes | Opaque external entity ID | #### `entity_association_list` @@ -812,7 +814,8 @@ is required (or accepted). | Parameter | Type | Required | Description | |-----------|------|----------|-------------| -| `entity_id` | string | yes | Opaque Clarion entity ID; not parsed | +| `entity_id` | string | yes | Opaque external entity ID; not parsed | +| `current_content_hash` | string | no | Caller-supplied current hash; response `freshness_status` is `fresh`, `stale`, or `unknown` | ### Agent Context Notes @@ -1028,6 +1031,17 @@ create a linked triage observation; full responses then include **Important:** Results are POSTed to the dashboard API at `/api/scan-results`, the living alias for the recommended Loom generation. Without an explicit `api_url`, scanners use the active local dashboard: ethereal mode reads `.filigree/ephemeral.port`, server mode reads the configured daemon port, and the legacy `http://localhost:8377` default is only used when no active ethereal port has been recorded. Ensure the target is reachable before triggering scans — if unreachable, results are silently lost. +External scanner producers should include a globally unique, non-empty +`scan_run_id` in scan-results POSTs when they want `GET /api/scan-runs` +history. An omitted or empty `scan_run_id` is accepted for fire-and-forget +findings, but those findings are intentionally excluded from scan-run history. + +Filigree does not parse SARIF on the scan-results endpoint. Wardline/SARIF +adapters must map SARIF `partialFingerprints` or `fingerprints` into each +posted finding's `fingerprint` field before POSTing. Filigree preserves that +`finding.fingerprint` through readback, promote-by-fingerprint, dedup, stale +cleanup, and reopen-on-regress lifecycle transitions. + **Scanner registration:** Use `scanner_available_list`, `scanner_enable`, and `scanner_disable` from MCP, or `filigree scanner available`, `filigree scanner enable `, and `filigree scanner disable ` from the CLI. Bundled scanners call installed `filigree-scanner-*` entrypoints, so projects do not need copied runner scripts. Custom scanners can still be added as TOML files under `.filigree/scanners/`. Custom scanners that declare `{prompt}` in their args template are expected to honor that prompt value themselves. **Prompt packs:** Use `prompt_pack_list` or `filigree scanner prompts` to list bundled review lenses. Agents can pass `prompt` to `scan_preview`, `scan_trigger`, or `scan_trigger_batch` to focus review without embedding long scanner instructions in their own prompt. Bundled packs include `security`, `pytorch`, `quality-engineering`, `solution-architecture`, `systems-thinking`, `system-interactions`, `python-engineering`, `css`, `javascript`, `typescript`, `react`, `rust`, `go`, `terraform`, `sql`, `comprehensive`, and `major-refactor`. Pack records include `language`, `expected_relative_cost`, `instructions`, and `prompt_pack_scope`; scanner records include `applicable_prompts` so agents do not need to infer language fit from names. The prompt pack only nudges model focus; file access is governed by the scanner CLI sandbox. diff --git a/src/filigree/cli_commands/admin.py b/src/filigree/cli_commands/admin.py index a3332b5c..ee8847f6 100644 --- a/src/filigree/cli_commands/admin.py +++ b/src/filigree/cli_commands/admin.py @@ -32,6 +32,7 @@ write_config, ) from filigree.db_schema import CURRENT_SCHEMA_VERSION +from filigree.install_support.doctor import CheckResult, build_doctor_summary, doctor_check_id from filigree.install_support.version_marker import ( format_schema_mismatch_guidance, read_install_version, @@ -369,159 +370,173 @@ def install( click.echo('Next: filigree create "My first issue"') +def _emit_doctor_json( + results: list[CheckResult], + *, + fixed_check_ids: set[str] | None = None, + fixed_check_names: set[str] | None = None, +) -> None: + click.echo( + json_mod.dumps( + build_doctor_summary( + results, + fixed_check_ids=fixed_check_ids, + fixed_check_names=fixed_check_names, + ), + indent=2, + ) + ) + + +def _remove_stale_doctor_pointer(path: Path) -> tuple[bool, str]: + try: + if path.exists(): + path.unlink() + return True, f"Removed {path}" + return True, f"{path} already absent" + except OSError as exc: + return False, str(exc) + + +def _apply_doctor_fixes( + results: list[CheckResult], + *, + emit: Callable[[str], None] | None, +) -> tuple[int, set[str], set[str]]: + from filigree.install import ( + install_claude_code_hooks, + install_claude_code_mcp, + install_codex_mcp, + ) + + try: + filigree_dir = find_filigree_root() + except ProjectNotInitialisedError as exc: + if emit is not None: + click.echo(str(exc), err=True) + raise click.ClickException(str(exc)) from exc + + project_root = filigree_dir.parent + try: + mode = get_mode(filigree_dir) + except ValueError as exc: + if emit is not None: + click.echo(f"⚠ {exc}. Falling back to 'ethereal'.", err=True) + mode = "ethereal" + server_port = 8377 + if mode == "server": + try: + from filigree.server import read_server_config + + server_port = read_server_config().port + except Exception: + logging.getLogger(__name__).debug("Failed to read server port for --fix; using default", exc_info=True) + + fixable: dict[str, str] = { + "Claude Code MCP": "claude_code_mcp", + "Codex MCP": "codex_mcp", + "Claude Code hooks": "hooks", + "Ephemeral PID": "ephemeral_pid", + "Ephemeral port": "ephemeral_port", + } + + fixed = 0 + fixed_check_ids: set[str] = set() + fixed_check_names: set[str] = set() + for r in results: + if r.passed or r.name not in fixable: + continue + + fix_key = fixable[r.name] + ok = False + try: + if fix_key == "claude_code_mcp": + ok, msg = install_claude_code_mcp(project_root, mode=mode, server_port=server_port) + if emit is not None: + emit(f" {'OK' if ok else '!!'} {r.name}: {msg}") + elif fix_key == "codex_mcp": + ok, msg = install_codex_mcp(project_root, mode=mode, server_port=server_port) + if emit is not None: + emit(f" {'OK' if ok else '!!'} {r.name}: {msg}") + elif fix_key == "hooks": + ok, msg = install_claude_code_hooks(project_root) + if emit is not None: + emit(f" {'OK' if ok else '!!'} {r.name}: {msg}") + elif fix_key == "ephemeral_pid": + ok, msg = _remove_stale_doctor_pointer(filigree_dir / "ephemeral.pid") + if emit is not None: + emit(f" {'OK' if ok else '!!'} {r.name}: {msg}") + elif fix_key == "ephemeral_port": + ok, msg = _remove_stale_doctor_pointer(filigree_dir / "ephemeral.port") + if emit is not None: + emit(f" {'OK' if ok else '!!'} {r.name}: {msg}") + if ok: + fixed += 1 + fixed_check_ids.add(doctor_check_id(r)) + fixed_check_names.add(r.name) + except Exception as e: + if emit is not None: + click.echo(f" !! Cannot fix {r.name}: {e}", err=True) + + return fixed, fixed_check_ids, fixed_check_names + + @click.command() @click.option("--fix", is_flag=True, help="Auto-fix issues where possible") @click.option("--verbose", is_flag=True, help="Show all checks including passed") -def doctor(fix: bool, verbose: bool) -> None: +@click.option("--json", "as_json", is_flag=True, help="Emit machine-readable doctor summary") +def doctor(fix: bool, verbose: bool, as_json: bool) -> None: """Run health checks on the filigree installation.""" from filigree.install import run_doctor - from filigree.summary import write_summary as _write_summary results = run_doctor() passed = sum(1 for r in results if r.passed) failed = sum(1 for r in results if not r.passed) - click.echo(f"filigree doctor ── {passed} passed {failed} issues") - click.echo() + if not as_json: + click.echo(f"filigree doctor ── {passed} passed {failed} issues") + click.echo() - for r in results: - if r.passed and not verbose: - continue - icon = "OK" if r.passed else "!!" - click.echo(f" {icon} {r.name}: {r.message}") - if not r.passed and r.fix_hint: - click.echo(f" -> {r.fix_hint}") + for r in results: + if r.passed and not verbose: + continue + icon = "OK" if r.passed else "!!" + click.echo(f" {icon} {r.name}: {r.message}") + if not r.passed and r.fix_hint: + click.echo(f" -> {r.fix_hint}") # Schema-mismatch (v+1) is a distinct exit code (3) from generic check # failures (1). Don't attempt --fix on this — there's nothing to fix # forward when the DB is newer than the installed filigree. if any(r.code == "schema_mismatch_forward" for r in results): + if as_json: + _emit_doctor_json(results) sys.exit(3) fixed = 0 + fixed_check_ids: set[str] = set() + fixed_check_names: set[str] = set() if fix and failed > 0: - click.echo("\nApplying fixes...") + if not as_json: + click.echo("\nApplying fixes...") try: - filigree_dir = find_filigree_root() - except ProjectNotInitialisedError as exc: - # filigree-dad647cf35: surface the rich ForeignDatabaseError - # message instead of the generic "Cannot fix" line, matching - # scanners.py and the run_doctor() check above. - click.echo(str(exc), err=True) + fixed, fixed_check_ids, fixed_check_names = _apply_doctor_fixes(results, emit=None if as_json else click.echo) + except click.ClickException: + if as_json: + _emit_doctor_json(results) sys.exit(1) - from filigree.install import ( - ensure_gitignore, - inject_instructions, - install_claude_code_hooks, - install_claude_code_mcp, - install_codex_mcp, - install_codex_skills, - install_skills, - ) - - project_root = filigree_dir.parent - try: - mode = get_mode(filigree_dir) - except ValueError as exc: - click.echo(f"⚠ {exc}. Falling back to 'ethereal'.", err=True) - mode = "ethereal" - server_port = 8377 - if mode == "server": - try: - from filigree.server import read_server_config - - server_port = read_server_config().port - except Exception: - logging.getLogger(__name__).debug("Failed to read server port for --fix; using default", exc_info=True) - - # Map check names to their fix functions - fixable: dict[str, tuple[str, ...]] = { - "context.md": ("context.md",), - ".gitignore": ("gitignore",), - "Schema version": ("schema",), - "Claude Code MCP": ("claude_code_mcp",), - "Codex MCP": ("codex_mcp",), - "Claude Code hooks": ("hooks",), - "Claude Code skills": ("skills",), - "Codex skills": ("codex_skills",), - "CLAUDE.md": ("claude_md",), - "AGENTS.md": ("agents_md",), - } - - for r in results: - if r.passed or r.name not in fixable: - continue - - fix_key = fixable[r.name][0] - ok = False - try: - if fix_key == "context.md": - with get_db() as db: - _write_summary(db, filigree_dir / SUMMARY_FILENAME) - click.echo(f" OK Fixed: {r.name}") - ok = True - elif fix_key == "schema": - # filigree-fa6309d551: route DB open through the v2.0 - # anchor-aware constructors. Without this, --fix would - # initialize/migrate a phantom .filigree/filigree.db - # while the project's actual DB (declared in the conf) - # stays un-migrated. - conf_path = project_root / CONF_FILENAME - if conf_path.is_file(): - conf_data = read_conf(conf_path) - db_path = (conf_path.parent / conf_data["db"]).resolve() - else: - db_path = filigree_dir / DB_FILENAME - raw_conn = sqlite3.connect(str(db_path)) - try: - old_ver = read_schema_version(raw_conn) - finally: - raw_conn.close() - db = FiligreeDB.from_conf(conf_path) if conf_path.is_file() else FiligreeDB.from_filigree_dir(filigree_dir) - try: - new_ver = db.get_schema_version() - finally: - db.close() - if new_ver > old_ver: - click.echo(f" OK Schema upgraded v{old_ver} → v{new_ver}") - else: - click.echo(f" OK Schema already current (v{new_ver})") - ok = True - elif fix_key == "gitignore": - ok, msg = ensure_gitignore(project_root) - click.echo(f" {'OK' if ok else '!!'} {r.name}: {msg}") - elif fix_key == "claude_code_mcp": - ok, msg = install_claude_code_mcp(project_root, mode=mode, server_port=server_port) - click.echo(f" {'OK' if ok else '!!'} {r.name}: {msg}") - elif fix_key == "codex_mcp": - ok, msg = install_codex_mcp(project_root, mode=mode, server_port=server_port) - click.echo(f" {'OK' if ok else '!!'} {r.name}: {msg}") - elif fix_key == "hooks": - ok, msg = install_claude_code_hooks(project_root) - click.echo(f" {'OK' if ok else '!!'} {r.name}: {msg}") - elif fix_key == "skills": - ok, msg = install_skills(project_root) - click.echo(f" {'OK' if ok else '!!'} {r.name}: {msg}") - elif fix_key == "codex_skills": - ok, msg = install_codex_skills(project_root) - click.echo(f" {'OK' if ok else '!!'} {r.name}: {msg}") - elif fix_key == "claude_md": - ok, msg = inject_instructions(project_root / "CLAUDE.md") - click.echo(f" {'OK' if ok else '!!'} {r.name}: {msg}") - elif fix_key == "agents_md": - ok, msg = inject_instructions(project_root / "AGENTS.md") - click.echo(f" {'OK' if ok else '!!'} {r.name}: {msg}") - if ok: - fixed += 1 - except Exception as e: - click.echo(f" !! Cannot fix {r.name}: {e}", err=True) - unfixed = failed - fixed - if unfixed > 0: + if unfixed > 0 and not as_json: click.echo(f"\n Fixed {fixed}/{failed} issues. {unfixed} require manual intervention.") + if as_json: + _emit_doctor_json(results, fixed_check_ids=fixed_check_ids, fixed_check_names=fixed_check_names) + if failed == 0 or (fix and (failed - fixed) == 0): + return + sys.exit(1) + if failed == 0: click.echo("\nAll checks passed.") return diff --git a/src/filigree/dashboard_routes/entities.py b/src/filigree/dashboard_routes/entities.py index 350ecbca..ba48ffe3 100644 --- a/src/filigree/dashboard_routes/entities.py +++ b/src/filigree/dashboard_routes/entities.py @@ -105,10 +105,16 @@ async def api_list_associations_by_entity(request: Request, db: FiligreeDB = Dep §"Decision 3". """ entity_id = request.query_params.get("entity_id", "") + current_content_hash = request.query_params.get("current_content_hash") if not isinstance(entity_id, str) or not entity_id.strip(): return _error_response("entity_id query parameter is required", ErrorCode.VALIDATION, 400) + if current_content_hash is not None and (not isinstance(current_content_hash, str) or not current_content_hash.strip()): + return _error_response("current_content_hash must be a non-empty string when provided", ErrorCode.VALIDATION, 400) try: - rows = db.list_associations_by_entity(make_clarion_entity_id(entity_id)) + rows = db.list_associations_by_entity( + make_clarion_entity_id(entity_id), + current_content_hash=make_content_hash(current_content_hash) if current_content_hash is not None else None, + ) except ValueError as exc: return _error_response(str(exc), ErrorCode.VALIDATION, 400) return JSONResponse({"associations": [dict(row) for row in rows]}) @@ -119,17 +125,20 @@ async def api_add_entity_association(issue_id: str, request: Request, db: Filigr key — re-attach refreshes ``content_hash_at_attach`` and ``attached_at`` while preserving the original ``attached_by``. - Body: ``{"entity_id": str, "content_hash": str, "actor": str?}``. + Body: ``{"entity_id": str, "content_hash": str, "entity_kind": str?, "actor": str?}``. """ body = await _parse_json_body(request) if isinstance(body, JSONResponse): return body entity_id = body.get("entity_id", "") content_hash = body.get("content_hash", "") + entity_kind = body.get("entity_kind", body.get("external_entity_kind")) if not isinstance(entity_id, str) or not entity_id.strip(): return _error_response("entity_id is required", ErrorCode.VALIDATION, 400) if not isinstance(content_hash, str) or not content_hash.strip(): return _error_response("content_hash is required", ErrorCode.VALIDATION, 400) + if entity_kind is not None and not isinstance(entity_kind, str): + return _error_response("entity_kind must be a string", ErrorCode.VALIDATION, 400) actor, actor_err = _validate_actor(body.get("actor", "dashboard")) if actor_err: return actor_err @@ -144,6 +153,7 @@ async def api_add_entity_association(issue_id: str, request: Request, db: Filigr make_clarion_entity_id(entity_id), make_content_hash(content_hash), actor=actor, + entity_kind=entity_kind, ) except WrongProjectError as exc: return _error_response(exc.safe_message, ErrorCode.VALIDATION, 400) diff --git a/src/filigree/dashboard_routes/files.py b/src/filigree/dashboard_routes/files.py index 1ce4c412..1fe9f788 100644 --- a/src/filigree/dashboard_routes/files.py +++ b/src/filigree/dashboard_routes/files.py @@ -31,6 +31,7 @@ _safe_int, _validate_actor, ) +from filigree.issue_payloads import issue_to_public from filigree.registry import ( REGISTRY_BACKEND_FEATURES, RegistryBriefingBlockedError, @@ -153,6 +154,42 @@ def _promote_finding_on_private_conn( return {"issue_id": result["issue"].id, "created": result["created"]} +def _promote_finding_and_attach_on_private_conn( + db: FiligreeDB, + *, + scan_source: str, + fingerprint: str, + entity_id: str, + content_hash: str, + priority: int | None, + labels: list[str] | None, + actor: str, + entity_kind: str | None, +) -> dict[str, Any] | None: + """Resolve ``(scan_source, fingerprint)`` then promote and attach an entity.""" + with db.borrow_for_worker_thread() as worker_db: + finding = worker_db.find_finding_by_fingerprint(scan_source, fingerprint) + if finding is None: + return None + result = worker_db.promote_finding_and_attach_entity( + finding["id"], + entity_id, + content_hash, + priority=priority, + labels=labels, + actor=actor, + entity_kind=entity_kind, + ) + response: dict[str, Any] = { + "issue_id": result["issue"].id, + "created": result["created"], + "association": dict(result["association"]), + } + if result.get("warnings"): + response["warnings"] = result["warnings"] + return response + + def _parse_promote_priority(raw: Any) -> tuple[int | None, str | None]: """Normalize an optional ``"P2"``/``"2"``/``2`` priority to an int 0-4. @@ -326,7 +363,10 @@ async def api_files_schema(db: FiligreeDB = Depends(_get_db)) -> JSONResponse: "request_body": { "scan_source": "string (required)", "findings": "array (required)", - "scan_run_id": "string (optional)", + "scan_run_id": ( + "string (optional). Send a globally unique non-empty scan_run_id when this POST should appear " + "in /api/scan-runs history; empty is accepted for fire-and-forget findings and intentionally excluded." + ), "mark_unseen": "boolean (optional)", "create_observations": "boolean (optional, default false)", "complete_scan_run": "boolean (optional, default true)", @@ -673,6 +713,124 @@ async def api_loom_list_findings(request: Request, db: FiligreeDB = Depends(_get items = [scan_finding_to_loom(f) for f in result["findings"]] return JSONResponse(list_response(items, limit=limit, offset=offset, total=result["total"])) + @router.get("/findings/{finding_id}/dossier") + async def api_loom_finding_dossier(finding_id: str, db: FiligreeDB = Depends(_get_db)) -> JSONResponse: + """Return finding, file, linked issue, file associations, and entity bindings.""" + try: + finding = db.get_finding(finding_id) + except KeyError: + return _error_response(f"Finding not found: {finding_id}", ErrorCode.NOT_FOUND, 404) + + file_payload: dict[str, Any] | None = None + file_associations: list[dict[str, Any]] = [] + try: + detail = db.get_file_detail(finding["file_id"]) + raw_file = dict(detail["file"]) + file_payload = {"file_id": raw_file.pop("id"), **raw_file} + file_associations = [dict(item) for item in detail.get("associations", [])] + except KeyError: + file_payload = None + + linked_issue: dict[str, Any] | None = None + issue_file_associations: list[dict[str, Any]] = [] + entity_associations: list[dict[str, Any]] = [] + issue_id = finding.get("issue_id") + if issue_id: + try: + issue = db.get_issue(str(issue_id)) + linked_issue = dict(issue_to_public(issue)) + issue_file_associations = [dict(item) for item in db.get_issue_files(issue.id)] + entity_associations = [dict(row) for row in db.list_entity_associations(issue.id)] + except KeyError: + linked_issue = None + + return JSONResponse( + { + "finding": scan_finding_to_loom(finding), + "file": file_payload, + "linked_issue": linked_issue, + "file_associations": issue_file_associations or file_associations, + "entity_associations": entity_associations, + } + ) + + @router.get("/session-evidence") + async def api_loom_session_evidence(request: Request, db: FiligreeDB = Depends(_get_db)) -> JSONResponse: + """V0 session evidence bundle by actor and optional time window.""" + params = request.query_params + actor = params.get("actor", "") + if not isinstance(actor, str) or not actor.strip(): + return _error_response("actor query parameter is required", ErrorCode.VALIDATION, 400) + since = params.get("since") + until = params.get("until") + limit = _safe_int(params.get("limit", "100"), "limit", min_value=1, max_value=_MAX_PAGINATION_LIMIT) + if isinstance(limit, JSONResponse): + return limit + + time_clause = "" + time_params: list[Any] = [] + if since: + time_clause += " AND created_at >= ?" + time_params.append(since) + if until: + time_clause += " AND created_at <= ?" + time_params.append(until) + + issue_rows = db.conn.execute( + f""" + SELECT DISTINCT issue_id + FROM events + WHERE actor = ?{time_clause} + ORDER BY issue_id ASC + LIMIT ? + """, + [actor, *time_params, limit], + ).fetchall() + issues = [] + for row in issue_rows: + try: + issues.append(dict(issue_to_public(db.get_issue(row["issue_id"])))) + except KeyError: + continue + + finding_rows = db.conn.execute( + """ + SELECT * + FROM scan_findings + WHERE created_by = ? OR updated_by = ? + ORDER BY updated_at DESC + LIMIT ? + """, + (actor, actor, limit), + ).fetchall() + findings = [scan_finding_to_loom(db._build_scan_finding(row).to_dict()) for row in finding_rows] + + assoc_rows = db.conn.execute( + """ + SELECT issue_id + FROM entity_associations + WHERE attached_by = ? + ORDER BY attached_at DESC + LIMIT ? + """, + (actor, limit), + ).fetchall() + entity_associations: list[dict[str, Any]] = [] + for row in assoc_rows: + entity_associations.extend(dict(item) for item in db.list_entity_associations(row["issue_id"])) + + return JSONResponse( + { + "query": {"actor": actor, "since": since, "until": until, "limit": limit}, + "issues": issues, + "findings": findings, + "entity_associations": entity_associations, + "observations": [], + "annotations": [], + "commands": [], + } + ) + @router.post("/findings/promote") async def api_loom_promote_finding(request: Request, db: FiligreeDB = Depends(_get_db)) -> JSONResponse: """Promote a finding to a tracked issue, keyed by ``(scan_source, fingerprint)``. @@ -744,6 +902,57 @@ async def api_loom_promote_finding(request: Request, db: FiligreeDB = Depends(_g ) return JSONResponse(result) + @router.post("/findings/promote-and-attach") + async def api_loom_promote_finding_and_attach(request: Request, db: FiligreeDB = Depends(_get_db)) -> JSONResponse: + """Promote a finding by fingerprint and attach an opaque entity binding.""" + body = await _parse_json_body(request) + if isinstance(body, JSONResponse): + return body + scan_source = body.get("scan_source", "") + fingerprint = body.get("fingerprint", "") + entity_id = body.get("entity_id", "") + content_hash = body.get("content_hash", "") + if not isinstance(scan_source, str) or not scan_source.strip(): + return _error_response("scan_source is required and must be a string", ErrorCode.VALIDATION, 400) + if not isinstance(fingerprint, str) or not fingerprint.strip(): + return _error_response("fingerprint is required and must be a string", ErrorCode.VALIDATION, 400) + if not isinstance(entity_id, str) or not entity_id.strip(): + return _error_response("entity_id is required and must be a string", ErrorCode.VALIDATION, 400) + if not isinstance(content_hash, str) or not content_hash.strip(): + return _error_response("content_hash is required and must be a string", ErrorCode.VALIDATION, 400) + entity_kind = body.get("entity_kind", body.get("external_entity_kind")) + if entity_kind is not None and not isinstance(entity_kind, str): + return _error_response("entity_kind must be a string", ErrorCode.VALIDATION, 400) + priority, priority_err = _parse_promote_priority(body.get("priority")) + if priority_err is not None: + return _error_response(priority_err, ErrorCode.VALIDATION, 400) + labels = body.get("labels") + if labels is not None and (not isinstance(labels, list) or not all(isinstance(x, str) for x in labels)): + return _error_response("labels must be a list of strings", ErrorCode.VALIDATION, 400) + actor, actor_err = _validate_actor(body.get("actor", "dashboard")) + if actor_err: + return actor_err + try: + result = await asyncio.to_thread( + _promote_finding_and_attach_on_private_conn, + db, + scan_source=scan_source, + fingerprint=fingerprint, + entity_id=entity_id, + content_hash=content_hash, + priority=priority, + labels=labels, + actor=actor, + entity_kind=entity_kind, + ) + except ValueError as e: + return _error_response(str(e), ErrorCode.VALIDATION, 400) + except sqlite3.Error as e: + return _error_response(f"database error promoting finding and attaching entity: {e}", ErrorCode.IO, 500) + if result is None: + return _error_response("no finding for fingerprint", ErrorCode.NOT_FOUND, 404) + return JSONResponse(result) + @router.post("/findings/clean-stale") async def api_loom_clean_stale_findings(request: Request, db: FiligreeDB = Depends(_get_db)) -> JSONResponse: """Retention sweep — soft-archive stale ``unseen_in_latest`` findings. diff --git a/src/filigree/data/instructions.md b/src/filigree/data/instructions.md index ed6572de..458bc991 100644 --- a/src/filigree/data/instructions.md +++ b/src/filigree/data/instructions.md @@ -76,11 +76,12 @@ either catalogue. The verbs you will reach for most: `entity_association_remove`, `entity_association_list`, `entity_association_list_by_entity`. Used when a sibling tool (e.g. Clarion) needs to bind a Filigree issue to a function, class, or - module identifier it owns. The `entity_id` is an opaque string - from Filigree's perspective; the consumer (the sibling tool's read + module identifier it owns. The `entity_id` is an opaque external string + from Filigree's perspective and may be a `clarion:eid:...` SEI or a legacy + locator; callers may also supply `entity_kind` explicitly. The consumer (the sibling tool's read path) does drift detection against the stored `content_hash_at_attach`. `entity_association_list_by_entity` is the - reverse-lookup surface — given a Clarion entity ID, return every + reverse-lookup surface — given an opaque external entity ID, return every Filigree issue bound to it (project isolation is by DB file). Also reachable over HTTP as `GET/POST /api/issue/{issue_id}/entity-associations`, diff --git a/src/filigree/db_entity_associations.py b/src/filigree/db_entity_associations.py index ef45479f..021cc548 100644 --- a/src/filigree/db_entity_associations.py +++ b/src/filigree/db_entity_associations.py @@ -1,14 +1,10 @@ """Entity-association CRUD (ADR-029, Clarion B.7 / WP9-A). -Binds Filigree issues to Clarion entities via opaque string IDs. The -``clarion_entity_id`` carries Clarion's three-segment grammar -(``{plugin_id}:{kind}:{canonical_qualified_name}``, per Clarion's -ADR-003) but Filigree never parses it — the federation enrich-only -rule (``loom.md`` §5) requires that Clarion's entity-ID grammar remain -Clarion's contract with itself. Filigree stores the ID as a string and -hands ``content_hash_at_attach`` back at query time so the consumer -(Clarion's ``issues_for`` MCP tool, lands separately in B.6) can -compute drift. +Binds Filigree issues to opaque external entity IDs. The historical +SQLite column is named ``clarion_entity_id`` for compatibility, but the +public projection exposes canonical ``entity_id`` and treats the value +as an opaque string. The value may be a Clarion SEI, a legacy locator, or +another caller-owned ID; Filigree never parses or validates its grammar. Four operations form the surface: @@ -26,7 +22,8 @@ from __future__ import annotations -from typing import TypedDict +from collections.abc import Mapping +from typing import Any, TypedDict from filigree.db_base import DBMixinProtocol, _in_immediate_tx, _now_iso, _retry_busy from filigree.types.core import ( @@ -44,10 +41,48 @@ class EntityAssociationRow(TypedDict): """One row of the entity_associations table.""" issue_id: IssueId + entity_id: ClarionEntityId clarion_entity_id: ClarionEntityId + entity_kind: str content_hash_at_attach: ContentHash attached_at: ISOTimestamp attached_by: str + migration_orphaned_at: ISOTimestamp | None + orphan_status: str + freshness_status: str + + +def _normalise_optional_entity_kind(entity_kind: str | None) -> str: + if entity_kind is None: + return "" + if not isinstance(entity_kind, str): + msg = "entity_kind must be a string" + raise TypeError(msg) + return entity_kind.strip() + + +def _freshness_status(content_hash_at_attach: str, current_content_hash: str | None) -> str: + if current_content_hash is None: + return "unknown" + return "fresh" if current_content_hash == content_hash_at_attach else "stale" + + +def _row_to_entity_association(r: Mapping[str, Any], *, current_content_hash: str | None = None) -> EntityAssociationRow: + entity_id = ClarionEntityId(r["clarion_entity_id"]) + migration_orphaned_at = r["migration_orphaned_at"] + content_hash_at_attach = ContentHash(r["content_hash_at_attach"]) + return EntityAssociationRow( + issue_id=IssueId(r["issue_id"]), + entity_id=entity_id, + clarion_entity_id=entity_id, + entity_kind=r["entity_kind"], + content_hash_at_attach=content_hash_at_attach, + attached_at=ISOTimestamp(r["attached_at"]), + attached_by=r["attached_by"], + migration_orphaned_at=ISOTimestamp(migration_orphaned_at) if migration_orphaned_at else None, + orphan_status="orphaned" if migration_orphaned_at else "unknown", + freshness_status=_freshness_status(str(content_hash_at_attach), current_content_hash), + ) class EntityAssociationsMixin(DBMixinProtocol): @@ -67,6 +102,7 @@ def add_entity_association( content_hash: ContentHash, *, actor: str = "", + entity_kind: str | None = None, ) -> EntityAssociationRow: """Attach a Clarion entity to a Filigree issue (or refresh an existing attachment). @@ -98,6 +134,7 @@ def add_entity_association( issue_id = make_issue_id(issue_id) entity_id = make_clarion_entity_id(entity_id) content_hash = make_content_hash(content_hash) + entity_kind = _normalise_optional_entity_kind(entity_kind) self._check_id_prefix(issue_id) # Validate issue exists (FK would catch this too, but the SQLite # error is less informative than a typed ValueError). @@ -123,13 +160,17 @@ def add_entity_association( self.conn.execute( """ INSERT INTO entity_associations - (issue_id, clarion_entity_id, content_hash_at_attach, attached_at, attached_by) - VALUES (?, ?, ?, ?, ?) + (issue_id, clarion_entity_id, content_hash_at_attach, attached_at, attached_by, entity_kind) + VALUES (?, ?, ?, ?, ?, ?) ON CONFLICT(issue_id, clarion_entity_id) DO UPDATE SET content_hash_at_attach = excluded.content_hash_at_attach, - attached_at = excluded.attached_at + attached_at = excluded.attached_at, + entity_kind = CASE + WHEN excluded.entity_kind <> '' THEN excluded.entity_kind + ELSE entity_associations.entity_kind + END """, - (issue_id, entity_id, content_hash, now, actor), + (issue_id, entity_id, content_hash, now, actor, entity_kind), ) # Re-read the row — necessary because re-attach preserves the @@ -137,7 +178,8 @@ def add_entity_association( # passed in for an existing row. stored = self.conn.execute( """ - SELECT issue_id, clarion_entity_id, content_hash_at_attach, attached_at, attached_by + SELECT issue_id, clarion_entity_id, entity_kind, content_hash_at_attach, + attached_at, attached_by, migration_orphaned_at FROM entity_associations WHERE issue_id = ? AND clarion_entity_id = ? """, @@ -167,13 +209,7 @@ def add_entity_association( new_value=str(content_hash), comment=str(entity_id), ) - return EntityAssociationRow( - issue_id=IssueId(stored["issue_id"]), - clarion_entity_id=ClarionEntityId(stored["clarion_entity_id"]), - content_hash_at_attach=ContentHash(stored["content_hash_at_attach"]), - attached_at=ISOTimestamp(stored["attached_at"]), - attached_by=stored["attached_by"], - ) + return _row_to_entity_association(stored) @_retry_busy() @_in_immediate_tx("remove_entity_association") @@ -219,25 +255,22 @@ def list_entity_associations(self, issue_id: IssueId) -> list[EntityAssociationR self._check_id_prefix(issue_id) rows = self.conn.execute( """ - SELECT issue_id, clarion_entity_id, content_hash_at_attach, attached_at, attached_by + SELECT issue_id, clarion_entity_id, entity_kind, content_hash_at_attach, + attached_at, attached_by, migration_orphaned_at FROM entity_associations WHERE issue_id = ? ORDER BY attached_at ASC, clarion_entity_id ASC """, (issue_id,), ).fetchall() - return [ - EntityAssociationRow( - issue_id=IssueId(r["issue_id"]), - clarion_entity_id=ClarionEntityId(r["clarion_entity_id"]), - content_hash_at_attach=ContentHash(r["content_hash_at_attach"]), - attached_at=ISOTimestamp(r["attached_at"]), - attached_by=r["attached_by"], - ) - for r in rows - ] + return [_row_to_entity_association(r) for r in rows] - def list_associations_by_entity(self, entity_id: ClarionEntityId) -> list[EntityAssociationRow]: + def list_associations_by_entity( + self, + entity_id: ClarionEntityId, + *, + current_content_hash: ContentHash | str | None = None, + ) -> list[EntityAssociationRow]: """Return all issue bindings for a given Clarion entity. The reverse of :meth:`list_entity_associations`: given an @@ -254,22 +287,17 @@ def list_associations_by_entity(self, entity_id: ClarionEntityId) -> list[Entity the consumer's job per ADR-029 §"Decision 3". """ entity_id = make_clarion_entity_id(entity_id) + if current_content_hash is not None: + current_content_hash = make_content_hash(current_content_hash) rows = self.conn.execute( """ - SELECT issue_id, clarion_entity_id, content_hash_at_attach, attached_at, attached_by + SELECT issue_id, clarion_entity_id, entity_kind, content_hash_at_attach, + attached_at, attached_by, migration_orphaned_at FROM entity_associations WHERE clarion_entity_id = ? ORDER BY attached_at ASC, issue_id ASC """, (entity_id,), ).fetchall() - return [ - EntityAssociationRow( - issue_id=IssueId(r["issue_id"]), - clarion_entity_id=ClarionEntityId(r["clarion_entity_id"]), - content_hash_at_attach=ContentHash(r["content_hash_at_attach"]), - attached_at=ISOTimestamp(r["attached_at"]), - attached_by=r["attached_by"], - ) - for r in rows - ] + current_hash = str(current_content_hash) if current_content_hash is not None else None + return [_row_to_entity_association(r, current_content_hash=current_hash) for r in rows] diff --git a/src/filigree/db_files.py b/src/filigree/db_files.py index 8c83e932..bd038590 100644 --- a/src/filigree/db_files.py +++ b/src/filigree/db_files.py @@ -2061,6 +2061,13 @@ def get_findings_paginated( "low": 3, "info": 3, } + _FINDING_SEVERITY_TO_BUG_SEVERITY: ClassVar[dict[str, str]] = { + "critical": "critical", + "high": "major", + "medium": "major", + "low": "minor", + "info": "cosmetic", + } def get_finding(self, finding_id: str) -> ScanFindingDict: """Get a single finding by ID. Raises *KeyError* if not found.""" @@ -2268,6 +2275,7 @@ def promote_finding_to_issue( priority=priority, description="\n\n".join(description_parts), fields={ + "severity": self._FINDING_SEVERITY_TO_BUG_SEVERITY.get(finding["severity"], "minor"), "source_finding_id": finding_id, "scan_source": finding["scan_source"], "rule_id": finding["rule_id"], @@ -2285,6 +2293,41 @@ def promote_finding_to_issue( result["warnings"] = warnings return result + def promote_finding_and_attach_entity( + self, + finding_id: str, + entity_id: str, + content_hash: str, + *, + priority: int | None = None, + actor: str = "", + labels: list[str] | None = None, + entity_kind: str | None = None, + ) -> dict[str, Any]: + """Promote a finding to an issue and attach an opaque entity binding. + + This composes the existing idempotent primitives so retrying the same + request returns the existing issue and refreshes the association hash. + Public HTTP routes run this on a private worker-thread connection. + """ + result = self.promote_finding_to_issue(finding_id, priority=priority, actor=actor, labels=labels) + issue = result["issue"] + association = self.add_entity_association( + issue.id, + entity_id, + content_hash, + actor=actor, + entity_kind=entity_kind, + ) + payload: dict[str, Any] = { + "issue": self.get_issue(issue.id), + "created": result["created"], + "association": association, + } + if result.get("warnings"): + payload["warnings"] = result["warnings"] + return payload + def _file_path_for_finding(self, file_id: str) -> str: """Look up the file path for a file_id, returning empty string if not found.""" row = self.conn.execute("SELECT path FROM file_records WHERE id = ?", (file_id,)).fetchone() diff --git a/src/filigree/db_schema.py b/src/filigree/db_schema.py index c39fec37..fb3c9821 100644 --- a/src/filigree/db_schema.py +++ b/src/filigree/db_schema.py @@ -399,12 +399,11 @@ ON annotation_closeout_acknowledgements(target_type, target_id); -- ---- Cross-product entity associations (ADR-029) -------------------------- --- Binds Filigree issues to Clarion entities (functions, classes, modules). --- The clarion_entity_id is OPAQUE to Filigree — no grammar parsing, no --- CHECK constraint on its shape. Filigree does not know what a "Clarion --- entity" is; it stores the ID as a string and hands content_hash back at --- query time so Clarion can detect drift. This preserves the federation --- enrich-only rule (loom.md §5). +-- Binds Filigree issues to opaque external entities. +-- The historical clarion_entity_id column name is retained for compatibility, +-- but the value is OPAQUE to Filigree: no grammar parsing, no CHECK constraint +-- on its shape, and no inference of kind from the ID string. Callers may supply +-- entity_kind metadata explicitly when they already know it. -- ``migration_orphaned_at`` (v22) supports the locator→SEI backfill (ADR-038 -- §7). NULL is the healthy/default state. A non-NULL ISO timestamp marks a row @@ -420,6 +419,7 @@ attached_at TEXT NOT NULL, attached_by TEXT NOT NULL, migration_orphaned_at TEXT, + entity_kind TEXT NOT NULL DEFAULT '', PRIMARY KEY (issue_id, clarion_entity_id) ); @@ -572,4 +572,4 @@ END; """ -CURRENT_SCHEMA_VERSION = 22 +CURRENT_SCHEMA_VERSION = 23 diff --git a/src/filigree/install_support/doctor.py b/src/filigree/install_support/doctor.py index d59ef51a..b2bf3e22 100644 --- a/src/filigree/install_support/doctor.py +++ b/src/filigree/install_support/doctor.py @@ -8,6 +8,8 @@ import json import logging +import os +import re import shutil import sqlite3 import subprocess @@ -59,12 +61,109 @@ class CheckResult: message: str fix_hint: str = "" code: str | None = None # machine-readable check identifier; e.g. "schema_mismatch_forward" + check_id: str | None = None @property def icon(self) -> str: return "OK" if self.passed else "!!" +_RESERVED_SUMMARY_CHECK_IDS = ( + "dashboard.port", + "mcp.registration", + "api.availability", + "auth.config", + "scanner.results", + "entity_associations.routes", +) + +_CHECK_ID_BY_NAME = { + ".filigree/ directory": "project.directory", + ".filigree.conf anchor": "project.anchor", + "Home-directory .filigree.conf": "project.home_anchor", + "config.json": "project.config", + "filigree.db": "database.access", + "Schema version": "database.schema", + "File registry backend state": "file_registry.backend", + "context.md": "context.summary", + ".gitignore": "git.ignore", + "Claude Code MCP": "mcp.registration", + "Codex MCP": "mcp.registration", + "Claude Code hooks": "hooks.session_context", + "Claude Code skills": "skills.claude_code", + "Codex skills": "skills.codex", + "CLAUDE.md": "instructions.claude_md", + "AGENTS.md": "instructions.agents_md", + "Ephemeral PID": "dashboard.port", + "Ephemeral port": "dashboard.port", + "Server daemon": "dashboard.port", + "API routes": "api.availability", + "Auth config": "auth.config", + "Scan results routes": "scanner.results", + "Entity association routes": "entity_associations.routes", + "Bundled scanner registrations": "scanner.registration", + "Git working tree": "git.working_tree", + "Installation": "installation.method", + "Installation method": "installation.method", +} + + +def doctor_check_id(result: CheckResult) -> str: + """Return the stable JSON-contract check id for a human doctor result.""" + if result.check_id: + return result.check_id + mapped = _CHECK_ID_BY_NAME.get(result.name) + if mapped is not None: + return mapped + normalized = re.sub(r"[^a-z0-9]+", ".", result.name.lower()).strip(".") + return normalized or "unknown" + + +def build_doctor_summary( + results: list[CheckResult], + *, + fixed_check_ids: set[str] | None = None, + fixed_check_names: set[str] | None = None, +) -> dict[str, Any]: + """Build the shared machine-readable doctor summary contract. + + The contract is intentionally compact so other agent-side tools can consume + it without parsing Filigree's human diagnostic text. + """ + fixed = fixed_check_ids or set() + fixed_names = fixed_check_names or set() + by_id: dict[str, dict[str, Any]] = {} + next_actions: list[str] = [] + seen_actions: set[str] = set() + + for result in results: + check_id = doctor_check_id(result) + entry = by_id.setdefault(check_id, {"id": check_id, "status": "ok", "fixed": False}) + if check_id in fixed or result.name in fixed_names: + if entry["status"] != "failed": + entry["status"] = "fixed" + entry["fixed"] = True + continue + if not result.passed: + entry["status"] = "failed" + entry["fixed"] = False + if result.fix_hint: + action = f"{check_id}: {result.fix_hint}" + if action not in seen_actions: + next_actions.append(action) + seen_actions.add(action) + + for check_id in _RESERVED_SUMMARY_CHECK_IDS: + by_id.setdefault(check_id, {"id": check_id, "status": "ok", "fixed": False}) + + checks = [by_id[check_id] for check_id in sorted(by_id)] + return { + "ok": all(check["status"] != "failed" for check in checks), + "checks": checks, + "next_actions": next_actions, + } + + def _is_venv_binary(path: str) -> bool: """Return True when *path* is inside a Python virtual environment.""" p = Path(path) @@ -140,7 +239,7 @@ def _doctor_file_registry_backend_state( "File registry backend state", False, f"Could not inspect file registry backend state: {exc}", - fix_hint="Database may be corrupted. Restore from backup or run: filigree doctor --fix", + fix_hint="Database may be corrupted. Restore from backup.", ) if local_count: return CheckResult( @@ -230,6 +329,108 @@ 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 _doctor_dashboard_contract_checks() -> list[CheckResult]: + """Validate dashboard/API route registration without issuing mutating calls.""" + try: + from filigree.dashboard import FEDERATION_API_ENV_VAR, LEGACY_API_ENV_VAR, 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 [ + CheckResult("API routes", False, message, fix_hint="Run: filigree doctor --verbose"), + CheckResult("Scan results routes", False, message, fix_hint="Run: filigree doctor --verbose"), + CheckResult("Entity association routes", False, message, fix_hint="Run: filigree doctor --verbose"), + CheckResult("Auth config", False, message, fix_hint="Run: filigree doctor --verbose"), + ] + + results: list[CheckResult] = [] + + if _route_supports(route_table, "/api/health", "GET"): + results.append(CheckResult("API routes", True, "GET /api/health registered")) + else: + results.append( + CheckResult( + "API routes", + False, + "GET /api/health is not registered", + fix_hint="Reinstall or upgrade filigree; dashboard route registration is incomplete.", + ) + ) + + scanner_routes = ( + ("/api/loom/scan-results", "POST"), + ("/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)] + if missing_scanner: + results.append( + CheckResult( + "Scan results routes", + False, + f"Missing route(s): {', '.join(missing_scanner)}", + fix_hint="Reinstall or upgrade filigree; scanner/result API route registration is incomplete.", + ) + ) + else: + results.append(CheckResult("Scan results routes", True, "scan-results and file schema routes registered")) + + entity_routes = ( + ("/api/issue/{issue_id}/entity-associations", "GET"), + ("/api/issue/{issue_id}/entity-associations", "POST"), + ("/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)] + if missing_entity: + results.append( + CheckResult( + "Entity association routes", + False, + f"Missing route(s): {', '.join(missing_entity)}", + fix_hint="Reinstall or upgrade filigree; entity-association API route registration is incomplete.", + ) + ) + else: + results.append(CheckResult("Entity association routes", True, "entity-association routes registered")) + + auth_envs = { + FEDERATION_API_ENV_VAR: os.environ.get(FEDERATION_API_ENV_VAR), + LEGACY_API_ENV_VAR: os.environ.get(LEGACY_API_ENV_VAR), + } + empty_envs = sorted(name for name, value in auth_envs.items() if value is not None and not value.strip()) + configured_envs = sorted(name for name, value in auth_envs.items() if value is not None and value.strip()) + if empty_envs: + results.append( + CheckResult( + "Auth config", + False, + f"Empty auth token environment variable(s): {', '.join(empty_envs)}", + fix_hint=f"Unset {', '.join(empty_envs)} or set a non-empty token before starting the dashboard.", + ) + ) + elif configured_envs: + results.append(CheckResult("Auth config", True, f"Federation bearer auth configured via {configured_envs[0]}")) + else: + results.append(CheckResult("Auth config", True, "Federation auth disabled; loopback dashboard remains open by default")) + + return results + + # --------------------------------------------------------------------------- # Mode-specific checks # --------------------------------------------------------------------------- @@ -581,7 +782,10 @@ def run_doctor(project_root: Path | None = None) -> list[CheckResult]: "Schema version", False, f"v{schema_version} (current: v{CURRENT_SCHEMA_VERSION})", - fix_hint="Database schema is outdated. Run: filigree doctor --fix", + fix_hint=( + "Database schema is outdated. After backing up, run a normal DB command " + "with the upgraded binary (for example: filigree stats)." + ), ) ) else: @@ -624,7 +828,7 @@ def run_doctor(project_root: Path | None = None) -> list[CheckResult]: "context.md", False, f"Found at {summary_path} but not a file", - fix_hint="Run any filigree mutation command to refresh, or: filigree doctor --fix", + fix_hint="Run any filigree mutation command to refresh generated context.", ) ) else: @@ -636,7 +840,7 @@ def run_doctor(project_root: Path | None = None) -> list[CheckResult]: "context.md", False, f"Found at {summary_path} but unreadable: {exc}", - fix_hint="Run any filigree mutation command to refresh, or: filigree doctor --fix", + fix_hint="Run any filigree mutation command to refresh generated context.", ) ) else: @@ -647,7 +851,7 @@ def run_doctor(project_root: Path | None = None) -> list[CheckResult]: "context.md", False, f"Stale ({int(age_minutes)} minutes old)", - fix_hint="Run any filigree mutation command to refresh, or: filigree doctor --fix", + fix_hint="Run any filigree mutation command to refresh generated context.", ) ) else: @@ -658,7 +862,7 @@ def run_doctor(project_root: Path | None = None) -> list[CheckResult]: "context.md", False, "Missing", - fix_hint="Run: filigree doctor --fix", + fix_hint="Run any filigree mutation command to refresh generated context.", ) ) @@ -937,10 +1141,13 @@ def run_doctor(project_root: Path | None = None) -> list[CheckResult]: elif mode == "server": results.extend(_doctor_server_checks(filigree_dir)) - # 13. Check scanner registration drift + # 13. Check dashboard/API route registration without mutating records. + results.extend(_doctor_dashboard_contract_checks()) + + # 14. Check scanner registration drift results.extend(_doctor_bundled_scanner_checks(filigree_dir)) - # 14. Check git working tree status + # 15. Check git working tree status try: result = subprocess.run( ["git", "-C", str(filigree_dir.parent), "status", "--porcelain"], @@ -974,7 +1181,7 @@ def run_doctor(project_root: Path | None = None) -> list[CheckResult]: ) ) - # 15. Check installation method + # 16. Check installation method results.extend(_doctor_install_method()) return results diff --git a/src/filigree/mcp_tools/entities.py b/src/filigree/mcp_tools/entities.py index 1c6eacd4..858e173d 100644 --- a/src/filigree/mcp_tools/entities.py +++ b/src/filigree/mcp_tools/entities.py @@ -11,8 +11,9 @@ - ``list_associations_by_entity`` — reverse lookup from opaque entity ID to every bound issue in this project. -The Clarion entity ID is opaque to Filigree — these tools do not parse -or validate the grammar (federation enrich-only rule, ``loom.md`` §5). +The entity ID is opaque to Filigree — these tools do not parse or validate +its grammar. It may be a Clarion SEI, a legacy locator, or another external +ID. Caller-supplied ``entity_kind`` metadata is stored only when provided. """ from __future__ import annotations @@ -54,11 +55,10 @@ def register() -> tuple[list[Tool], dict[str, Callable[..., Any]]]: Tool( name="add_entity_association", description=( - "Attach a Clarion entity to a Filigree issue (ADR-029). " + "Attach an opaque external entity to a Filigree issue (ADR-029). " "Idempotent on (issue_id, entity_id): re-attaching refreshes " "content_hash and timestamp while preserving the original actor. " - "The entity_id is opaque to Filigree — its grammar is Clarion's " - "contract (ADR-003)." + "The entity_id is opaque to Filigree and may be an SEI or legacy locator." ), inputSchema={ "type": "object", @@ -66,7 +66,15 @@ def register() -> tuple[list[Tool], dict[str, Callable[..., Any]]]: "issue_id": {"type": "string", "description": "Filigree issue ID"}, "entity_id": { "type": "string", - "description": "Clarion entity ID (opaque string; not parsed)", + "description": "Opaque external entity ID; not parsed", + }, + "entity_kind": { + "type": "string", + "description": "Optional caller-supplied kind metadata; never inferred from entity_id", + }, + "external_entity_kind": { + "type": "string", + "description": "Compatibility synonym for entity_kind", }, "content_hash": { "type": "string", @@ -93,7 +101,7 @@ def register() -> tuple[list[Tool], dict[str, Callable[..., Any]]]: "issue_id": {"type": "string", "description": "Filigree issue ID"}, "entity_id": { "type": "string", - "description": "Clarion entity ID (opaque string)", + "description": "Opaque external entity ID", }, "actor": { "type": "string", @@ -106,7 +114,7 @@ def register() -> tuple[list[Tool], dict[str, Callable[..., Any]]]: Tool( name="list_entity_associations", description=( - "Return all Clarion entity bindings attached to an issue. " + "Return all opaque external entity bindings attached to an issue. " "Returns raw rows — drift detection is the caller's job per " 'ADR-029 §"Decision 3" (Clarion\'s issues_for compares ' "content_hash_at_attach against the live hash)." @@ -134,7 +142,11 @@ def register() -> tuple[list[Tool], dict[str, Callable[..., Any]]]: "properties": { "entity_id": { "type": "string", - "description": "Clarion entity ID (opaque string)", + "description": "Opaque external entity ID", + }, + "current_content_hash": { + "type": "string", + "description": "Optional caller-supplied current content hash for freshness_status comparison", }, }, "required": ["entity_id"], @@ -165,6 +177,7 @@ async def _handle_add_entity_association(arguments: dict[str, Any]) -> list[Text issue_id = args.get("issue_id", "") entity_id = args.get("entity_id", "") content_hash = args.get("content_hash", "") + entity_kind = args.get("entity_kind", args.get("external_entity_kind")) actor, actor_err = _validate_actor(args.get("actor", "mcp")) if actor_err: return actor_err @@ -176,6 +189,8 @@ async def _handle_add_entity_association(arguments: dict[str, Any]) -> list[Text ): if err is not None: return err + if entity_kind is not None and not isinstance(entity_kind, str): + return _text(ErrorResponse(error="entity_kind must be a string", code=ErrorCode.VALIDATION)) try: row = tracker.add_entity_association( @@ -183,6 +198,7 @@ async def _handle_add_entity_association(arguments: dict[str, Any]) -> list[Text make_clarion_entity_id(entity_id), make_content_hash(content_hash), actor=actor, + entity_kind=entity_kind, ) except WrongProjectError as exc: # 2.1.0 §1.2: untrusted-surface serialisation uses safe_message. @@ -261,13 +277,19 @@ async def _handle_list_associations_by_entity(arguments: dict[str, Any]) -> list args = _parse_args(arguments, ListAssociationsByEntityArgs) tracker = get_db() entity_id = args.get("entity_id", "") + current_content_hash = args.get("current_content_hash") err = _require_nonempty_str(entity_id, "entity_id") if err is not None: return err + if current_content_hash is not None and (not isinstance(current_content_hash, str) or not current_content_hash.strip()): + return _text(ErrorResponse(error="current_content_hash must be a non-empty string when provided", code=ErrorCode.VALIDATION)) try: - rows = tracker.list_associations_by_entity(make_clarion_entity_id(entity_id)) + rows = tracker.list_associations_by_entity( + make_clarion_entity_id(entity_id), + current_content_hash=make_content_hash(current_content_hash) if current_content_hash is not None else None, + ) except ValueError as exc: return _text(ErrorResponse(error=str(exc), code=ErrorCode.VALIDATION)) return _text({"associations": [dict(row) for row in rows]}) diff --git a/src/filigree/mcp_tools/files.py b/src/filigree/mcp_tools/files.py index 09a90e8c..cb4db80e 100644 --- a/src/filigree/mcp_tools/files.py +++ b/src/filigree/mcp_tools/files.py @@ -46,6 +46,7 @@ ListFilesArgs, ListFindingsArgs, PromoteFindingArgs, + PromoteFindingAttachEntityArgs, RegisterFileArgs, UpdateFindingArgs, ) @@ -277,6 +278,36 @@ def register() -> tuple[list[Tool], dict[str, Callable[..., Any]]]: "required": ["finding_id"], }, ), + Tool( + name="promote_finding_and_attach_entity", + description="Promote a scan finding to a tracked issue and attach an opaque external entity binding in one operation.", + inputSchema={ + "type": "object", + "properties": { + "finding_id": {"type": "string", "description": "Finding ID"}, + "entity_id": {"type": "string", "description": "Opaque external entity ID"}, + "content_hash": {"type": "string", "description": "Current content hash to snapshot on the association"}, + "entity_kind": { + "type": "string", + "description": "Optional caller-supplied kind metadata; never inferred from entity_id", + }, + "external_entity_kind": {"type": "string", "description": "Compatibility synonym for entity_kind"}, + "priority": { + "type": "integer", + "minimum": 0, + "maximum": 4, + "description": "Override priority (default: inferred from severity)", + }, + "labels": { + "type": "array", + "items": {"type": "string"}, + "description": "Additional labels to attach to the promoted issue", + }, + "actor": {"type": "string", "description": "Actor identity"}, + }, + "required": ["finding_id", "entity_id", "content_hash"], + }, + ), Tool( name="dismiss_finding", description=( @@ -319,6 +350,7 @@ def register() -> tuple[list[Tool], dict[str, Callable[..., Any]]]: "update_finding": _handle_update_finding, "batch_update_findings": _handle_batch_update_findings, "promote_finding": _handle_promote_finding, + "promote_finding_and_attach_entity": _handle_promote_finding_and_attach_entity, "dismiss_finding": _handle_dismiss_finding, } @@ -747,6 +779,58 @@ async def _handle_promote_finding(arguments: dict[str, Any]) -> list[TextContent return _text(response) +async def _handle_promote_finding_and_attach_entity(arguments: dict[str, Any]) -> list[TextContent]: + args = _parse_args(arguments, PromoteFindingAttachEntityArgs) + finding_id = args.get("finding_id", "") + entity_id = args.get("entity_id", "") + content_hash = args.get("content_hash", "") + if not isinstance(finding_id, str) or not finding_id.strip(): + return _text(ErrorResponse(error="finding_id is required", code=ErrorCode.VALIDATION)) + if not isinstance(entity_id, str) or not entity_id.strip(): + return _text(ErrorResponse(error="entity_id is required", code=ErrorCode.VALIDATION)) + if not isinstance(content_hash, str) or not content_hash.strip(): + return _text(ErrorResponse(error="content_hash is required", code=ErrorCode.VALIDATION)) + priority = args.get("priority") + priority_err = _validate_int_range(priority, "priority", min_val=0, max_val=4) + if priority_err: + return priority_err + labels = args.get("labels") + if labels is not None and (not isinstance(labels, list) or not all(isinstance(lbl, str) for lbl in labels)): + return _text(ErrorResponse(error="labels must be a list of strings", code=ErrorCode.VALIDATION)) + entity_kind = args.get("entity_kind", args.get("external_entity_kind")) + if entity_kind is not None and not isinstance(entity_kind, str): + return _text(ErrorResponse(error="entity_kind must be a string", code=ErrorCode.VALIDATION)) + actor, actor_err = _validate_actor(args.get("actor", "mcp")) + if actor_err: + return actor_err + + tracker = get_db() + try: + result = tracker.promote_finding_and_attach_entity( + finding_id, + entity_id, + content_hash, + priority=priority, + actor=actor, + labels=labels, + entity_kind=entity_kind, + ) + except KeyError: + return _text(ErrorResponse(error=f"Finding not found: {finding_id}", code=ErrorCode.NOT_FOUND)) + except ValueError as exc: + _logger.warning("Failed to promote finding and attach entity %s: %s", finding_id, exc) + return _text(ErrorResponse(error=f"Failed to promote finding and attach entity: {exc}", code=ErrorCode.VALIDATION)) + except sqlite3.Error as exc: + _logger.exception("Database error promoting finding and attaching entity %s", finding_id) + return _text(ErrorResponse(error=f"Database error promoting finding and attaching entity: {exc}", code=ErrorCode.IO)) + refresh_summary() + response: dict[str, object] = dict(issue_to_public(result["issue"])) + response["association"] = dict(result["association"]) + if result.get("warnings"): + response["warnings"] = result["warnings"] + return _text(response) + + async def _handle_dismiss_finding(arguments: dict[str, Any]) -> list[TextContent]: args = _parse_args(arguments, DismissFindingArgs) finding_id = args.get("finding_id", "") diff --git a/src/filigree/mcp_tools/rename.py b/src/filigree/mcp_tools/rename.py index d691b465..6ec549e7 100644 --- a/src/filigree/mcp_tools/rename.py +++ b/src/filigree/mcp_tools/rename.py @@ -94,11 +94,12 @@ "delete_file_record": "file_delete", "get_file_timeline": "file_timeline_get", "get_file_annotations": "file_annotation_list", - # finding (7) + # finding (8) "list_findings": "finding_list", "get_finding": "finding_get", "dismiss_finding": "finding_dismiss", "promote_finding": "finding_promote", + "promote_finding_and_attach_entity": "finding_promote_and_attach_entity", "update_finding": "finding_update", "batch_update_findings": "finding_batch_update", "report_finding": "finding_report", diff --git a/src/filigree/mcp_tools/tiers.py b/src/filigree/mcp_tools/tiers.py index 3cbd75d7..99301d03 100644 --- a/src/filigree/mcp_tools/tiers.py +++ b/src/filigree/mcp_tools/tiers.py @@ -139,6 +139,7 @@ "get_file_timeline", "dismiss_finding", "promote_finding", + "promote_finding_and_attach_entity", "update_finding", # annotations (whole subsystem is niche) "annotate_file", diff --git a/src/filigree/migrations.py b/src/filigree/migrations.py index d2256563..d3d9dc08 100644 --- a/src/filigree/migrations.py +++ b/src/filigree/migrations.py @@ -777,6 +777,17 @@ def migrate_v21_to_v22(conn: sqlite3.Connection) -> None: add_column(conn, "entity_associations", "migration_orphaned_at", "TEXT", default=None) +def migrate_v22_to_v23(conn: sqlite3.Connection) -> None: + """v22 -> v23: Add caller-supplied ``entity_associations.entity_kind``. + + Entity IDs are opaque external identifiers: they may be SEIs, legacy + locators, or non-Clarion IDs. Filigree must not parse kind out of the ID, + but callers that already know the kind can now store it explicitly for UI + and API presentation. Empty string means unknown/not supplied. + """ + add_column(conn, "entity_associations", "entity_kind", "TEXT NOT NULL", default="''") + + MIGRATIONS: dict[int, MigrationFn] = { 1: migrate_v1_to_v2, 2: migrate_v2_to_v3, @@ -799,6 +810,7 @@ def migrate_v21_to_v22(conn: sqlite3.Connection) -> None: 19: migrate_v19_to_v20, 20: migrate_v20_to_v21, 21: migrate_v21_to_v22, + 22: migrate_v22_to_v23, } diff --git a/src/filigree/scanner_scripts/scan_utils.py b/src/filigree/scanner_scripts/scan_utils.py index 3e5a8d16..b66ad454 100644 --- a/src/filigree/scanner_scripts/scan_utils.py +++ b/src/filigree/scanner_scripts/scan_utils.py @@ -367,8 +367,13 @@ def post_to_api( Args: api_url: Base URL (e.g., "http://localhost:8377"). scan_source: Scanner identifier (e.g., "codex", "claude"). - scan_run_id: Unique run identifier. + scan_run_id: Globally unique run identifier. Use a non-empty value for + scan-run history; an empty string is accepted by Filigree for + fire-and-forget findings but is excluded from ``GET /api/scan-runs``. findings: List of finding dicts. + Filigree expects native scan-results findings. SARIF adapters must + map SARIF ``partialFingerprints``/``fingerprints`` into each + finding's ``fingerprint`` before calling this helper. create_observations: If True, auto-promote findings to observations for triage. complete_scan_run: If False, don't mark the scan run as completed. Use for batch scans where multiple POSTs share a scan_run_id. diff --git a/src/filigree/static/js/api.js b/src/filigree/static/js/api.js index 8ca28d15..29fdd9ea 100644 --- a/src/filigree/static/js/api.js +++ b/src/filigree/static/js/api.js @@ -137,6 +137,17 @@ export async function fetchIssueFiles(issueId) { } } +export async function fetchIssueEntityAssociations(issueId) { + try { + const resp = await fetch(apiUrl(`/issue/${issueId}/entity-associations`)); + if (!resp.ok) return null; + return await resp.json(); + } catch (err) { + console.warn("[fetchIssueEntityAssociations] Network error:", err); + return null; + } +} + export async function fetchTransitions(issueId) { try { const resp = await fetch(apiUrl(`/issue/${issueId}/transitions`)); diff --git a/src/filigree/static/js/views/detail.js b/src/filigree/static/js/views/detail.js index de6377e5..7648e52d 100644 --- a/src/filigree/static/js/views/detail.js +++ b/src/filigree/static/js/views/detail.js @@ -5,6 +5,7 @@ import { deleteIssueDep, fetchIssueDetail, + fetchIssueEntityAssociations, fetchIssueFiles, fetchSearch, fetchTransitions, @@ -68,17 +69,22 @@ export async function openDetail(issueId) { let eventsData = []; let commentsData = []; let issueFilesData = []; + let entityAssociationsData = []; let staleBanner = ""; try { - const [detailData, filesData] = await Promise.all([ + const [detailData, filesData, entityAssocData] = await Promise.all([ fetchIssueDetail(issueId), fetchIssueFiles(issueId), + fetchIssueEntityAssociations(issueId), ]); d = detailData; if (!d) throw new Error("Not found"); eventsData = d.events || []; commentsData = d.comments || []; issueFilesData = Array.isArray(filesData) ? filesData : []; + entityAssociationsData = Array.isArray(entityAssocData?.associations) + ? entityAssocData.associations + : []; } catch (err) { console.warn("[detail] Failed to fetch issue detail, using cached data:", err); // Fall back to local data if detail endpoint fails @@ -174,6 +180,40 @@ export async function openDetail(issueId) { ); }) .join(""); + const entityAssocCounts = entityAssociationsData.reduce( + (acc, assoc) => { + if (assoc.orphan_status === "orphaned") acc.orphaned += 1; + if (assoc.freshness_status === "fresh") acc.fresh += 1; + else if (assoc.freshness_status === "stale") acc.stale += 1; + else acc.unknown += 1; + return acc; + }, + { fresh: 0, stale: 0, unknown: 0, orphaned: 0 }, + ); + const entityAssociationsHtml = entityAssociationsData + .map((assoc) => { + const freshness = assoc.freshness_status || "unknown"; + const orphaned = assoc.orphan_status === "orphaned"; + const freshnessClass = + freshness === "fresh" + ? "text-emerald-400" + : freshness === "stale" + ? "text-amber-300" + : "text-slate-300"; + const kind = assoc.entity_kind + ? `${escHtml(assoc.entity_kind)}` + : ""; + return ( + '
' + + '
' + + `${escHtml(assoc.entity_id || assoc.clarion_entity_id || "")}` + + kind + + `${escHtml(freshness)}` + + (orphaned ? 'orphaned' : "") + + "
" + ); + }) + .join(""); const readyBadge = detailReadinessBadgeHtml(d); @@ -237,6 +277,12 @@ export async function openDetail(issueId) { issueFilesHtml + "" : "") + + (entityAssociationsData.length + ? '
Entity Associations
' + + `
${entityAssociationsData.length} total · ${entityAssocCounts.stale} stale · ${entityAssocCounts.orphaned} orphaned
` + + entityAssociationsHtml + + "
" + : "") + (commentsData.length ? '
Comments
' + commentsHtml + diff --git a/src/filigree/types/inputs.py b/src/filigree/types/inputs.py index 4ab31b8f..8c15da01 100644 --- a/src/filigree/types/inputs.py +++ b/src/filigree/types/inputs.py @@ -639,6 +639,8 @@ class AddEntityAssociationArgs(TypedDict): issue_id: IssueId entity_id: ClarionEntityId content_hash: ContentHash + entity_kind: NotRequired[str] + external_entity_kind: NotRequired[str] actor: NotRequired[str] @@ -654,6 +656,7 @@ class ListEntityAssociationsArgs(TypedDict): class ListAssociationsByEntityArgs(TypedDict): entity_id: ClarionEntityId + current_content_hash: NotRequired[ContentHash] class TriggerScanArgs(TypedDict): @@ -710,6 +713,17 @@ class PromoteFindingArgs(TypedDict): actor: NotRequired[str] +class PromoteFindingAttachEntityArgs(TypedDict): + finding_id: str + entity_id: str + content_hash: str + priority: NotRequired[int] + labels: NotRequired[list[str]] + entity_kind: NotRequired[str] + external_entity_kind: NotRequired[str] + actor: NotRequired[str] + + class DismissFindingArgs(TypedDict): finding_id: str reason: NotRequired[str] @@ -939,6 +953,7 @@ class PromoteObservationArgs(TypedDict): "update_finding": UpdateFindingArgs, "batch_update_findings": BatchUpdateFindingsArgs, "promote_finding": PromoteFindingArgs, + "promote_finding_and_attach_entity": PromoteFindingAttachEntityArgs, "dismiss_finding": DismissFindingArgs, # scanners.py (list_scanners has no args — excluded) "enable_scanner": EnableScannerArgs, diff --git a/tests/api/test_entity_associations.py b/tests/api/test_entity_associations.py index feba2021..35c8314e 100644 --- a/tests/api/test_entity_associations.py +++ b/tests/api/test_entity_associations.py @@ -68,10 +68,26 @@ async def test_attach_returns_201(self, client: AsyncClient, dashboard_db: Popul ) assert resp.status_code == 201 body = resp.json() + assert body["entity_id"] == "py:func:tokenize" assert body["clarion_entity_id"] == "py:func:tokenize" assert body["content_hash_at_attach"] == "hash-a" assert body["attached_by"] == "alice" + async def test_attach_preserves_optional_entity_kind(self, client: AsyncClient, dashboard_db: PopulatedDB) -> None: + issue_id = dashboard_db.ids["a"] + resp = await client.post( + f"/api/issue/{issue_id}/entity-associations", + json={ + "entity_id": "not-a-clarion-locator", + "content_hash": "hash-a", + "entity_kind": "function", + }, + ) + assert resp.status_code == 201 + body = resp.json() + assert body["entity_id"] == "not-a-clarion-locator" + assert body["entity_kind"] == "function" + async def test_attach_idempotent_refreshes_hash(self, client: AsyncClient, dashboard_db: PopulatedDB) -> None: issue_id = dashboard_db.ids["a"] await client.post( @@ -168,8 +184,28 @@ async def test_returns_every_issue_bound_to_entity(self, client: AsyncClient, da assert resp.status_code == 200 body = resp.json() assert {row["issue_id"] for row in body["associations"]} == {a_id, b_id} + assert all(row["entity_id"] == target for row in body["associations"]) assert all(row["clarion_entity_id"] == target for row in body["associations"]) + async def test_current_content_hash_marks_freshness(self, client: AsyncClient, dashboard_db: PopulatedDB) -> None: + a_id = dashboard_db.ids["a"] + target = "clarion:eid:fresh" + dashboard_db.db.add_entity_association(a_id, target, content_hash="hash-a") + + fresh = await client.get( + "/api/entity-associations", + params={"entity_id": target, "current_content_hash": "hash-a"}, + ) + stale = await client.get( + "/api/entity-associations", + params={"entity_id": target, "current_content_hash": "hash-b"}, + ) + + assert fresh.status_code == 200 + assert stale.status_code == 200 + assert fresh.json()["associations"][0]["freshness_status"] == "fresh" + assert stale.json()["associations"][0]["freshness_status"] == "stale" + async def test_foreign_looking_entity_id_is_opaque_lookup_key(self, client: AsyncClient) -> None: resp = await client.get("/api/entity-associations", params={"entity_id": "other-1234567890"}) assert resp.status_code == 200 diff --git a/tests/api/test_files_api.py b/tests/api/test_files_api.py index 842bbe26..5a9560f1 100644 --- a/tests/api/test_files_api.py +++ b/tests/api/test_files_api.py @@ -17,6 +17,7 @@ _OLD_TS = "2020-01-01T00:00:00+00:00" # well past any clean-stale cutoff _CLEAN_STALE_FIXTURE = Path(__file__).resolve().parents[2] / "tests" / "fixtures" / "contracts" / "loom" / "findings-clean-stale.json" +_SCAN_RESULTS_FIXTURE = Path(__file__).resolve().parents[2] / "tests" / "fixtures" / "contracts" / "loom" / "scan-results.json" class TestFilesSchemaAPI: @@ -216,6 +217,18 @@ async def test_schema_includes_scan_runs_endpoint(self, client: AsyncClient) -> paths = [ep["path"] for ep in data["endpoints"]] assert "/api/scan-runs" in paths + async def test_schema_documents_empty_scan_run_id_history_tradeoff(self, client: AsyncClient) -> None: + resp = await client.get("/api/files/_schema") + data = resp.json() + scan_results = next(ep for ep in data["endpoints"] if ep["path"] == "/api/v1/scan-results") + scan_run_id_doc = scan_results["request_body"]["scan_run_id"] + + assert "globally unique non-empty" in scan_run_id_doc + assert "/api/scan-runs" in scan_run_id_doc + assert "empty" in scan_run_id_doc + assert "fire-and-forget" in scan_run_id_doc + assert "excluded" in scan_run_id_doc + class TestUnknownScanRunIdContract: """POST scan-results with a client-supplied scan_run_id Filigree has never @@ -403,6 +416,126 @@ async def test_non_string_fingerprint_rejected_over_endpoint(self, client: Async assert resp.status_code == 400 assert resp.json()["code"] == "VALIDATION" + async def test_wardline_fingerprint_survives_native_ingest_readback_promote_dedup_and_lifecycle( + self, + client: AsyncClient, + dashboard_db: PopulatedDB, + ) -> None: + fingerprint = "wardline:sarif:partial-fingerprint:v1" + first = await client.post( + "/api/loom/scan-results", + json={ + "scan_source": "wardline", + "scan_run_id": "wardline-native-run-001", + "findings": [ + { + "path": "src/flow.py", + "rule_id": "WLN-TAINT", + "message": "tainted source reaches sink", + "severity": "high", + "line_start": 12, + "fingerprint": fingerprint, + } + ], + }, + ) + assert first.status_code == 200 + assert first.json()["stats"]["findings_created"] == 1 + + listing = await client.get(f"/api/loom/findings?scan_source=wardline&fingerprint={fingerprint}") + assert listing.status_code == 200 + items = listing.json()["items"] + assert len(items) == 1 + finding = items[0] + assert finding["scan_run_id"] == "wardline-native-run-001" + assert finding["fingerprint"] == fingerprint + + promoted = await client.post( + "/api/loom/findings/promote", + json={"scan_source": "wardline", "fingerprint": fingerprint, "actor": "wardline"}, + ) + assert promoted.status_code == 200 + issue_id = promoted.json()["issue_id"] + + second = await client.post( + "/api/loom/scan-results", + json={ + "scan_source": "wardline", + "scan_run_id": "wardline-native-run-002", + "findings": [ + { + "path": "src/flow.py", + "rule_id": "WLN-TAINT", + "message": "same taint path after line movement", + "severity": "high", + "line_start": 48, + "fingerprint": fingerprint, + } + ], + }, + ) + assert second.status_code == 200 + assert second.json()["stats"]["findings_updated"] == 1 + assert second.json()["succeeded"] == [] + + listing = await client.get(f"/api/loom/findings?scan_source=wardline&fingerprint={fingerprint}") + finding = listing.json()["items"][0] + assert finding["seen_count"] == 2 + assert finding["line_start"] == 48 + assert finding["issue_id"] == issue_id + # First attribution wins: later dedup/readback must not erase the run + # that originally introduced this finding. + assert finding["scan_run_id"] == "wardline-native-run-001" + + dashboard_db.db.conn.execute( + "UPDATE scan_findings SET status = 'unseen_in_latest', last_seen_at = ? WHERE fingerprint = ?", + (_OLD_TS, fingerprint), + ) + dashboard_db.db.conn.commit() + swept = await client.post("/api/loom/findings/clean-stale", json={"scan_source": "wardline", "older_than_days": 30}) + assert swept.status_code == 200 + fixed = (await client.get(f"/api/loom/findings?scan_source=wardline&fingerprint={fingerprint}")).json()["items"][0] + assert fixed["status"] == "fixed" + assert dashboard_db.db._resolve_status_category("bug", dashboard_db.db.get_issue(issue_id).status) == "done" + + reopened = await client.post( + "/api/loom/scan-results", + json={ + "scan_source": "wardline", + "scan_run_id": "wardline-native-run-003", + "findings": [ + { + "path": "src/flow.py", + "rule_id": "WLN-TAINT", + "message": "same taint path regressed", + "severity": "high", + "line_start": 50, + "fingerprint": fingerprint, + } + ], + }, + ) + assert reopened.status_code == 200 + current = (await client.get(f"/api/loom/findings?scan_source=wardline&fingerprint={fingerprint}")).json()["items"][0] + assert current["status"] == "open" + assert current["seen_count"] == 3 + assert dashboard_db.db._resolve_status_category("bug", dashboard_db.db.get_issue(issue_id).status) != "done" + + def test_scan_results_fixture_pins_sarif_adapter_fingerprint_contract(self) -> None: + fixture = json.loads(_SCAN_RESULTS_FIXTURE.read_text()) + adapter_contract = fixture["shape_decl"]["external_adapter_contract"] + assert "SARIF" in adapter_contract + assert "partialFingerprints" in adapter_contract + assert "finding.fingerprint" in adapter_contract + + wardline_example = next( + example for example in fixture["examples"] if example["name"] == "success_wardline_sarif_adapter_fingerprint" + ) + finding = wardline_example["request"]["body"]["findings"][0] + assert finding["fingerprint"] + assert "partialFingerprints" in wardline_example["note"] + assert wardline_example["response"]["body"]["stats"]["findings_created"] == 1 + class TestLoomCleanStaleFindingsAPI: """POST /api/loom/findings/clean-stale — federation retention surface. @@ -636,7 +769,7 @@ async def _ingest(self, client: AsyncClient, fingerprint: str = "fp-http") -> No ) assert resp.status_code == 200 - async def test_promote_returns_issue_id_created(self, client: AsyncClient) -> None: + async def test_promote_returns_issue_id_created(self, client: AsyncClient, dashboard_db: PopulatedDB) -> None: await self._ingest(client) resp = await client.post("/api/loom/findings/promote", json={"scan_source": "wardline", "fingerprint": "fp-http"}) assert resp.status_code == 200 @@ -644,6 +777,8 @@ async def test_promote_returns_issue_id_created(self, client: AsyncClient) -> No assert body["created"] is True assert body["issue_id"] assert set(body.keys()) == {"issue_id", "created"} + issue = dashboard_db.db.get_issue(body["issue_id"]) + assert issue.fields["severity"] == "major" async def test_promote_is_idempotent(self, client: AsyncClient) -> None: await self._ingest(client) @@ -654,6 +789,103 @@ async def test_promote_is_idempotent(self, client: AsyncClient) -> None: assert second.json()["created"] is False assert second.json()["issue_id"] == first.json()["issue_id"] + async def test_promote_and_attach_entity_is_idempotent(self, client: AsyncClient, dashboard_db: PopulatedDB) -> None: + await self._ingest(client) + + first = await client.post( + "/api/loom/findings/promote-and-attach", + json={ + "scan_source": "wardline", + "fingerprint": "fp-http", + "entity_id": "clarion:eid:abc123", + "content_hash": "hash-v1", + "entity_kind": "function", + "actor": "wardline", + }, + ) + assert first.status_code == 200 + body = first.json() + assert body["created"] is True + assert body["association"]["entity_id"] == "clarion:eid:abc123" + assert body["association"]["entity_kind"] == "function" + + second = await client.post( + "/api/loom/findings/promote-and-attach", + json={ + "scan_source": "wardline", + "fingerprint": "fp-http", + "entity_id": "clarion:eid:abc123", + "content_hash": "hash-v2", + "actor": "wardline", + }, + ) + assert second.status_code == 200 + assert second.json()["issue_id"] == body["issue_id"] + assert second.json()["created"] is False + assert second.json()["association"]["content_hash_at_attach"] == "hash-v2" + + rows = dashboard_db.db.list_entity_associations(body["issue_id"]) + assert len(rows) == 1 + assert rows[0]["content_hash_at_attach"] == "hash-v2" + + async def test_finding_dossier_includes_file_issue_and_entity_context(self, client: AsyncClient, dashboard_db: PopulatedDB) -> None: + await self._ingest(client) + listing = await client.get("/api/loom/findings", params={"scan_source": "wardline", "fingerprint": "fp-http"}) + finding_id = listing.json()["items"][0]["finding_id"] + promoted = await client.post( + "/api/loom/findings/promote-and-attach", + json={ + "scan_source": "wardline", + "fingerprint": "fp-http", + "entity_id": "clarion:eid:dossier", + "content_hash": "hash-v1", + "entity_kind": "function", + }, + ) + issue_id = promoted.json()["issue_id"] + dashboard_db.db.add_file_association(listing.json()["items"][0]["file_id"], issue_id, "scan_finding", actor="tester") + + resp = await client.get(f"/api/loom/findings/{finding_id}/dossier") + + assert resp.status_code == 200 + body = resp.json() + assert body["finding"]["finding_id"] == finding_id + assert body["file"]["file_id"] == listing.json()["items"][0]["file_id"] + assert body["linked_issue"]["issue_id"] == issue_id + assert body["entity_associations"][0]["entity_id"] == "clarion:eid:dossier" + assert body["file_associations"][0]["issue_id"] == issue_id + + async def test_session_evidence_bundle_actor_window(self, client: AsyncClient) -> None: + await self._ingest(client, fingerprint="fp-session") + promoted = await client.post( + "/api/loom/findings/promote-and-attach", + json={ + "scan_source": "wardline", + "fingerprint": "fp-session", + "entity_id": "clarion:eid:session", + "content_hash": "hash-v1", + "actor": "session-agent", + }, + ) + assert promoted.status_code == 200 + + resp = await client.get("/api/loom/session-evidence", params={"actor": "session-agent"}) + + assert resp.status_code == 200 + body = resp.json() + assert body["query"]["actor"] == "session-agent" + assert {issue["issue_id"] for issue in body["issues"]} == {promoted.json()["issue_id"]} + assert body["findings"] + assert body["entity_associations"][0]["entity_id"] == "clarion:eid:session" + + async def test_session_evidence_empty_bundle(self, client: AsyncClient) -> None: + resp = await client.get("/api/loom/session-evidence", params={"actor": "no-such-agent"}) + + assert resp.status_code == 200 + assert resp.json()["issues"] == [] + assert resp.json()["findings"] == [] + assert resp.json()["entity_associations"] == [] + async def test_unknown_fingerprint_404(self, client: AsyncClient) -> None: await self._ingest(client) resp = await client.post("/api/loom/findings/promote", json={"scan_source": "wardline", "fingerprint": "fp-nope"}) diff --git a/tests/cli/test_admin_commands.py b/tests/cli/test_admin_commands.py index 5d9dd186..2e6662ca 100644 --- a/tests/cli/test_admin_commands.py +++ b/tests/cli/test_admin_commands.py @@ -445,6 +445,146 @@ def test_doctor_fix(self, cli_in_project: tuple[CliRunner, Path], monkeypatch: p result = runner.invoke(cli, ["doctor", "--fix"]) assert result.exit_code == 0 + def test_doctor_json_emits_shared_summary_contract( + self, cli_in_project: tuple[CliRunner, Path], monkeypatch: pytest.MonkeyPatch + ) -> None: + runner, _ = cli_in_project + + from filigree.install_support.doctor import CheckResult + + monkeypatch.setattr( + "filigree.install.run_doctor", + lambda **_kw: [ + CheckResult("Claude Code MCP", True, "configured"), + CheckResult("Bundled scanner registrations", False, "stale", fix_hint="run scanner enable"), + ], + ) + + result = runner.invoke(cli, ["doctor", "--json"]) + + assert result.exit_code == 1 + payload = json.loads(result.output) + assert set(payload) == {"ok", "checks", "next_actions"} + assert payload["ok"] is False + assert isinstance(payload["checks"], list) + assert isinstance(payload["next_actions"], list) + checks = {check["id"]: check for check in payload["checks"]} + assert checks["mcp.registration"] == {"id": "mcp.registration", "status": "ok", "fixed": False} + assert checks["scanner.registration"] == {"id": "scanner.registration", "status": "failed", "fixed": False} + assert "api.availability" in checks + assert "auth.config" in checks + assert "entity_associations.routes" in checks + + def test_doctor_json_real_project_includes_stable_route_ids(self, cli_in_project: tuple[CliRunner, Path]) -> None: + runner, _ = cli_in_project + + result = runner.invoke(cli, ["doctor", "--json"]) + + assert result.output + payload = json.loads(result.output) + check_ids = {check["id"] for check in payload["checks"]} + assert { + "dashboard.port", + "mcp.registration", + "api.availability", + "scanner.results", + "entity_associations.routes", + }.issubset(check_ids) + + def test_doctor_fix_json_reports_repair_and_is_idempotent( + self, cli_in_project: tuple[CliRunner, Path], monkeypatch: pytest.MonkeyPatch + ) -> None: + runner, _ = cli_in_project + + from filigree.install_support.doctor import CheckResult + + repaired = False + + def fake_run_doctor(**_kw: object) -> list[CheckResult]: + if repaired: + return [CheckResult("Claude Code MCP", True, "configured")] + return [CheckResult("Claude Code MCP", False, "missing", fix_hint="hint")] + + def fake_install_claude_code_mcp(*_args: object, **_kwargs: object) -> tuple[bool, str]: + nonlocal repaired + repaired = True + return True, "Configured .mcp.json" + + monkeypatch.setattr("filigree.install.run_doctor", fake_run_doctor) + monkeypatch.setattr("filigree.install.install_claude_code_mcp", fake_install_claude_code_mcp) + + first = runner.invoke(cli, ["doctor", "--fix", "--json"]) + second = runner.invoke(cli, ["doctor", "--fix", "--json"]) + + assert first.exit_code == 0 + first_payload = json.loads(first.output) + assert first_payload["ok"] is True + assert {"id": "mcp.registration", "status": "fixed", "fixed": True} in first_payload["checks"] + + assert second.exit_code == 0 + second_payload = json.loads(second.output) + assert second_payload["ok"] is True + assert {"id": "mcp.registration", "status": "ok", "fixed": False} in second_payload["checks"] + + def test_doctor_fix_json_does_not_mutate_scanner_results_by_default( + self, cli_in_project: tuple[CliRunner, Path], monkeypatch: pytest.MonkeyPatch + ) -> None: + runner, _ = cli_in_project + + from filigree.install_support.doctor import CheckResult + + monkeypatch.setattr( + "filigree.install.run_doctor", + lambda **_kw: [ + CheckResult( + "Bundled scanner registrations", + False, + "Stale bundled scanner registration(s): codex", + fix_hint="Run: filigree scanner enable codex --force", + code="stale_bundled_scanner", + ) + ], + ) + + result = runner.invoke(cli, ["doctor", "--fix", "--json"]) + + assert result.exit_code == 1 + payload = json.loads(result.output) + assert payload["ok"] is False + assert {"id": "scanner.registration", "status": "failed", "fixed": False} in payload["checks"] + assert payload["next_actions"] == ["scanner.registration: Run: filigree scanner enable codex --force"] + + def test_doctor_fix_json_only_repairs_local_bindings_and_stale_dashboard_pointers( + self, cli_in_project: tuple[CliRunner, Path], monkeypatch: pytest.MonkeyPatch + ) -> None: + runner, _ = cli_in_project + + from filigree.install_support.doctor import CheckResult + + monkeypatch.setattr( + "filigree.install.run_doctor", + lambda **_kw: [ + CheckResult(".gitignore", False, "missing", fix_hint="Run: filigree install --gitignore"), + CheckResult("Claude Code MCP", False, "missing", fix_hint="Run: filigree install --claude-code"), + CheckResult("Ephemeral port", False, "stale", fix_hint="Remove .filigree/ephemeral.port"), + ], + ) + + def fail_gitignore(_root: Path) -> tuple[bool, str]: + raise AssertionError("doctor --fix must not repair .gitignore") + + monkeypatch.setattr("filigree.install.ensure_gitignore", fail_gitignore) + monkeypatch.setattr("filigree.install.install_claude_code_mcp", lambda *_args, **_kwargs: (True, "repaired MCP")) + + result = runner.invoke(cli, ["doctor", "--fix", "--json"]) + + assert result.exit_code == 1 + payload = json.loads(result.output) + checks = {check["id"]: check for check in payload["checks"]} + assert checks["git.ignore"] == {"id": "git.ignore", "status": "failed", "fixed": False} + assert checks["mcp.registration"] == {"id": "mcp.registration", "status": "fixed", "fixed": True} + assert checks["dashboard.port"] == {"id": "dashboard.port", "status": "fixed", "fixed": True} + def test_doctor_fix_reports_manual_intervention_on_fixer_failure( self, cli_in_project: tuple[CliRunner, Path], monkeypatch: pytest.MonkeyPatch ) -> None: @@ -453,25 +593,25 @@ def test_doctor_fix_reports_manual_intervention_on_fixer_failure( from filigree.install_support.doctor import CheckResult - # Two fixable failures: .gitignore (will fail to fix) and CLAUDE.md (will succeed) + # Two fixable failures: MCP binding (will fail to fix) and stale dashboard pointer (will succeed) mock_results = [ - CheckResult(".gitignore", False, "missing", fix_hint="hint"), - CheckResult("CLAUDE.md", False, "missing", fix_hint="hint"), + CheckResult("Claude Code MCP", False, "missing", fix_hint="hint"), + CheckResult("Ephemeral port", False, "stale", fix_hint="hint"), ] monkeypatch.setattr("filigree.install.run_doctor", lambda **_kw: mock_results) monkeypatch.setattr( - "filigree.install.ensure_gitignore", - lambda _root: (False, "Permission denied"), + "filigree.install.install_claude_code_mcp", + lambda *_args, **_kwargs: (False, "Permission denied"), ) monkeypatch.setattr( - "filigree.install.inject_instructions", - lambda _path: (True, "Injected"), + "filigree.cli_commands.admin._remove_stale_doctor_pointer", + lambda _path: (True, "Removed stale pointer"), ) result = runner.invoke(cli, ["doctor", "--fix"]) - assert "!! .gitignore: Permission denied" in result.output - assert "OK CLAUDE.md: Injected" in result.output + assert "!! Claude Code MCP: Permission denied" in result.output + assert "OK Ephemeral port: Removed stale pointer" in result.output assert "Fixed 1/2 issues" in result.output assert "1 require manual intervention" in result.output @@ -521,11 +661,11 @@ def test_doctor_fix_exits_zero_when_all_fixed(self, cli_in_project: tuple[CliRun monkeypatch.setattr( "filigree.install.run_doctor", - lambda **_kw: [CheckResult(".gitignore", False, "missing", fix_hint="hint")], + lambda **_kw: [CheckResult("Claude Code MCP", False, "missing", fix_hint="hint")], ) monkeypatch.setattr( - "filigree.install.ensure_gitignore", - lambda _root: (True, "Added .filigree/ to .gitignore"), + "filigree.install.install_claude_code_mcp", + lambda *_args, **_kwargs: (True, "Configured .mcp.json"), ) result = runner.invoke(cli, ["doctor", "--fix"]) @@ -833,9 +973,11 @@ def test_init_existing_no_upgrade_message_when_current( class TestDoctorFixHonoursConfDbPath: - """filigree-fa6309d551: --fix schema repair must use the conf-declared DB.""" + """filigree-fa6309d551: --fix must not touch a phantom legacy DB.""" - def test_doctor_fix_migrates_conf_relocated_db(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch, cli_runner: CliRunner) -> None: + def test_doctor_fix_diagnoses_conf_relocated_schema_without_migrating( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch, cli_runner: CliRunner + ) -> None: import shutil import sqlite3 @@ -868,22 +1010,26 @@ def test_doctor_fix_migrates_conf_relocated_db(self, tmp_path: Path, monkeypatch assert result.exit_code in (0, 1), result.output assert not legacy_db.exists(), "doctor --fix must not create a phantom legacy DB" - # The custom DB should now be at the current schema. + # Schema repair is now validate-and-report only; doctor --fix must not + # mutate the database schema while repairing local bindings/pointers. conn = sqlite3.connect(str(custom_db)) try: - from filigree.db_schema import CURRENT_SCHEMA_VERSION - ver = conn.execute("PRAGMA user_version").fetchone()[0] - assert ver == CURRENT_SCHEMA_VERSION, f"custom DB still at v{ver}" + assert ver == 1 finally: conn.close() + assert "Schema version: v1" in result.output class TestDoctorFixSchema: - """Test that `filigree doctor --fix` can repair outdated schemas.""" + """Test `filigree doctor --fix` schema handling.""" + + def test_doctor_fix_reports_outdated_schema_without_migrating( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch, cli_runner: CliRunner + ) -> None: + """doctor --fix should diagnose outdated schema without applying migrations.""" + import sqlite3 - def test_doctor_fix_upgrades_outdated_schema(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch, cli_runner: CliRunner) -> None: - """doctor --fix should apply migrations when schema is outdated.""" monkeypatch.chdir(tmp_path) cli_runner.invoke(cli, ["init"]) _downgrade_db(tmp_path, target_version=1) @@ -891,9 +1037,15 @@ def test_doctor_fix_upgrades_outdated_schema(self, tmp_path: Path, monkeypatch: result = cli_runner.invoke(cli, ["doctor", "--fix"]) # filigree-467d1e7487: doctor exits 1 when unfixable env checks # remain (e.g. duplicate venv+uv-tool install in test env). Assert - # the schema-fix payload happened, not the global exit code. - assert result.exit_code in (0, 1) - assert "Schema upgraded v1" in result.output + # the schema diagnostic happened, not the global exit code. + assert result.exit_code == 1 + assert "Schema version: v1" in result.output + assert "Schema upgraded v1" not in result.output + conn = sqlite3.connect(str(tmp_path / ".filigree" / "filigree.db")) + try: + assert conn.execute("PRAGMA user_version").fetchone()[0] == 1 + finally: + conn.close() def test_doctor_fix_no_schema_issue_when_current(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch, cli_runner: CliRunner) -> None: """doctor --fix on a current schema should not mention schema upgrades.""" diff --git a/tests/cli/test_files_commands.py b/tests/cli/test_files_commands.py index 2a89d7a5..36facbc7 100644 --- a/tests/cli/test_files_commands.py +++ b/tests/cli/test_files_commands.py @@ -1709,6 +1709,7 @@ def test_promote_finding_happy_path_json(self, initialized_project_with_finding: assert data["issue_id"] assert "Test finding" in data["title"] or data["title"] assert "from-finding" in data["labels"] + assert data["fields"]["severity"] == "major" finally: os.chdir(original) diff --git a/tests/core/test_crud.py b/tests/core/test_crud.py index 7eda1738..e55d6e97 100644 --- a/tests/core/test_crud.py +++ b/tests/core/test_crud.py @@ -1420,6 +1420,7 @@ def test_import_roundtrip_preserves_entity_associations(self, db: FiligreeDB, tm "attached_at": attached["attached_at"], "attached_by": "alice", "migration_orphaned_at": None, + "entity_kind": "", } ] diff --git a/tests/core/test_finding_triage.py b/tests/core/test_finding_triage.py index 4be9566d..7aab7792 100644 --- a/tests/core/test_finding_triage.py +++ b/tests/core/test_finding_triage.py @@ -217,12 +217,44 @@ def test_creates_issue_and_links_finding(self, db: FiligreeDB) -> None: assert issue.type == "bug" assert issue.priority == 0 + assert issue.fields["severity"] == "critical" assert "SQL injection" in issue.title assert issue.fields["source_finding_id"] == ids["sqli"] assert db.get_finding(ids["sqli"])["issue_id"] == issue.id labels = db.conn.execute("SELECT label FROM labels WHERE issue_id = ?", (issue.id,)).fetchall() assert any(row["label"] == "from-finding" for row in labels) + @pytest.mark.parametrize( + ("finding_severity", "bug_severity"), + [ + ("critical", "critical"), + ("high", "major"), + ("medium", "major"), + ("low", "minor"), + ("info", "cosmetic"), + ], + ) + def test_maps_finding_severity_to_bug_workflow_severity( + self, + db: FiligreeDB, + finding_severity: str, + bug_severity: str, + ) -> None: + result = db.process_scan_results( + scan_source="test-scanner", + findings=[ + { + "path": f"src/{finding_severity}.py", + "rule_id": "R1", + "severity": finding_severity, + "message": "finding", + } + ], + ) + issue = db.promote_finding_to_issue(result["new_finding_ids"][0])["issue"] + + assert issue.fields["severity"] == bug_severity + def test_reuses_existing_issue_on_retry(self, db: FiligreeDB) -> None: ids = _seed_findings(db) first = db.promote_finding_to_issue(ids["sqli"]) diff --git a/tests/core/test_schema.py b/tests/core/test_schema.py index e605f8df..3598fb4b 100644 --- a/tests/core/test_schema.py +++ b/tests/core/test_schema.py @@ -198,6 +198,7 @@ def test_fresh_schema_contains_entity_associations_table(self, tmp_path: Path) - "attached_at", "attached_by", "migration_orphaned_at", + "entity_kind", } # Composite PK — no surrogate association_id. pk_rows = conn.execute("PRAGMA table_info(entity_associations)").fetchall() @@ -319,6 +320,32 @@ def test_migration_v21_to_v22_idempotent(self, tmp_path: Path) -> None: assert "migration_orphaned_at" in _get_table_columns(conn, "entity_associations") conn.close() + def test_migration_v22_to_v23_adds_entity_kind_column(self, tmp_path: Path) -> None: + conn = _make_db(tmp_path) + conn.executescript(SCHEMA_SQL) + conn.execute("ALTER TABLE entity_associations DROP COLUMN entity_kind") + conn.execute("PRAGMA user_version = 22") + conn.execute( + "INSERT INTO issues (id, title, created_at, updated_at) VALUES ('iss-1', 't', ?, ?)", + ("2026-05-01T00:00:00+00:00", "2026-05-01T00:00:00+00:00"), + ) + conn.execute( + "INSERT INTO entity_associations " + "(issue_id, clarion_entity_id, content_hash_at_attach, attached_at, attached_by) " + "VALUES ('iss-1', 'not-a-clarion-id', 'h', ?, 'x')", + ("2026-05-01T00:00:00+00:00",), + ) + conn.commit() + + applied = apply_pending_migrations(conn, 23) + + assert applied == 1 + assert _get_schema_version(conn) == 23 + assert "entity_kind" in _get_table_columns(conn, "entity_associations") + row = conn.execute("SELECT entity_kind FROM entity_associations WHERE issue_id = 'iss-1'").fetchone() + assert row["entity_kind"] == "" + conn.close() + def test_migration_v15_to_v16_adds_event_seq_and_rebuilds_index(self, tmp_path: Path) -> None: """v15→v16 (2.1.0 §0.2): event_seq column added with DEFAULT 0; the dedup UNIQUE index is rebuilt to include the new column so @@ -1904,8 +1931,8 @@ class TestDeletedIssuesTombstoneSchema: """v19 -> v20: the ``deleted_issues`` tombstone (F5); v20 -> v21 adds the ``entity_ids`` column (F5 entity-association amplifier, filigree-f3bf56554c).""" - def test_current_schema_version_is_22(self) -> None: - assert CURRENT_SCHEMA_VERSION == 22 + def test_current_schema_version_is_23(self) -> None: + assert CURRENT_SCHEMA_VERSION == 23 def test_fresh_schema_contains_deleted_issues_table(self, tmp_path: Path) -> None: conn = _make_db(tmp_path) diff --git a/tests/fixtures/contracts/loom/scan-results.json b/tests/fixtures/contracts/loom/scan-results.json index 42663332..5f2430ed 100644 --- a/tests/fixtures/contracts/loom/scan-results.json +++ b/tests/fixtures/contracts/loom/scan-results.json @@ -29,6 +29,7 @@ }, "warnings": "list[str] # validation warnings from db._validate_scan_findings (e.g. unknown severity coerced to 'info')" }, + "external_adapter_contract": "Filigree does not parse SARIF on this endpoint. External SARIF adapters MUST map SARIF result.partialFingerprints or result.fingerprints into each scan-results finding.fingerprint before POSTing. Filigree then preserves finding.fingerprint through ingest, readback, promotion, fingerprint dedup, stale/fixed transitions, and reopen-on-regress lifecycle handling. Fingerprint-less legacy findings remain accepted and use the file/source/rule/line heuristic.", "rationale": "BatchResponse-style wrapper per ADR-002 §1 and work-package Phase A2. Scan-results has mixed semantics (batch ingest + summary counts); the envelope honours the batch-semantics-where-applicable rule by exposing succeeded/failed (per-finding granularity) while preserving the counts signal in a stats sibling. Named subtype so future loom endpoints with similar mixed semantics have a pattern to follow." }, "examples": [ @@ -110,6 +111,56 @@ } } }, + { + "name": "success_wardline_sarif_adapter_fingerprint", + "note": "Representative Wardline SARIF-adapter case. Filigree does not parse SARIF here; the external adapter maps SARIF partialFingerprints/fingerprints to finding.fingerprint before POSTing so fingerprint identity survives Filigree ingestion and later lifecycle operations.", + "request": { + "method": "POST", + "path": "/api/loom/scan-results", + "headers": {"Content-Type": "application/json"}, + "body": { + "scan_source": "wardline", + "scan_run_id": "wardline-20260604-sarif-a1b2c3", + "mark_unseen": true, + "findings": [ + { + "path": "src/example/flow.py", + "rule_id": "WLN-TAINT", + "severity": "high", + "message": "Tainted source reaches sink", + "line_start": 21, + "line_end": 21, + "fingerprint": "wardline:v1:3fd0f7f0b4b84567", + "metadata": { + "sarif": { + "ruleId": "WLN-TAINT", + "partialFingerprints": { + "primaryLocationLineHash": "3fd0f7f0b4b84567" + } + } + } + } + ] + } + }, + "response": { + "status": 200, + "headers": {"Content-Type": "application/json"}, + "body": { + "succeeded": ["sf_example_generated_id"], + "failed": [], + "stats": { + "files_created": 1, + "files_updated": 0, + "findings_created": 1, + "findings_updated": 0, + "observations_created": 0, + "observations_failed": 0 + }, + "warnings": [] + } + } + }, { "name": "error_missing_findings", "note": "Loom uses the same flat error envelope as classic (ErrorResponse TypedDict: error + code + NotRequired details). ErrorCode enum value VALIDATION.", diff --git a/tests/mcp/test_entity_associations.py b/tests/mcp/test_entity_associations.py index 4d4318a0..7c4f5407 100644 --- a/tests/mcp/test_entity_associations.py +++ b/tests/mcp/test_entity_associations.py @@ -39,10 +39,27 @@ async def test_attach_returns_row(self, mcp_db: FiligreeDB) -> None: ) ) assert result["issue_id"] == issue.id + assert result["entity_id"] == "py:func:parser.tokenize" assert result["clarion_entity_id"] == "py:func:parser.tokenize" assert result["content_hash_at_attach"] == "abc123" assert result["attached_by"] == "alice" + async def test_attach_accepts_entity_kind(self, mcp_db: FiligreeDB) -> None: + issue = mcp_db.create_issue("Refactor parser", priority=2) + result = _parse( + await call_tool( + "add_entity_association", + { + "issue_id": issue.id, + "entity_id": "not-a-clarion-locator", + "content_hash": "abc123", + "entity_kind": "function", + }, + ) + ) + assert result["entity_id"] == "not-a-clarion-locator" + assert result["entity_kind"] == "function" + async def test_attach_idempotent_preserves_attached_by(self, mcp_db: FiligreeDB) -> None: issue = mcp_db.create_issue("t", priority=2) await call_tool( @@ -216,6 +233,7 @@ async def test_returns_every_issue_bound_to_entity(self, mcp_db: FiligreeDB) -> result = _parse(await call_tool("list_associations_by_entity", {"entity_id": target})) issue_ids = {row["issue_id"] for row in result["associations"]} assert issue_ids == {a.id, b.id} + assert all(row["entity_id"] == target for row in result["associations"]) async def test_rejects_blank_entity_id(self, mcp_db: FiligreeDB) -> None: result = _parse(await call_tool("list_associations_by_entity", {"entity_id": " "})) diff --git a/tests/mcp/test_finding_triage_tools.py b/tests/mcp/test_finding_triage_tools.py index f660ebfb..192cf363 100644 --- a/tests/mcp/test_finding_triage_tools.py +++ b/tests/mcp/test_finding_triage_tools.py @@ -467,6 +467,7 @@ async def test_promote_creates_issue(self, mcp_db: FiligreeDB) -> None: assert "id" not in data assert "observation_id" not in data assert data["type"] == "bug" + assert data["fields"]["severity"] == "critical" assert "SQL injection" in data["title"] assert "from-finding" in data["labels"] assert mcp_db.get_finding(ids["sqli"])["issue_id"] == data["issue_id"] @@ -507,6 +508,27 @@ async def test_promote_carries_labels_to_created_issue(self, mcp_db: FiligreeDB) assert set(data["labels"]) == {"from-finding", "cluster:mcp"} + async def test_promote_and_attach_entity(self, mcp_db: FiligreeDB) -> None: + ids = _seed_findings(mcp_db) + + data = _parse( + await call_tool( + "promote_finding_and_attach_entity", + { + "finding_id": ids["sqli"], + "entity_id": "clarion:eid:mcp", + "content_hash": "hash-v1", + "entity_kind": "function", + "actor": "mcp-agent", + }, + ) + ) + + assert data["issue_id"] + assert data["association"]["entity_id"] == "clarion:eid:mcp" + assert data["association"]["entity_kind"] == "function" + assert mcp_db.list_entity_associations(data["issue_id"])[0]["content_hash_at_attach"] == "hash-v1" + async def test_promote_not_found(self, mcp_db: FiligreeDB) -> None: data = _parse(await call_tool("promote_finding", {"finding_id": "nonexistent"})) assert data["code"] == ErrorCode.NOT_FOUND diff --git a/tests/mcp/test_tool_aliases.py b/tests/mcp/test_tool_aliases.py index b9e52aaa..cd1d53ca 100644 --- a/tests/mcp/test_tool_aliases.py +++ b/tests/mcp/test_tool_aliases.py @@ -34,8 +34,8 @@ class TestServedSurface: async def test_list_tools_serves_exactly_the_new_names(self) -> None: names = [tool.name for tool in await list_tools()] - assert len(names) == 114 - assert len(set(names)) == 114, "served names must be unique" + assert len(names) == 115 + assert len(set(names)) == 115, "served names must be unique" # Every served name is a NEW name; no OLD name leaks onto the surface. assert set(names) == set(RENAME_MAP.values()) assert set(names) & set(RENAME_MAP.keys()) == set() diff --git a/tests/test_doctor.py b/tests/test_doctor.py index b502c5ec..eb7941da 100644 --- a/tests/test_doctor.py +++ b/tests/test_doctor.py @@ -33,6 +33,7 @@ _find_all_filigree_binaries, _is_absolute_command_path, _is_venv_binary, + doctor_check_id, run_doctor, ) @@ -488,7 +489,7 @@ def test_summary_directory_reports_failure(self, tmp_path: Path) -> None: ctx_result = next(r for r in results if r.name == "context.md") assert ctx_result.passed is False assert "not a file" in ctx_result.message - assert "filigree doctor --fix" in ctx_result.fix_hint + assert "generated context" in ctx_result.fix_hint # --------------------------------------------------------------------------- @@ -1021,6 +1022,23 @@ 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 TestDoctorSharedContractChecks: + def test_run_doctor_emits_real_route_and_auth_checks(self, tmp_path: Path) -> None: + project = _make_project(tmp_path) + + results = run_doctor(project) + by_id = {doctor_check_id(result): result for result in results} + + assert by_id["api.availability"].name == "API routes" + assert by_id["scanner.results"].name == "Scan results routes" + assert by_id["entity_associations.routes"].name == "Entity association routes" + assert by_id["auth.config"].name == "Auth config" + assert by_id["api.availability"].passed is True + assert by_id["scanner.results"].passed is True + assert by_id["entity_associations.routes"].passed is True + assert by_id["auth.config"].passed is True + + # --------------------------------------------------------------------------- # _find_all_filigree_binaries # --------------------------------------------------------------------------- diff --git a/tests/test_entity_associations_federation.py b/tests/test_entity_associations_federation.py index 0d0418ac..a826b16c 100644 --- a/tests/test_entity_associations_federation.py +++ b/tests/test_entity_associations_federation.py @@ -41,8 +41,31 @@ def test_malformed_entity_id_is_accepted(self, db: FiligreeDB) -> None: db.add_entity_association(issue.id, "not-a-valid-clarion-id", content_hash="h") rows = db.list_entity_associations(issue.id) assert len(rows) == 1 + assert rows[0]["entity_id"] == "not-a-valid-clarion-id" assert rows[0]["clarion_entity_id"] == "not-a-valid-clarion-id" + def test_caller_supplied_entity_kind_round_trips_without_inference(self, db: FiligreeDB) -> None: + issue = db.create_issue("Kind metadata", priority=2) + + db.add_entity_association( + issue.id, + "not-a-valid-clarion-id", + content_hash="h", + entity_kind="function", + ) + + rows = db.list_entity_associations(issue.id) + assert rows[0]["entity_id"] == "not-a-valid-clarion-id" + assert rows[0]["entity_kind"] == "function" + + def test_absent_entity_kind_stays_empty_for_locator_like_ids(self, db: FiligreeDB) -> None: + issue = db.create_issue("No kind inference", priority=2) + + db.add_entity_association(issue.id, "py:class:LooksLikeKind", content_hash="h") + + rows = db.list_entity_associations(issue.id) + assert rows[0]["entity_kind"] == "" + def test_grammar_violations_round_trip_unchanged(self, db: FiligreeDB) -> None: """Strings that look syntactically wrong (wrong segment count, empty segments, unicode, whitespace) must all round-trip @@ -190,5 +213,33 @@ def test_associations_table_with_orphaned_entity_ids_does_not_break_reads(self, ) rows = db.list_entity_associations(issue.id) assert len(rows) == 1 + assert rows[0]["orphan_status"] == "unknown" + assert rows[0]["freshness_status"] == "unknown" assert rows[0]["clarion_entity_id"] == "py:func:long-since-deleted::very-much-removed" assert rows[0]["content_hash_at_attach"] == "abandoned-hash" + + def test_current_hash_comparison_is_caller_supplied(self, db: FiligreeDB) -> None: + issue = db.create_issue("Caller supplied freshness", priority=2) + entity_id = "clarion:eid:abc123" + db.add_entity_association(issue.id, entity_id, content_hash="hash-old") + + fresh = db.list_associations_by_entity(entity_id, current_content_hash="hash-old") + stale = db.list_associations_by_entity(entity_id, current_content_hash="hash-new") + + assert fresh[0]["freshness_status"] == "fresh" + assert stale[0]["freshness_status"] == "stale" + + def test_migration_orphan_marker_is_exposed(self, db: FiligreeDB) -> None: + issue = db.create_issue("Orphan marker", priority=2) + entity_id = "py:func:removed" + db.add_entity_association(issue.id, entity_id, content_hash="h") + db.conn.execute( + "UPDATE entity_associations SET migration_orphaned_at = ? WHERE issue_id = ? AND clarion_entity_id = ?", + ("2026-06-04T00:00:00+00:00", issue.id, entity_id), + ) + db.conn.commit() + + rows = db.list_entity_associations(issue.id) + + assert rows[0]["migration_orphaned_at"] == "2026-06-04T00:00:00+00:00" + assert rows[0]["orphan_status"] == "orphaned" diff --git a/tests/util/test_module_split.py b/tests/util/test_module_split.py index 651b8139..d2028ae9 100644 --- a/tests/util/test_module_split.py +++ b/tests/util/test_module_split.py @@ -49,7 +49,7 @@ def test_mcp_tools_register_shape() -> None: def test_mcp_tools_total_count() -> None: - """All 114 tools are registered across domain modules. + """All 115 tools are registered across domain modules. Count includes the structured observation triage surfaces and the four entity-association tools (ADR-029) so the split-module @@ -66,7 +66,7 @@ def test_mcp_tools_total_count() -> None: total += len(tools) # +3 for structured observation triage: link, batch-link, promote-many-to-one. # +4 for entity_associations (ADR-029): add/remove/list-by-issue/list-by-entity. - assert total == 114, f"Expected 114 tools total, got {total}" + assert total == 115, f"Expected 115 tools total, got {total}" def test_issue_and_file_mcp_tools_do_not_import_mcp_server_private_globals() -> None: From 99075c1dc7abceb38654626f2ecd54c93132d2b1 Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Thu, 4 Jun 2026 16:59:35 +0000 Subject: [PATCH 004/135] =?UTF-8?q?=F0=9F=8E=A8=20Palette:=20Add=20ARIA=20?= =?UTF-8?q?labels=20to=20icon-only=20buttons=20for=20accessibility?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add missing `aria-label`s to `×` icon-only `
" ); @@ -279,7 +282,9 @@ export async function openDetail(issueId) { : "") + (entityAssociationsData.length ? '
Entity Associations
' + - `
${entityAssociationsData.length} total · ${entityAssocCounts.stale} stale · ${entityAssocCounts.orphaned} orphaned
` + + `
${entityAssociationsData.length} total` + + (entityAssocCounts.stale ? ` · ${entityAssocCounts.stale} stale` : "") + + ` · ${entityAssocCounts.orphaned} orphaned
` + entityAssociationsHtml + "
" : "") + diff --git a/tests/core/test_finding_triage.py b/tests/core/test_finding_triage.py index 7aab7792..9bc009d8 100644 --- a/tests/core/test_finding_triage.py +++ b/tests/core/test_finding_triage.py @@ -7,6 +7,7 @@ import pytest from filigree.core import FiligreeDB +from filigree.types.core import make_issue_id def _seed_findings(db: FiligreeDB) -> dict[str, str]: @@ -281,6 +282,57 @@ def test_rejects_non_string_actor(self, db: FiligreeDB) -> None: db.promote_finding_to_issue(ids["sqli"], actor=123) # type: ignore[arg-type] +class TestPromoteFindingAndAttachEntity: + def test_creates_issue_and_attaches_entity(self, db: FiligreeDB) -> None: + ids = _seed_findings(db) + result = db.promote_finding_and_attach_entity(ids["sqli"], "py:func:login", "hash-v1") + + assert result["created"] is True + assert result["association"]["entity_id"] == "py:func:login" + assert result["association"]["content_hash_at_attach"] == "hash-v1" + assoc = db.list_entity_associations(make_issue_id(result["issue"].id)) + assert len(assoc) == 1 + + def test_retry_converges_after_attach_failure(self, db: FiligreeDB, monkeypatch: pytest.MonkeyPatch) -> None: + """The promote+attach pair is non-atomic but idempotent: a failure in the + attach step (after the issue is already promoted and committed) leaves a + promoted-but-unassociated issue, and re-issuing the same request converges + to that issue with the association now present. Guards the partial-state + contract documented on ``promote_finding_and_attach_entity``. + """ + ids = _seed_findings(db) + real_attach = db.add_entity_association + calls = {"n": 0} + + def flaky_attach(*args: Any, **kwargs: Any) -> Any: + calls["n"] += 1 + if calls["n"] == 1: + msg = "simulated attach failure" + raise RuntimeError(msg) + return real_attach(*args, **kwargs) + + monkeypatch.setattr(db, "add_entity_association", flaky_attach) + + # First attempt: promote commits, then attach raises. + with pytest.raises(RuntimeError, match="simulated attach failure"): + db.promote_finding_and_attach_entity(ids["sqli"], "py:func:login", "hash-v1") + + # The issue was promoted (finding linked) despite the attach failure... + issue_id = db.get_finding(ids["sqli"])["issue_id"] + assert issue_id + # ...but no association exists yet — the partial state the contract warns about. + assert db.list_entity_associations(make_issue_id(issue_id)) == [] + + # Retry: promote reuses the existing issue, attach now succeeds. + result = db.promote_finding_and_attach_entity(ids["sqli"], "py:func:login", "hash-v1") + assert result["issue"].id == issue_id + assert result["created"] is False + assoc = db.list_entity_associations(make_issue_id(issue_id)) + assert len(assoc) == 1 + assert assoc[0]["entity_id"] == "py:func:login" + assert assoc[0]["content_hash_at_attach"] == "hash-v1" + + class TestProcessScanResultsBreakingChange: """The old create_issues parameter was removed — callers must use create_observations.""" From 0fd7e3d6d3f71c6151157cc9b4cc2bafb76cfd77 Mon Sep 17 00:00:00 2001 From: John Morrissey <544926+tachyon-beep@users.noreply.github.com> Date: Fri, 5 Jun 2026 05:35:13 +1000 Subject: [PATCH 008/135] feat(core): bidirectional back-pointer verification for worktree discovery MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Port the security-relevant half of the abandoned anchor-discovery-hardening branch (190 commits stale; its other hardening — permission/decode/empty-gitdir handling — already landed in the refactored `_read_gitdir_pointer`, and its import of `find_filigree_anchor` predates the rename to `find_filigree_conf`). The one genuinely-missing control: `_main_worktree_from_git_path` redirected to a main worktree on the strength of the worktree's `.git` pointer alone, without checking git's bidirectional linkage. A spoofed `.git` file (shipped in an untrusted clone, aimed at a victim project's `worktrees/` admin dir) or a stale pointer (admin dir renamed, worktree removed but `.git` file lingering) would silently redirect discovery onto the wrong project's database. Now verify the admin dir's `gitdir` back-pointer resolves back to *this* `.git` file before redirecting; on mismatch or read failure, `.git` stands as a boundary. This also tightens `_classify_git_entry` (its second call site): a spoofed pointer now classifies as a plain gitdir_file, not a worktree_pointer. Test fixtures now write the back-pointer git always creates on disk (the prior skeleton omitted it, which is why HEAD could drop the check without failures). Co-Authored-By: Claude Opus 4.8 (1M context) --- src/filigree/core.py | 31 ++++++++++++++++++ tests/core/test_project_anchor.py | 54 ++++++++++++++++++++++++++++++- 2 files changed, 84 insertions(+), 1 deletion(-) diff --git a/src/filigree/core.py b/src/filigree/core.py index 22bbfdf1..ba86e205 100644 --- a/src/filigree/core.py +++ b/src/filigree/core.py @@ -366,9 +366,40 @@ def _main_worktree_from_git_path(git_path: Path) -> Path | None: main_git_dir = gitdir.parent.parent if main_git_dir.name != ".git" or not main_git_dir.is_dir(): return None + # Bidirectional verification: git records a ``gitdir`` back-pointer in the + # admin dir that names *this* worktree's ``.git`` file. Requiring it to + # resolve back to ``git_path`` defeats two failure modes — an attacker- + # controlled ``.git`` file (an untrusted clone could otherwise redirect + # discovery onto an arbitrary victim project) and stale pointers left after + # ``git worktree remove`` or an admin-dir rename. On any mismatch or read + # failure we decline to redirect and let ``.git`` stand as a boundary. + if not _worktree_back_pointer_matches(gitdir, git_path): + return None return main_git_dir.parent +def _worktree_back_pointer_matches(admin_dir: Path, git_path: Path) -> bool: + """Return whether *admin_dir*'s ``gitdir`` back-pointer resolves to *git_path*. + + Git's linked-worktree bookkeeping is bidirectional: the worktree's ``.git`` + file points at ``
/.git/worktrees/`` (``admin_dir``), and that + admin dir contains a ``gitdir`` file holding the absolute path back to the + worktree's ``.git`` file. A genuine worktree round-trips; a spoofed or stale + pointer does not. A missing or unreadable back-pointer counts as no match. + """ + back_pointer_file = admin_dir / "gitdir" + try: + recorded = back_pointer_file.read_text(encoding="utf-8").strip() + except (OSError, UnicodeDecodeError): + return False + if not recorded: + return False + try: + return Path(recorded).resolve() == git_path.resolve() + except (OSError, RuntimeError, ValueError): + return False + + def _classify_git_entry(git_path: Path) -> str: """Classify a ``.git`` filesystem entry for discovery diagnostics.""" if git_path.is_dir(): diff --git a/tests/core/test_project_anchor.py b/tests/core/test_project_anchor.py index 3814b6c7..24c4ca01 100644 --- a/tests/core/test_project_anchor.py +++ b/tests/core/test_project_anchor.py @@ -504,11 +504,17 @@ def _make_main_repo(tmp_path: Path, *, with_anchor: bool = True) -> Path: @staticmethod def _make_worktree(main_repo: Path, worktree_root: Path, name: str) -> Path: - """Create a linked-worktree skeleton: ``.git`` file + main-repo bookkeeping.""" + """Create a linked-worktree skeleton: ``.git`` file + main-repo bookkeeping. + + Mirrors git's real on-disk layout, including the bidirectional + ``gitdir`` back-pointer in the admin dir that names the worktree's + ``.git`` file — discovery verifies this round-trip before redirecting. + """ wt_admin = main_repo / ".git" / "worktrees" / name wt_admin.mkdir(parents=True) worktree_root.mkdir(parents=True, exist_ok=True) (worktree_root / ".git").write_text(f"gitdir: {wt_admin}\n") + (wt_admin / "gitdir").write_text(f"{worktree_root / '.git'}\n") return worktree_root def test_worktree_inside_main_repo_finds_main_anchor(self, tmp_path: Path) -> None: @@ -616,6 +622,7 @@ def test_relative_gitdir_in_worktree_pointer(self, tmp_path: Path) -> None: # Relative pointer: from wt/.git up to main/.git/worktrees/rel rel = os.path.relpath(wt_admin, wt) (wt / ".git").write_text(f"gitdir: {rel}\n") + (wt_admin / "gitdir").write_text(f"{wt / '.git'}\n") project_root, _ = find_filigree_anchor(wt) assert project_root == main @@ -790,6 +797,51 @@ def test_foreign_database_error_remediation_for_malformed_git_file(self, tmp_pat git_file = weird.resolve() / ".git" assert f"If `{git_file}` is malformed, fix or remove it before running `filigree init`." in str(excinfo.value) + def test_spoofed_worktree_pointer_is_rejected(self, tmp_path: Path) -> None: + """A ``.git`` file aimed at a worktree admin dir whose back-pointer + resolves to a *different* ``.git`` must not redirect. + + Threat: an untrusted clone ships a ``.git`` file pointing at a victim + project's ``
/.git/worktrees//`` admin dir, hoping discovery + latches onto the victim's database. Git's bidirectional linkage defeats + this — the admin dir's ``gitdir`` back-pointer names the *real* + worktree's ``.git`` file, not the attacker's — so discovery refuses. + """ + main = self._make_main_repo(tmp_path) + # The genuine worktree this admin dir belongs to. + legit_wt = tmp_path / "legit-wt" + legit_wt.mkdir() + (legit_wt / ".git").write_text(f"gitdir: {main / '.git' / 'worktrees' / 'victim'}\n") + wt_admin = main / ".git" / "worktrees" / "victim" + wt_admin.mkdir(parents=True) + (wt_admin / "gitdir").write_text(f"{legit_wt / '.git'}\n") + # Attacker directory points its ``.git`` at the same admin dir. + attacker = tmp_path / "attacker" + attacker.mkdir() + (attacker / ".git").write_text(f"gitdir: {wt_admin}\n") + + # Must NOT redirect to main's anchor; attacker has no anchor of its own. + with pytest.raises(ProjectNotInitialisedError) as excinfo: + find_filigree_anchor(attacker) + assert not isinstance(excinfo.value, ForeignDatabaseError) + + def test_worktree_with_missing_back_pointer_is_not_redirected(self, tmp_path: Path) -> None: + """A worktree pointer whose admin dir has no ``gitdir`` back-pointer is + treated as stale (e.g. ``git worktree remove`` left the ``.git`` file + behind) and must not redirect to the main anchor. + """ + main = self._make_main_repo(tmp_path) + wt_admin = main / ".git" / "worktrees" / "stale" + wt_admin.mkdir(parents=True) + # Deliberately no back-pointer file written. + wt = tmp_path / "stale-wt" + wt.mkdir() + (wt / ".git").write_text(f"gitdir: {wt_admin}\n") + + with pytest.raises(ProjectNotInitialisedError) as excinfo: + find_filigree_anchor(wt) + assert not isinstance(excinfo.value, ForeignDatabaseError) + # --------------------------------------------------------------------------- # FiligreeDB.from_project / from_conf — discovery integration From 7d15dc72ac4a69d6434607342222c2645a10db36 Mon Sep 17 00:00:00 2001 From: John Morrissey <544926+tachyon-beep@users.noreply.github.com> Date: Fri, 5 Jun 2026 05:39:10 +1000 Subject: [PATCH 009/135] =?UTF-8?q?chore(release):=20open=203.0.0=20?= =?UTF-8?q?=E2=80=94=20version=20bump,=20CHANGELOG,=20release=20plan=20&?= =?UTF-8?q?=20file=20manifest?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - pyproject 2.3.0 -> 3.0.0 (major boundary for the deferred breaking wire work) - CHANGELOG: promote merged work to [3.0.0] with a major-release preamble that states the breaking items are tracked in the PR and land incrementally - docs/plans/2026-06-05-3.0.0-release-plan.md: archive/rename/update/create file manifest + breaking-work checklist + the instruction-file untracking note Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 46 +++++++++ docs/plans/2026-06-05-3.0.0-release-plan.md | 105 ++++++++++++++++++++ pyproject.toml | 2 +- 3 files changed, 152 insertions(+), 1 deletion(-) create mode 100644 docs/plans/2026-06-05-3.0.0-release-plan.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 7cf4a38b..d29cc97e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [3.0.0] - Unreleased + +3.0.0 is a **major release**. It opens a SemVer-major boundary to land the +deferred breaking wire-surface changes that could not ship mid-2.x without +breaking federation consumers (Clarion / Wardline / Shuttle): subsystem +namespacing of the ~115 MCP tool names (ADR-016), removal of the deprecated +`get_stats` alias keys, entity-association de-Clarionization, and the +`TransitionMode` enum. **Those breaking items are tracked as a checklist in the +release PR and land incrementally on this branch — the entries below are the +work already merged.** Consumers should not pin to 3.0.0 until the breaking +checklist is complete and a coordinated consumer-migration window is published. + ### Added - **`scanned_paths` on `POST /api/loom/scan-results` (and the classic/living @@ -45,6 +57,40 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 shared close transaction, so the age-gated `clean-stale` sweep gets the same protection. +- **Entity-association surface polish (ADR-029).** The forward (issue→entity) + read projection only badges a content-axis freshness state it actually owns; + the identity axis remains Clarion's (`unknown`, never a fabricated `active`), + per ADR-017's two-axis model. `add_entity_association` now declares + `_skip_begin` on both the protocol stub and its `@_in_immediate_tx` + implementation so the stub-signature contract test and the mypy override check + agree. + +### Changed + +- **Accessibility: ARIA labels on icon-only dashboard buttons.** Icon-only + controls across the app, detail, graph, health, ready, releases, and workflow + views now carry `aria-label`s so screen-reader users get a meaningful name. + +- **Performance: dropped a redundant open-blockers query in the issue batch + fetch** (`db_issues`), removing a per-issue round trip from the batch path. + +### Security + +- **Bidirectional back-pointer verification for git-worktree discovery.** + Worktree-aware anchor discovery previously redirected to a main worktree on + the strength of a worktree's `.git` pointer alone. A spoofed `.git` file + (shipped in an untrusted clone, aimed at a victim project's + `worktrees/` admin dir) or a stale pointer (admin dir renamed, worktree + removed but the `.git` file left behind) could silently latch discovery onto + the wrong project's database. Discovery now verifies that the admin dir's + `gitdir` back-pointer resolves back to *this* `.git` file before redirecting; + on mismatch or read failure the `.git` entry stands as a project boundary. + +### Dependencies + +- **Bumped `starlette` 0.52.1 → 1.0.1** (major). Full test suite green against + the 1.0 line; no application-level ASGI changes were required. + ## [2.3.0] - 2026-06-02 ### Added diff --git a/docs/plans/2026-06-05-3.0.0-release-plan.md b/docs/plans/2026-06-05-3.0.0-release-plan.md new file mode 100644 index 00000000..404fd563 --- /dev/null +++ b/docs/plans/2026-06-05-3.0.0-release-plan.md @@ -0,0 +1,105 @@ +# 3.0.0 Release Plan & File Manifest + +**Branch:** `release/3.0.0` (off `origin/main` @ `d057645`) +**Type:** Major (SemVer-major boundary) +**Status:** Foundation landed; breaking-work bundle in progress +**Authored:** 2026-06-05 + +--- + +## 1. Why a major + +2.x deferred a cluster of **breaking wire-surface changes** because shipping them +mid-2.x would break federation consumers (Clarion / Wardline / Shuttle) without a +coordinated migration window. 3.0.0 opens that boundary. The release **bundles** +those breaking items (per the maintainer decision "bundle breaking work first"), +so the PR is not release-ready until the breaking checklist in §4 is complete. + +## 2. What has already landed on `release/3.0.0` + +Merged from the outstanding branches + one re-implementation: + +| Source | Nature | Result | +|--------|--------|--------| +| `codex/loom-feedback-tickets` | feature | loom close-on-fixed cascade via `scanned_paths`; ADR-029 entity-association polish; doctor contract notes | +| `bolt-opt-issue-batch-query` | perf | removed redundant open-blockers query in batch fetch | +| `palette-aria-labels` | a11y | ARIA labels on icon-only dashboard buttons (duplicate commit collapsed by merge) | +| `dependabot/uv/starlette-1.0.1` | deps | starlette 0.52.1 → 1.0.1 (major); full suite green | +| `fix/anchor-discovery-hardening` | security | **not merged** (190 commits stale). Re-implemented its one genuinely-missing control — bidirectional worktree back-pointer verification — fresh against current code. Its other hardening already landed (refactored `_read_gitdir_pointer`); its `find_filigree_anchor` import predates the `find_filigree_conf` rename. | + +**Excluded:** `gh-pages` (deploy artifact), `dependabot` is the only lock-only change. +CI gate green: ruff ✓ · ruff format ✓ · mypy (105 files) ✓ · pytest (full, 4 skips) ✓ · biome JS ✓. + +## 3. File manifest + +### 3.1 ARCHIVE (move / mark complete — no longer current) + +| Path | Action | Reason | +|------|--------|--------| +| `docs/plans/2026-04-26-2.0-phase-d-mcp-forward-migration.md` | → `docs/plans/completed/` | 2.0 migration, shipped | +| `docs/plans/2026-04-28-2.0-phase-e-cli-forward-migration.md` | → `docs/plans/completed/` | 2.0 migration, shipped | +| `docs/plans/2026-04-28-2.0-phase-f-release-readiness.md` | → `docs/plans/completed/` | 2.0 release readiness, shipped | +| `docs/plans/2026-05-18-2.1.0-release-prep.md` | → `docs/plans/completed/` | 2.1.0 shipped (v2.1.0/v2.1.1 tags) | +| stale remote branches (`release/2.0.x`, `1.6.0`, `v1.4.x`, `feat/*` merged) | delete after release | branch hygiene; not files | + +### 3.2 RENAME (breaking surface renames — the heart of the major) + +| Surface | From → To | Files | Ticket | +|---------|-----------|-------|--------| +| MCP tool names (~115) | `finding_list` → `filigree_finding_list`, etc. (subsystem prefix; dual-resolution transition window) | `src/filigree/mcp_tools/*.py`, `src/filigree/mcp_server.py`, tool registry/metadata, `tests/mcp/*`, `docs/mcp.md`, served prose | `filigree-7771610917` (ADR-016) | +| Entity-association field | `clarion_entity_id` → `entity_id` / `external_entity_id` (retain `clarion_entity_id` as documented compat alias) | `db_entity_associations.py`, `db_schema.py`, `db_events.py`, `db_files.py`, `dashboard_routes/entities.py`, `generations/loom/types.py`, `types/core.py`, `types/events.py`, `migrations.py`, `static/js/views/detail.js` | `filigree-45d76e71bb` | +| Stats keys | drop `status_name_counts` / `status_category_counts` (→ `by_status` / `by_category`) | `db_meta.py`, `types/planning.py`, `mcp_tools/meta.py`, HTTP schema | `filigree-e4181ae767` | +| Transition flag | `backward: bool` → `TransitionMode` enum | `db_issues.py`, `db_base.py`, workflow templates, `InvalidTransitionError` | `filigree-9b4bb6e52e` | + +### 3.3 UPDATE + +| Path | Change | Status | +|------|--------|--------| +| `pyproject.toml` | `version = "3.0.0"` | ✅ done | +| `CHANGELOG.md` | `[3.0.0]` section + major preamble | ✅ done (landed work); breaking entries pending | +| `src/filigree/db_schema.py` | `CURRENT_SCHEMA_VERSION` 23 → 24 **if** a schema change lands (actor verified column; entity_id column rename) | pending §4 | +| `src/filigree/migrations.py` | new `_migrate_v23_to_v24` for any schema change | pending §4 | +| `docs/UPGRADING.md` | 2.3 → 3.0 upgrade steps | pending | +| `docs/MIGRATION.md` | consumer migration narrative | pending | +| `docs/SCHEMA_MIGRATIONS.md` | record v24 if added | pending §4 | +| `docs/mcp.md`, `docs/api-reference.md` | new tool names + alias window | pending §4 | +| `docs/architecture/decisions/ADR-016-mcp-tool-namespacing.md` | status → Accepted/Implemented | pending §4 | +| `CLAUDE.md` / `AGENTS.md` | instruction stamp → v3.0.0 | ⚠️ see §5 (now untracked) | + +### 3.4 CREATE + +| Path | Purpose | +|------|---------| +| `docs/architecture/decisions/ADR-029-entity-association-opacity.md` | **cited by commits + CLAUDE.md but does not exist** — backfill the ADR for the opaque/de-Clarionized entity-association model | +| `docs/architecture/decisions/ADR-019-transition-mode-enum.md` | new — TransitionMode design | +| `docs/architecture/decisions/ADR-020-transport-bound-actor-identity.md` | new (or extend ADR-012) — verified vs claimed actor | +| `docs/MIGRATION-3.0.md` (or §in MIGRATION.md) | **consumer migration guide**: old→new MCP tool-name mapping table, `get_stats` key migration, `entity_id` rename, transition-mode change | +| `docs/plans/2026-06-05-3.0.0-release-plan.md` | this document | +| migration test fixtures / contract fixtures under `tests/fixtures/contracts/` | regenerate for renamed surfaces | + +## 4. Breaking-work checklist (must land before PR is release-ready) + +- [ ] `filigree-7771610917` — MCP tool namespacing (~115 tools) + dual-resolution alias window — **largest item; ripples to all consumers** (ADR-016) +- [ ] `filigree-45d76e71bb` — De-Clarionize entity-association naming (compat alias) *(in_progress; partially merged via codex)* +- [ ] `filigree-e4181ae767` — Remove deprecated `get_stats` alias keys +- [ ] `filigree-9b4bb6e52e` — `TransitionMode` enum replacing `backward` bool +- [ ] `filigree-d25e75cebf` — `safe_message` parity for claim/transition errors +- [ ] `filigree-81d3971467` — Transport-bound actor identity verification *(event-schema change → likely the v24 bump)* + +Parent epics: `filigree-18bd3b8c98` (Toolkit DX), `filigree-e51a7c2263`. + +Each is a feature in its own right (TDD + full gate). The MCP namespacing item +needs a **published consumer-migration window** with both names resolving, not a +hard cutover. Sequence suggestion: schema-bearing change (`81d3971467`) → +de-Clarionize (`45d76e71bb`) → stats keys (`e4181ae767`) → TransitionMode +(`9b4bb6e52e`) → safe_message (`d25e75cebf`) → MCP namespacing (`7771610917`, +last, as the broadest wire change). + +## 5. Open item for the maintainer — instruction-file untracking + +During this session the filigree instruction-manager rewrote `.gitignore` and +**untracked** `AGENTS.md` / `CLAUDE.md` (now gitignored as "untracked +per-checkout"; files remain on disk, deletion staged). This is a repo-policy +change unrelated to 3.0.0 and was **deliberately kept out of the release +commits**. Decide separately whether to adopt it (commit the `.gitignore` +rewrite + `git rm --cached` of the instruction files) or revert it. diff --git a/pyproject.toml b/pyproject.toml index 58972d5e..a48e4f3f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "filigree" -version = "2.3.0" +version = "3.0.0" description = "Agent-native issue tracker with convention-based project discovery" requires-python = ">=3.11" license = "MIT" From 543027d9a3c99d431f51ecc141a87b39343aecb5 Mon Sep 17 00:00:00 2001 From: John Morrissey <544926+tachyon-beep@users.noreply.github.com> Date: Fri, 5 Jun 2026 05:39:23 +1000 Subject: [PATCH 010/135] chore(release): sync uv.lock to filigree 3.0.0 Co-Authored-By: Claude Opus 4.8 (1M context) --- uv.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/uv.lock b/uv.lock index 3ea00079..dffda338 100644 --- a/uv.lock +++ b/uv.lock @@ -461,7 +461,7 @@ wheels = [ [[package]] name = "filigree" -version = "2.3.0" +version = "3.0.0" source = { editable = "." } dependencies = [ { name = "click" }, From 999e5b116a0e73155a3af13564e5d891ebaed69f Mon Sep 17 00:00:00 2001 From: John Morrissey <544926+tachyon-beep@users.noreply.github.com> Date: Fri, 5 Jun 2026 05:43:18 +1000 Subject: [PATCH 011/135] chore(release): drop Jules bot scratchpads (case-collision) and dedup Unreleased header - .jules/bolt.md + .Jules/palette.md rode in via the bolt/palette merges; they are bot learning notes, not release content, and the .jules vs .Jules case-only difference collides on case-insensitive filesystems. - CHANGELOG had an empty [Unreleased] directly above [3.0.0] - Unreleased. Co-Authored-By: Claude Opus 4.8 (1M context) --- .Jules/palette.md | 3 --- .jules/bolt.md | 3 --- CHANGELOG.md | 2 -- 3 files changed, 8 deletions(-) delete mode 100644 .Jules/palette.md delete mode 100644 .jules/bolt.md diff --git a/.Jules/palette.md b/.Jules/palette.md deleted file mode 100644 index f8c1bff0..00000000 --- a/.Jules/palette.md +++ /dev/null @@ -1,3 +0,0 @@ -## 2024-06-04 - Adding ARIA labels to dynamically rendered elements -**Learning:** Found several icon-only (`×`) close/remove buttons generated dynamically via template literals in `app.js`, `detail.js`, `files.js`, `graph.js` and `workflow.js` that were missing `aria-label`s. Screen readers wouldn't know what these buttons do. -**Action:** Always scan JavaScript view files that render HTML strings for interactive elements like `