Skip to content

Commit dffd2bd

Browse files
authored
feat(extract): extract call-form event.listen registrations (events v2) (#33)
Module-level SQLAlchemy event.listen(Target, "event", fn) statements are now extracted (the v1 KNOWN GAP) as family sqlalchemy_listen: fn resolves to a first-party top-level function (same-module or imported via the calls.py import tables, lifted to module level); the registration attaches to the HANDLER's artifact (which may live in another module) and grounds additionally on the registering file's module span (new role registration_site) - wiring in a third module grounds ONE artifact across THREE files. Module-level only (runs at import time, confidence 1.0); function-body/lambda/bare-import forms stay documented gaps. Call-form registrations sort before merging so full and incremental indexing stay byte-identical. events extractor_version 1 -> 2 (all event_handler ids recompute, idempotent). Tier-1 events gate: known-gap test inverted + three-file provenance + two new gap assertions (9 -> 12 tests). Gates stay fifteen.
1 parent 0969299 commit dffd2bd

6 files changed

Lines changed: 325 additions & 94 deletions

File tree

CHANGELOG.md

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,33 @@ All notable changes to this project are documented in this file.
55
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
66
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
77

8+
## [Unreleased]
9+
10+
### Added
11+
12+
- **Events v2 — call-form registrations** (`kb.extract.deterministic.events`): the extractor now
13+
also extracts module-level SQLAlchemy `event.listen(Target, "event", fn)` statements — the v1
14+
KNOWN GAP — as a new registration family `sqlalchemy_listen`. `fn` must resolve to a first-party
15+
top-level function of the same module or an imported one (reusing the calls.py import-table
16+
machinery, now shared at module level); the registration attaches to the HANDLER's artifact,
17+
which may live in another module than the `listen()` call, and the artifact gains a grounding
18+
edge on the registering file's module span (new role `registration_site`) — a call-form
19+
registration wired in a third module grounds ONE artifact across THREE files (handler + target
20+
class + registration site). Only module-level calls are extracted (they run deterministically at
21+
import time); `listen(...)` inside a function/class body, lambda/attribute listeners, and the
22+
bare `from sqlalchemy.event import listen` form are documented gaps, never a wrong guess.
23+
Registrations gained `form`/`registration_module`/`registration_line` payload fields. The Tier-1
24+
events gate gains three tests (call-form extracted; three-file provenance; the two new gaps
25+
asserted); headline HARD gates stay **fifteen**.
26+
27+
### Changed
28+
29+
- **`events` extractor version 1 → 2**: all `event_handler` artifact ids are recomputed on the
30+
next index (idempotent re-index rewrites snapshots; superseded rows are orphaned — GC remains
31+
deferred, harmless); `kb describe` regenerates event-handler descriptions on its next key-gated
32+
run. Purely-decorator handlers keep semantically identical payloads (only the new registration
33+
fields are added).
34+
835
## [0.7.0] - 2026-07-08
936

1037
### Added

DESIGN.md

Lines changed: 13 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -261,10 +261,15 @@ test.)
261261
user code); scored against **griffe** as a dev-only independent static oracle (never on `index`).
262262
- **(d)** Event-handler extractor *(shipped)*: one `event_handler` per handler carrying decorator
263263
registrations — pydantic `@field_validator`/`@model_validator`, FastAPI `@app.on_event`,
264-
SQLAlchemy `@event.listens_for` (stacked decorators aggregate in `payload.registrations`; one
265-
artifact per handler because `artifact_id` is content-addressed over spans + extractor, §6).
266-
Grounded on the handler span + cross-file on the listened-to class; hand-labeled Tier-1 gate.
267-
Known gaps (asserted): call-form `event.listen(...)`, lifespan, pydantic-v1, dynamic names.
264+
SQLAlchemy `@event.listens_for` — AND module-level call-form `event.listen(Target, "e", fn)`
265+
registrations (family `sqlalchemy_listen`; `fn` resolved same-module or via the calls.py import
266+
tables — the handler may live in another module than the listen; grounded additionally on the
267+
registering file's module span, role `registration_site`). Stacked decorators and call sites
268+
aggregate in `payload.registrations`; one artifact per handler. Grounded on the handler span +
269+
cross-file on the listened-to class; hand-labeled Tier-1 gate incl. a three-file provenance case
270+
(handler + target class + registration site). Known gaps (asserted): `listen(...)` inside a
271+
function/class body (conditional), lambda/attribute `fn`, the bare
272+
`from sqlalchemy.event import listen` form, lifespan, pydantic-v1, dynamic names.
268273
- **(e)** Call-graph edge extractor *(shipped)*: one `call_edge` per RESOLVED caller→callee pair,
269274
three deterministic tiers (same-module; imported — **cross-file**; `self.` method of the same
270275
class); precision-first — only first-party-resolved edges are emitted, recall bounded by
@@ -404,7 +409,7 @@ freshness(current|stale@sha)`, with a deterministic tie-break for reproducible e
404409
| Module | Responsibility | Key tech |
405410
|--------|----------------|----------|
406411
| `kb.structural` | Parse Python without executing it; enumerate symbols/imports/call-sites with per-SHA byte/line ranges; compute content-addressed span identity; incremental reparse. Hidden behind a `StructuralIndex`/`PathEngine` interface so a SCIP backend can replace tree-sitter later. | tree-sitter + tree-sitter-python (canonical bindings) |
407-
| `kb.extract.deterministic` | No-LLM extractors → exact artifacts (confidence=1.0): import graph; FastAPI API contract (static, cross-file grounded); domain entities (pydantic/dataclass/SQLAlchemy, static, cross-file links to referenced entities, hand-labeled gate); library public-API surface (static tree-sitter, cross-file `__init__` re-export resolution, independent griffe-oracle gate); event handlers (pydantic validators / FastAPI `on_event` / SQLAlchemy `listens_for`, static, cross-file target grounding, hand-labeled gate); call-graph edges (per-edge artifacts, three resolution tiers, caller+callee span grounding, hand-labeled gate); process paths (`paths.PathEngine` — the first shipped increment of the PathEngine seam — BFS to sink-registry matches, multi-file grounded, registry digest identity-bearing). | grimp, tree-sitter queries; griffe (dev-only oracle) |
412+
| `kb.extract.deterministic` | No-LLM extractors → exact artifacts (confidence=1.0): import graph; FastAPI API contract (static, cross-file grounded); domain entities (pydantic/dataclass/SQLAlchemy, static, cross-file links to referenced entities, hand-labeled gate); library public-API surface (static tree-sitter, cross-file `__init__` re-export resolution, independent griffe-oracle gate); event handlers (pydantic validators / FastAPI `on_event` / SQLAlchemy `listens_for` + module-level call-form `listen`, static, cross-file target + registration-site grounding, hand-labeled gate); call-graph edges (per-edge artifacts, three resolution tiers, caller+callee span grounding, hand-labeled gate); process paths (`paths.PathEngine` — the first shipped increment of the PathEngine seam — BFS to sink-registry matches, multi-file grounded, registry digest identity-bearing). | grimp, tree-sitter queries; griffe (dev-only oracle) |
408413
| `kb.introspect` | Eval-only runtime oracle: runs a FastAPI app in a network-blocked sandbox and emits `app.openapi()` for the Tier-1 API gate. Never on the index path. | subprocess sandbox, fastapi |
409414
| `kb.embed` | Replaceable embedding adapters + snapshot population for `search_knowledge`. Torch isolated behind the `embed` extra and a lazy import. | sentence-transformers (default), OpenAI (optional), pgvector |
410415
| `kb.rag` | Frozen pgvector RAG-over-source baseline — the "other arm" of the knowledge-vs-RAG A/B (no provenance/grounding). | deterministic line-window chunker, pgvector |
@@ -490,8 +495,9 @@ Review fact-checked these against current (2026) sources. Caveats are first-clas
490495

491496
1. Second deterministic family: **entities (pydantic/dataclass/SQLAlchemy) — shipped** (static
492497
tree-sitter, hand-labeled Tier-1 gate); **events — shipped** (decorator registrations for
493-
pydantic/FastAPI/SQLAlchemy, static tree-sitter, hand-labeled Tier-1 gate; call-form
494-
`event.listen` deferred).
498+
pydantic/FastAPI/SQLAlchemy plus module-level call-form `event.listen` — v2, static
499+
tree-sitter, hand-labeled Tier-1 gate; function-body/lambda/bare-import forms and lifespan
500+
stay documented gaps).
495501
2. The **one** grounded business-process extractor (named real path + labeler + validator +
496502
deterministic sub-property gate) — **shipped**: the `call_edge` extractor + Tier-1 calls gate;
497503
the `process_path` builder (PathEngine BFS + sink registry + `.kb/sinks.yaml` override,

README.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -87,7 +87,7 @@ flowchart LR
8787
**v0.7 — spine (artifact-identity rule v2), six deterministic extractors (cross-file grounding, incl. the library public-API surface, event handlers, and call-graph edges) plus the grounded process-path builder over them, LLM-grounded descriptions up to whole-repo architecture overviews and business-process-path labels, ADR-candidate mining from git history (`kb mine`), live incremental re-index (`kb watch`), MCP serving, the fifteen knowledge gates, and published Docker images.** Everything here grounds what it claims, and nothing it cannot:
8888

8989
- **Provenance spine** — content-addressed `span_id` (LOCKED); tree-sitter spans with a normalized S-expression fingerprint and per-SHA location; a single-Postgres, Alembic-managed store with content-addressed idempotent writes; the ≥ 1 `derived_from` anti-hallucination invariant enforced in-app *and* by a deferred DB trigger; pygit2 git ingest (no checkout) with a diff-based invalidation seed.
90-
- **Deterministic extractors** — the **import / dependency graph** (grimp resolves the edge, tree-sitter grounds it on the exact import statement, with an honest `approximate` fallback for re-exports / relative / unmappable imports — never a silent loss); the **FastAPI API-contract** extractor, which grounds a single route **across files** (handler in `routes.py` + `response_model` class in `schemas.py`); the **domain-entity** extractor (pydantic / dataclass / SQLAlchemy classes and their fields, grounded on the class definition **and cross-file on the entities they reference** — purely static, with documented detection limits); the **library public-API-surface** extractor (what a package exposes from its `__init__.py` — `__all__`-authoritative, with `__init__` re-exports resolved **cross-file** to the defining function/class — validated against an independent **griffe** static oracle); the **event-handler** extractor (pydantic `@field_validator`/`@model_validator`, FastAPI `@app.on_event`, SQLAlchemy `@event.listens_for` — one artifact per handler, grounded on the handler span and **cross-file** on the class it listens to; the call-form `event.listen(...)` and dynamic names are documented gaps); and the **call-graph edge** extractor (deterministic caller→callee edges — same-module, imported **cross-file**, and `self.` method calls — one `call_edge` per resolved pair with call-site lines aggregated; `obj.method(...)`, `getattr`, inherited self-calls and decorator/default-arg expressions are documented gaps; the deterministic foundation under the future business-process extractor); and the **process-path builder** (second-order: BFS over the call edges from extracted entrypoints — route/event handlers — to sink-registry matches, one `process_path` grounded on EVERY span of the chain, multi-file provenance; built-in sink registry + per-repo `.kb/sinks.yaml` override).
90+
- **Deterministic extractors** — the **import / dependency graph** (grimp resolves the edge, tree-sitter grounds it on the exact import statement, with an honest `approximate` fallback for re-exports / relative / unmappable imports — never a silent loss); the **FastAPI API-contract** extractor, which grounds a single route **across files** (handler in `routes.py` + `response_model` class in `schemas.py`); the **domain-entity** extractor (pydantic / dataclass / SQLAlchemy classes and their fields, grounded on the class definition **and cross-file on the entities they reference** — purely static, with documented detection limits); the **library public-API-surface** extractor (what a package exposes from its `__init__.py` — `__all__`-authoritative, with `__init__` re-exports resolved **cross-file** to the defining function/class — validated against an independent **griffe** static oracle); the **event-handler** extractor (pydantic `@field_validator`/`@model_validator`, FastAPI `@app.on_event`, SQLAlchemy `@event.listens_for` **and the module-level call-form `event.listen(Target, "e", fn)`** — one artifact per handler, grounded on the handler span, **cross-file** on the class it listens to, and on the registering file for call-form wiring; function-body/lambda `listen` and dynamic names are documented gaps); and the **call-graph edge** extractor (deterministic caller→callee edges — same-module, imported **cross-file**, and `self.` method calls — one `call_edge` per resolved pair with call-site lines aggregated; `obj.method(...)`, `getattr`, inherited self-calls and decorator/default-arg expressions are documented gaps; the deterministic foundation under the future business-process extractor); and the **process-path builder** (second-order: BFS over the call edges from extracted entrypoints — route/event handlers — to sink-registry matches, one `process_path` grounded on EVERY span of the chain, multi-file provenance; built-in sink registry + per-repo `.kb/sinks.yaml` override).
9191
- **`kb introspect`** — a sandboxed, network-blocked `app.openapi()` oracle, eval-only and never on the index path, that the API gate scores the static contract against.
9292
- **Read-only MCP server**`find_provenance`, `get_knowledge`, and `search_knowledge`, each returning provenance-carrying units (method + confidence + freshness).
9393
- **pgvector embeddings + semantic search** — a replaceable embedding provider (sentence-transformers by default, OpenAI optional) populated by a separate `kb embed` pass; torch stays out of the index path.
@@ -244,7 +244,7 @@ A Python package `kb` (uv, src-layout). Modules and their responsibilities:
244244
| `kb.extract.deterministic.library_surface` | Static library public-API-surface extractor — one `public_symbol` per name a package exposes from its `__init__.py` (`__all__`-authoritative), `__init__` re-exports resolved **cross-file** to the defining function/class; validated by an independent griffe static oracle. Never imports user code. |
245245
| `kb.extract.deterministic.calls` | Static call-graph edge extractor — one `call_edge` per resolved caller→callee pair (same-module / imported **cross-file** / `self.` method), call-site lines aggregated; only resolved first-party edges are emitted; dynamic/attribute/inherited calls are documented gaps. Never imports user code. |
246246
| `kb.extract.deterministic.paths` | Second-order process-path builder — `process_path` per (entrypoint, sink, terminal): shortest chain of resolved call edges from an extracted route/event handler to a sink-registry match, grounded on every span along the path (multi-file); built-in registry + `.kb/sinks.yaml` override (registry digest is identity-bearing); caps flagged, never silent. |
247-
| `kb.extract.deterministic.events` | Static event-handler extractor — one `event_handler` per handler carrying pydantic validator / FastAPI `on_event` / SQLAlchemy `listens_for` decorator registrations (stacked decorators in `payload.registrations`), grounded on the handler span **and, across files, on the class it listens to**; call-form `event.listen(...)` and dynamic names are documented gaps. |
247+
| `kb.extract.deterministic.events` | Static event-handler extractor — one `event_handler` per handler carrying pydantic validator / FastAPI `on_event` / SQLAlchemy `listens_for` decorator registrations **and module-level call-form `event.listen(...)` registrations** (all aggregated in `payload.registrations`; the handler may live in another module than the `listen()`), grounded on the handler span, **across files on the class it listens to**, and on the registering file's module span (role `registration_site`); function-body/lambda `listen` and dynamic names are documented gaps. |
248248
| `kb.introspect` | Sandboxed, network-blocked `app.openapi()` oracle — eval-only ground truth for the API gate, never on the index path. |
249249
| `kb.mcp` | Read-only MCP server and its provenance-carrying records: `find_provenance`, `get_knowledge`, `search_knowledge`. |
250250
| `kb.embed` | Replaceable embedding adapters (sentence-transformers default, OpenAI optional) + snapshot population. Torch isolated behind the `embed` extra and a lazy import. |
@@ -272,7 +272,7 @@ CI (GitHub Actions, workflow **"CI"**, `.github/workflows/ci.yml`) runs ruff, `m
272272
4. **Tier-1 API oracle** — the statically-extracted FastAPI contract equals the app's own `openapi()` (from the sandboxed introspect oracle), and the route's cross-file grounding (handler + `response_model`) is asserted.
273273
5. **Tier-1 entities oracle** — extracted pydantic / dataclass / SQLAlchemy entities + their fields match a hand-labeled oracle, each grounded on its class span; a bare declarative `Base` is correctly *not* an entity and a `create_model(...)` model is asserted as a *known* gap.
274274
6. **Tier-1 library-surface oracle** — the statically-extracted public API surface of a package (`__all__`-authoritative, `__init__` re-exports resolved **cross-file**) equals an independent **griffe** static oracle; underscore-private names are excluded, and a third-party / dynamic-`__all__` re-export is grounded-but-flagged, asserted as a *known* gap.
275-
7. **Tier-1 events oracle** — extracted event handlers (pydantic validators, FastAPI `on_event`, SQLAlchemy `listens_for`) match a hand-labeled oracle, each grounded on its handler span and **cross-file** on the class it listens to; the call-form `event.listen(...)` and a dynamic `@app.on_event(EVENT)` name are asserted as *known* gaps.
275+
7. **Tier-1 events oracle** — extracted event handlers (pydantic validators, FastAPI `on_event`, SQLAlchemy `listens_for` and the module-level call-form `event.listen`) match a hand-labeled oracle, each grounded on its handler span and **cross-file** on the class it listens to; a call-form registration wired in a third module grounds ONE artifact across **three files** (handler + target class + registration site); `listen(...)` inside a function body, a lambda listener, and a dynamic `@app.on_event(EVENT)` name are asserted as *known* gaps.
276276
8. **Tier-1 call-graph oracle** — extracted call edges match a hand-labeled oracle (same-module, cross-file imported — incl. aliases and module-attribute forms — `self.` method calls, and class instantiation), each grounded on the caller **and** callee def spans (cross-file); a mutually recursive pair yields two distinct artifacts (identity-v2 regression); `obj.method(...)`, `getattr`, inherited self-calls and decorator-expression calls are asserted as *known* gaps.
277277
9. **Tier-3 knowledge-vs-RAG recall** — knowbase cross-file recall@k == 1.0 for every cross-file question (API contracts **and** domain entities: in each case one artifact already spans both files, so the floor is *structural*, independent of embedding quality); the RAG arm is reported but **never asserted**, so a model bump can't redden CI.
278278
10. **Tier-4 one-hop invalidation** — a content diff invalidates *exactly* the artifacts whose grounding span changed (set-equality: no over-invalidation, no stale survivors); a version bump invalidates everything.

0 commit comments

Comments
 (0)