From 76fb3e951b7352b58121a91fc0e0476695a9dd85 Mon Sep 17 00:00:00 2001
From: Timwood0x10 <121157680+Timwood0x10@users.noreply.github.com>
Date: Mon, 20 Jul 2026 08:10:15 +0800
Subject: [PATCH 01/18] feat: add semantic evidence and verification pipeline
---
.gitignore | 1 +
Makefile | 7 +-
README.md | 24 +-
docs/dev_plans/ffi.md | 20 -
docs/dev_plans/ffi_detection_plan.md | 389 --------
docs/dev_plans/improve.md | 538 +++++++++++
docs/dev_plans/v0.3_roadmap.md | 849 +++++++++++++++++
engine/CMakeLists.txt | 11 +
engine/include/engine.h | 78 ++
engine/src/engine_evidence_ffi.cpp | 159 ++++
engine/src/engine_internal.h | 16 +
engine/src/engine_project_state_ffi.cpp | 70 ++
engine/src/engine_queries.cpp | 14 +
engine/src/engine_verify_planner_ffi.cpp | 99 ++
engine/src/evidence/evidence_builder.cpp | 594 ++++++++++++
engine/src/evidence/evidence_builder.h | 112 +++
engine/src/evidence/rule.cpp | 642 +++++++++++++
engine/src/evidence/rule.h | 114 +++
engine/src/evidence/rules/concurrency.json | 58 ++
engine/src/evidence/rules/drift.json | 46 +
engine/src/evidence/rules/error.json | 31 +
engine/src/evidence/rules/ffi.json | 31 +
engine/src/evidence/rules/framework.json | 22 +
engine/src/evidence/rules/memory.json | 19 +
engine/src/evidence/rules/pattern.json | 57 ++
engine/src/evidence/rules/security.json | 57 ++
engine/src/evidence/rules/sync.json | 19 +
engine/src/evidence/rules/test_quality.json | 44 +
engine/src/model/project_state_builder.cpp | 859 ++++++++++++++++++
engine/src/model/project_state_builder.h | 104 +++
engine/src/model/semantic_fact_extractor.cpp | 780 ++++++++++++++++
engine/src/model/semantic_fact_extractor.h | 100 ++
engine/src/store/store.h | 26 +
engine/src/store/store_schema.cpp | 145 +++
engine/src/store/store_semantic_fact.cpp | 141 +++
engine/src/verify/intent.h | 66 ++
engine/src/verify/intent_parser.cpp | 279 ++++++
engine/src/verify/intent_parser.h | 63 ++
engine/src/verify/planner.cpp | 97 ++
engine/src/verify/planner.h | 80 ++
engine/src/verify/verdict_builder.cpp | 324 +++++++
engine/src/verify/verdict_builder.h | 83 ++
engine/tests/test_domain_rules.cpp | 346 +++++++
engine/tests/test_evidence_builder.cpp | 376 ++++++++
engine/tests/test_project_state.cpp | 396 ++++++++
engine/tests/test_semantic_fact_extractor.cpp | 317 +++++++
engine/tests/test_verify_planner.cpp | 321 +++++++
server/src/ffi/mod.rs | 77 ++
server/src/tools/mod.rs | 101 ++
49 files changed, 8787 insertions(+), 415 deletions(-)
delete mode 100644 docs/dev_plans/ffi.md
delete mode 100644 docs/dev_plans/ffi_detection_plan.md
create mode 100644 docs/dev_plans/improve.md
create mode 100644 docs/dev_plans/v0.3_roadmap.md
create mode 100644 engine/src/engine_evidence_ffi.cpp
create mode 100644 engine/src/engine_project_state_ffi.cpp
create mode 100644 engine/src/engine_verify_planner_ffi.cpp
create mode 100644 engine/src/evidence/evidence_builder.cpp
create mode 100644 engine/src/evidence/evidence_builder.h
create mode 100644 engine/src/evidence/rule.cpp
create mode 100644 engine/src/evidence/rule.h
create mode 100644 engine/src/evidence/rules/concurrency.json
create mode 100644 engine/src/evidence/rules/drift.json
create mode 100644 engine/src/evidence/rules/error.json
create mode 100644 engine/src/evidence/rules/ffi.json
create mode 100644 engine/src/evidence/rules/framework.json
create mode 100644 engine/src/evidence/rules/memory.json
create mode 100644 engine/src/evidence/rules/pattern.json
create mode 100644 engine/src/evidence/rules/security.json
create mode 100644 engine/src/evidence/rules/sync.json
create mode 100644 engine/src/evidence/rules/test_quality.json
create mode 100644 engine/src/model/project_state_builder.cpp
create mode 100644 engine/src/model/project_state_builder.h
create mode 100644 engine/src/model/semantic_fact_extractor.cpp
create mode 100644 engine/src/model/semantic_fact_extractor.h
create mode 100644 engine/src/store/store_semantic_fact.cpp
create mode 100644 engine/src/verify/intent.h
create mode 100644 engine/src/verify/intent_parser.cpp
create mode 100644 engine/src/verify/intent_parser.h
create mode 100644 engine/src/verify/planner.cpp
create mode 100644 engine/src/verify/planner.h
create mode 100644 engine/src/verify/verdict_builder.cpp
create mode 100644 engine/src/verify/verdict_builder.h
create mode 100644 engine/tests/test_domain_rules.cpp
create mode 100644 engine/tests/test_evidence_builder.cpp
create mode 100644 engine/tests/test_project_state.cpp
create mode 100644 engine/tests/test_semantic_fact_extractor.cpp
create mode 100644 engine/tests/test_verify_planner.cpp
diff --git a/.gitignore b/.gitignore
index 98689e8..63afaa9 100644
--- a/.gitignore
+++ b/.gitignore
@@ -191,6 +191,7 @@
plan/
**/build_*/
+**/build-*/
**/build-release/
**/.deps-cache/
**/*.ll
diff --git a/Makefile b/Makefile
index 65eba40..b5a259d 100644
--- a/Makefile
+++ b/Makefile
@@ -167,7 +167,12 @@ TEST_EXES := \
test_call_graph_p1 test_readme_ingestion test_call_graph_method \
test_project_id \
test_enhance_e2e \
- test_js_visitor test_ts_visitor test_tsx_visitor
+ test_js_visitor test_ts_visitor test_tsx_visitor \
+ test_semantic_fact_extractor \
+ test_evidence_builder \
+ test_project_state \
+ test_verify_planner \
+ test_domain_rules
test-engine: $(ENGINE_LIB)
@printf "$(CYAN)[test/engine]$(RESET) Building and running C++ tests...\n"
diff --git a/README.md b/README.md
index f7fd911..8708493 100644
--- a/README.md
+++ b/README.md
@@ -4,7 +4,7 @@
It transforms source code into verifiable facts, understandable models, and inspectable evidence — enabling AI to validate claims against reality instead of hallucinating.
-**Version**: v0.2.1 | **License**: Apache 2.0
+**Version**: v0.3 | **License**: Apache 2.0
---
@@ -16,7 +16,7 @@ CodeScope is a **Project Truth Engine** that answers one question:
Not "what does this code mean", but "does the code actually do what you claim?"
-It indexes source code into a structured code graph (call graph + reference graph + module knowledge), then exposes **37 MCP tools** that let AI agents locate symbols, trace call paths, verify claims, detect documentation drift, and analyze architecture — all with **~98.9% token savings** vs reading raw source files.
+It indexes source code into a structured code graph (call graph + reference graph + module knowledge), then exposes **41 MCP tools** that let AI agents locate symbols, trace call paths, verify claims, detect documentation drift, and analyze architecture — all with **~98.9% token savings** vs reading raw source files.
### Supported Languages (8)
@@ -53,7 +53,7 @@ graph TB
end
subgraph "Rust MCP Server"
- MCP["MCP Protocol (JSON-RPC 2.0)
37 tools / stdio transport"]
+ MCP["MCP Protocol (JSON-RPC 2.0)
41 tools / stdio transport"]
DISPATCH["Tool Dispatch
project_id auto-restore"]
end
@@ -200,7 +200,7 @@ codescope index-parallel /path/to/large/project
---
-## 4. MCP Tools (37 Tools)
+## 4. MCP Tools (41 Tools)
### Indexing
@@ -267,6 +267,17 @@ codescope index-parallel /path/to/large/project
| `verify_review` | Verify code review comment claims. | `{"text": "string (required)"}` |
| `verify_reality` | Verify a single AI statement against code evidence. | `{"text": "string (required)"}` |
+### Evidence Pipeline (v0.3)
+
+The v0.3 Evidence Pipeline transforms indexed code into verifiable evidence and project health snapshots. Flow: `Facts → SemanticFacts → Evidence → Verification → ProjectState`. Semantic facts are extracted by `engine_enhance_project` (Step 1.5); evidence is built by applying declarative rule files (`engine/src/evidence/rules/*.json`) to the semantic_fact table.
+
+| Tool | Description | Parameters |
+|------|-------------|------------|
+| `build_evidence` | Build evidence findings by applying the rule set (sync/memory/error/pattern/framework/ffi) to the project's semantic_fact rows. Each rule declares fact needs + a combine mode (Collect / MissingMatch / MissingMatchPerFunction / Count). Returns a JSON array of Evidence objects. Run after `enhance_project` so semantic facts are populated. | `{"category": "string (optional, one of: sync|memory|error|pattern|framework|ffi)"}` |
+| `verify_statement` | Verify a natural-language claim against the project's indexed evidence. Pipeline: IntentParser → Planner → EvidenceBuilder → VerdictBuilder. Returns JSON with `verdict` (Supported\|Contradicted\|PartiallyVerified\|Unknown), `confidence`, `requirements[]`, and `evidence[]`. Use this for yes/no questions about code behavior (e.g. "does this project safely handle CString?"). | `{"claim": "string (required)"}` |
+| `build_project_state` | Build (or rebuild) and persist the project state snapshot. Runs the full v0.3 Evidence Pipeline (evidence aggregation + state queries) and UPSERTs the result into the `project_state` table. Returns the snapshot JSON: overall confidence, capability/architecture/workflow/dead_code scores, per-category issue counts, and `last_updated` timestamp. | `{}` |
+| `get_project_state` | Get the previously persisted project state snapshot (without rebuilding). Returns the `snapshot_json` string for the project, or a JSON error object if no snapshot exists yet (run `build_project_state` first). Use this for fast reads of the latest health snapshot. | `{}` |
+
### Drift Detection
| Tool | Description | Parameters |
@@ -302,6 +313,9 @@ Deep dive symbol → explain_symbol
HTTP routes → get_routes
Type info → get_type_info
Verify claim → verify_claim
+Verify statement → verify_statement
+Build evidence → build_evidence
+Project health → build_project_state
Detect drift → detect_documentation_drift
Change impact → detect_changes
```
@@ -444,4 +458,4 @@ Each script calls `codescope cli ''` internally. See `ski
Apache 2.0 — see [LICENSE](LICENSE).
-**CodeScope v0.2.1** — Built with Rust 2024 + C++23 + tree-sitter + SQLite.
\ No newline at end of file
+**CodeScope v0.3** — Built with Rust 2024 + C++23 + tree-sitter + SQLite.
\ No newline at end of file
diff --git a/docs/dev_plans/ffi.md b/docs/dev_plans/ffi.md
deleted file mode 100644
index fee2be0..0000000
--- a/docs/dev_plans/ffi.md
+++ /dev/null
@@ -1,20 +0,0 @@
-FFI 静态检测可行性
-
- 回到你最初的问题——CodeScope 不给 AI 用,作为静态检测工具行不行?行,已具备一半能力。
-
- ┌───────────────────┬──────────────────────────────────────────────────────────────────────────────────┐
- │ 已有能力 │ 证据 │
- ├───────────────────┼──────────────────────────────────────────────────────────────────────────────────┤
- │ 跨语言调用图 │ 8 语言 Visitor + Resolver Pipeline │
- ├───────────────────┼──────────────────────────────────────────────────────────────────────────────────┤
- │ 第三方依赖标注 │ resolve_strategy=external/p1_intra/unresolved(刚修) │
- ├───────────────────┼──────────────────────────────────────────────────────────────────────────────────┤
- │ FFI boundary 检测 │ engine_detect_ffi_boundaries:cross_language_files + ffi_symbols + orphan_symbols │
- ├───────────────────┼──────────────────────────────────────────────────────────────────────────────────┤
- │ 死代码/文档漂移 │ dead_code_inspector + documentation_drift │
- └───────────────────┴──────────────────────────────────────────────────────────────────────────────────┘
-
- 但现有工具只检测「FFI 边界存在性」,不检测「FFI 边界正确性」——真正的 FFI 隐藏 bug(类型不匹配、异常穿越、所有权跨语言转移、声明/定义不匹配)还需补 5 类 verifier。MVP 路径只需补 2 项就能让「FFI 检测」从 0 到有用:
-
- 1.缺口1 修复(insertModule 调用)——1 个文件改动,让 module_tree 工作
- 2.FFI 符号声明/定义匹配 verifier——复用 resolve_strategy=unresolved + ffi_symbols,新增一个 verify/ffi_resolver_verifier.cpp,对每个 extern 声明查定义,无定义产 Finding
\ No newline at end of file
diff --git a/docs/dev_plans/ffi_detection_plan.md b/docs/dev_plans/ffi_detection_plan.md
deleted file mode 100644
index 0382572..0000000
--- a/docs/dev_plans/ffi_detection_plan.md
+++ /dev/null
@@ -1,389 +0,0 @@
-# CodeScope FFI Hidden-Bug Detection — Development Plan
-
-## Meta
-
-| Item | Value |
-|------|-------|
-| Status | Draft (pending review) |
-| Owner | CodeScope engine team |
-| Depends on | `resolve_strategy` propagation (done 2026-07-17); `engine_detect_ffi_boundaries` (existing) |
-| Blocks | none yet |
-
-## 1. Motivation
-
-CodeScope today answers **"where is the FFI boundary?"** (`engine_detect_ffi_boundaries`
-returns `cross_language_files`, `ffi_symbols`, `orphan_symbols`). It does not answer
-**"is the FFI boundary correct?"** — the class of hidden bugs that cross language
-boundaries:
-
-| Bug class | Example | Detectable today? |
-|-----------|---------|-------------------|
-| Third-party false positive | `dropout` treated as in-project callee | ✅ `resolve_strategy=external` |
-| Type mismatch across boundary | C exports `int32_t`, Rust binds `i64` | ❌ |
-| String encoding mismatch | C `char*` ↔ Rust `CString` ↔ JS `string` | ❌ |
-| Panic/exception crosses FFI | C++ `throw` into Rust = UB | ❌ |
-| Symbol declared but never exported | `extern` declaration with no definition | ❌ |
-| Ownership transferred then freed twice | `Box::from_raw` after C `free` | ❌ |
-
-This plan turns CodeScope from a **boundary locator** into a **boundary correctness checker**.
-
-## 2. Design Principles
-
-- **Reuse the existing IR pipeline.** Every new check emits a `Finding` via the
- `verify/` registry (`VerifierRegistry`); no new top-level subsystem.
-- **Visitor-side capture only what the AST already exposes.** No type-inference
- engine, no control-flow solver. If the tree-sitter AST lacks the info, defer the
- check — do not synthesize it.
-- **Block-level FFI transfer** per `plan/rules/code_rules.md` §FFI: findings are
- accumulated in C++ and returned as one JSON array, never one FFI call per finding.
-- **File size ≤ 1000 lines.** Each verifier lives in its own `.cpp`; split when
- a single verifier crosses 800 lines.
-- **English comments, no silent errors, error chain `[module=, method=]`.**
-
-## 3. Phase 1 — MVP (2 verifiers, ~200 LOC)
-
-Goal: from "we found an FFI symbol" to "we found an FFI **bug**".
-
-### 1.1 Symbol declared/exported mismatch verifier
-
-`engine/src/verify/ffi_resolver_verifier.cpp` — registered into `VerifierRegistry`.
-
-For every `ffi_symbol` already detected by `engine_detect_ffi_boundaries`
-(`extern_*`, `ffi_*`, `wasm_*`, `cabi_*`, `jni_*`, `CALLBACK_*`):
-
-1. Query `entity` for a matching definition (same `name`, `node_type=1` decl).
-2. If no definition row exists → emit `Finding{rule="ffi_undefined_decl", severity=warn}`.
-3. If the definition lives in a different language than the declaration's file →
- emit `Finding{rule="ffi_lang_mismatch"}`.
-
-Reuses: `resolve_strategy=unresolved` (already set when the Resolver Pipeline cannot
-find a definition), the `ffi_symbols` SQL already in `engine_detect_ffi_boundaries`.
-
-### 1.2 Project-id misselection guard (orthogonal but cheap)
-
-Add a `rootPath`-keyed lookup to `get_latest_project_id` callers so MCP
-`initialize` reuses the project whose `root_path` matches `rootPath`, not `MAX(id)`.
-(~30 LOC, in `engine_ffi.cpp` or `engine_internal.h`.) This is the issue you raised
-today; fold it into Phase 1 because it blocks every MCP test.
-
-> **Update 2026-07-17**: verified in `engine/src/store/store_core.cpp:683-695` —
-> `GraphStore::getLatestProjectId` already picks the project with the most
-> `graph_nodes` (ties broken by `id DESC`), not `MAX(id)`. The empty-shell hazard
-> is already mitigated. §1.2 is therefore **down-scoped**: keep a defensive
-> `rootPath` match in MCP `initialize` as belt-and-suspenders, but not a blocker.
-
-## 3.1 Accuracy re-estimate — scope narrowed to in-project source
-
-Original §3-§5 estimates (60-92%) assumed the callee might be a third-party / stdlib
-symbol whose ABI is not visible. **If we exclude third-party and stdlib callees
-and only analyse FFI where both ends are defined in the target project source**,
-the problem degrades from "cross-language semantic alignment" to "same-project
-two-language signature alignment" — a qualitative change, because the callee
-definition is in hand, no semantic guessing needed.
-
-| Phase | Detection | In-project accuracy | Reason |
-|-------|-----------|---------------------|--------|
-| 1 | undeclared extern, lang mismatch, missing export | **95-98%** | Discrete lookup in `entity` table; ~0 false positive |
-| 2 | type mismatch, arity mismatch, panic-cross | **80-90%** | callee true signature pulled from project source, not a hand-maintained type-map; `void*` polymorphism is the residual ~10-20% miss |
-| 3 | ownership transfer, UAF across boundary | **60-75%** | call-graph still has no time order; cross-function UAF needs control-flow, static topology only |
-
-### Three accuracy gains driving Phase 2 from 60-75% → 80-90%
-
-1. **callee true signature replaces the type-map table.** Phase 2's original
- ceiling was the hand-maintained `c↔rust↔js↔python` type table — `char*` being
- "out string" vs "in buffer" is statically undecidable. With the callee in-project,
- both ends' signatures are in the AST: C `extern int process(const char* in, size_t len)`
- ↔ Rust `pub extern "C" fn process(buf: *const c_char, len: usize) -> i32` —
- align by parameter position, no `char*` semantic guessing.
-2. **Arity mismatch goes from "shaky" to "deterministic".** Third-party arity
- needs ABI docs (often wrong); in-project arity is "count the signature parameters".
- C decl `extern void f(int, int, int)` ↔ Rust impl `fn f(a: i32)` — arity 3 vs 1,
- discrete verdict, zero false positive. This class moves from "shaky detection"
- to "confirmed bug".
-3. **panic-cross from "high-risk pattern" to "locatable".** Third-party panic
- paths are invisible (binary); in-project Rust callee panic paths are in source —
- scan the callee body for `unwrap`/`expect`/`panic!`/`unreachable!`, emit finding.
- False-positive rate drops from "uncertain" to "only misses, no false alarms":
- misses happen when callee calls a third-party that panics (not scanned).
-
-### Hard constraints keeping accuracy ≤ 92%
-
-- **Cross-language ownership order**: C `free(x)` in function A, Rust `use x` in
- function B — A→B or B→A needs control-flow. Static call-graph has topology only.
- Cross-function UAF still misses ~15-20% in Phase 3.
-- **Implicit conversion layers**: Rust `CString::into_raw` → C holds `char*` →
- forgets to call `CString::from_raw`. This is a semantic bug, not a signature bug;
- static AST cannot see it without lifetime annotation — out of scope.
-
-### How the project distinguishes custom code from third-party / stdlib
-
-Already solved today, reused verbatim by the FFI verifier:
-
-1. Each language Visitor's `resolveSymbol(name)` looks up the name in the current
- file's scope stack. Hit → `resolve_strategy = "p1_intra"` (in-project, resolved).
-2. Miss → `BuiltinRegistry::resolve(language, name)` (`engine/src/ir/builtin_registry.cpp:1765`)
- consults a per-language registry of compiler builtins + stdlib symbols. Hit →
- `"external"`. Miss → `"unresolved"`.
-3. Each Visitor additionally has a language-local builtin filter
- (`isCBuiltin` / `isPythonBuiltin` / `isRustBuiltin` / `isJsBuiltin` ...) that
- **skips reference creation entirely** for compiler intrinsics (`__builtin_*`,
- Python `len/print/str`, Rust `vec!/println!/dbg!`), so they never reach the
- Resolver Pipeline and never pollute edges.
-4. Public API: `BuiltinRegistry::isKnownExternal(name)` and
- `externalSymbols(language)` give the verifier an O(1) third-party check.
-
-**FFI verifier decision rule** (cheap, discrete, no semantic guessing):
-
-```
-is_in_project := (resolve_strategy == "p1_intra") AND callee row exists in entity
-is_third_party := BuiltinRegistry::isKnownExternal(callee_name) OR resolve_strategy == "external"
-is_stdlib := callee_name in BuiltinRegistry::externalSymbols(language)
-is_unknown := resolve_strategy == "unresolved"
-```
-
-The verifier **only analyses edges where `is_in_project` is true on BOTH ends**.
-This is the scope narrowing that lifts accuracy to the 80-98% band above.
-Third-party / stdlib / unknown edges are counted in a summary histogram but
-never trigger findings — their ABI is not statically visible.
-
-## 4. Phase 2 — Type & control-flow verifiers (~600 LOC)
-
-### 2.1 FFI type-boundary edge
-
-Extend each language Visitor (`c_visitor`, `rust_visitor`, `js_visitor`, `python_visitor`,
-`swift_visitor`, `go_visitor`, `java_visitor`) to emit a `kind=FFIBoundary` record
-when parsing `extern`/`JNIEXPORT`/`PyMethodDef`/`napi_*`/`#[no_mangle]`/`cgo` declarations.
-The record carries:
-
-| Field | Source |
-|-------|--------|
-| `callee_language` | opposite end of the boundary (heuristic: file language vs. declared extern language) |
-| `callee_signature` | tree-sitter `parameter_list` subtree, raw text |
-| `ownership_kind` | `none`/`borrow`/`transfer` (Rust `&mut`/`Box::into_raw`/`ManuallyDrop`; C `free`/`malloc`; JS `napi_create_*`) |
-
-Schema migration: add `kind=FFIBoundary` to the `RecordKind` enum, plus two columns
-`callee_signature TEXT`, `ownership_kind TEXT` on `semantic_records`. Reuses the same
-migration pattern as `resolve_strategy` (see `store_schema.cpp:921-994`).
-
-### 2.2 Type-mismatch verifier
-
-`verify/ffi_type_verifier.cpp` — for each `FFIBoundary` record, compare the two
-signatures via a per-language type-map table (`c ↔ rust ↔ js ↔ python`):
-
-| C | Rust | JS (napi) | Python (PyMethod) |
-|---|------|-----------|-------------------|
-| `int32_t` | `i32` | `number` | `int` |
-| `char*` (out) | `CString` | `string` | `str` |
-| `void*` | `*mut c_void` | `unknown` | `int` (capsule) |
-
-Mismatch → `Finding{rule="ffi_type_mismatch", severity=error}`. The type-map lives
-in `engine/src/verify/ffi_type_map.h` as a `constexpr` table — no runtime DB.
-
-### 2.3 Panic-crossing verifier
-
-`verify/ffi_panic_verifier.cpp` — statically scan the body of every `extern "C"`
-function (C++) and every `unsafe extern "Rust"` block (Rust). If the body contains
-`throw`/`panic!`/`unwrap`/`expect`/`unreachable!` and the function is on an FFI edge,
-emit `Finding{rule="ffi_panic_cross", severity=error}`.
-
-Reuses: Visitor CallExpr scan already enumerates these; add a body-walk pass gated
-on `kind=FFIBoundary`.
-
-## 5. Phase 3 — Ownership & lifecycle (~800 LOC)
-
-### 3.1 Ownership-transfer edge
-
-Stamp `ownership_kind=transfer` on every FFI record where one side releases and
-the other acquires (Rust `Box::from_raw`, C `free` after Rust `into_raw`, JS
-`napi_delete_reference`). Track the pair via `reference.resolve_strategy` chain.
-
-### 3.2 Use-after-free across boundary
-
-Reuse the call graph: if a symbol S is `free`-called on the C side and then
-referenced on the Rust side after the free (call-graph predecessor edge), emit
-`Finding{rule="ffi_use_after_free", severity=error}`. This is the hardest check;
-defer to Phase 3 unless Phase 2 surfaces a need.
-
-## 6. Test Plan (per `plan/rules/code_rules.md` §4)
-
-| Phase | Fixture | Asserts |
-|-------|---------|---------|
-| 1.1 | `tests/ffi/undefined_decl.{c,rs}` — `extern` with no body | 1 `ffi_undefined_decl` finding |
-| 1.1 | `tests/ffi/lang_mismatch.{c,rs}` — C decl vs JS body | 1 `ffi_lang_mismatch` finding |
-| 1.2 | MCP `initialize` twice on same `rootPath` | reuses `project_id`, no empty shell |
-| 2.2 | `tests/ffi/type_mismatch.{c,rs}` — `int32_t` vs `i64` | 1 `ffi_type_mismatch` finding |
-| 2.3 | `tests/ffi/panic_cross.cpp` — `throw` inside `extern "C"` | 1 `ffi_panic_cross` finding |
-| 3.2 | `tests/ffi/uaf.{c,rs}` — free then use | 1 `ffi_use_after_free` finding |
-
-Each fixture is a 20–40-line real project; integration tests use real sqlite
-(no mocks), per §4 "Integration tests must use real dependencies."
-
-## 7. Roll-out
-
-| Phase | LOC | New files | Risk |
-|-------|-----|-----------|------|
-| 1 | ~230 | `ffi_resolver_verifier.cpp`, small `engine_ffi.cpp` edit | Low — reuses existing SQL |
-| 2 | ~600 | `ffi_type_verifier.cpp`, `ffi_panic_verifier.cpp`, `ffi_type_map.h`, 7 Visitor edits | Medium — schema migration, multi-language visitor touches |
-| 3 | ~800 | `ffi_ownership_verifier.cpp` | High — call-graph predecessor walk |
-
-Review gate between phases: Phase 2 cannot start until Phase 1 ships and the type-map
-table is reviewed for correctness (the table is the single source of truth for every
-mismatch verdict — a wrong row means wrong findings).
-
-## 8. Non-goals
-
-- No type inference across the whole project (no Hindley-Milner, no flow typing).
-- No ABI-layout check (struct member offsets, padding) — that needs a separate
- compiler-rt / libclang tool, out of scope.
-- No runtime FFI hooking (LD_PRELOAD, frida) — static only.
-- No replacing `engine_detect_ffi_boundaries`; the new verifiers sit on top of its
- output, never re-implement the boundary scan.
-- **No third-party / stdlib callee analysis.** Verifiers only fire on edges where
- `is_in_project == true` on BOTH ends (see §3.1 decision rule). Third-party / stdlib
- / unknown callees are tallied in a summary histogram but never produce findings —
- their ABI is not statically visible and would cause false positives.
-- **No cross-function UAF (Phase 3) until control-flow analysis exists.** Static
- call-graph has topology only, no time order. Cross-function ownership transfer is
- tallied as "suspicious" not "bug" until a control-flow pass is added.
-
-## 9. Independent storage schema — FFI analysis isolation
-
-FFI detection data is stored in a **separate SQLite table group** from the main
-analysis (semantic_records / graph_edges / entity / modules). Rationale:
-- FFI findings are append-only and idempotent — re-running a verifier replaces only
- its own rows, never touches the main analysis tables.
-- Schema migration is independent — adding a Phase 3 verifier does not force a
- rebuild of the main call graph.
-- Query surface is narrow — only `engine_get_ffi_findings` / `engine_ffi_summary`
- read these tables; no risk of join explosion with the main tables.
-
-### 9.1 Tables
-
-All tables live in the same SQLite DB (the per-project `*.db` file). They are
-prefixed `ffi_` to make the namespace explicit and to allow `ATTACH`/`DETACH` for
-future per-language FFI DB sharding without name collisions.
-
-```sql
--- ─────────────────────────────────────────────────────────────────────
--- ffi_boundary: one row per FFI boundary symbol discovered by the
--- boundary scan (the persistent form of engine_detect_ffi_boundaries
--- output). Replaces the ephemeral JSON return with queryable rows.
--- ─────────────────────────────────────────────────────────────────────
-CREATE TABLE IF NOT EXISTS ffi_boundary (
- id INTEGER PRIMARY KEY AUTOINCREMENT,
- project_id INTEGER NOT NULL,
- symbol_name TEXT NOT NULL,
- file_path TEXT NOT NULL,
- language TEXT NOT NULL, -- 'c' / 'cpp' / 'rust' / 'js' / ...
- boundary_kind TEXT NOT NULL, -- 'extern_c' / 'no_mangle' / 'jni' / 'napi' / 'cabi' / 'cgo' / 'pymethod'
- decl_entity_id INTEGER, -- entity.id of the declaration, NULL if not found
- callee_language TEXT, -- opposite language declared (extern "C" from Rust → 'c')
- callee_signature TEXT, -- raw parameter_list subtree text, NULL if Phase 2 not run
- ownership_kind TEXT DEFAULT 'none', -- 'none' / 'borrow' / 'transfer' (Phase 3)
- is_in_project INTEGER NOT NULL DEFAULT 0, -- 1 iff decl_entity_id resolves AND callee entity in same project
- scanned_at INTEGER NOT NULL, -- epoch seconds, for incremental re-scan gating
- FOREIGN KEY (project_id) REFERENCES projects(id),
- FOREIGN KEY (decl_entity_id) REFERENCES entity(id)
-);
-CREATE INDEX IF NOT EXISTS ffi_boundary_proj ON ffi_boundary(project_id);
-CREATE INDEX IF NOT EXISTS ffi_boundary_proj_in_project ON ffi_boundary(project_id, is_in_project);
-
--- ─────────────────────────────────────────────────────────────────────
--- ffi_findings: one row per detected bug or suspicious pattern.
--- Populated by the verify/ verifiers (Phase 1-3). Append-only per scan;
--- a re-scan DELETEs rows WHERE project_id=? AND verifier=? then re-inserts.
--- ─────────────────────────────────────────────────────────────────────
-CREATE TABLE IF NOT EXISTS ffi_findings (
- id INTEGER PRIMARY KEY AUTOINCREMENT,
- project_id INTEGER NOT NULL,
- boundary_id INTEGER NOT NULL,
- verifier TEXT NOT NULL, -- 'ffi_resolver_verifier' / 'ffi_type_verifier' / ...
- rule TEXT NOT NULL, -- 'ffi_undefined_decl' / 'ffi_type_mismatch' / 'ffi_panic_cross' / ...
- severity TEXT NOT NULL, -- 'error' / 'warn' / 'info' / 'suspicious'
- message TEXT NOT NULL, -- human-readable, e.g. "C declares arity 3, Rust impl arity 1"
- callee_entity_id INTEGER, -- entity.id of the callee if resolved, NULL otherwise
- detail_json TEXT, -- machine-readable details (e.g. {"expected":"int32_t","got":"i64"})
- scanned_at INTEGER NOT NULL,
- FOREIGN KEY (project_id) REFERENCES projects(id),
- FOREIGN KEY (boundary_id) REFERENCES ffi_boundary(id),
- FOREIGN KEY (callee_entity_id) REFERENCES entity(id)
-);
-CREATE INDEX IF NOT EXISTS ffi_findings_proj ON ffi_findings(project_id);
-CREATE INDEX IF NOT EXISTS ffi_findings_proj_severity ON ffi_findings(project_id, severity);
-CREATE INDEX IF NOT EXISTS ffi_findings_proj_rule ON ffi_findings(project_id, rule);
-
--- ─────────────────────────────────────────────────────────────────────
--- ffi_scan_summary: one row per (project, verifier) run.
--- Cheap histogram for project_overview and the MCP `ffi_summary` tool;
--- avoids COUNT(*) over ffi_findings on every call.
--- ─────────────────────────────────────────────────────────────────────
-CREATE TABLE IF NOT EXISTS ffi_scan_summary (
- project_id INTEGER NOT NULL,
- verifier TEXT NOT NULL,
- run_at INTEGER NOT NULL,
- total_boundaries INTEGER NOT NULL DEFAULT 0,
- in_project_edges INTEGER NOT NULL DEFAULT 0,
- third_party_count INTEGER NOT NULL DEFAULT 0,
- stdlib_count INTEGER NOT NULL DEFAULT 0,
- unknown_count INTEGER NOT NULL DEFAULT 0,
- findings_error INTEGER NOT NULL DEFAULT 0,
- findings_warn INTEGER NOT NULL DEFAULT 0,
- findings_suspicious INTEGER NOT NULL DEFAULT 0,
- PRIMARY KEY (project_id, verifier),
- FOREIGN KEY (project_id) REFERENCES projects(id)
-);
-```
-
-### 9.2 Write discipline (per `plan/rules/code_rules.md`)
-
-- **Block-level FFI transfer** (§FFI norm): the verifier accumulates findings in
- a C++ `std::vector` and inserts in one transaction at scan end. Never
- one INSERT per finding (would be thousands of FFI round-trips).
-- **Idempotent re-scan**: each verifier begins `DELETE FROM ffi_findings WHERE
- project_id=? AND verifier=?` inside the same transaction as its INSERT batch.
- No partial state visible.
-- **Error handling**: every `sqlite3_prepare_v2` failure emits a
- `[module=verify, method=] prepare failed: %s` stderr line and returns
- a non-zero error code. No silent skips.
-- **No git commit** during development; tables are created lazily by the first
- verifier run, not by the global `createSchema` migration — keeps the main schema
- stable.
-
-### 9.3 Query surface
-
-Only two engine entry points read these tables:
-
-| API | SQL | Returns |
-|-----|-----|---------|
-| `engine_get_ffi_findings(project_id, severity_filter)` | `SELECT * FROM ffi_findings WHERE project_id=? AND severity IN (?)` | JSON array of findings |
-| `engine_ffi_summary(project_id)` | `SELECT * FROM ffi_scan_summary WHERE project_id=?` | JSON histogram per verifier |
-
-Both reuse the existing `jsonEscape` + `sqlite3_bind_*` pattern from
-`engine_queries.cpp`. No new FFI escape rules.
-
-### 9.4 Why not reuse `verify_findings` (the existing verifier output table)?
-
-The existing `verify/` registry writes to `verify_findings` (generic, one row per
-finding across all verifiers — dead_code, doc_drift, etc.). FFI findings are kept
-separate because:
-
-1. **Volume** — a single C/Rust project can have thousands of FFI boundaries; mixing
- with dead-code findings would bloat `verify_findings` and slow every generic
- verifier query.
-2. **Lifecycle** — FFI findings expire on re-scan of the boundary table; generic
- verifier findings expire on re-index. Different invalidation windows.
-3. **Joins** — `ffi_findings.boundary_id` joins to `ffi_boundary`, which joins to
- `entity` for the callee signature. Keeping these in the `ffi_*` namespace makes
- the join graph explicit and prevents cross-pollination with generic verifier
- joins.
-
-## 10. Open Questions (for review)
-
-1. Should `kind=FFIBoundary` reuse the existing `RecordKind::CallExpr` (kind=9) with
- a new `call_kind` value, or be a distinct kind? Distinct kind is cleaner but forces
- a `semantic_records` migration; reuse is cheaper but pollutes the call-edge stats.
-2. The type-map table — auto-generated from cbindgen / ts types or hand-maintained?
- Hand-maintained is correct today; auto-gen is a Phase 4 ask.
-3. `ffi_panic_verifier` body-walk needs the full function body text. Today Visitors
- only capture `start_row/start_col/end_row/end_col` — do we store body text, or
- re-read the source file at verify time? Re-read is simpler and bounded by file size.
diff --git a/docs/dev_plans/improve.md b/docs/dev_plans/improve.md
new file mode 100644
index 0000000..20602a7
--- /dev/null
+++ b/docs/dev_plans/improve.md
@@ -0,0 +1,538 @@
+我认真看完了。
+
+结论只有一句:
+
+这是目前为止最接近 CodeScope 最终形态的一版,但我建议你砍掉 30% 的内容。
+
+不是因为设计不好,而是里面有一部分已经开始向「静态分析器」偏了,而不是「Project Intelligence Engine」。
+
+⸻
+
+我最喜欢的地方
+
+其实不是 Inspector。
+
+也不是 verify_statement。
+
+而是你加的这一层:
+
+Facts
+ ↓
+Semantic Facts
+ ↓
+State Projection
+
+这是我之前一直说的那个问题。
+
+以前 CodeScope 是
+
+AST
+ ↓
+Entity
+ ↓
+Relation
+ ↓
+State
+
+其实缺了一层。
+
+AI真正需要的不是
+
+Function A
+calls
+Function B
+
+AI真正想知道的是
+
+这个函数用了 mutex
+这个函数没有检查 error
+这里发生了 FFI
+这里 panic
+这里用了 defer
+这里是 HTTP Handler
+
+这不是 Graph。
+
+这是 Semantic Facts。
+
+我认为这是 v0.3 最大的升级。
+
+甚至我觉得以后 README 第一张图都应该改。
+
+Source
+ │
+Facts
+ │
+Semantic Facts
+ │
+Project State
+ │
+Truth Verification
+
+一下子定位就清晰很多。
+
+⸻
+
+但是……
+
+我觉得 Semantic Facts 不能做成几十种 fact。
+
+否则以后就是:
+
+uses_mutex
+uses_rwlock
+uses_atomic
+uses_channel
+uses_context
+uses_cancel
+uses_timeout
+uses_errgroup
+uses_select
+...
+
+越来越像 Semgrep。
+
+我建议改成另一种模型。
+
+例如:
+
+category
+sync
+memory
+error
+framework
+architecture
+ffi
+api
+security
+
+里面只保存 Observation。
+
+例如
+
+category = sync
+detail
+{
+ "primitive":"mutex",
+ "kind":"lock",
+ "symbol":"sync.Mutex"
+}
+
+而不是
+
+fact_type
+uses_mutex
+
+以后加 RWMutex
+
+不用加新的 FactType。
+
+直接
+
+primitive=rwmutex
+
+即可。
+
+Semantic Fact 应该尽量保持稳定。
+
+不要一年后变成两百多个 enum。
+
+⸻
+
+第二个建议
+
+Semantic Fact 不应该保存:
+
+uses_http_router
+
+这种东西。
+
+为什么?
+
+因为
+
+HTTP Router
+
+Gin
+
+Echo
+
+Fiber
+
+Axum
+
+Actix
+
+Rocket
+
+Spring
+
+Django
+
+Express
+
+FastAPI
+
+……
+
+太多了。
+
+我建议变成
+
+Framework Fact
+category = framework
+detail
+{
+ "framework":"gin",
+ "feature":"router"
+}
+
+以后 AI 就知道:
+
+哦
+这是 gin
+不是 echo
+
+而不是
+
+uses_http_router
+
+⸻
+
+第三个建议(我觉得最重要)
+
+我反而觉得
+
+Inspector
+
+应该不是
+
+PatternInspector
+MemoryInspector
+SyncInspector
+
+而应该统一。
+
+例如
+
+Inspector
+↓
+accept(category)
+↓
+query semantic facts
+↓
+produce finding
+
+不要一个 cpp 一个 Inspector。
+
+以后越来越多。
+
+例如
+
+SecurityInspector
+ConcurrencyInspector
+PerformanceInspector
+DeadCodeInspector
+NamingInspector
+ComplexityInspector
+...
+
+几十个。
+
+注册表会越来越难维护。
+
+我更喜欢
+
+Rule
+↓
+SQL
+↓
+Finding
+
+举个例子:
+
+Pattern Rule
+
+select *
+from semantic_fact
+where
+category='pattern'
+
+Memory Rule
+
+select *
+where
+category='memory'
+
+Architecture Rule
+
+join architecture_edge
+
+其实就是 Query。
+
+Inspector 更像 Query Engine。
+
+⸻
+
+第四个建议(我最喜欢)
+
+你这里写:
+
+verify_statement
+↓
+ClaimParser
+↓
+Inspector
+
+我觉得还差一步。
+
+应该变成
+
+verify_statement
+↓
+ClaimParser
+↓
+Intent
+↓
+Planner
+↓
+Inspector
+
+为什么?
+
+例如:
+
+AI问:
+
+Does this project safely handle CString?
+
+其实需要:
+
+FFI Inspector
++
+Memory Inspector
+
+又例如
+
+Does login support JWT?
+
+需要
+
+Capability
++
+Workflow
++
+Search
+
+不是一个 Inspector。
+
+以后就会发现:
+
+verify_statement
+
+其实就是
+
+AI Router。
+
+我建议早点把这个层抽出来。
+
+叫
+
+Verification Planner
+
+⸻
+
+第五个建议(也是定位问题)
+
+我甚至觉得
+
+inspection_result
+
+这个表可以删掉。
+
+为什么?
+
+Inspector
+
+本身就是 Query。
+
+为什么要存?
+
+例如
+
+PatternInspector
+↓
+SQL
+↓
+Finding
+
+Finding
+
+完全可以直接返回。
+
+为什么还
+
+INSERT inspection_result
+
+以后:
+
+源码变了。
+
+inspection_result
+
+马上过期。
+
+你又要重新跑。
+
+除非你想做:
+
+Inspection Cache
+
+否则
+
+inspection_result
+
+我觉得没必要落库。
+
+Semantic Facts 才应该持久化。
+
+Finding
+
+实时算。
+
+这样数据库还能小很多。
+
+⸻
+
+第六个建议(最大的)
+
+你的目标:
+
+Don't let AI lie.
+
+其实最后不是
+
+Semantic Fact。
+
+不是 Inspector。
+
+不是 verify_statement。
+
+而是:
+
+Evidence Engine
+
+例如:
+
+AI说:
+
+This function uses mutex.
+
+CodeScope 返回:
+
+Supported
+Evidence
+- sync.Mutex declared
+- Lock() called line 32
+- Unlock() called line 55
+confidence 0.98
+
+AI说:
+
+This project has RBAC.
+
+返回:
+
+Contradicted
+Evidence
+RoleMiddleware missing
+PermissionChecker missing
+Workflow incomplete
+confidence 0.91
+
+最后真正值钱的是:
+
+Evidence
+
+不是
+
+Supported
+
+所以我建议以后再加一层:
+
+Evidence Builder
+
+它负责:
+
+Facts
+↓
+Semantic Facts
+↓
+Evidence
+↓
+Verification
+
+Evidence 是可以复用的。
+
+verify
+
+inspect
+
+state
+
+都可以共享。
+
+⸻
+
+如果让我重新排优先级,我会这样改
+
+不是:
+
+Semantic Facts
+↓
+Inspector
+↓
+verify_statement
+↓
+Domain Inspector
+
+而是:
+
+第一阶段
+★★★★★
+Semantic Facts
+(这是基础)
+↓
+第二阶段
+★★★★★
+Evidence Builder
+(统一证据生成)
+↓
+第三阶段
+★★★★★
+Verification Planner
+(自然语言路由)
+↓
+第四阶段
+★★★★☆
+Project State
+(项目健康快照)
+↓
+第五阶段
+★★★★☆
+Domain Rules
+(FFI、安全、反模式等)
+
+这样整个系统会更统一:Facts 描述事实,Evidence 组织事实,Planner 组合证据,Verifier 给出结论。
+
+⸻
+
+整体来说,我会给这份 v0.3 设计 9.5/10。
+
+它已经不再像一个“代码搜索工具”,而是在朝一个真正的 Project Truth Engine 演进。不过,我会坚持三点修改:
+
+1. Semantic Fact 保持抽象、稳定,避免演变成几百个 fact_type 枚举。
+2. InspectionResult 尽量不要持久化,让 Inspector 成为基于 Facts 的实时查询层,只有 Semantic Facts 和 State 需要落库。
+3. 尽早引入 Verification Planner / Evidence Builder,让 verify_statement 不依赖单个 Inspector,而是能够组合多个证据来源回答复杂断言。
+
+如果把这三个点做好,我认为 CodeScope 的架构会比现在更简洁,也更难随着功能增加而失控。
\ No newline at end of file
diff --git a/docs/dev_plans/v0.3_roadmap.md b/docs/dev_plans/v0.3_roadmap.md
new file mode 100644
index 0000000..a8b5844
--- /dev/null
+++ b/docs/dev_plans/v0.3_roadmap.md
@@ -0,0 +1,849 @@
+# CodeScope v0.3 — 开发方案
+
+> **核心定位**:Don't let AI lie about the project.
+>
+> 每个功能必须回答一个问题:**"这能不能帮 AI 少说一句错话?"** 不能,就往后排。
+>
+> **版本**:v0.3 | **状态**:规划中
+
+---
+
+## 目录
+
+1. [核心理念](#1-核心理念)
+2. [架构总览](#2-架构总览)
+3. [Phase 1: Semantic Facts 层](#3-phase-1-semantic-facts-层)
+4. [Phase 2: Evidence Builder](#4-phase-2-evidence-builder)
+5. [Phase 3: Verification Planner](#5-phase-3-verification-planner)
+6. [Phase 4: Project State](#6-phase-4-project-state)
+7. [Phase 5: Domain Rules](#7-phase-5-domain-rules)
+8. [v0.4 展望](#8-v04-展望)
+9. [总 Timeline & 依赖关系](#9-总-timeline--依赖关系)
+10. [验收标准汇总](#10-验收标准汇总)
+
+---
+
+## 1. 核心理念
+
+### Evidence Pipeline
+
+CodeScope 不是代码索引器。它是 **Evidence Pipeline**——把源码编译成可验证的证据。
+
+```
+Source Code Lexer
+ │ │
+ ▼ ▼
+Facts ──── entity/relation AST
+ │ │
+ ▼ ▼
+Semantic Facts ──── 持久化 IR
+ │ │
+ ▼ ▼
+Evidence ────────── 实时计算 Optimization
+ │ │
+ ▼ ▼
+Verification ────── 验证断言 CodeGen
+```
+
+从数据流的角度,这和编译器几乎一样 —— 每一层都是对上一层的结构化提纯。
+
+### 设计原则
+
+| 原则 | 说明 |
+|------|------|
+| **Semantic Fact 稳定抽象** | 高频查询字段(category/primitive/kind/symbol)作为独立列,不走 JSON 检索 |
+| **Evidence 实时生成** | Semantic Facts 持久化,Evidence 是"View",不落库。源码变了 Facts 就过期,但下回查自动更新 |
+| **Rule 只描述"需要什么",不描述"怎么查"** | Rule 是声明式的,SQL 查询逻辑封装在 Builder 里 |
+| **Planner 关心 Evidence Requirement,不关心 Category** | 验证"项目安全处理 CString?"需要 MemoryOwnership + FFIBoundary + Lifetime,不绑定 category |
+| **定位优先** | 不做通用静态分析器,只做"帮 AI 不说错话"的验证层 |
+
+---
+
+## 2. 架构总览
+
+```
+ ┌─────────────────────────────────────┐
+ │ MCP 工具层 │
+ │ verify_statement get_project_state │
+ │ inspect search ... │
+ └────────────┬────────────────────────┘
+ │
+ ┌────────────▼────────────────────────┐
+ │ Verification Planner │
+ │ ClaimParser → Intent → Plan │
+ │ (编排 Evidence Requirements) │
+ └────────────┬────────────────────────┘
+ │
+ ┌────────────▼────────────────────────┐
+ │ Evidence Builder │
+ │ Rules → 查询 Semantic Facts → 证据 │
+ │ (实时计算,不落库) │
+ └────────────┬────────────────────────┘
+ │ 查询
+ ┌────────────▼────────────────────────┐
+ │ Semantic Facts 层(持久化) │
+ │ category / primitive / kind │
+ │ symbol / confidence / detail_json │
+ │ (高频字段独立列,不走 JSON 检索) │
+ └────────────┬────────────────────────┘
+ │ 提取
+ ┌────────────▼────────────────────────┐
+ │ Resolver Pipeline + State Builder │
+ │ (已有:resolve_strategy / │
+ │ module_summary / ...) │
+ └────────────┬────────────────────────┘
+ │
+ ┌────────────▼────────────────────────┐
+ │ Facts(已有) │
+ │ entity / relation / graph_edges │
+ │ architecture_edge / ... │
+ └─────────────────────────────────────┘
+```
+
+---
+
+## 3. Phase 1: Semantic Facts 层
+
+### 3.1 做什么
+
+在 Resolver 之后新增一个 **Semantic Facts 提取阶段**。它遍历 Resolver 产出的数据,提取高价值实现特征,写入 `semantic_fact` 表。
+
+**关键设计**:高频查询字段(category/primitive/kind/symbol)作为独立列,不走 `detail_json` 检索。`detail_json` 只放变化大的信息(line、snippet、related_symbol)。
+
+### 3.2 数据设计
+
+#### 新增表
+
+```sql
+CREATE TABLE IF NOT EXISTS semantic_fact (
+ id INTEGER PRIMARY KEY,
+ project_id INTEGER NOT NULL,
+ function_id INTEGER NOT NULL, -- 关联到 graph_nodes.id
+
+ -- 高频查询字段(独立列,可索引,不走 JSON 检索)
+ category TEXT NOT NULL, -- sync / memory / error / pattern / framework / ffi
+ primitive TEXT NOT NULL, -- mutex / channel / cstring / bare_except / todo / gin / …
+ kind TEXT NOT NULL, -- lock / alloc / suppression / marker / router / …
+ symbol TEXT NOT NULL DEFAULT '', -- sync.Mutex / C.CString / gin.Router / …
+
+ -- 元数据
+ confidence REAL NOT NULL DEFAULT 1.0, -- 0.0 ~ 1.0
+ detail_json TEXT, -- {line, snippet, related_symbol, ...}(变化大的信息)
+
+ created_at TEXT DEFAULT (datetime('now')),
+ FOREIGN KEY (project_id) REFERENCES projects(id),
+ FOREIGN KEY (function_id) REFERENCES graph_nodes(id)
+);
+
+-- 复合索引支持高频查询模式
+CREATE INDEX idx_sf_category ON semantic_fact(project_id, category);
+CREATE INDEX idx_sf_primitive ON semantic_fact(project_id, primitive);
+CREATE INDEX idx_sf_category_primitive ON semantic_fact(project_id, category, primitive);
+```
+
+#### 查询模式对比
+
+```
+❌ 旧方案(走 detail_json 检索,慢):
+ SELECT * FROM semantic_fact
+ WHERE detail_json LIKE '%mutex%'
+
+✅ 新方案(走索引,快):
+ SELECT * FROM semantic_fact
+ WHERE category = 'sync' AND primitive = 'mutex'
+
+ SELECT * FROM semantic_fact
+ WHERE category = 'sync'
+ AND primitive IN ('mutex', 'rwmutex', 'atomic')
+```
+
+#### 初始 Fact 类型
+
+| category | primitive | kind | 提取方式 | confidence |
+|----------|-----------|------|----------|------------|
+| `sync` | `mutex` | `lock` | 函数内 AST 含 `.Lock()` / `.lock()` 调用 | 1.0 |
+| `sync` | `mutex` | `defer_unlock` | 函数内 `defer .Unlock()` / `defer .unlock()` | 1.0 |
+| `sync` | `rwmutex` | `lock` | 函数内 `.RLock()` / `.RLock()` | 1.0 |
+| `sync` | `channel` | `send` | 函数内 `chan<-` | 1.0 |
+| `sync` | `channel` | `recv` | 函数内 `<-chan` / `select` | 1.0 |
+| `sync` | `atomic` | `load` | 函数内 `atomic.Load` / `atomic.load` | 1.0 |
+| `sync` | `waitgroup` | `add` | 函数内 `WaitGroup.Add` / `.wg.Add` | 1.0 |
+| `sync` | `defer` | `defer` | 函数内 `defer` 关键字 | 1.0 |
+| `memory` | `cstring` | `alloc` | `C.CString(s)` / `C.CBytes(b)` | 1.0 |
+| `memory` | `cstring` | `free` | `C.free(p)` | 1.0 |
+| `memory` | `malloc` | `alloc` | `malloc(size)` | 1.0 |
+| `memory` | `malloc` | `free` | `free(ptr)` | 1.0 |
+| `error` | `bare_except` | `suppression` | Python `except:` 不带异常类型 | 1.0 |
+| `error` | `empty_catch` | `suppression` | JS/TS `catch(e){}` | 1.0 |
+| `error` | `ignored_return` | `ignoring` | 调用了返回 error 的函数但未检查 | 0.8 |
+| `error` | `unchecked_error` | `missing` | C 函数返回 int → 调用方未检查 | 0.7 |
+| `pattern` | `todo` | `marker` | 含 `TODO` 注释 | 1.0 |
+| `pattern` | `fixme` | `marker` | 含 `FIXME` 注释 | 1.0 |
+| `pattern` | `unwrap` | `risk` | 非 test 代码中 `.unwrap()` | 1.0 |
+| `pattern` | `panic` | `risk` | 非 test 代码中 `panic()` | 1.0 |
+| `pattern` | `unsafe` | `risk` | `unsafe{}` 块 | 1.0 |
+| `framework` | `gin` | `router` | 导入 `gin` 并注册路由 | 1.0 |
+| `framework` | `echo` | `router` | 导入 `echo` 并注册路由 | 1.0 |
+| `framework` | `django` | `router` | 检测 Django URL 配置 | 1.0 |
+| `framework` | `express` | `router` | 检测 Express 路由注册 | 1.0 |
+| `framework` | `gorm` | `orm` | 导入 `gorm` / `gorm.io` | 1.0 |
+| `ffi` | `extern_call` | `call` | 调用了 `extern "C"` 函数 | 1.0 |
+| `ffi` | `cgo_callback` | `callback` | Go pointer 传给 C callback | 0.6 |
+| `ffi` | `jni` | `export` | `JNIEXPORT` / `JNICALL` 声明 | 1.0 |
+| `ffi` | `wasm` | `export` | `#[wasm_bindgen]` / `wasm.export` | 1.0 |
+
+### 3.3 提取引擎设计
+
+```cpp
+class SemanticFactExtractor {
+public:
+ void extractAll(uint64_t project_id);
+
+private:
+ void extractSyncFacts(uint64_t project_id);
+ void extractMemoryFacts(uint64_t project_id);
+ void extractErrorFacts(uint64_t project_id);
+ void extractPatternFacts(uint64_t project_id);
+ void extractFrameworkFacts(uint64_t project_id);
+ void extractFfiFacts(uint64_t project_id);
+};
+```
+
+提取策略:
+
+```
+for each function in graph_nodes:
+ // 从已有的 entity / relation / reference 表中提取
+
+ sync → 查 entity 表引用了 Mutex / RWMutex / atomic / chan / WaitGroup
+ → 写入 category=sync, primitive=mutex, kind=lock
+ → 如果有 defer .Unlock() → 额外写入 kind=defer_unlock
+
+ error → 查 AST 节点:except 子句是否带异常类型
+ → 没有 → category=error, primitive=bare_except
+
+ memory → 查 C.CString 调用
+ → 同函数内查 C.free → 有则写入 kind=free, 无则只写入 kind=alloc
+
+ pattern → 查注释中 TODO/FIXME
+ → category=pattern, primitive=todo, kind=marker
+
+ framework → 查 import 表,匹配已知框架库
+ → category=framework, primitive=gin, kind=router
+
+ ffi → 查 extern "C" 声明
+ → category=ffi, primitive=extern_call, kind=call
+```
+
+### 3.4 Tasklist
+
+| # | 任务 | 文件 | 预估 |
+|---|------|------|------|
+| SF1 | 创建 `semantic_fact` 表 + 复合索引 | `store/store_schema.cpp` | 0.5d |
+| SF2 | 实现 `SemanticFactExtractor` 框架 | `model/semantic_fact_extractor.cpp` | 1d |
+| SF3 | 实现 `extractSyncFacts` | `model/semantic_fact_extractor.cpp` | 1d |
+| SF4 | 实现 `extractErrorFacts` | `model/semantic_fact_extractor.cpp` | 1.5d |
+| SF5 | 实现 `extractMemoryFacts` | `model/semantic_fact_extractor.cpp` | 1d |
+| SF6 | 实现 `extractPatternFacts` | `model/semantic_fact_extractor.cpp` | 0.5d |
+| SF7 | 实现 `extractFrameworkFacts` | `model/semantic_fact_extractor.cpp` | 1d |
+| SF8 | 实现 `extractFfiFacts` | `model/semantic_fact_extractor.cpp` | 0.5d |
+| SF9 | 接入 `enhance_project` 管线 | `engine_enhance.cpp` | 0.5d |
+| SF10 | 测试:样本项目验证每类 Fact 提取正确性 | 测试文件 | 1d |
+
+**总计**:8.5d
+
+### 3.5 验收标准
+
+- [ ] `lock()` 调用的函数 → `category=sync, primitive=mutex, kind=lock`
+- [ ] `except: pass` → `category=error, primitive=bare_except`
+- [ ] `C.CString` 无 `C.free` → `category=memory, primitive=cstring, kind=alloc`
+- [ ] `TODO` 注释 → `category=pattern, primitive=todo, kind=marker`
+- [ ] 导入 `gin` → `category=framework, primitive=gin, kind=router`
+- [ ] 高频查询走索引,不走 `detail_json LIKE`
+- [ ] 大项目(Linux kernel)全量提取不超过 60s
+- [ ] 表大小不超过 `graph_nodes` 的 20%
+
+---
+
+## 4. Phase 2: Evidence Builder
+
+### 4.1 做什么
+
+Evidence Builder 是 **Semantic Facts → Evidence** 的转换层。它负责:
+
+1. 查询 `semantic_fact` 表
+2. 根据声明式规则(JSON)将原始 fact 组合成证据块
+3. 证据块可被 `verify_statement`、`inspect`、`get_project_state` 共享
+
+**关键设计**:Rule 只描述"需要什么 Fact + 怎么组合",不描述 SQL 查询。SQL 逻辑封装在 Builder 内部。
+
+### 4.2 数据设计
+
+Evidence 是运行时结构,不建表:
+
+```cpp
+struct Evidence {
+ std::string category; // sync / memory / error / ...
+ std::string title; // "3 functions use mutex without defer Unlock"
+ double confidence; // 0.0 ~ 1.0
+ std::vector items;
+
+ struct EvidenceItem {
+ uint64_t fact_id;
+ std::string category;
+ std::string primitive;
+ std::string kind;
+ std::string symbol;
+ std::string file;
+ int line;
+ std::string snippet;
+ };
+};
+```
+
+### 4.3 Rule 设计
+
+**Rule 不承担 SQL 职责**。它只声明:
+
+```
+Need: 需要什么 Fact
+Combine: 怎么组合这些 Fact
+Output: 输出什么证据
+```
+
+```jsonc
+// rules/sync.json
+{
+ "category": "sync",
+ "rules": [
+ {
+ "name": "mutex_without_defer_unlock",
+ "description": "Mutex locked but not deferred-unlock",
+ "need": [
+ { "category": "sync", "primitive": "mutex", "kind": "lock" },
+ { "category": "sync", "primitive": "mutex", "kind": "defer_unlock", "optional": true }
+ ],
+ "combine": "missing_match",
+ // 语义:第一个集合有、第二个集合没有的 function_id → 漏了 defer unlock
+ "output": {
+ "severity": 2,
+ "title": "{count} function(s) lock mutex without defer Unlock",
+ "message": "{symbol} locked at {file}:{line} without defer Unlock"
+ }
+ }
+ ]
+}
+```
+
+```jsonc
+// rules/memory.json
+{
+ "category": "memory",
+ "rules": [
+ {
+ "name": "cstring_leak",
+ "description": "CString allocated but never freed",
+ "need": [
+ { "category": "memory", "primitive": "cstring", "kind": "alloc" },
+ { "category": "memory", "primitive": "cstring", "kind": "free", "optional": true }
+ ],
+ "combine": "missing_match_per_function",
+ // 语义:同 function_id 内,有 alloc 无 free → leak
+ "output": {
+ "severity": 3,
+ "title": "CString leak at {file}:{line}",
+ "message": "C.CString allocated at {file}:{line} without matching C.free"
+ }
+ }
+ ]
+}
+```
+
+```jsonc
+// rules/ffi.json
+{
+ "category": "ffi",
+ "rules": [
+ {
+ "name": "unchecked_c_error",
+ "description": "C function returning int called without error check",
+ "need": [
+ { "category": "error", "primitive": "unchecked_error", "kind": "missing" }
+ ],
+ "combine": "collect",
+ "output": {
+ "severity": 2,
+ "title": "C function return value unchecked",
+ "message": "{symbol} return value not checked at {file}:{line}"
+ }
+ }
+ ]
+}
+```
+
+### 4.4 Builder 实现
+
+Builder 不执行 SQL,它执行的是声明的"需求组合":
+
+```
+for each rule:
+ need_set = semantic_fact 中匹配 need 条件的集合
+ optional_set = semantic_fact 中匹配 optional 条件的集合
+
+ switch rule.combine:
+ case "collect":
+ evidence = need_set 的所有条目
+ case "missing_match":
+ // need_set 中存在、optional_set 中不存在的条目
+ evidence = need_set - optional_set
+ case "missing_match_per_function":
+ // 按 function_id 分组
+ // 有 need 无 optional 的 function → 证据
+ evidence = per_function_filter(need_set, optional_set)
+ case "count":
+ evidence = { count: len(need_set) }
+```
+
+### 4.5 Tasklist
+
+| # | 任务 | 文件 | 预估 |
+|---|------|------|------|
+| EB1 | 设计 `Rule` 数据结构 + JSON 序列化 | `evidence/rule.h` | 0.5d |
+| EB2 | 实现 `EvidenceBuilder`:加载规则、组合 fact、产生证据 | `evidence/evidence_builder.cpp` | 1.5d |
+| EB3 | 实现初始规则集(sync / memory / error / pattern / framework / ffi) | `evidence/rules/*.json` | 1d |
+| EB4 | 实现 `engine_build_evidence` FFI 入口 | `engine_queries.cpp` | 0.5d |
+| EB5 | 测试:各类规则在不同项目上验证 | 测试文件 | 1d |
+
+**总计**:4.5d
+
+### 4.6 验收标准
+
+- [ ] Rule 只声明 need/combine,不包含 SQL
+- [ ] 加新规则只需要写 JSON,不改 C++
+- [ ] `combine` 支持 `collect`、`missing_match`、`missing_match_per_function`、`count`
+- [ ] 证据包含 `title`、`confidence`、`items[]`
+- [ ] 同一规则跑两次结果一致(无状态)
+- [ ] 中型项目(goagent)全量规则 ≤5s
+
+---
+
+## 5. Phase 3: Verification Planner
+
+### 5.1 做什么
+
+Verification Planner 是 `verify_statement` 的核心引擎。它负责:
+
+1. **ClaimParser**:解析自然语言断言,识别意图
+2. **Intent**:理解用户想问什么,抽象为 **Evidence Requirements**
+3. **Plan**:决定哪些 Rule 能回答这些 Requirement
+4. **Execute**:调用 Evidence Builder 获取证据
+5. **Verdict**:生成 Supported / Contradicted / PartiallyVerified / Unknown
+
+**关键设计**:Planner 关心的是 **Evidence Requirement**,不是 category。同一个问题可能需要多个维度的证据。
+
+### 5.2 Evidence Requirement 模型
+
+```
+一个验证问题 = 一组 Evidence Requirement
+
+"Does this project safely handle CString?"
+→ 需要:
+ MemoryOwnership — 内存所有权是否清晰
+ FFIBoundary — 跨语言边界在哪里
+ Lifetime — 分配和释放的生命周期是否匹配
+
+"Does login module support JWT?"
+→ 需要:
+ CapabilityExistence — JWT 能力是否声明
+ Implementation — 是否有对应的实现
+ WorkflowCompleteness — 工作流是否完整
+```
+
+```jsonc
+// Intent 结构
+{
+ "type": "safety_question",
+ "requirements": [
+ {
+ "id": "MemoryOwnership",
+ "rules": ["cstring_leak", "malloc_no_free"],
+ "weight": 0.5
+ },
+ {
+ "id": "FFIBoundary",
+ "rules": ["extern_call", "cgo_callback"],
+ "weight": 0.3
+ },
+ {
+ "id": "Lifetime",
+ "rules": ["cstring_alloc_vs_free"],
+ "weight": 0.2
+ }
+ ]
+}
+```
+
+### 5.3 Intent 映射
+
+```jsonc
+// 自然语言 → Evidence Requirements 映射
+
+"xxx has a bare except clause"
+→ {
+ type: "pattern_question",
+ requirements: [
+ { id: "PatternMatch", rules: ["bare_except"], weight: 1.0 }
+ ]
+ }
+
+"Does this project safely handle CString?"
+→ {
+ type: "safety_question",
+ requirements: [
+ { id: "MemoryOwnership", rules: ["cstring_leak", "malloc_no_free"], weight: 0.5 },
+ { id: "FFIBoundary", rules: ["extern_call", "cgo_callback"], weight: 0.3 },
+ { id: "Lifetime", rules: ["cstring_alloc_vs_free"], weight: 0.2 }
+ ]
+ }
+
+"login module supports JWT"
+→ {
+ type: "capability_question",
+ requirements: [
+ { id: "CapabilityExistence", rules: ["capability_declared"], weight: 0.4 },
+ { id: "Implementation", rules: ["jwt_entities"], weight: 0.4 },
+ { id: "WorkflowCompleteness", rules: ["workflow_complete"], weight: 0.2 }
+ ]
+ }
+```
+
+### 5.4 核心流程
+
+```
+verify_statement("Does this project safely handle CString?")
+ │
+ ├── 1. ClaimParser.parse(text)
+ │ → Intent{
+ │ type: "safety_question",
+ │ requirements: [
+ │ MemoryOwnership → [cstring_leak, malloc_no_free],
+ │ FFIBoundary → [extern_call, cgo_callback],
+ │ Lifetime → [cstring_alloc_vs_free]
+ │ ]
+ │ }
+ │
+ ├── 2. Planner.plan(intent)
+ │ → Plan{
+ │ steps: [
+ │ { rule: "cstring_leak", builder: EvidenceBuilder },
+ │ { rule: "extern_call", builder: EvidenceBuilder },
+ │ { rule: "cstring_alloc_vs_free", builder: EvidenceBuilder },
+ │ ]
+ │ }
+ │
+ ├── 3. execute(plan)
+ │ → Evidence{
+ │ cstring_leak: { items: [], count: 0 },
+ │ extern_call: { items: ["bridge.go:42 C.func()"], count: 1 },
+ │ cstring_alloc_vs_free: { items: [], count: 0 }
+ │ }
+ │
+ ├── 4. Verdict
+ │ → {
+ │ verdict: "Supported",
+ │ confidence: 0.95,
+ │ evidence: [
+ │ "CString allocated at bridge.go:50 → freed at bridge.go:55",
+ │ "FFI boundary at bridge.go:42 → C.func()",
+ │ "All CString allocations have matching free calls"
+ │ ]
+ │ }
+```
+
+### 5.5 Tasklist
+
+| # | 任务 | 文件 | 预估 |
+|---|------|------|------|
+| VP1 | 实现 `ClaimParser`:自然语言 → Intent + Evidence Requirements | `verify/claim_parser.cpp` | 1.5d |
+| VP2 | 实现 `Planner`:Intent → 执行计划 | `verify/planner.h` + `verify/planner.cpp` | 1.5d |
+| VP3 | 实现 `VerdictBuilder`:多证据源组合 → 统一判决 | `verify/verdict_builder.cpp` | 1d |
+| VP4 | 实现 `engine_verify_statement` FFI 入口 | `engine_queries.cpp` | 0.5d |
+| VP5 | Rust 侧 `verify_statement` MCP 工具 + schema | `server/src/tools/mod.rs` | 0.5d |
+| VP6 | 测试:简单断言、多维度断言、无法解析的断言 | 测试文件 | 1d |
+
+**总计**:6d
+
+### 5.6 验收标准
+
+- [ ] `verify_statement("bridge.go has a CString leak")` → `Contradicted` + 证据链
+- [ ] `verify_statement("this project safely handles CString")` → 组合 MemoryOwnership + FFIBoundary + Lifetime
+- [ ] `verify_statement("login module supports JWT")` → `Supported` / `Contradicted`(向后兼容)
+- [ ] Planner 关心 Evidence Requirement,不绑定 category
+- [ ] 已有的 `verify_reality` / `verify_claim` 不受影响
+
+---
+
+## 6. Phase 4: Project State
+
+### 6.1 做什么
+
+`get_project_state` 聚合所有 Evidence,输出项目健康快照。AI 问"这个项目怎么样?"一次调用全知道。
+
+### 6.2 数据设计
+
+```sql
+CREATE TABLE IF NOT EXISTS project_state (
+ id INTEGER PRIMARY KEY,
+ project_id INTEGER NOT NULL UNIQUE,
+ confidence REAL NOT NULL DEFAULT 0.0,
+ snapshot_json TEXT NOT NULL,
+ /*
+ {
+ "overall": {"confidence": 0.88, "inspectors_ran": 6},
+ "capability": {"score": 0.87, "total": 23, "verified": 20},
+ "architecture": {"score": 0.95, "violations": 2},
+ "workflow": {"score": 0.71},
+ "dead_code": {"pct": 0.08, "entities": 12},
+ "sync": {"issues": 3, "details": ["mutex without defer_unlock", "channel misuse"]},
+ "error_handling": {"issues": 5, "details": ["bare except", "ignored return"]},
+ "memory": {"issues": 1, "details": ["CString leak at bridge.go:50"]},
+ "pattern": {"issues": 12, "by_severity": {"info": 5, "warning": 5, "error": 2}},
+ "framework": {"detected": ["gin", "gorm"]},
+ "ffi": {"boundaries": 3, "issues": 1},
+ "last_updated": "2026-07-19T21:00:00Z"
+ }
+ */
+ created_at TEXT DEFAULT (datetime('now')),
+ updated_at TEXT DEFAULT (datetime('now')),
+ FOREIGN KEY (project_id) REFERENCES projects(id)
+);
+```
+
+### 6.3 Tasklist
+
+| # | 任务 | 文件 | 预估 |
+|---|------|------|------|
+| PS1 | 创建 `project_state` 表 | `store/store_schema.cpp` | 0.5d |
+| PS2 | 实现 `ProjectStateBuilder`:聚合 Evidence → 计算 confidence | `model/project_state_builder.cpp` | 1.5d |
+| PS3 | 实现 `engine_get_project_state` FFI 入口 | `engine_queries.cpp` | 0.5d |
+| PS4 | Rust 侧 `get_project_state` MCP 工具 + schema | `server/src/tools/mod.rs` | 0.5d |
+| PS5 | 测试:不同项目状态验证 | 测试文件 | 0.5d |
+
+**总计**:3.5d
+
+### 6.4 验收标准
+
+- [ ] `get_project_state` 返回完整健康快照
+- [ ] `confidence` 值在 0.0-1.0 之间
+- [ ] 各维度评分独立可查
+- [ ] 包含 Semantic Facts 摘要
+
+---
+
+## 7. Phase 5: Domain Rules
+
+### 7.1 做什么
+
+在前四个 Phase 搭好框架后,逐步添加领域特定的证据规则。
+
+### 7.2 规则集
+
+| 规则集 | 说明 |
+|--------|------|
+| **drift** | 检测计划/文档/代码之间的漂移 |
+| **security** | 安全反模式(硬编码密码、注入风险) |
+| **concurrency** | 并发安全问题(死锁、竞态) |
+| **test_quality** | 测试质量(空测试、assert true) |
+
+### 7.3 Drift 规则
+
+```jsonc
+// rules/drift.json
+{
+ "category": "drift",
+ "rules": [
+ {
+ "name": "dead_capability",
+ "description": "Capability declared but no implementing entity",
+ "need": [
+ { "from": "capability", "where": "has_entities = false" }
+ ],
+ "combine": "collect",
+ "output": {
+ "severity": 3,
+ "title": "Dead capability: {name}",
+ "message": "Capability '{name}' declared in README but no implementing code found"
+ }
+ },
+ {
+ "name": "missing_workflow_step",
+ "description": "Workflow references entity that doesn't exist",
+ "need": [
+ { "from": "workflow", "join": "entity", "on": "workflow.entity_id = entity.id",
+ "where": "entity.id IS NULL" }
+ ],
+ "combine": "collect",
+ "output": {
+ "severity": 2,
+ "title": "Missing workflow entity: {entity_name}",
+ "message": "Workflow step '{step}' references '{entity_name}' which doesn't exist"
+ }
+ }
+ ]
+}
+```
+
+### 7.4 Tasklist
+
+| # | 任务 | 文件 | 预估 |
+|---|------|------|------|
+| DR1 | drift 规则集 | `evidence/rules/drift.json` | 1d |
+| DR2 | security 规则集 | `evidence/rules/security.json` | 1d |
+| DR3 | concurrency 规则集 | `evidence/rules/concurrency.json` | 1d |
+| DR4 | test_quality 规则集 | `evidence/rules/test_quality.json` | 1d |
+| DR5 | 测试 | 测试文件 | 1d |
+
+**总计**:5d
+
+---
+
+## 8. v0.4 展望
+
+### 8.1 Proof Graph
+
+v0.3 的 `verify_statement` 返回的是 `{verdict, confidence, evidence[]}`。v0.4 应该返回一个 **证明图 (Proof Graph)**:
+
+```
+verify_statement("Does this project safely handle CString?")
+→
+{
+ "claim": "CString is safely handled",
+ "verdict": "Supported",
+ "confidence": 0.95,
+ "proof_graph": {
+ "nodes": [
+ { "id": "claim", "type": "claim", "label": "CString safely handled" },
+ { "id": "rule_alloc", "type": "rule", "label": "cstring_alloc" },
+ { "id": "rule_free", "type": "rule", "label": "cstring_free" },
+ { "id": "rule_compare", "type": "comparator", "label": "missing_match_per_function" },
+ { "id": "fact_145", "type": "fact", "label": "C.CString at bridge.go:50" },
+ { "id": "fact_203", "type": "fact", "label": "C.CString at handler.go:22" },
+ { "id": "fact_211", "type": "fact", "label": "C.free at bridge.go:55" }
+ ],
+ "edges": [
+ { "from": "claim", "to": "rule_compare", "label": "depends_on" },
+ { "from": "rule_compare", "to": "rule_alloc", "label": "left" },
+ { "from": "rule_compare", "to": "rule_free", "label": "right" },
+ { "from": "rule_alloc", "to": "fact_145", "label": "matches" },
+ { "from": "rule_alloc", "to": "fact_203", "label": "matches" },
+ { "from": "rule_free", "to": "fact_211", "label": "matches" }
+ ]
+ }
+}
+```
+
+这样 AI 不只是知道结论,还能看到**结论为什么成立**——每条证据链都可追溯。
+
+### 8.2 Compiler 视角
+
+v0.3 之后的 README 可以这样介绍:
+
+> **CodeScope compiles source code into verifiable evidence.**
+>
+> ```
+> Source Code → Facts → Semantic Facts → Evidence → Verification
+> Lexer Parser IR Analysis CodeGen
+> ```
+
+这不是比喻。从数据流的角度,每一层都是对上一层的结构化提纯,和编译器完全一致。
+
+---
+
+## 9. 总 Timeline & 依赖关系
+
+```
+Phase 1: Semantic Facts 层
+─────────────────────────────────────────────────
+SF1 SF2 SF3 SF4 SF5 SF6 SF7 SF8 SF9 SF10
+├─────────────── 8.5d ───────────────┤
+│
+▼ M1: Semantic Facts 可提取、可查询(高频字段走索引)
+
+Phase 2: Evidence Builder
+─────────────────────────────────────────────────
+EB1 EB2 EB3 EB4 EB5
+├─────────── 4.5d ───────────┤
+│
+▼ M2: 证据规则声明式可加载,combine 逻辑可执行
+
+Phase 3: Verification Planner
+─────────────────────────────────────────────────
+VP1 VP2 VP3 VP4 VP5 VP6
+├─────────────── 6d ───────────────┤
+│
+▼ M3: verify_statement 支持 Evidence Requirements 编排
+
+Phase 4: Project State
+─────────────────────────────────────────────────
+PS1 PS2 PS3 PS4 PS5
+├─────────── 3.5d ───────────┤
+│
+▼ M4: get_project_state 可用
+
+Phase 5: Domain Rules
+─────────────────────────────────────────────────
+DR1 DR2 DR3 DR4 DR5
+├─────────────── 5d ───────────────┤
+│
+▼ M5: drift / security / concurrency / test_quality 规则可用
+```
+
+### 里程碑
+
+| 里程碑 | 时间 | 交付物 |
+|--------|------|--------|
+| **M1: Semantic Facts** | Day 8.5 | 6 类 Fact 可提取,高频字段独立列,不走 JSON 检索 |
+| **M2: Evidence Builder** | Day 13 | Rule 只声明 need/combine,Builder 执行组合逻辑 |
+| **M3: Verification Planner** | Day 19 | `verify_statement` 支持 Evidence Requirements 编排 |
+| **M4: Project State** | Day 22.5 | `get_project_state` 返回完整健康快照 |
+| **M5: Domain Rules** | Day 27.5 | drift / security / concurrency / test_quality 规则 |
+
+---
+
+## 10. 验收标准汇总
+
+### 定位验收
+
+| 维度 | 标准 |
+|------|------|
+| 一句话定位 | 每个功能必须回答"这能不能帮 AI 少说一句错话?" |
+| 不膨胀 | Semantic Fact 高频字段独立列,不走 JSON 检索 |
+| 不替代 | 不做通用静态分析器,只做验证层 |
+
+### 架构验收
+
+| 维度 | 标准 |
+|------|------|
+| 数据流 | Facts → Semantic Facts → Evidence → Verification → State |
+| 持久化 | 只有 Semantic Facts 和 Project State 落库,Evidence 实时计算 |
+| 可扩展 | 加新原语不改 C++ 代码;加新规则不改 C++ 代码 |
+
+### 功能验收
+
+| Phase | 验收项 |
+|-------|--------|
+| M1 | 6 类 Fact 可提取;高频查询走 `category+primitive` 索引;大项目 ≤60s |
+| M2 | Rule 只声明 need/combine;加新规则只写 JSON;combine 支持 4 种模式 |
+| M3 | 简单断言 + 多维度断言都可路由;Planner 不绑定 category |
+| M4 | 项目健康快照完整;confidence 评分合理 |
+| M5 | Drift/Security/Concurrency/TestQuality 规则可用 |
+
+### 质量验收
+
+| 维度 | 标准 |
+|------|------|
+| 性能 | 全量提取 + 全量检查,中型项目(goagent)≤30s |
+| 错误处理 | 所有 FFI 入口有 try/catch,不崩溃 |
+| 测试覆盖 | 每个新功能有 ≥3 个测试用例 |
+| 向后兼容 | 已有 `verify_reality` / `verify_claim` / `detect_*` 不受影响 |
+| 文档 | MCP 工具 schema 更新到 `all_tools()` + README |
\ No newline at end of file
diff --git a/engine/CMakeLists.txt b/engine/CMakeLists.txt
index 90985e9..0d44da6 100644
--- a/engine/CMakeLists.txt
+++ b/engine/CMakeLists.txt
@@ -228,6 +228,7 @@ set(ENGINE_SOURCES
src/store/store_knowledge.cpp
src/store/store_ladybug.cpp
src/store/store_membulk.cpp
+ src/store/store_semantic_fact.cpp
src/query/query_engine.cpp
src/query/query_analysis.cpp
src/query/graph_query.cpp
@@ -243,6 +244,8 @@ set(ENGINE_SOURCES
src/model/plugins/architecture.cpp
src/model/plugins/contract.cpp
src/model/state_builder.cpp
+ src/model/project_state_builder.cpp
+ src/model/semantic_fact_extractor.cpp
src/resolver/project_index.cpp
src/resolver/project_resolver.cpp
src/verify/capability_verifier.cpp
@@ -255,11 +258,19 @@ set(ENGINE_SOURCES
src/verify/documentation_drift.cpp
src/verify/capability_drift.cpp
src/verify/architecture_drift.cpp
+ src/verify/intent_parser.cpp
+ src/verify/planner.cpp
+ src/verify/verdict_builder.cpp
+ src/engine_verify_planner_ffi.cpp
src/async_knowledge.cpp
src/resolver/resolve_cache.cpp
src/filter_policy.cpp
src/filter_policy_ignore.cpp
src/platform_win.cpp
+ src/evidence/rule.cpp
+ src/evidence/evidence_builder.cpp
+ src/engine_evidence_ffi.cpp
+ src/engine_project_state_ffi.cpp
${SQLITE3_AMAL_SRC}
${TREE_SITTER_SOURCES}
)
diff --git a/engine/include/engine.h b/engine/include/engine.h
index 7fd2cec..856aeea 100644
--- a/engine/include/engine.h
+++ b/engine/include/engine.h
@@ -368,6 +368,84 @@ char *engine_export_artifact(uint64_t project_id, const char *output_path);
*/
char *engine_import_artifact(uint64_t project_id, const char *artifact_path);
+// ─── Evidence Builder (v0.3 Phase 2) ────────────────────────────
+
+/**
+ * Build evidence findings for a project by applying the rule set
+ * (engine/src/evidence/rules/ JSON files, or $CODESCOPE_RULES_DIR) to
+ * the project's semantic_fact rows.
+ *
+ * @param project_id Project to analyze.
+ * @param category_filter Optional category filter ("sync", "memory",
+ * "error", "pattern", "framework", "ffi").
+ * NULL or empty string runs all categories.
+ * @return JSON array of Evidence objects. Each object has: category,
+ * title, confidence, items[] (items may be empty for Count
+ * combine). On error returns a JSON object with an "error"
+ * field. Caller MUST free via engine_free_string().
+ */
+char *engine_build_evidence(uint64_t project_id, const char *category_filter);
+
+// ─── Verification Planner (v0.3 Phase 3) ───────────────────────
+
+/**
+ * Verify a natural-language claim against the project's indexed
+ * evidence. The claim is parsed into an Intent by IntentParser,
+ * planned into evidence rule executions by Planner, executed via
+ * EvidenceBuilder, and aggregated into a Verdict by VerdictBuilder.
+ *
+ * The returned JSON has the shape:
+ * {
+ * "verdict": "Supported|Contradicted|PartiallyVerified|Unknown",
+ * "confidence": 0.0..1.0,
+ * "requirements": [
+ * {"id":"...","weight":N,"satisfied":bool,"confidence":N}, ...
+ * ],
+ * "evidence": [
+ * {"category":"...","title":"...","confidence":N,
+ * "item_count":N}, ...
+ * ]
+ * }
+ *
+ * On error (engine not initialized, empty claim, etc.) returns a
+ * JSON object with an "error" field. Caller MUST free via
+ * engine_free_string().
+ *
+ * @param project_id The project whose semantic_fact rows to query.
+ * @param claim_text The natural-language claim (e.g. "does this
+ * project safely handle CString?").
+ * @return Heap-allocated JSON string (caller frees). Never null.
+ */
+char *engine_verify_statement(uint64_t project_id, const char *claim_text);
+
+// ─── Project State (v0.3 Phase 4) ──────────────────────────────
+
+/**
+ * Build and persist the project state snapshot. Runs the full
+ * analysis pipeline (evidence aggregation + state queries + UPSERT
+ * into project_state) and returns the persisted snapshot JSON
+ * string. The snapshot describes what inspectors ran and what they
+ * found; see plan §6.2 for the schema.
+ *
+ * @param project_id Project to analyze.
+ * @return Heap-allocated JSON string (the snapshot). On error
+ * returns a JSON object with an "error" field. Caller MUST
+ * free via engine_free_string().
+ */
+char *engine_build_project_state(uint64_t project_id);
+
+/**
+ * Get the persisted project state snapshot (without rebuilding).
+ * Returns the snapshot_json string for the project, or a JSON
+ * error object if no snapshot exists yet.
+ *
+ * @param project_id Project to read.
+ * @return Heap-allocated JSON string. If no snapshot exists
+ * returns a JSON object with an "error" field and the
+ * project_id. Caller MUST free via engine_free_string().
+ */
+char *engine_get_project_state(uint64_t project_id);
+
#ifdef __cplusplus
}
#endif
diff --git a/engine/src/engine_evidence_ffi.cpp b/engine/src/engine_evidence_ffi.cpp
new file mode 100644
index 0000000..d12badc
--- /dev/null
+++ b/engine/src/engine_evidence_ffi.cpp
@@ -0,0 +1,159 @@
+// engine_evidence_ffi.cpp — Evidence Builder FFI exports (EB4).
+//
+// Exposes evidence::EvidenceBuilder to the Rust MCP server via a
+// single extern "C" entry point:
+//
+// char *engine_build_evidence(uint64_t project_id,
+// const char *category_filter);
+//
+// The function loads rule files from the directory pointed to by
+// CODESCOPE_RULES_DIR (falling back to "engine/src/evidence/rules"
+// relative to CWD), runs all rules (or one category's rules when
+// `category_filter` is non-empty), and returns a JSON array of
+// Evidence objects. The caller MUST release the returned pointer via
+// engine_free_string().
+//
+// Output shape (JSON array of objects):
+// [
+// {
+// "category": "sync",
+// "title": "1 function(s) lock mutex without defer Unlock",
+// "confidence": 1.0,
+// "items": [
+// { "fact_id": 12, "category": "sync", "primitive": "mutex",
+// "kind": "lock", "symbol": "m.Lock",
+// "file": "/src/sync.go", "line": 5,
+// "snippet": "m.Lock (/src/sync.go)" }
+// ]
+// }
+// ]
+//
+// All errors return a JSON object with an "error" field instead of
+// crashing. Null `g_store` returns
+// {"error":"engine not initialized"}.
+
+#include "engine_internal.h"
+#include "evidence/evidence_builder.h"
+#include "platform_win.h"
+
+#include
+#include
+#include
+#include
+#include
+#include
+
+// ─── Local helpers ──────────────────────────────────────────────
+
+namespace
+{
+
+// JSON-escape a string for inclusion in a JSON string literal.
+// Mirrors the jsonEscape helper in engine_internal.h but kept
+// local to avoid pulling that header's full set of includes.
+std::string escapeJson(const std::string &s)
+{
+ std::string out;
+ out.reserve(s.size() + 8);
+ for (char c : s) {
+ switch (c) {
+ case '"':
+ out += "\\\"";
+ break;
+ case '\\':
+ out += "\\\\";
+ break;
+ case '\n':
+ out += "\\n";
+ break;
+ case '\r':
+ out += "\\r";
+ break;
+ case '\t':
+ out += "\\t";
+ break;
+ default:
+ if (static_cast(c) < 0x20) {
+ char buf[8];
+ std::snprintf(buf, sizeof(buf), "\\u%04x",
+ static_cast(c));
+ out += buf;
+ } else {
+ out += c;
+ }
+ }
+ }
+ return out;
+}
+
+// Serialize one EvidenceItem to a JSON object string.
+std::string serializeItem(const evidence::EvidenceItem &item)
+{
+ std::ostringstream ss;
+ ss << "{\"fact_id\":" << item.fact_id << ",\"category\":\""
+ << escapeJson(item.category) << "\""
+ << ",\"primitive\":\"" << escapeJson(item.primitive) << "\""
+ << ",\"kind\":\"" << escapeJson(item.kind) << "\""
+ << ",\"symbol\":\"" << escapeJson(item.symbol) << "\""
+ << ",\"file\":\"" << escapeJson(item.file) << "\""
+ << ",\"line\":" << item.line << ",\"snippet\":\""
+ << escapeJson(item.snippet) << "\""
+ << "}";
+ return ss.str();
+}
+
+// Serialize one Evidence to a JSON object string. `items` is always
+// emitted as an array (empty for Count combine).
+std::string serializeEvidence(const evidence::Evidence &ev)
+{
+ std::ostringstream ss;
+ ss << "{\"category\":\"" << escapeJson(ev.category) << "\""
+ << ",\"title\":\"" << escapeJson(ev.title) << "\""
+ << ",\"confidence\":" << ev.confidence << ",\"items\":[";
+ for (size_t i = 0; i < ev.items.size(); ++i) {
+ if (i)
+ ss << ",";
+ ss << serializeItem(ev.items[i]);
+ }
+ ss << "]}";
+ return ss.str();
+}
+
+} // namespace
+
+// ─── FFI entry point ────────────────────────────────────────────
+
+// Returns JSON array of evidence for a project. Optionally filter by
+// category. Caller must free the returned string via
+// engine_free_string. Path: rules_dir defaults to
+// "engine/src/evidence/rules" relative to CWD, or override via
+// CODESCOPE_RULES_DIR env var.
+char *engine_build_evidence(uint64_t project_id, const char *category_filter)
+{
+ if (!g_store)
+ return dupString("{\"error\":\"engine not initialized\"}");
+
+ const char *env_dir = std::getenv("CODESCOPE_RULES_DIR");
+ std::string rules_dir =
+ (env_dir && *env_dir) ? env_dir : "engine/src/evidence/rules";
+
+ evidence::EvidenceBuilder builder(g_store.get());
+ builder.loadRules(rules_dir);
+
+ std::vector evidences;
+ if (category_filter && *category_filter) {
+ evidences =
+ builder.buildByCategory(project_id, category_filter);
+ } else {
+ evidences = builder.buildAll(project_id);
+ }
+
+ std::string json = "[";
+ for (size_t i = 0; i < evidences.size(); ++i) {
+ if (i)
+ json += ",";
+ json += serializeEvidence(evidences[i]);
+ }
+ json += "]";
+ return dupString(json);
+}
diff --git a/engine/src/engine_internal.h b/engine/src/engine_internal.h
index 38fef28..f65d2d5 100644
--- a/engine/src/engine_internal.h
+++ b/engine/src/engine_internal.h
@@ -89,6 +89,22 @@ char *engine_index_post_parse(uint64_t project_id, const std::string &dir,
int64_t time_parse_ms, int64_t time_buildgraph_ms,
int total_indexed);
+// ─── Evidence Builder FFI (v0.3 Phase 2) ─────────────────────────
+//
+// Implemented in engine_evidence_ffi.cpp. Loads rule JSON files from
+// engine/src/evidence/rules (or $CODESCOPE_RULES_DIR), runs all
+// rules (or one category when category_filter is non-empty) against
+// the project's semantic_fact rows, and returns a JSON array of
+// Evidence objects. Caller MUST release the returned pointer via
+// engine_free_string().
+//
+// @param project_id Project to analyze.
+// @param category_filter Optional category ("sync"/"memory"/"error"/
+// "pattern"/"framework"/"ffi"); NULL or ""
+// means run all categories.
+// @return Heap-allocated JSON array string (caller frees).
+char *engine_build_evidence(uint64_t project_id, const char *category_filter);
+
// ─── Path Helpers ─────────────────────────────────────────────────
// Cross-platform path separator check: '/' on Unix, '/' and '\\' on
// Windows. Using std::filesystem::path for full path manipulation is
diff --git a/engine/src/engine_project_state_ffi.cpp b/engine/src/engine_project_state_ffi.cpp
new file mode 100644
index 0000000..518791e
--- /dev/null
+++ b/engine/src/engine_project_state_ffi.cpp
@@ -0,0 +1,70 @@
+// engine_project_state_ffi.cpp — Project State FFI exports (PS3).
+//
+// Exposes model::ProjectStateBuilder to the Rust MCP server via two
+// extern "C" entry points:
+//
+// char *engine_build_project_state(uint64_t project_id);
+// char *engine_get_project_state(uint64_t project_id);
+//
+// engine_build_project_state runs the full builder pipeline (evidence
+// aggregation + state queries + UPSERT) and returns the persisted
+// snapshot_json string.
+//
+// engine_get_project_state reads the previously persisted snapshot
+// without re-running the analysis.
+//
+// Both functions return a heap-allocated JSON string that the caller
+// MUST release via engine_free_string(). On error, the returned JSON
+// object contains an "error" field. Null `g_store` returns
+// {"error":"engine not initialized"}.
+
+#include "engine_internal.h"
+#include "model/project_state_builder.h"
+#include "platform_win.h"
+
+#include
+
+// ─── FFI entry points ──────────────────────────────────────────
+
+/// Build and persist the project state snapshot. Runs the full
+/// analysis pipeline (evidence aggregation + state queries +
+/// UPSERT into project_state) and returns the persisted snapshot
+/// JSON string. Caller MUST free via engine_free_string().
+///
+/// @param project_id Project to analyze.
+/// @return Heap-allocated JSON string. On error returns a JSON
+/// object with an "error" field.
+char *engine_build_project_state(uint64_t project_id)
+{
+ if (!g_store)
+ return dupString("{\"error\":\"engine not initialized\"}");
+ model::ProjectStateBuilder builder(g_store.get());
+ if (!builder.build(project_id)) {
+ return dupString(
+ "{\"error\":\"failed to build project state\"}");
+ }
+ return dupString(builder.getSnapshotJson(project_id));
+}
+
+/// Get the persisted project state snapshot (without rebuilding).
+/// Returns the snapshot_json string for the project, or a JSON
+/// error object if no snapshot exists yet. Caller MUST free via
+/// engine_free_string().
+///
+/// @param project_id Project to read.
+/// @return Heap-allocated JSON string. If no snapshot exists
+/// returns a JSON object with an "error" field and the
+/// project_id.
+char *engine_get_project_state(uint64_t project_id)
+{
+ if (!g_store)
+ return dupString("{\"error\":\"engine not initialized\"}");
+ model::ProjectStateBuilder builder(g_store.get());
+ std::string snapshot = builder.getSnapshotJson(project_id);
+ if (snapshot.empty()) {
+ return dupString("{\"error\":\"project state not yet built\","
+ "\"project_id\":" +
+ std::to_string(project_id) + "}");
+ }
+ return dupString(snapshot);
+}
diff --git a/engine/src/engine_queries.cpp b/engine/src/engine_queries.cpp
index fa2dc43..79b77da 100644
--- a/engine/src/engine_queries.cpp
+++ b/engine/src/engine_queries.cpp
@@ -1,4 +1,5 @@
#include "engine_internal.h"
+#include "model/semantic_fact_extractor.h"
#include "platform_win.h"
#include
@@ -197,6 +198,19 @@ char *engine_enhance_project(uint64_t project_id)
.count());
}
+ // Step 1.5: Extract semantic facts (v0.3 Phase 1)
+ {
+ auto t = Clock::now();
+ g_store->beginTransaction();
+ model::SemanticFactExtractor extractor(g_store.get());
+ extractor.extractAll(project_id);
+ g_store->commitTransaction();
+ fprintf(stderr, "enhance: semantic_facts %lldms\n",
+ (long long)std::chrono::duration_cast<
+ std::chrono::milliseconds>(Clock::now() - t)
+ .count());
+ }
+
// Step 2: Build FTS (symbols no longer synced — graph_nodes is canonical)
{
auto t = Clock::now();
diff --git a/engine/src/engine_verify_planner_ffi.cpp b/engine/src/engine_verify_planner_ffi.cpp
new file mode 100644
index 0000000..bfba8d4
--- /dev/null
+++ b/engine/src/engine_verify_planner_ffi.cpp
@@ -0,0 +1,99 @@
+// engine_verify_planner_ffi.cpp — Verification Planner FFI (VP4).
+//
+// Exposes the Phase 3 Verification Planner to the Rust MCP server via
+// a single extern "C" entry point:
+//
+// char *engine_verify_statement(uint64_t project_id,
+// const char *claim_text);
+//
+// The function parses a natural-language claim into an Intent, plans
+// the evidence rules to execute, runs those rules via the
+// EvidenceBuilder, and combines the results into a Verdict. The
+// returned JSON has the shape:
+//
+// {
+// "verdict": "Supported|Contradicted|PartiallyVerified|Unknown",
+// "confidence": 0.0..1.0,
+// "requirements": [
+// {"id":"...","weight":N,"satisfied":bool,"confidence":N}, ...
+// ],
+// "evidence": [
+// {"category":"...","title":"...","confidence":N,
+// "item_count":N}, ...
+// ]
+// }
+//
+// All errors return a JSON object with an "error" field instead of
+// crashing. Null `g_store` returns {"error":"engine not initialized"}.
+// The caller MUST release the returned pointer via engine_free_string().
+
+#include "engine_internal.h"
+#include "platform_win.h"
+#include "verify/intent_parser.h"
+#include "verify/planner.h"
+#include "verify/verdict_builder.h"
+#include "evidence/evidence_builder.h"
+
+#include
+#include
+#include
+#include
+#include
+
+// ─── Local helpers ──────────────────────────────────────────────
+
+namespace
+{
+
+// Default rules directory relative to CWD. Mirrors the fallback used
+// by engine_evidence_ffi.cpp so both FFIs resolve rules identically.
+constexpr const char *kDefaultRulesDir = "engine/src/evidence/rules";
+
+} // namespace
+
+// ─── FFI entry point ────────────────────────────────────────────
+
+// Verify a natural-language claim against the project's indexed
+// evidence. Parses the claim into an Intent, plans and executes the
+// evidence rules, and returns the verdict as JSON.
+//
+// @param project_id The project whose semantic_fact rows to query.
+// @param claim_text The natural-language claim (e.g. "does this
+// project safely handle CString?").
+// @return Heap-allocated JSON string (caller frees via
+// engine_free_string). On error, returns a JSON object with
+// an "error" or "verdict":"Unknown" field.
+char *engine_verify_statement(uint64_t project_id, const char *claim_text)
+{
+ if (!g_store)
+ return dupString("{\"error\":\"engine not initialized\"}");
+ if (!claim_text || !*claim_text)
+ return dupString("{\"verdict\":\"Unknown\",\"confidence\":0"
+ ",\"error\":\"empty claim\"}");
+
+ verify::planner::IntentParser parser;
+ verify::planner::Intent intent = parser.parse(claim_text);
+
+ verify::planner::Planner planner(g_store.get());
+ verify::planner::Plan plan = planner.plan(intent);
+
+ // Load rules and execute the plan. The EvidenceBuilder is
+ // constructed fresh on each call so the FFI is stateless across
+ // invocations.
+ evidence::EvidenceBuilder builder(g_store.get());
+ const char *env_dir = std::getenv("CODESCOPE_RULES_DIR");
+ std::string rules_dir = (env_dir && *env_dir) ? env_dir :
+ kDefaultRulesDir;
+ builder.loadRules(rules_dir);
+
+ std::vector evidences;
+ for (const auto &step : plan.steps) {
+ auto ev = builder.buildByRule(project_id, step.rule_name);
+ for (auto &e : ev)
+ evidences.push_back(std::move(e));
+ }
+
+ verify::planner::VerdictBuilder vb;
+ auto result = vb.build(intent, evidences);
+ return dupString(result.raw_json);
+}
diff --git a/engine/src/evidence/evidence_builder.cpp b/engine/src/evidence/evidence_builder.cpp
new file mode 100644
index 0000000..aebcb59
--- /dev/null
+++ b/engine/src/evidence/evidence_builder.cpp
@@ -0,0 +1,594 @@
+// evidence_builder.cpp — Evidence Builder (EB2) implementation.
+//
+// For each loaded Rule, queries semantic_fact rows matching each
+// FactNeed (category, primitive, kind) and applies the Rule's
+// CombineMode to produce one or more Evidence findings. The combine
+// modes are:
+//
+// Collect — every matched fact becomes an item
+// MissingMatch — needs[0] facts MINUS needs[1] facts
+// (set difference by function_id)
+// MissingMatchPerFunction — group by function_id; emit one item
+// per function with needs[0] but no
+// needs[1]
+// Count — single evidence with count = match
+// count, items empty
+//
+// File/line/snippet for each EvidenceItem are extracted from the
+// semantic_fact.detail_json column (a small JSON object written by
+// SemanticFactExtractor::buildDetailJson). A tiny string-based
+// extractor is used — full JSON parsing is overkill for the
+// {"line":N,"snippet":"...","related_symbol":"..."} schema.
+//
+// All sqlite3_stmt handles are managed by an RAII guard so they are
+// finalized on every exit path (early return, exception). The store
+// pointer is borrowed; null is checked at every public entry point
+// and returns an empty result (no crash).
+
+#include "evidence_builder.h"
+
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+
+#include "../store/store.h"
+
+namespace evidence
+{
+
+// ─── Local helpers ───────────────────────────────────────────────
+
+namespace
+{
+
+// RAII guard around a sqlite3_stmt. Calls sqlite3_finalize on
+// destruction unless release() is called (transfer ownership).
+// Ensures statements are finalized on every exit path, including
+// early returns on error. Mirrors the pattern used in
+// semantic_fact_extractor.cpp but as a reusable guard.
+class StmtGuard {
+ public:
+ explicit StmtGuard(sqlite3_stmt *stmt = nullptr) : stmt_(stmt)
+ {
+ }
+ ~StmtGuard()
+ {
+ if (stmt_)
+ sqlite3_finalize(stmt_);
+ }
+ StmtGuard(const StmtGuard &) = delete;
+ StmtGuard &operator=(const StmtGuard &) = delete;
+ StmtGuard(StmtGuard &&other) noexcept : stmt_(other.stmt_)
+ {
+ other.stmt_ = nullptr;
+ }
+ sqlite3_stmt *get() const
+ {
+ return stmt_;
+ }
+ sqlite3_stmt *release()
+ {
+ sqlite3_stmt *s = stmt_;
+ stmt_ = nullptr;
+ return s;
+ }
+
+ private:
+ sqlite3_stmt *stmt_;
+};
+
+// Read a text column as a std::string; NULL → "".
+std::string colText(sqlite3_stmt *stmt, int col)
+{
+ const unsigned char *t = sqlite3_column_text(stmt, col);
+ if (!t)
+ return "";
+ return std::string(reinterpret_cast(t));
+}
+
+// Extract the value of a string field from a flat JSON object. Only
+// handles the simple "key":"value" form — sufficient for the
+// detail_json schema written by buildDetailJson. Returns "" if the
+// key is absent or the value is not a string literal. Used to pull
+// the `snippet` field out of detail_json.
+std::string detailJsonString(const std::string &json,
+ const std::string &key)
+{
+ std::string needle = "\"" + key + "\":\"";
+ size_t k = json.find(needle);
+ if (k == std::string::npos)
+ return "";
+ k += needle.size();
+ std::string out;
+ while (k < json.size() && json[k] != '"') {
+ if (json[k] == '\\' && k + 1 < json.size()) {
+ char next = json[k + 1];
+ switch (next) {
+ case 'n':
+ out += '\n';
+ break;
+ case 't':
+ out += '\t';
+ break;
+ case 'r':
+ out += '\r';
+ break;
+ case '"':
+ out += '"';
+ break;
+ case '\\':
+ out += '\\';
+ break;
+ default:
+ out += next;
+ break;
+ }
+ k += 2;
+ } else {
+ out += json[k++];
+ }
+ }
+ return out;
+}
+
+// Extract the value of an integer field from a flat JSON object.
+// Only handles the simple "key":N form. Returns 0 if the key is
+// absent or the value is not an integer literal. Used to pull the
+// `line` field out of detail_json.
+int detailJsonInt(const std::string &json, const std::string &key)
+{
+ std::string needle = "\"" + key + "\":";
+ size_t k = json.find(needle);
+ if (k == std::string::npos)
+ return 0;
+ k += needle.size();
+ // Skip optional whitespace.
+ while (k < json.size() && (json[k] == ' ' || json[k] == '\t'))
+ k++;
+ int sign = 1;
+ if (k < json.size() && json[k] == '-') {
+ sign = -1;
+ k++;
+ }
+ int value = 0;
+ bool any = false;
+ while (k < json.size() && json[k] >= '0' && json[k] <= '9') {
+ value = value * 10 + (json[k] - '0');
+ any = true;
+ k++;
+ }
+ return any ? value * sign : 0;
+}
+
+// Parse detail_json and populate the file/line/snippet fields of an
+// EvidenceItem. The detail_json schema (written by
+// SemanticFactExtractor::buildDetailJson) is:
+// {"line":N,"snippet":"...","related_symbol":"..."}
+// The snippet typically embeds the file path in parentheses
+// ("name (file_path)"); we extract the file_path from inside the
+// parentheses as a best-effort heuristic. Empty / unparseable
+// detail_json leaves the fields at their defaults (file="", line=0,
+// snippet="").
+void applyDetailJson(const std::string &detail, EvidenceItem &item)
+{
+ if (detail.empty())
+ return;
+ item.line = detailJsonInt(detail, "line");
+ item.snippet = detailJsonString(detail, "snippet");
+ // Snippet shape: "name (file_path)" — extract file_path.
+ if (!item.snippet.empty()) {
+ size_t open = item.snippet.rfind('(');
+ size_t close = item.snippet.rfind(')');
+ if (open != std::string::npos &&
+ close != std::string::npos && close > open) {
+ item.file = item.snippet.substr(
+ open + 1, close - open - 1);
+ }
+ }
+}
+
+// Substitute {placeholder} tokens in a template string with values
+// from a lookup map. Unknown tokens are left as-is (defensive: a
+// typo in a template should not blank the output). Used to fill
+// {count}, {symbol}, {file}, {line}, {name} in rule output
+// templates.
+std::string formatTemplate(const std::string &tmpl,
+ const std::unordered_map &vars)
+{
+ std::string out;
+ out.reserve(tmpl.size() + 16);
+ for (size_t i = 0; i < tmpl.size();) {
+ if (tmpl[i] == '{') {
+ size_t end = tmpl.find('}', i + 1);
+ if (end == std::string::npos) {
+ out += tmpl[i++];
+ continue;
+ }
+ std::string key = tmpl.substr(i + 1, end - i - 1);
+ auto it = vars.find(key);
+ if (it != vars.end())
+ out += it->second;
+ else
+ out += tmpl.substr(i, end - i + 1);
+ i = end + 1;
+ } else {
+ out += tmpl[i++];
+ }
+ }
+ return out;
+}
+
+// A matched semantic_fact row. Read once per (rule, FactNeed) and
+// reused across combine modes. function_id is needed for set
+// difference and per-function grouping.
+struct MatchedFact {
+ uint64_t fact_id = 0;
+ uint64_t function_id = 0;
+ std::string category;
+ std::string primitive;
+ std::string kind;
+ std::string symbol;
+ double confidence = 1.0;
+ std::string detail_json;
+};
+
+// SELECT semantic_fact rows matching a single FactNeed for one
+// project. Returns the rows as a vector of MatchedFact; logs a
+// prepare/step error to stderr with the [module=evidence, ...]
+// trace chain and returns what was collected so far (best-effort).
+std::vector
+queryFactsForNeed(store::GraphStore *store, uint64_t project_id,
+ const FactNeed &need)
+{
+ std::vector out;
+ if (!store)
+ return out;
+ sqlite3 *db = store->handle();
+ if (!db)
+ return out;
+ const char *sql =
+ "SELECT id, function_id, category, primitive, kind, "
+ "symbol, confidence, IFNULL(detail_json, '') "
+ "FROM semantic_fact WHERE project_id = ? "
+ "AND category = ? AND primitive = ? AND kind = ?";
+ sqlite3_stmt *stmt = nullptr;
+ if (sqlite3_prepare_v2(db, sql, -1, &stmt, nullptr) !=
+ SQLITE_OK) {
+ fprintf(stderr,
+ "[module=evidence, method=queryFactsForNeed] "
+ "prepare failed: %s\n",
+ sqlite3_errmsg(db));
+ return out;
+ }
+ StmtGuard guard(stmt);
+ sqlite3_bind_int64(stmt, 1, static_cast(project_id));
+ sqlite3_bind_text(stmt, 2, need.category.c_str(), -1,
+ SQLITE_TRANSIENT);
+ sqlite3_bind_text(stmt, 3, need.primitive.c_str(), -1,
+ SQLITE_TRANSIENT);
+ sqlite3_bind_text(stmt, 4, need.kind.c_str(), -1,
+ SQLITE_TRANSIENT);
+ while (sqlite3_step(stmt) == SQLITE_ROW) {
+ MatchedFact mf;
+ mf.fact_id = static_cast(
+ sqlite3_column_int64(stmt, 0));
+ mf.function_id = static_cast(
+ sqlite3_column_int64(stmt, 1));
+ mf.category = colText(stmt, 2);
+ mf.primitive = colText(stmt, 3);
+ mf.kind = colText(stmt, 4);
+ mf.symbol = colText(stmt, 5);
+ mf.confidence = sqlite3_column_double(stmt, 6);
+ mf.detail_json = colText(stmt, 7);
+ out.push_back(std::move(mf));
+ }
+ return out;
+}
+
+// Convert a MatchedFact to an EvidenceItem (parses detail_json for
+// file/line/snippet). The fact_id is preserved for traceability.
+EvidenceItem toEvidenceItem(const MatchedFact &mf)
+{
+ EvidenceItem item;
+ item.fact_id = mf.fact_id;
+ item.category = mf.category;
+ item.primitive = mf.primitive;
+ item.kind = mf.kind;
+ item.symbol = mf.symbol;
+ applyDetailJson(mf.detail_json, item);
+ return item;
+}
+
+// Compute the minimum confidence across a list of MatchedFact. Used
+// as the Evidence.confidence value (1.0 for empty / Count combine).
+double minConfidence(const std::vector &facts)
+{
+ if (facts.empty())
+ return 1.0;
+ double m = facts[0].confidence;
+ for (const auto &f : facts)
+ m = std::min(m, f.confidence);
+ return m;
+}
+
+// Build the variable map for formatTemplate from a representative
+// item + a count. The first matched fact's symbol/file/line are used
+// for {symbol}/{file}/{line}; {count} is the item count; {name} is
+// the rule name (filled by the caller).
+std::unordered_map
+buildTemplateVars(const std::vector &facts,
+ const std::string &rule_name)
+{
+ std::unordered_map vars;
+ vars["count"] = std::to_string(facts.size());
+ vars["name"] = rule_name;
+ if (!facts.empty()) {
+ EvidenceItem item = toEvidenceItem(facts.front());
+ vars["symbol"] = item.symbol;
+ vars["file"] = item.file;
+ vars["line"] = std::to_string(item.line);
+ } else {
+ vars["symbol"] = "";
+ vars["file"] = "";
+ vars["line"] = "0";
+ }
+ return vars;
+}
+
+// ─── Combine mode implementations ────────────────────────────────
+
+// Collect: every matched fact becomes an item. Multiple non-optional
+// needs are merged (deduplicated by fact_id). Optional needs are
+// ignored — they only matter for MissingMatch.
+std::vector
+combineCollect(store::GraphStore *store, uint64_t project_id,
+ const Rule &rule)
+{
+ std::vector result;
+ std::vector all;
+ std::unordered_set seen;
+ for (const auto &need : rule.needs) {
+ if (need.optional)
+ continue;
+ auto facts = queryFactsForNeed(store, project_id, need);
+ for (auto &f : facts) {
+ if (seen.insert(f.fact_id).second)
+ all.push_back(std::move(f));
+ }
+ }
+ if (all.empty())
+ return result;
+ Evidence ev;
+ ev.category = rule.category;
+ ev.confidence = minConfidence(all);
+ auto vars = buildTemplateVars(all, rule.name);
+ ev.title = formatTemplate(rule.output.title_template, vars);
+ for (const auto &f : all)
+ ev.items.push_back(toEvidenceItem(f));
+ result.push_back(std::move(ev));
+ return result;
+}
+
+// MissingMatch: needs[0] facts MINUS needs[1] facts, where the set
+// difference is by function_id (a needs[0] fact is dropped if its
+// function_id appears in any needs[1] fact). Emits one Evidence with
+// all surviving needs[0] facts as items.
+std::vector
+combineMissingMatch(store::GraphStore *store, uint64_t project_id,
+ const Rule &rule)
+{
+ std::vector result;
+ if (rule.needs.empty())
+ return result;
+ auto primary = queryFactsForNeed(store, project_id,
+ rule.needs[0]);
+ if (primary.empty())
+ return result;
+ // Collect function_ids of all optional needs (needs[1..]).
+ std::unordered_set excluded;
+ for (size_t i = 1; i < rule.needs.size(); ++i) {
+ if (!rule.needs[i].optional)
+ continue;
+ auto secondary = queryFactsForNeed(store, project_id,
+ rule.needs[i]);
+ for (const auto &s : secondary)
+ excluded.insert(s.function_id);
+ }
+ std::vector kept;
+ kept.reserve(primary.size());
+ for (auto &f : primary) {
+ if (excluded.find(f.function_id) == excluded.end())
+ kept.push_back(std::move(f));
+ }
+ if (kept.empty())
+ return result;
+ Evidence ev;
+ ev.category = rule.category;
+ ev.confidence = minConfidence(kept);
+ auto vars = buildTemplateVars(kept, rule.name);
+ ev.title = formatTemplate(rule.output.title_template, vars);
+ for (const auto &f : kept)
+ ev.items.push_back(toEvidenceItem(f));
+ result.push_back(std::move(ev));
+ return result;
+}
+
+// MissingMatchPerFunction: group needs[0] facts by function_id; for
+// each function whose function_id is NOT in any optional need's
+// matches, emit one Evidence with that function's needs[0] facts as
+// items. Returns multiple Evidence values (one per surviving
+// function), each titled with the rule template substituted with
+// that function's first fact.
+std::vector
+combineMissingMatchPerFunction(store::GraphStore *store,
+ uint64_t project_id,
+ const Rule &rule)
+{
+ std::vector result;
+ if (rule.needs.empty())
+ return result;
+ auto primary = queryFactsForNeed(store, project_id,
+ rule.needs[0]);
+ if (primary.empty())
+ return result;
+ // Collect function_ids of all optional needs (needs[1..]).
+ std::unordered_set excluded;
+ for (size_t i = 1; i < rule.needs.size(); ++i) {
+ if (!rule.needs[i].optional)
+ continue;
+ auto secondary = queryFactsForNeed(store, project_id,
+ rule.needs[i]);
+ for (const auto &s : secondary)
+ excluded.insert(s.function_id);
+ }
+ // Group primary by function_id, preserving first-seen order so
+ // the output is deterministic.
+ std::vector order;
+ std::unordered_map>
+ by_function;
+ for (auto &f : primary) {
+ if (excluded.find(f.function_id) != excluded.end())
+ continue;
+ if (by_function.find(f.function_id) ==
+ by_function.end())
+ order.push_back(f.function_id);
+ by_function[f.function_id].push_back(std::move(f));
+ }
+ for (uint64_t fn_id : order) {
+ const auto &facts = by_function[fn_id];
+ Evidence ev;
+ ev.category = rule.category;
+ ev.confidence = minConfidence(facts);
+ auto vars = buildTemplateVars(facts, rule.name);
+ ev.title = formatTemplate(
+ rule.output.title_template, vars);
+ for (const auto &f : facts)
+ ev.items.push_back(toEvidenceItem(f));
+ result.push_back(std::move(ev));
+ }
+ return result;
+}
+
+// Count: emit a single Evidence with count = needs[0] match count,
+// items empty. Used for aggregate counts (e.g. total TODO comments).
+std::vector
+combineCount(store::GraphStore *store, uint64_t project_id,
+ const Rule &rule)
+{
+ std::vector result;
+ if (rule.needs.empty())
+ return result;
+ auto primary = queryFactsForNeed(store, project_id,
+ rule.needs[0]);
+ Evidence ev;
+ ev.category = rule.category;
+ ev.confidence = 1.0;
+ auto vars = buildTemplateVars(primary, rule.name);
+ ev.title = formatTemplate(rule.output.title_template, vars);
+ // items intentionally left empty for Count combine.
+ result.push_back(std::move(ev));
+ return result;
+}
+
+// Dispatch a single Rule through its CombineMode. Returns 0+ Evidence
+// values (0 = no matches, 1 = Collect/MissingMatch/Count, N =
+// MissingMatchPerFunction).
+std::vector
+applyRule(store::GraphStore *store, uint64_t project_id,
+ const Rule &rule)
+{
+ switch (rule.combine) {
+ case CombineMode::Collect:
+ return combineCollect(store, project_id, rule);
+ case CombineMode::MissingMatch:
+ return combineMissingMatch(store, project_id, rule);
+ case CombineMode::MissingMatchPerFunction:
+ return combineMissingMatchPerFunction(store, project_id,
+ rule);
+ case CombineMode::Count:
+ return combineCount(store, project_id, rule);
+ }
+ return {};
+}
+
+} // namespace
+
+// ─── EvidenceBuilder public API ──────────────────────────────────
+
+void EvidenceBuilder::loadRules(const std::string &dir_path)
+{
+ RuleLoader loader;
+ rule_sets_ = loader.loadFromDirectory(dir_path);
+}
+
+std::vector
+EvidenceBuilder::buildAll(uint64_t project_id) const
+{
+ std::vector result;
+ if (!store_) {
+ fprintf(stderr,
+ "[module=evidence, method=buildAll] "
+ "store is null\n");
+ return result;
+ }
+ for (const auto &rs : rule_sets_) {
+ for (const auto &rule : rs.rules) {
+ auto evs = applyRule(store_, project_id, rule);
+ for (auto &ev : evs)
+ result.push_back(std::move(ev));
+ }
+ }
+ return result;
+}
+
+std::vector EvidenceBuilder::buildByCategory(
+ uint64_t project_id, const std::string &category) const
+{
+ std::vector result;
+ if (!store_) {
+ fprintf(stderr,
+ "[module=evidence, method=buildByCategory] "
+ "store is null\n");
+ return result;
+ }
+ for (const auto &rs : rule_sets_) {
+ if (rs.category != category)
+ continue;
+ for (const auto &rule : rs.rules) {
+ auto evs = applyRule(store_, project_id, rule);
+ for (auto &ev : evs)
+ result.push_back(std::move(ev));
+ }
+ }
+ return result;
+}
+
+std::vector EvidenceBuilder::buildByRule(
+ uint64_t project_id, const std::string &rule_name) const
+{
+ std::vector result;
+ if (!store_) {
+ fprintf(stderr,
+ "[module=evidence, method=buildByRule] "
+ "store is null\n");
+ return result;
+ }
+ for (const auto &rs : rule_sets_) {
+ for (const auto &rule : rs.rules) {
+ if (rule.name != rule_name)
+ continue;
+ auto evs = applyRule(store_, project_id, rule);
+ for (auto &ev : evs)
+ result.push_back(std::move(ev));
+ }
+ }
+ return result;
+}
+
+} // namespace evidence
diff --git a/engine/src/evidence/evidence_builder.h b/engine/src/evidence/evidence_builder.h
new file mode 100644
index 0000000..481cdb5
--- /dev/null
+++ b/engine/src/evidence/evidence_builder.h
@@ -0,0 +1,112 @@
+#ifndef CODESCOPE_EVIDENCE_BUILDER_H
+#define CODESCOPE_EVIDENCE_BUILDER_H
+
+#include
+#include
+#include
+
+#include "rule.h"
+
+namespace store
+{
+class GraphStore;
+} // namespace store
+
+namespace evidence
+{
+
+// ─── Evidence Builder (EB2) ──────────────────────────────────────
+//
+// EvidenceBuilder turns Rule definitions + semantic_fact rows into
+// human-readable Evidence findings. It is the Phase 2 layer between
+// the Phase 1 fact extractor (which populates semantic_fact) and the
+// MCP-facing build_evidence tool.
+//
+// The builder is stateless across projects: loadRules() populates the
+// internal RuleSet vector once, then buildAll / buildByCategory /
+// buildByRule run read-only SELECTs against semantic_fact for a given
+// project_id. The store pointer is borrowed; the caller owns it and
+// must keep it alive for the lifetime of the builder.
+
+/// One evidence item: a single semantic_fact row referenced by an
+/// Evidence finding. `file`/`line`/`snippet` come from the fact's
+/// detail_json column; `fact_id` is the semantic_fact.id.
+struct EvidenceItem {
+ uint64_t fact_id = 0;
+ std::string category;
+ std::string primitive;
+ std::string kind;
+ std::string symbol;
+ std::string file;
+ int line = 0;
+ std::string snippet;
+};
+
+/// An evidence finding: one Rule's output for one project. `title`
+/// is the substituted title_template; `confidence` is the minimum
+/// confidence across all items (1.0 for Count combine); `items` is
+/// the list of contributing facts (empty for Count combine).
+struct Evidence {
+ std::string category;
+ std::string title;
+ double confidence = 1.0;
+ std::vector items;
+};
+
+/// EvidenceBuilder applies RuleSets to semantic_fact rows.
+///
+/// Usage:
+/// evidence::EvidenceBuilder builder(store);
+/// builder.loadRules("engine/src/evidence/rules");
+/// auto evidences = builder.buildAll(project_id);
+class EvidenceBuilder {
+ public:
+ /// Construct with the store handle used for read-only SELECTs
+ /// against semantic_fact. The pointer is borrowed; the caller
+ /// owns it and must keep it alive for the lifetime of the
+ /// builder.
+ explicit EvidenceBuilder(store::GraphStore *store)
+ : store_(store)
+ {
+ }
+
+ /// Load all rule files from `dir_path`. Replaces any previously
+ /// loaded rules. Empty / missing directory is a soft failure:
+ /// the builder will simply produce no evidence on build* calls.
+ void loadRules(const std::string &dir_path);
+
+ /// Run all loaded rules against the project's semantic_fact
+ /// rows. Returns one Evidence per rule that produced at least
+ /// one item (rules with zero matches are omitted — there is
+ /// nothing to report). Order matches the order rules were
+ /// loaded (file-sorted within the rules directory).
+ std::vector buildAll(uint64_t project_id) const;
+
+ /// Like buildAll but restricted to one category (sync / memory /
+ /// error / pattern / framework / ffi). Returns an empty vector
+ /// if no rules match the category.
+ std::vector
+ buildByCategory(uint64_t project_id,
+ const std::string &category) const;
+
+ /// Like buildAll but restricted to a single rule by name.
+ /// Returns an empty vector if the rule is not found or produces
+ /// no matches.
+ std::vector
+ buildByRule(uint64_t project_id,
+ const std::string &rule_name) const;
+
+ /// Read-only access to the loaded rule sets (for tests).
+ const std::vector &ruleSets() const
+ {
+ return rule_sets_;
+ }
+
+ private:
+ store::GraphStore *store_;
+ std::vector rule_sets_;
+};
+
+} // namespace evidence
+
+#endif // CODESCOPE_EVIDENCE_BUILDER_H
diff --git a/engine/src/evidence/rule.cpp b/engine/src/evidence/rule.cpp
new file mode 100644
index 0000000..461d60d
--- /dev/null
+++ b/engine/src/evidence/rule.cpp
@@ -0,0 +1,642 @@
+// rule.cpp — Rule data structures + JSON loader for Evidence Builder (EB1).
+//
+// Loads rule definition files from engine/src/evidence/rules/*.json.
+// The JSON schema is intentionally flat (see plan section 4.3):
+//
+// {
+// "category": "sync",
+// "rules": [
+// {
+// "name": "mutex_without_defer_unlock",
+// "description": "...",
+// "need": [
+// { "category": "sync", "primitive": "mutex", "kind": "lock" },
+// { "category": "sync", "primitive": "mutex",
+// "kind": "defer_unlock", "optional": true }
+// ],
+// "combine": "missing_match",
+// "output": {
+// "severity": 2,
+// "title": "{count} function(s) ...",
+// "message": "{symbol} locked at {file}:{line} ..."
+// }
+// }
+// ]
+// }
+//
+// The parser below is a tiny recursive-descent JSON reader scoped to
+// objects, arrays, strings, numbers, booleans, and null — enough for
+// this schema. NO external JSON dependency is pulled in (plan rule:
+// vendored deps only). On any parse error the loader logs to stderr
+// with the [module=evidence, method=loadFromDirectory] trace chain
+// and skips that file (non-fatal).
+
+#include "rule.h"
+
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+
+namespace evidence
+{
+
+// ─── CombineMode string conversion ───────────────────────────────
+
+CombineMode combineModeFromString(const std::string &s)
+{
+ if (s == "missing_match")
+ return CombineMode::MissingMatch;
+ if (s == "missing_match_per_function")
+ return CombineMode::MissingMatchPerFunction;
+ if (s == "count")
+ return CombineMode::Count;
+ // Empty string or unknown → Collect (defensive default).
+ return CombineMode::Collect;
+}
+
+const char *combineModeToString(CombineMode mode)
+{
+ switch (mode) {
+ case CombineMode::Collect:
+ return "collect";
+ case CombineMode::MissingMatch:
+ return "missing_match";
+ case CombineMode::MissingMatchPerFunction:
+ return "missing_match_per_function";
+ case CombineMode::Count:
+ return "count";
+ }
+ return "collect";
+}
+
+// ─── Minimal JSON parser (recursive descent) ─────────────────────
+//
+// The parser produces a JsonValue variant that the rule loader walks
+// to build Rule / FactNeed / RuleOutput. Only the subset of JSON
+// needed by the rule schema is implemented: object, array, string,
+// number (int/double), bool, null. Errors are reported via a flag
+// and a message; the loader logs them and skips the file.
+
+namespace
+{
+
+// Tagged-union JSON value. Uses std::string for keys (object) and a
+// vector for arrays — simplicity over performance, since rule files
+// are tiny (<1 KB each) and only loaded once per build_evidence call.
+struct JsonValue {
+ enum class Type {
+ Null,
+ Bool,
+ Number,
+ String,
+ Array,
+ Object,
+ };
+ Type type = Type::Null;
+ bool bool_value = false;
+ double number_value = 0.0;
+ std::string string_value;
+ std::vector array_value;
+ // Object: parallel vectors of (key, value) — keeps insertion
+ // order, which a map would not. Lookups are
+ // linear but N is tiny (<10 keys per object in this schema).
+ std::vector object_keys;
+ std::vector object_values;
+
+ /// Convenience: look up a key in an Object. Returns nullptr if
+ /// this value is not an Object or the key is absent.
+ const JsonValue *find(const std::string &key) const
+ {
+ if (type != Type::Object)
+ return nullptr;
+ for (size_t i = 0; i < object_keys.size(); ++i) {
+ if (object_keys[i] == key)
+ return &object_values[i];
+ }
+ return nullptr;
+ }
+};
+
+// Parser state: a cursor over the input string. All methods advance
+// `pos` past the consumed characters. On error, `error` is set and
+// further parse calls become no-ops.
+class JsonParser {
+ public:
+ explicit JsonParser(const std::string &src)
+ : src_(src)
+ {
+ }
+
+ bool hasError() const
+ {
+ return !error_.empty();
+ }
+ const std::string &error() const
+ {
+ return error_;
+ }
+
+ /// Parse a JSON value. Returns true on success (value_ populated).
+ bool parse(JsonValue &out)
+ {
+ skipWhitespace();
+ if (eof()) {
+ setError("unexpected end of input");
+ return false;
+ }
+ if (!parseValue(out))
+ return false;
+ skipWhitespace();
+ if (!eof()) {
+ setError("trailing characters after JSON value");
+ return false;
+ }
+ return true;
+ }
+
+ private:
+ bool eof() const
+ {
+ return pos_ >= src_.size();
+ }
+ char peek() const
+ {
+ return pos_ < src_.size() ? src_[pos_] : '\0';
+ }
+ char next()
+ {
+ return pos_ < src_.size() ? src_[pos_++] : '\0';
+ }
+ void setError(const std::string &msg)
+ {
+ if (error_.empty())
+ error_ = msg + " (at offset " + std::to_string(pos_) +
+ ")";
+ }
+
+ void skipWhitespace()
+ {
+ while (!eof()) {
+ char c = peek();
+ if (c == ' ' || c == '\t' || c == '\n' || c == '\r')
+ pos_++;
+ else
+ break;
+ }
+ }
+
+ bool parseValue(JsonValue &out)
+ {
+ skipWhitespace();
+ if (eof()) {
+ setError("unexpected end of input");
+ return false;
+ }
+ char c = peek();
+ if (c == '{')
+ return parseObject(out);
+ if (c == '[')
+ return parseArray(out);
+ if (c == '"')
+ return parseString(out);
+ if (c == 't' || c == 'f')
+ return parseBool(out);
+ if (c == 'n')
+ return parseNull(out);
+ if (c == '-' || (c >= '0' && c <= '9'))
+ return parseNumber(out);
+ setError(std::string("unexpected character '") + c + "'");
+ return false;
+ }
+
+ bool parseObject(JsonValue &out)
+ {
+ out.type = JsonValue::Type::Object;
+ next(); // consume '{'
+ skipWhitespace();
+ if (peek() == '}') {
+ next();
+ return true;
+ }
+ while (true) {
+ skipWhitespace();
+ if (peek() != '"') {
+ setError("expected string key in object");
+ return false;
+ }
+ JsonValue key;
+ if (!parseString(key))
+ return false;
+ skipWhitespace();
+ if (peek() != ':') {
+ setError("expected ':' after object key");
+ return false;
+ }
+ next(); // consume ':'
+ JsonValue val;
+ if (!parseValue(val))
+ return false;
+ out.object_keys.push_back(key.string_value);
+ out.object_values.push_back(std::move(val));
+ skipWhitespace();
+ char c = peek();
+ if (c == ',') {
+ next();
+ continue;
+ }
+ if (c == '}') {
+ next();
+ return true;
+ }
+ setError("expected ',' or '}' in object");
+ return false;
+ }
+ }
+
+ bool parseArray(JsonValue &out)
+ {
+ out.type = JsonValue::Type::Array;
+ next(); // consume '['
+ skipWhitespace();
+ if (peek() == ']') {
+ next();
+ return true;
+ }
+ while (true) {
+ JsonValue val;
+ if (!parseValue(val))
+ return false;
+ out.array_value.push_back(std::move(val));
+ skipWhitespace();
+ char c = peek();
+ if (c == ',') {
+ next();
+ continue;
+ }
+ if (c == ']') {
+ next();
+ return true;
+ }
+ setError("expected ',' or ']' in array");
+ return false;
+ }
+ }
+
+ bool parseString(JsonValue &out)
+ {
+ out.type = JsonValue::Type::String;
+ next(); // consume opening '"'
+ std::string s;
+ while (!eof()) {
+ char c = next();
+ if (c == '"') {
+ out.string_value = std::move(s);
+ return true;
+ }
+ if (c == '\\') {
+ if (eof()) {
+ setError("unterminated escape");
+ return false;
+ }
+ char esc = next();
+ switch (esc) {
+ case '"':
+ s += '"';
+ break;
+ case '\\':
+ s += '\\';
+ break;
+ case '/':
+ s += '/';
+ break;
+ case 'b':
+ s += '\b';
+ break;
+ case 'f':
+ s += '\f';
+ break;
+ case 'n':
+ s += '\n';
+ break;
+ case 'r':
+ s += '\r';
+ break;
+ case 't':
+ s += '\t';
+ break;
+ case 'u': {
+ // \uXXXX — decode 4 hex digits to a UTF-8
+ // byte sequence. Basic BMP support is
+ // sufficient for rule files (ASCII keys +
+ // descriptions); surrogate pairs are not
+ // handled (rule files do not use emoji).
+ std::string hex;
+ for (int i = 0; i < 4; ++i) {
+ if (eof()) {
+ setError(
+ "unterminated \\u escape");
+ return false;
+ }
+ hex += next();
+ }
+ unsigned code = 0;
+ try {
+ code = static_cast(
+ std::stoul(hex, nullptr,
+ 16));
+ } catch (...) {
+ setError("bad \\u escape: " +
+ hex);
+ return false;
+ }
+ if (code < 0x80) {
+ s += static_cast(code);
+ } else if (code < 0x800) {
+ s += static_cast(
+ 0xC0 | (code >> 6));
+ s += static_cast(
+ 0x80 | (code & 0x3F));
+ } else {
+ s += static_cast(
+ 0xE0 | (code >> 12));
+ s += static_cast(
+ 0x80 |
+ ((code >> 6) & 0x3F));
+ s += static_cast(
+ 0x80 | (code & 0x3F));
+ }
+ break;
+ }
+ default:
+ setError(std::string("bad escape '\\") +
+ esc + "'");
+ return false;
+ }
+ } else {
+ s += c;
+ }
+ }
+ setError("unterminated string");
+ return false;
+ }
+
+ bool parseNumber(JsonValue &out)
+ {
+ out.type = JsonValue::Type::Number;
+ size_t start = pos_;
+ if (peek() == '-')
+ pos_++;
+ while (!eof()) {
+ char c = peek();
+ if ((c >= '0' && c <= '9') || c == '.' || c == 'e' ||
+ c == 'E' || c == '+' || c == '-')
+ pos_++;
+ else
+ break;
+ }
+ std::string num = src_.substr(start, pos_ - start);
+ try {
+ size_t idx = 0;
+ out.number_value = std::stod(num, &idx);
+ if (idx != num.size()) {
+ setError("bad number: " + num);
+ return false;
+ }
+ } catch (...) {
+ setError("bad number: " + num);
+ return false;
+ }
+ return true;
+ }
+
+ bool parseBool(JsonValue &out)
+ {
+ out.type = JsonValue::Type::Bool;
+ if (src_.compare(pos_, 4, "true") == 0) {
+ pos_ += 4;
+ out.bool_value = true;
+ return true;
+ }
+ if (src_.compare(pos_, 5, "false") == 0) {
+ pos_ += 5;
+ out.bool_value = false;
+ return true;
+ }
+ setError("invalid literal (expected true/false)");
+ return false;
+ }
+
+ bool parseNull(JsonValue &out)
+ {
+ out.type = JsonValue::Type::Null;
+ if (src_.compare(pos_, 4, "null") == 0) {
+ pos_ += 4;
+ return true;
+ }
+ setError("invalid literal (expected null)");
+ return false;
+ }
+
+ const std::string &src_;
+ size_t pos_ = 0;
+ std::string error_;
+};
+
+// ─── JSON value → rule struct extraction ─────────────────────────
+
+// Read a string field from a JSON object; returns "" if absent or
+// not a string. Used for required string fields where the loader
+// applies its own validation (empty → skip rule / file).
+std::string getString(const JsonValue &obj, const std::string &key)
+{
+ const JsonValue *v = obj.find(key);
+ if (!v || v->type != JsonValue::Type::String)
+ return "";
+ return v->string_value;
+}
+
+// Read an integer field; returns `fallback` if absent or non-numeric.
+int getInt(const JsonValue &obj, const std::string &key, int fallback)
+{
+ const JsonValue *v = obj.find(key);
+ if (!v || v->type != JsonValue::Type::Number)
+ return fallback;
+ return static_cast(v->number_value);
+}
+
+// Read a boolean field; returns `fallback` if absent or non-bool.
+bool getBool(const JsonValue &obj, const std::string &key, bool fallback)
+{
+ const JsonValue *v = obj.find(key);
+ if (!v || v->type != JsonValue::Type::Bool)
+ return fallback;
+ return v->bool_value;
+}
+
+// Parse one entry of a rule's "need" array into a FactNeed. Missing
+// fields default to empty strings (which will match no facts at query
+// time — safer than silently defaulting to a wrong primitive).
+FactNeed parseFactNeed(const JsonValue &obj)
+{
+ FactNeed need;
+ need.category = getString(obj, "category");
+ need.primitive = getString(obj, "primitive");
+ need.kind = getString(obj, "kind");
+ need.optional = getBool(obj, "optional", false);
+ return need;
+}
+
+// Parse the "output" object of a rule. Severity defaults to 1 (info)
+// when absent — matches RuleOutput's default initializer.
+RuleOutput parseRuleOutput(const JsonValue &obj)
+{
+ RuleOutput out;
+ out.severity = getInt(obj, "severity", 1);
+ out.title_template = getString(obj, "title");
+ out.message_template = getString(obj, "message");
+ return out;
+}
+
+// Parse one element of the "rules" array into a Rule. Returns false
+// if the rule is missing a required field (name/needs); the caller
+// skips such rules with a warning.
+bool parseRule(const JsonValue &obj, const std::string &fallback_cat, Rule &out)
+{
+ out.name = getString(obj, "name");
+ out.description = getString(obj, "description");
+ out.category = getString(obj, "category");
+ if (out.category.empty())
+ out.category = fallback_cat;
+ out.combine = combineModeFromString(getString(obj, "combine"));
+ const JsonValue *needs = obj.find("need");
+ if (!needs || needs->type != JsonValue::Type::Array) {
+ // "needs" is also accepted as an alias for "need".
+ needs = obj.find("needs");
+ }
+ if (!needs || needs->type != JsonValue::Type::Array)
+ return false;
+ for (const auto &n : needs->array_value)
+ out.needs.push_back(parseFactNeed(n));
+ const JsonValue *output = obj.find("output");
+ if (output && output->type == JsonValue::Type::Object)
+ out.output = parseRuleOutput(*output);
+ return !out.name.empty();
+}
+
+// Parse one JSON file's contents into a RuleSet. Returns false on
+// parse error or missing top-level "rules" array; the caller logs
+// and skips the file.
+bool parseRuleFile(const std::string &content, RuleSet &out)
+{
+ JsonParser parser(content);
+ JsonValue root;
+ if (!parser.parse(root)) {
+ fprintf(stderr,
+ "[module=evidence, method=loadFromDirectory] "
+ "JSON parse error: %s\n",
+ parser.error().c_str());
+ return false;
+ }
+ if (root.type != JsonValue::Type::Object) {
+ fprintf(stderr, "[module=evidence, method=loadFromDirectory] "
+ "top-level JSON is not an object\n");
+ return false;
+ }
+ out.category = getString(root, "category");
+ const JsonValue *rules = root.find("rules");
+ if (!rules || rules->type != JsonValue::Type::Array) {
+ fprintf(stderr, "[module=evidence, method=loadFromDirectory] "
+ "missing or non-array 'rules' field\n");
+ return false;
+ }
+ for (const auto &r : rules->array_value) {
+ Rule rule;
+ if (!parseRule(r, out.category, rule)) {
+ fprintf(stderr,
+ "[module=evidence, "
+ "method=loadFromDirectory] "
+ "skipping rule with missing name/needs\n");
+ continue;
+ }
+ out.rules.push_back(std::move(rule));
+ }
+ return true;
+}
+
+} // namespace
+
+// ─── RuleLoader::loadFromDirectory ───────────────────────────────
+//
+// Walks `dir_path` non-recursively, reads each *.json file, parses
+// it into a RuleSet, and appends to the result. Files are sorted by
+// name so the resulting RuleSet order is deterministic (matters for
+// tests asserting evidence order). Missing directory is a soft
+// failure: log + return empty vector.
+
+std::vector
+RuleLoader::loadFromDirectory(const std::string &dir_path) const
+{
+ std::vector result;
+ std::error_code ec;
+ if (!std::filesystem::is_directory(dir_path, ec)) {
+ fprintf(stderr,
+ "[module=evidence, method=loadFromDirectory] "
+ "rules directory not found: %s\n",
+ dir_path.c_str());
+ return result;
+ }
+
+ std::vector files;
+ for (const auto &entry :
+ std::filesystem::directory_iterator(dir_path, ec)) {
+ if (ec)
+ break;
+ if (!entry.is_regular_file())
+ continue;
+ const auto &path = entry.path();
+ if (path.extension() != ".json")
+ continue;
+ files.push_back(path.string());
+ }
+ std::sort(files.begin(), files.end());
+
+ for (const auto &file : files) {
+ std::ifstream in(file);
+ if (!in) {
+ fprintf(stderr,
+ "[module=evidence, "
+ "method=loadFromDirectory] "
+ "cannot open rule file: %s\n",
+ file.c_str());
+ continue;
+ }
+ std::stringstream ss;
+ ss << in.rdbuf();
+ RuleSet rs;
+ if (!parseRuleFile(ss.str(), rs)) {
+ // parseRuleFile already logged the specific error.
+ continue;
+ }
+ if (rs.rules.empty()) {
+ fprintf(stderr,
+ "[module=evidence, "
+ "method=loadFromDirectory] "
+ "no valid rules in %s — skipping\n",
+ file.c_str());
+ continue;
+ }
+ result.push_back(std::move(rs));
+ }
+ return result;
+}
+
+} // namespace evidence
diff --git a/engine/src/evidence/rule.h b/engine/src/evidence/rule.h
new file mode 100644
index 0000000..f6dfa71
--- /dev/null
+++ b/engine/src/evidence/rule.h
@@ -0,0 +1,114 @@
+#ifndef CODESCOPE_EVIDENCE_RULE_H
+#define CODESCOPE_EVIDENCE_RULE_H
+
+#include
+#include
+
+namespace evidence
+{
+
+// ─── Rule data structures (EB1) ──────────────────────────────────
+//
+// A Rule describes how to turn one or more semantic_fact rows into a
+// human-readable Evidence finding. Each Rule belongs to a category
+// (sync / memory / error / pattern / framework / ffi) and lists one
+// or more FactNeed specs that select matching semantic_fact rows by
+// (category, primitive, kind). The CombineMode field decides how the
+// matched facts are turned into the final Evidence list.
+//
+// All structs are value types with default copy/move; they are
+// populated by RuleLoader::loadFromDirectory from JSON rule files
+// shipped under engine/src/evidence/rules/.
+
+/// One fact requirement inside a Rule. The (category, primitive, kind)
+/// triple selects matching semantic_fact rows; `optional` marks a
+/// secondary need (used by MissingMatch / MissingMatchPerFunction as
+/// the "exclusion" set — facts that, if present, suppress the finding).
+struct FactNeed {
+ std::string category;
+ std::string primitive;
+ std::string kind;
+ bool optional = false;
+};
+
+/// How a Rule combines its matched facts into Evidence items.
+/// Collect — every matched fact becomes an item
+/// MissingMatch — facts matching needs[0] MINUS facts
+/// matching needs[1] (set difference by
+/// function_id)
+/// MissingMatchPerFunction — group by function_id; for each function
+/// with needs[0] but no needs[1], emit
+/// one evidence item
+/// Count — emit a single evidence with count =
+/// needs[0] match count, items empty
+enum class CombineMode {
+ Collect = 0,
+ MissingMatch = 1,
+ MissingMatchPerFunction = 2,
+ Count = 3,
+};
+
+/// Output metadata for a Rule. Severity is a small integer (1=info,
+/// 2=warning, 3=error) used by the MCP layer for ranking. The title
+/// and message templates use {count}, {symbol}, {file}, {line}, {name}
+/// placeholders that are substituted per-Evidence by formatTemplate.
+struct RuleOutput {
+ int severity = 1;
+ std::string title_template;
+ std::string message_template;
+};
+
+/// A single rule definition. `name` is the unique identifier used by
+/// buildByRule(); `description` is human-readable and shown in the
+/// evidence output. `needs` is ordered: needs[0] is the primary
+/// match, needs[1] (if present and optional) is the exclusion set.
+struct Rule {
+ std::string name;
+ std::string description;
+ std::string category;
+ std::vector needs;
+ CombineMode combine = CombineMode::Collect;
+ RuleOutput output;
+};
+
+/// A loaded rule file. `category` is the file-level grouping (matches
+/// Rule.category for every contained rule, enforced by the loader).
+struct RuleSet {
+ std::string category;
+ std::vector rules;
+};
+
+// ─── CombineMode string conversion ───────────────────────────────
+
+/// Parse a CombineMode from its JSON string form. Returns
+/// CombineMode::Collect for the empty string and for unknown values
+/// (defensive: a typo in a rule file should not crash the builder).
+/// Recognized strings: "collect", "missing_match",
+/// "missing_match_per_function", "count".
+CombineMode combineModeFromString(const std::string &s);
+
+/// Inverse of combineModeFromString. Returns the canonical JSON
+/// string for a CombineMode. Used by RuleLoader diagnostics and tests.
+const char *combineModeToString(CombineMode mode);
+
+/// RuleLoader reads all *.json rule files from a directory and
+/// returns a vector of RuleSet. The JSON schema is intentionally
+/// flat (see plan section 4.3) so a small string-based parser is
+/// sufficient — no external JSON dependency is required.
+///
+/// On a per-file parse error the loader logs to stderr with the
+/// [module=evidence, method=loadFromDirectory] trace chain and
+/// SKIPS that file (non-fatal: a typo in one rule file must not
+/// prevent the rest from loading). An empty or missing directory
+/// returns an empty vector and logs a warning.
+class RuleLoader {
+ public:
+ /// Load every *.json file in `dir_path` as a RuleSet.
+ /// @return Vector of RuleSet (one per successfully parsed file).
+ std::vector
+ loadFromDirectory(const std::string &dir_path) const;
+};
+
+} // namespace evidence
+
+#endif // CODESCOPE_EVIDENCE_RULE_H
diff --git a/engine/src/evidence/rules/concurrency.json b/engine/src/evidence/rules/concurrency.json
new file mode 100644
index 0000000..dfa4a23
--- /dev/null
+++ b/engine/src/evidence/rules/concurrency.json
@@ -0,0 +1,58 @@
+{
+ "category": "concurrency",
+ "rules": [
+ {
+ "name": "mutex_without_unlock",
+ "description": "Mutex locked without a matching defer Unlock is a deadlock risk",
+ "need": [
+ { "category": "sync", "primitive": "mutex", "kind": "lock" },
+ { "category": "sync", "primitive": "mutex", "kind": "defer_unlock", "optional": true }
+ ],
+ "combine": "missing_match",
+ "output": {
+ "severity": 2,
+ "title": "{count} function(s) lock mutex without defer Unlock (deadlock risk)",
+ "message": "{symbol} locked at {file}:{line} without defer Unlock (deadlock risk)"
+ }
+ },
+ {
+ "name": "rwmutex_usage",
+ "description": "RWMutex usage is a concurrency signal worth tracking",
+ "need": [
+ { "category": "sync", "primitive": "rwmutex", "kind": "lock" }
+ ],
+ "combine": "collect",
+ "output": {
+ "severity": 1,
+ "title": "{count} RWMutex lock(s) detected (concurrency signal)",
+ "message": "RWMutex locked at {file}:{line}: {symbol}"
+ }
+ },
+ {
+ "name": "atomic_operations",
+ "description": "Atomic operations are a concurrency signal worth tracking",
+ "need": [
+ { "category": "sync", "primitive": "atomic", "kind": "load" }
+ ],
+ "combine": "collect",
+ "output": {
+ "severity": 1,
+ "title": "{count} atomic operation(s) detected (concurrency signal)",
+ "message": "atomic op at {file}:{line}: {symbol}"
+ }
+ },
+ {
+ "name": "waitgroup_usage",
+ "description": "WaitGroup usage is a concurrency signal worth tracking",
+ "need": [
+ { "category": "sync", "primitive": "waitgroup", "kind": "add" }
+ ],
+ "combine": "collect",
+ "output": {
+ "severity": 1,
+ "title": "{count} WaitGroup add(s) detected (concurrency signal)",
+ "message": "WaitGroup add at {file}:{line}: {symbol}"
+ }
+ }
+ ]
+}
diff --git a/engine/src/evidence/rules/drift.json b/engine/src/evidence/rules/drift.json
new file mode 100644
index 0000000..d5f1ea9
--- /dev/null
+++ b/engine/src/evidence/rules/drift.json
@@ -0,0 +1,46 @@
+{
+ "_note": "Phase 5 v0.3 drift rules. Plan section 7.3 specifies a richer 'from'/'where' rule format with cross-category joins, but the current EvidenceBuilder (rule.cpp / evidence_builder.cpp) only supports the flat (category, primitive, kind) need schema plus Collect / MissingMatch / MissingMatchPerFunction / Count combine modes. For this delivery, drift rules reuse existing fact categories (pattern, framework, ffi) so they load with the current loader without an EB upgrade. A future EB version will introduce cross-category joins and replace these approximations.",
+ "category": "drift",
+ "rules": [
+ {
+ "name": "dead_pattern_todo",
+ "description": "TODO markers may indicate unfinished work drifting from the plan",
+ "need": [
+ { "category": "pattern", "primitive": "todo", "kind": "marker" }
+ ],
+ "combine": "collect",
+ "output": {
+ "severity": 1,
+ "title": "{count} TODO marker(s) may indicate plan-code drift",
+ "message": "TODO at {file}:{line}: {symbol} (potential drift)"
+ }
+ },
+ {
+ "name": "dead_pattern_fixme",
+ "description": "FIXME markers signal known issues that may indicate design drift",
+ "need": [
+ { "category": "pattern", "primitive": "fixme", "kind": "marker" }
+ ],
+ "combine": "collect",
+ "output": {
+ "severity": 2,
+ "title": "{count} FIXME marker(s) signal known issues",
+ "message": "FIXME at {file}:{line}: {symbol} (potential drift)"
+ }
+ },
+ {
+ "name": "framework_without_ffi",
+ "description": "Framework adoption (router/ORM) without a detected FFI boundary may indicate integration drift. Primary need is framework/gin/router; optional ffi/extern_call/call is the exclusion set. Other framework primitives (echo/django/express/gorm) are covered by separate rules of the same shape in future EB versions that support multi-primary joins.",
+ "need": [
+ { "category": "framework", "primitive": "gin", "kind": "router" },
+ { "category": "ffi", "primitive": "extern_call", "kind": "call", "optional": true }
+ ],
+ "combine": "missing_match",
+ "output": {
+ "severity": 1,
+ "title": "{count} framework import(s) without FFI boundary detected",
+ "message": "Framework import at {file}:{line} has no matching FFI boundary (potential drift)"
+ }
+ }
+ ]
+}
diff --git a/engine/src/evidence/rules/error.json b/engine/src/evidence/rules/error.json
new file mode 100644
index 0000000..253b218
--- /dev/null
+++ b/engine/src/evidence/rules/error.json
@@ -0,0 +1,31 @@
+{
+ "category": "error",
+ "rules": [
+ {
+ "name": "bare_except_collect",
+ "description": "Bare except clauses that swallow all exceptions",
+ "need": [
+ { "category": "error", "primitive": "bare_except", "kind": "suppression" }
+ ],
+ "combine": "collect",
+ "output": {
+ "severity": 2,
+ "title": "{count} bare except clause(s) suppress exceptions",
+ "message": "{symbol} at {file}:{line} swallows all exceptions"
+ }
+ },
+ {
+ "name": "empty_catch_collect",
+ "description": "Empty catch blocks that silently swallow errors",
+ "need": [
+ { "category": "error", "primitive": "empty_catch", "kind": "suppression" }
+ ],
+ "combine": "collect",
+ "output": {
+ "severity": 2,
+ "title": "{count} empty catch block(s) swallow errors",
+ "message": "{symbol} at {file}:{line} silently swallows errors"
+ }
+ }
+ ]
+}
diff --git a/engine/src/evidence/rules/ffi.json b/engine/src/evidence/rules/ffi.json
new file mode 100644
index 0000000..700bce5
--- /dev/null
+++ b/engine/src/evidence/rules/ffi.json
@@ -0,0 +1,31 @@
+{
+ "category": "ffi",
+ "rules": [
+ {
+ "name": "extern_call_collect",
+ "description": "extern \"C\" declarations exposing FFI surface area",
+ "need": [
+ { "category": "ffi", "primitive": "extern_call", "kind": "call" }
+ ],
+ "combine": "collect",
+ "output": {
+ "severity": 1,
+ "title": "{count} extern \"C\" declaration(s) found",
+ "message": "extern \"C\" at {file}:{line}: {symbol}"
+ }
+ },
+ {
+ "name": "unchecked_c_error",
+ "description": "C calls whose return value is not checked for errors",
+ "need": [
+ { "category": "ffi", "primitive": "unchecked_error", "kind": "missing" }
+ ],
+ "combine": "collect",
+ "output": {
+ "severity": 2,
+ "title": "{count} unchecked C call(s) may miss errors",
+ "message": "{symbol} at {file}:{line} return value not checked"
+ }
+ }
+ ]
+}
diff --git a/engine/src/evidence/rules/framework.json b/engine/src/evidence/rules/framework.json
new file mode 100644
index 0000000..9f1cec6
--- /dev/null
+++ b/engine/src/evidence/rules/framework.json
@@ -0,0 +1,22 @@
+{
+ "category": "framework",
+ "rules": [
+ {
+ "name": "framework_detected",
+ "description": "Detected web/ORM framework adoption via import table",
+ "need": [
+ { "category": "framework", "primitive": "gin", "kind": "router" },
+ { "category": "framework", "primitive": "echo", "kind": "router" },
+ { "category": "framework", "primitive": "django", "kind": "router" },
+ { "category": "framework", "primitive": "express", "kind": "router" },
+ { "category": "framework", "primitive": "gorm", "kind": "orm" }
+ ],
+ "combine": "collect",
+ "output": {
+ "severity": 1,
+ "title": "{count} framework(s) detected",
+ "message": "Framework import at {file}:{line}: {symbol}"
+ }
+ }
+ ]
+}
diff --git a/engine/src/evidence/rules/memory.json b/engine/src/evidence/rules/memory.json
new file mode 100644
index 0000000..89a50e5
--- /dev/null
+++ b/engine/src/evidence/rules/memory.json
@@ -0,0 +1,19 @@
+{
+ "category": "memory",
+ "rules": [
+ {
+ "name": "cstring_leak",
+ "description": "C.CString allocated without matching C.free in the same function",
+ "need": [
+ { "category": "memory", "primitive": "cstring", "kind": "alloc" },
+ { "category": "memory", "primitive": "cstring", "kind": "free", "optional": true }
+ ],
+ "combine": "missing_match_per_function",
+ "output": {
+ "severity": 2,
+ "title": "{count} function(s) leak C.CString (alloc without free)",
+ "message": "{symbol} allocated at {file}:{line} without C.free"
+ }
+ }
+ ]
+}
diff --git a/engine/src/evidence/rules/pattern.json b/engine/src/evidence/rules/pattern.json
new file mode 100644
index 0000000..b5821e0
--- /dev/null
+++ b/engine/src/evidence/rules/pattern.json
@@ -0,0 +1,57 @@
+{
+ "category": "pattern",
+ "rules": [
+ {
+ "name": "todo_collect",
+ "description": "TODO markers in code comments",
+ "need": [
+ { "category": "pattern", "primitive": "todo", "kind": "marker" }
+ ],
+ "combine": "collect",
+ "output": {
+ "severity": 1,
+ "title": "{count} TODO marker(s) in code",
+ "message": "TODO at {file}:{line}: {symbol}"
+ }
+ },
+ {
+ "name": "unwrap_risk",
+ "description": "Rust .unwrap() calls that may panic",
+ "need": [
+ { "category": "pattern", "primitive": "unwrap", "kind": "risk" }
+ ],
+ "combine": "collect",
+ "output": {
+ "severity": 2,
+ "title": "{count} .unwrap() call(s) may panic",
+ "message": "{symbol} at {file}:{line} may panic on None/Err"
+ }
+ },
+ {
+ "name": "panic_risk",
+ "description": "panic! / panic() calls that abort the process",
+ "need": [
+ { "category": "pattern", "primitive": "panic", "kind": "risk" }
+ ],
+ "combine": "collect",
+ "output": {
+ "severity": 2,
+ "title": "{count} panic call(s) may abort",
+ "message": "{symbol} at {file}:{line} panics the process"
+ }
+ },
+ {
+ "name": "unsafe_risk",
+ "description": "Rust unsafe blocks that bypass memory safety",
+ "need": [
+ { "category": "pattern", "primitive": "unsafe", "kind": "risk" }
+ ],
+ "combine": "collect",
+ "output": {
+ "severity": 2,
+ "title": "{count} unsafe block(s) bypass memory safety",
+ "message": "{symbol} at {file}:{line} uses unsafe code"
+ }
+ }
+ ]
+}
diff --git a/engine/src/evidence/rules/security.json b/engine/src/evidence/rules/security.json
new file mode 100644
index 0000000..62f6163
--- /dev/null
+++ b/engine/src/evidence/rules/security.json
@@ -0,0 +1,57 @@
+{
+ "category": "security",
+ "rules": [
+ {
+ "name": "unsafe_code_risk",
+ "description": "Rust unsafe blocks bypass memory-safety guarantees and are security-relevant",
+ "need": [
+ { "category": "pattern", "primitive": "unsafe", "kind": "risk" }
+ ],
+ "combine": "collect",
+ "output": {
+ "severity": 2,
+ "title": "{count} unsafe block(s) bypass memory safety (security risk)",
+ "message": "unsafe block at {file}:{line}: {symbol} (security-relevant)"
+ }
+ },
+ {
+ "name": "unchecked_ffi",
+ "description": "extern \"C\" calls without error checking are a security risk (untrusted boundary)",
+ "need": [
+ { "category": "ffi", "primitive": "extern_call", "kind": "call" }
+ ],
+ "combine": "collect",
+ "output": {
+ "severity": 2,
+ "title": "{count} extern \"C\" call(s) may skip error checking (security risk)",
+ "message": "extern \"C\" at {file}:{line}: {symbol} (unchecked FFI boundary)"
+ }
+ },
+ {
+ "name": "jni_without_validation",
+ "description": "JNI exports should validate inputs; missing validation is a security risk",
+ "need": [
+ { "category": "ffi", "primitive": "jni", "kind": "export" }
+ ],
+ "combine": "collect",
+ "output": {
+ "severity": 2,
+ "title": "{count} JNI export(s) require input validation (security risk)",
+ "message": "JNI export at {file}:{line}: {symbol} (validate inputs)"
+ }
+ },
+ {
+ "name": "bare_except_security",
+ "description": "Bare except clauses can hide security-relevant errors (e.g. auth failures)",
+ "need": [
+ { "category": "error", "primitive": "bare_except", "kind": "suppression" }
+ ],
+ "combine": "collect",
+ "output": {
+ "severity": 2,
+ "title": "{count} bare except clause(s) may hide security errors",
+ "message": "bare except at {file}:{line}: {symbol} (may hide security errors)"
+ }
+ }
+ ]
+}
diff --git a/engine/src/evidence/rules/sync.json b/engine/src/evidence/rules/sync.json
new file mode 100644
index 0000000..8185f8b
--- /dev/null
+++ b/engine/src/evidence/rules/sync.json
@@ -0,0 +1,19 @@
+{
+ "category": "sync",
+ "rules": [
+ {
+ "name": "mutex_without_defer_unlock",
+ "description": "Mutex locked but not deferred-unlock",
+ "need": [
+ { "category": "sync", "primitive": "mutex", "kind": "lock" },
+ { "category": "sync", "primitive": "mutex", "kind": "defer_unlock", "optional": true }
+ ],
+ "combine": "missing_match",
+ "output": {
+ "severity": 2,
+ "title": "{count} function(s) lock mutex without defer Unlock",
+ "message": "{symbol} locked at {file}:{line} without defer Unlock"
+ }
+ }
+ ]
+}
diff --git a/engine/src/evidence/rules/test_quality.json b/engine/src/evidence/rules/test_quality.json
new file mode 100644
index 0000000..dcc1db0
--- /dev/null
+++ b/engine/src/evidence/rules/test_quality.json
@@ -0,0 +1,44 @@
+{
+ "category": "test_quality",
+ "rules": [
+ {
+ "name": "unwrap_in_non_test",
+ "description": "Rust .unwrap() in non-test code may panic and degrade quality",
+ "need": [
+ { "category": "pattern", "primitive": "unwrap", "kind": "risk" }
+ ],
+ "combine": "collect",
+ "output": {
+ "severity": 2,
+ "title": "{count} .unwrap() call(s) in non-test code (quality risk)",
+ "message": "{symbol} at {file}:{line} may panic in non-test code"
+ }
+ },
+ {
+ "name": "panic_in_non_test",
+ "description": "panic!() in non-test code aborts the process and degrades quality",
+ "need": [
+ { "category": "pattern", "primitive": "panic", "kind": "risk" }
+ ],
+ "combine": "collect",
+ "output": {
+ "severity": 2,
+ "title": "{count} panic call(s) in non-test code (quality risk)",
+ "message": "{symbol} at {file}:{line} panics in non-test code"
+ }
+ },
+ {
+ "name": "todo_accumulation",
+ "description": "Accumulating TODOs in tests indicate incomplete test coverage",
+ "need": [
+ { "category": "pattern", "primitive": "todo", "kind": "marker" }
+ ],
+ "combine": "count",
+ "output": {
+ "severity": 1,
+ "title": "{count} TODO marker(s) accumulating in code (test-quality signal)",
+ "message": "{count} TODO marker(s) indicate incomplete tests"
+ }
+ }
+ ]
+}
diff --git a/engine/src/model/project_state_builder.cpp b/engine/src/model/project_state_builder.cpp
new file mode 100644
index 0000000..a31b182
--- /dev/null
+++ b/engine/src/model/project_state_builder.cpp
@@ -0,0 +1,859 @@
+// project_state_builder.cpp — Phase 4 Project State snapshot builder.
+//
+// Aggregates all per-project evidence + state rows into a single
+// project_state row. The snapshot is a JSON object with one sub-object
+// per category (sync / memory / error / pattern / framework / ffi)
+// plus an overall confidence + inspector-count summary.
+//
+// Pipeline (plan §6.2):
+// 1. evidence::EvidenceBuilder::buildAll → vector
+// 2. Aggregate evidence by category: count items + collect titles.
+// 3. SELECT COUNT(*) FROM semantic_fact WHERE category=? for a
+// per-category raw fact count.
+// 4. SELECT counts from capability_state, architecture_state,
+// workflow_state, module_summary.dead_entities.
+// 5. Compute overall confidence: 1.0 − penalty_per_issue × count,
+// clamped to [0.0, 1.0]. Penalty constants live in the header.
+// 6. Build snapshot_json (see plan §6.2 schema).
+// 7. UPSERT into project_state (ON CONFLICT project_id DO UPDATE).
+//
+// All sqlite3_stmt handles are managed by an RAII guard so they are
+// finalized on every exit path. The store pointer is borrowed; null
+// is checked at every public entry point and returns false / empty
+// (no crash). Empty state tables are not errors — the snapshot simply
+// omits the corresponding key (or sets its score to a default).
+
+#include "project_state_builder.h"
+
+#include
+#include
+#include
+#include
+#include
+#include