Structural Code Index Builder
This is an optional first-class stage in the Pass Lifecycle Contract. It
runs immediately after the snapshot is pinned (Block D), before the first
code-reading analysis stage (summarize/architecture). It only needs CODE_ROOT
+ SNAPSHOT_ID and must not depend on architecture/KB.
System Goal
Structural Code Index Builder. Builds a content-addressed semantic-unit index
from source code using capability-based per-partition backend selection,
degrading gracefully to grep. Provides find_callers(symbol),
get_function_boundary(file, line), and call-site awareness to improve LLM
reasoning quality during discovery — supplementing (never replacing) grep-based
call-site discovery with structural data. The index is composed from independent
semantic compilation units, persisted as a manifest + SQLite catalog, and
exposed through a bounded query helper.
Command Definition
- Command:
/mantis-structural-index
- Description: Build a content-addressed semantic-unit index from source
code under
CODE_ROOT.
- Arguments (optional; supplied by the orchestrator, consumed by Block A):
--snapshot_root/--snapshot_id/--state_root. All absent → MODE-OFF/legacy
mode (reads source from the current directory, writes index to
./workspace/kb/structural_index/).
Input/Output Contract
- Reads:
workspace/.mantis_state.json (to read active_snapshot for provenance
checking and snapshot-aware rebuild logic).
workspace/kb/structural_index/manifest.json (to check snapshot_id for
reuse-on-match idempotency — primary check).
workspace/kb/structural_index.jsonl (backward-compat provenance check if
manifest absent).
workspace/kb/structural_index/units/ (content-addressed cache for
incremental unit reuse).
workspace/kb/structural_index/native/ and sidecar provenance.json files
(prebuilt index attachments and metadata manifests).
- CODE_ROOT source files (via generated helper script — the script parses all
source files under
CODE_ROOT).
- Writes:
workspace/kb/structural_index/manifest.json (STATE-RELATIVE — atomic
commit point, written LAST).
workspace/kb/structural_index/catalog.sqlite (STATE-RELATIVE —
query-optimized serving store).
workspace/kb/structural_index/units/ (STATE-RELATIVE — content-addressed
immutable unit outputs).
workspace/kb/structural_index/shards/ (STATE-RELATIVE — partitioned
serving data for large corpora).
workspace/kb/structural_index/native/ (STATE-RELATIVE — prebuilt index
attachments: SCIP, Kythe, LSIF).
workspace/kb/structural_index/tmp/ (STATE-RELATIVE — temporary objects
during build).
workspace/kb/structural_index.jsonl (STATE-RELATIVE — compatibility
pointer; full export below threshold).
workspace/helpers/build_structural_index.py (STATE-RELATIVE — the builder
helper script).
workspace/helpers/query_structural_index.py (STATE-RELATIVE — the query
helper script).
- Preconditions:
- Source files must exist under
CODE_ROOT. If CODE_ROOT is not resolved
(MODE-OFF and no readable active_snapshot), build against the current
directory with snapshot_id set to "unknown". Do NOT skip — this is the
standalone-efficiency case.
- Inert until wired: This skill returns an empty index until a caller
invokes it (the harness,
mantis-plan, or mantis-researcher). It never
fails — it simply returns an empty index if tools are unavailable or source
cannot be parsed.
- Idempotency Guarantee:
- Read-only on
CODE_ROOT. Writes only to STATE-RELATIVE paths. Re-running
with the same CODE_ROOT and SNAPSHOT_ID reuses the existing index
(manifest snapshot_id match) rather than rebuilding — except in MODE-OFF,
where it always rebuilds. Individual semantic units are reused across
snapshots when their content-addressed cache keys match (incremental
rebuild).
Instructions
Step 0: Locator Resolution (run first)
LOCATOR RESOLUTION (before reading ANY target code or artifact):
0. ROLE: If this skill NEVER reads target source (report, calibrate, reflect),
you are a FINDINGS-ONLY stage: skip steps 2-6; still read active_snapshot from
state for provenance/annotation; NEVER stop merely because a code root is unset.
1. Determine CODE_ROOT, in this priority order:
a. If --target_root is passed on THIS invocation, CODE_ROOT = --target_root.
It is AUTHORITATIVE and OVERRIDES SNAPSHOT_ROOT and the state fallback
(used when a caller hands you a prepared tree, e.g. a patched shadow).
b. Else if --snapshot_root (or SNAPSHOT_ROOT) is passed, use it.
c. Else read state_root/workspace/.mantis_state.json (state_root from
--state_root if passed, else ./workspace/... relative to the current dir)
-> active_snapshot.root / .snapshot_id / .snapshot_pinned.
d. Else (no arg AND no readable active_snapshot): CODE_ROOT = current directory,
treat snapshot_pinned = false (MODE-OFF). Do NOT stop.
2. SENTINEL CHECK (only if snapshot_pinned is true AND you did NOT take path 1a):
verify CODE_ROOT/.mantis_snapshot_id exists and equals SNAPSHOT_ID. If missing
or different -> STOP "snapshot sentinel mismatch". (A --target_root tree (1a) is
deliberately mutated and is sentinel-EXEMPT.)
3. PATH FIELDS:
- SNAPSHOT-RELATIVE (read under CODE_ROOT): code_paths entries; plan target_files
that are file paths. Strip ONLY a trailing ":<digits>". A code_paths entry
containing "://" is a URL/endpoint, NOT a file read. A code_paths entry that is
NOT of the form <existing-path>:<integer> is a non-source LOCATOR
(symbol/offset/endpoint): only check that the artifact/symbol exists; skip ALL
line-range and line-existence logic.
- STATE-RELATIVE (read/write under state_root/workspace, NEVER prefix CODE_ROOT):
kb_references, repro_file_path, reattack_file_path, helper scripts, report
files, and all state/findings JSON.
4. Never WRITE under CODE_ROOT when snapshot_pinned is true. Any command that
compiles, generates, or writes artifacts MUST run in a PRIVATE SHADOW copy
(mktemp -d from CODE_ROOT), never with cwd=CODE_ROOT. Read-only inspection may
cd into CODE_ROOT.
5. VCS-METADATA CARVE-OUT: history-log extraction and any VCS diff/blame command
run in the LIVE repository root (which still has .git/.hg/.repo), NOT CODE_ROOT
(the snapshot copy strips VCS metadata). Do NOT stop merely because CODE_ROOT
lacks .git/.hg/.repo.
6. Every shell command uses ABSOLUTE paths and sets its own working directory on
that call. Do NOT assume the working directory persists between calls.
This is a CODE-READING stage — it reads target source files under CODE_ROOT via
the helper script. Block A step 0's findings-only skip does NOT apply.
Step 1: Idempotency / Freshness Check (MANDATORY FIRST)
MANDATORY FIRST STEP: Before writing any build scripts, probing backends, or
extracting symbols, check for MODE-OFF or an existing manifest:
- MODE-OFF check (FIRST): In MODE-OFF (
SNAPSHOT_ID is "unknown" or
absent) → always rebuild. Do NOT reuse a previous "unknown" index,
because the live tree is mutable and "unknown" is a constant (not a
freshness signal). Skip directly to Step 2 (individual units whose content
has not changed may still hit the content-addressed cache).
- If
workspace/kb/structural_index/manifest.json exists, read its
snapshot_id field.
- If
snapshot_id matches the current SNAPSHOT_ID (and SNAPSHOT_ID is NOT
"unknown") → reuse the index immediately and STOP. Do NOT invoke Step
2, probe backends, or write build scripts. This bounds cost across
retries/crash-resume.
- If
manifest.json is absent but workspace/kb/structural_index.jsonl exists
(backward compat), read its provenance header (_provenance, snapshot_id
keys). If snapshot_id matches (and is NOT "unknown") → reuse and
STOP. Otherwise proceed to rebuild.
- If
SNAPSHOT_ID differs → proceed to rebuild (Steps 2–5). For incremental
reuse: before rebuilding a semantic unit, check units/ for a
content-addressed cache hit (see Content-Addressed Cache Key below). A cache
hit reuses the unit output without re-extraction.
Step 2: Select Backend per Partition
Partition the codebase into semantic units (see Per-Language Semantic Units
below). For each unit, select the most precise backend available in this
environment using a capability-based, per-partition decision — not a single
global ladder. The following are examples, not an exhaustive enum. The
helper probes each tier per partition and selects the highest available:
- Snapshot-matched SCIP / LSIF / Kythe / clangd-static / remote index
(precision:
semantic) — if a pre-built index matching the current
snapshot_id or root_fingerprint is available. Most precise: full
type-aware cross-reference, call hierarchy, and hover/signature data.
- Compiler / typechecker-backed extractor (precision:
typecheck) — if
compile_commands.json, build context, or typechecker is available.
Type-accurate symbol resolution and call edges.
- Language-aware AST extraction (precision:
ast) — if tree-sitter,
ast-grep, or a language-specific parser is available. Full AST parsing:
function boundaries, call expressions, signatures.
- Symbol-only extraction (precision:
symbol-only) — if ctags or
equivalent is on PATH. Symbol table only (function definitions, locations —
no call graph). Call-site extraction uses a lightweight regex pass within
known function boundaries.
- Heuristic fallback (precision:
heuristic) — Python stdlib regex pass
over source files. Identifies function definitions and call patterns using
language-agnostic heuristics. Less precise but zero-dependency.
- Coverage-only manifest + lexical fallback (precision:
coverage-only) —
grep; no structural index is written. Manifest records status: "empty".
Consumers fall back to grep-based discovery (today's behavior byte-for-byte).
Native Index Probing & Resolution Rules:
- Probe instruction: Before evaluating per-partition backends, probe
workspace/kb/structural_index/native/ and subdirectories
native/{scip,lsif,kythe}/ for prebuilt index files.
- Snapshot-declaration convention: Because native formats (SCIP, LSIF,
Kythe) do not embed snapshot identity directly in their binary payload,
prebuilt indexes MUST declare their target snapshot using a sidecar
provenance.json manifest located at
workspace/kb/structural_index/native/provenance.json or
native/<kind>/provenance.json. The manifest contains an array of
attachments:
[{"kind": "scip|lsif|kythe", "path": "...", "snapshot_id": "...", "root_fingerprint": "...", "language": "...", "indexer": "...", "precision": "semantic", "files": [...]}].
A native index is matched if its declared snapshot_id equals SNAPSHOT_ID
(when SNAPSHOT_ID != "unknown") or its root_fingerprint matches the
workspace's calculated root fingerprint. If the files array is absent or
empty, treat the native index as covering no individual files directly (record
in manifest.native_indexes but do not update coverage rows; fall through
to lower tiers for all files).
- Record-and-Defer Ingestion Rule: Parsing raw binary native indexes (e.g.
SCIP protobuf) in pure Python without dependencies is costly and complex.
Option A builder scripts MUST detect matching prebuilt native indexes, record
their entries in
manifest.native_indexes, and set the coverage table
backend (e.g., "scip" or "scip-clangd") for all files listed in the
provenance manifest. If catalog.sqlite is NOT populated with symbols from
the native index (raw binary deferred to harness/MCP readers), set
coverage.status = "deferred" and precision = "deferred" (with
indexed_files = 0). This prevents the query helper from claiming an
un-ingested partition is "authoritative empty" at semantic precision,
ensuring consumers run the mandatory grep fallback. When catalog.sqlite IS
populated (e.g., via Option B pre-ingestion or scip-to-sqlite), set
precision = "semantic" and status = "indexed".
LSP is NOT equivalent to SCIP / LSIF. LSP is an interactive protocol whose
workspace state may be partial or mutable. Use it only when the server can
demonstrate snapshot identity AND complete workspace coverage. A running
language server does not automatically qualify as a semantic backend.
SCIP merging. SCIP explicitly permits merging complementary information from
indexers with different precision levels. Its format records indexer version and
per-document language metadata. The builder MAY merge results from multiple
indexers (e.g., a SCIP index for Go + tree-sitter for Python) within a single
catalog.
The determinism lives in a runtime-generated versioned helper
(build_structural_index.py, # MANTIS_HELPER_VERSION = 5, grep-and-regenerate
on reuse) that probes and selects backends per partition. No shipped binaries;
air-gapped-safe.
Deterministic partial coverage. On very large source trees, a full rebuild
can dominate stage-0 wall-clock. Replace discovery-order truncation with a
deterministic priority queue:
- Explicit target files and symbols (from
plan.json, if available).
- Changed units and known reverse dependencies (from Block E diff +
reverse-dependency edges in the existing catalog).
- Containing packages/modules and direct imports of tier 1+2 units.
- Remaining units in normalized path order.
Apply deterministic max_units (default: 10000) or max_source_bytes (default:
500MB) bounds to that queue. Persist deferred units in the manifest so another
invocation can resume. Statuses: complete, partial, empty, failed.
A consumer should only interpret "no callers" strongly when the relevant
partition is complete, snapshot-matched, covered, and semantically precise.
Otherwise the answer is "no indexed callers", followed by the existing lexical
fallback. The index is HINT-only, so partial coverage is safe (grep remains
authoritative).
Step 3: Write and Run Helper Scripts
Two runtime-generated helpers are used. Both follow the grep-and-regenerate
pattern: before reuse, grep the first line for the version marker; if absent or
a different integer, REGENERATE.
Builder: build_structural_index.py
- Write the builder to
workspace/helpers/build_structural_index.py. The FIRST
LINE MUST be exactly # MANTIS_HELPER_VERSION = 5. Before reusing an
existing helper, grep its first lines for MANTIS_HELPER_VERSION = 5; if
that marker is absent or a different integer, REGENERATE the helper.
- The builder partitions the codebase into semantic units (see Per-Language
Semantic Units), computes content-addressed cache keys, checks
units/ for
reuse, selects a backend per partition (Step 2), extracts symbols + edges,
and writes results to catalog.sqlite.
- The builder must extract:
- Symbols:
symbol_id, name, qualified_name, namespace,
language, file_path, start_line, end_line, kind, signature,
backend, precision.
- Call edges:
caller_id, callee_id, callee_name, file_path,
line, edge_kind.
- Function boundaries:
symbol_id, file_path, start_line,
end_line, signature, language.
- Coverage:
file_path, indexed, backend, precision,
unit_cache_key, status.
- The builder writes the manifest LAST (Step 4) and the compatibility pointer.
- The builder MUST use ABSOLUTE paths and set its own working directory (Block
A step 6). It MUST NOT write anything under
CODE_ROOT when
snapshot_pinned is true (Block A step 4). Backends that produce sidecar
files (ctags tags, cscope cscope.out, clangd cache) MUST be redirected to
STATE-RELATIVE paths: ctags -f <state>/helpers/tags,
cscope -f <state>/helpers/cscope.out,
CLANGD_INDEX_STORAGE=<state>/helpers/. Read-only LSP/SCIP queries to a
running server need no redirect.
Query helper: query_structural_index.py
- Write the query helper to
workspace/helpers/query_structural_index.py. The
FIRST LINE MUST be exactly # MANTIS_HELPER_VERSION = 5. Before reusing an
existing helper, grep its first lines for MANTIS_HELPER_VERSION = 5; if
absent or a different integer, REGENERATE.
- The query helper provides bounded, paginated operations against
catalog.sqlite (or a remote endpoint — identical API). It IS the
consumption contract (see Query Interface below).
- If
catalog.sqlite is absent but structural_index.jsonl exists, the query
helper falls back to linear scanning of the JSONL file (slower but
functional). If both are absent, it returns empty results with
coverage.partition_status = "empty".
Step 4: Write Manifest and Commit
- Write the manifest to
workspace/kb/structural_index/tmp/manifest.json
first. Then atomically rename it to
workspace/kb/structural_index/manifest.json. This is the atomic commit
point — the manifest is written LAST, after all units, catalog, and coverage
data are written.
- Write a compatibility pointer to
workspace/kb/structural_index.jsonl
(STATE-RELATIVE — NEVER under CODE_ROOT). Below a configurable threshold
(default: 10K records), emit a complete JSONL export (provenance header + all
records). Above the threshold, emit only a provenance header with
compat_pointer.full_export = false and compat_pointer.symbol_count set.
Large consumers MUST use the query interface.
- The manifest
provider.backend_versions field records which backend was used
per language (e.g.,
{"go": {"backend_name": "scip-clangd", "precision": "semantic"}, "python": {"backend_name": "tree-sitter", "precision": "ast"}}),
so consumers know the precision level.
- Interrupted builds leave unreferenced temp objects in
tmp/ without
corrupting the last published index. On resume, check manifest.json status
and snapshot_id (Step 1).
Step 5: Return Results / Notify Caller
- Return the path to
manifest.json, catalog.sqlite, and the query helper.
Include a summary (unit count, symbol count, call-edge count, backends used,
coverage status).
- If the index is empty (no tools available or no source files found), notify
the caller: "Structural index is empty — structural context unavailable."
- Do not notify the user directly — this skill is invoked as a sub-agent by the
harness, planner, or researcher.
Index Schema and On-Disk Contract
On-Disk Layout
workspace/kb/structural_index/
├── manifest.json # Atomic commit point — written LAST
├── catalog.sqlite # Query-optimized serving store (both directions indexed)
├── units/ # Content-addressed immutable unit outputs
│ └── ab/cd/abcdef... # sha256 prefix sharding (2+2 hex dirs)
├── shards/ # Partitioned serving data (large corpora)
│ └── shard_0000.sqlite
├── native/ # Prebuilt index attachments (SCIP, Kythe, LSIF)
│ ├── provenance.json # Prebuilt index provenance manifest
│ ├── scip/
│ └── kythe/
└── tmp/ # Temporary objects during build
workspace/helpers/
├── build_structural_index.py # Builder (MANTIS_HELPER_VERSION = 5)
└── query_structural_index.py # Query helper (MANTIS_HELPER_VERSION = 5)
workspace/kb/structural_index.jsonl # Compatibility pointer
Manifest Schema (manifest.json)
{
"schema_version": 1,
"snapshot_id": "<SNAPSHOT_ID or 'unknown'>",
"root_fingerprint": "<sha256 of sorted (path, content_sha256) for all source files>",
"status": "complete|partial|empty|failed",
"provider": {
"kind": "local-build|baseline+overlay|remote",
"catalog": "catalog.sqlite",
"backend_versions": {
"cpp": {"backend_name": "tree-sitter", "backend_version": "0.20.8", "precision": "ast"},
"go": {"backend_name": "scip-clangd", "backend_version": "0.2.3", "precision": "semantic"}
}
},
"units": {"total": 0, "reused": 0, "rebuild": 0, "failed": 0},
"coverage": {"total_files": 0, "indexed_files": 0, "failed_files": 0, "deferred_files": 0},
"shards": [{"id": "", "path": "", "checksum": "", "partition_key": "", "symbol_count": 0, "edge_count": 0}],
"deferred_units": [{"unit_id": "", "language": "", "files": [], "priority": 4, "reason": ""}],
"native_indexes": [{"kind": "scip", "path": "", "snapshot_id": "", "root_fingerprint": "", "language": "", "indexer": "", "precision": ""}],
"baseline": {"source": "ci|local|none", "snapshot_id": "", "manifest_path": ""},
"overlay": {"units_added": 0, "units_modified": 0, "files": []},
"compat_pointer": {"path": "structural_index.jsonl", "full_export": true, "symbol_count": 0},
"created_at": "<ISO 8601>",
"build_duration_ms": 0
}
Atomic commit: The manifest is written LAST (atomic rename from tmp/).
Interrupted builds leave unreferenced temp objects without corrupting the last
published index.
SQLite Catalog Schema (catalog.sqlite)
CREATE TABLE IF NOT EXISTS schema_meta (
key TEXT PRIMARY KEY, value TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS symbols (
symbol_id TEXT PRIMARY KEY,
name TEXT NOT NULL,
qualified_name TEXT NOT NULL,
namespace TEXT,
language TEXT NOT NULL,
file_path TEXT NOT NULL,
start_line INTEGER NOT NULL,
end_line INTEGER,
kind TEXT NOT NULL,
signature TEXT,
backend TEXT NOT NULL,
precision TEXT NOT NULL CHECK (precision IN ('semantic','typecheck','ast','symbol-only','heuristic','deferred','coverage-only')),
corpus TEXT DEFAULT 'default',
partition_key TEXT,
unit_cache_key TEXT,
source_layer TEXT NOT NULL DEFAULT 'baseline'
);
CREATE TABLE IF NOT EXISTS call_edges (
edge_id INTEGER PRIMARY KEY AUTOINCREMENT,
caller_id TEXT NOT NULL,
callee_id TEXT,
callee_name TEXT NOT NULL,
file_path TEXT NOT NULL,
line INTEGER NOT NULL,
edge_kind TEXT NOT NULL CHECK (edge_kind IN ('direct','indirect','virtual','macro','unresolved')),
corpus TEXT DEFAULT 'default',
partition_key TEXT,
source_layer TEXT NOT NULL DEFAULT 'baseline',
FOREIGN KEY (caller_id) REFERENCES symbols(symbol_id),
FOREIGN KEY (callee_id) REFERENCES symbols(symbol_id)
);
CREATE TABLE IF NOT EXISTS function_boundaries (
symbol_id TEXT PRIMARY KEY,
file_path TEXT NOT NULL,
start_line INTEGER NOT NULL,
end_line INTEGER NOT NULL,
signature TEXT,
language TEXT NOT NULL,
FOREIGN KEY (symbol_id) REFERENCES symbols(symbol_id)
);
CREATE TABLE IF NOT EXISTS coverage (
file_path TEXT NOT NULL,
indexed INTEGER NOT NULL DEFAULT 0,
backend TEXT,
precision TEXT,
unit_cache_key TEXT,
status TEXT NOT NULL DEFAULT 'pending',
corpus TEXT DEFAULT 'default',
partition_key TEXT,
PRIMARY KEY (file_path, corpus)
);
CREATE TABLE IF NOT EXISTS unit_cache (
cache_key TEXT PRIMARY KEY,
unit_id TEXT NOT NULL,
language TEXT NOT NULL,
extractor_name TEXT NOT NULL,
extractor_version TEXT NOT NULL,
file_count INTEGER NOT NULL,
symbol_count INTEGER NOT NULL,
edge_count INTEGER NOT NULL,
source_bytes INTEGER NOT NULL,
created_at TEXT NOT NULL,
snapshot_id TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS deferred_units (
unit_id TEXT PRIMARY KEY,
language TEXT NOT NULL,
files TEXT NOT NULL,
priority INTEGER NOT NULL,
reason TEXT NOT NULL,
cache_key TEXT,
created_at TEXT NOT NULL
);
-- Indexes: both directions!
CREATE INDEX IF NOT EXISTS idx_symbols_name ON symbols(name);
CREATE INDEX IF NOT EXISTS idx_symbols_qualified ON symbols(qualified_name);
CREATE INDEX IF NOT EXISTS idx_symbols_file ON symbols(file_path, start_line);
CREATE INDEX IF NOT EXISTS idx_symbols_lang ON symbols(language);
CREATE INDEX IF NOT EXISTS idx_edges_caller ON call_edges(caller_id);
CREATE INDEX IF NOT EXISTS idx_edges_callee ON call_edges(callee_id);
CREATE INDEX IF NOT EXISTS idx_edges_callee_name ON call_edges(callee_name);
CREATE INDEX IF NOT EXISTS idx_edges_file ON call_edges(file_path, line);
CREATE INDEX IF NOT EXISTS idx_boundaries_file ON function_boundaries(file_path, start_line, end_line);
CREATE INDEX IF NOT EXISTS idx_coverage_status ON coverage(status);
CREATE INDEX IF NOT EXISTS idx_deferred_priority ON deferred_units(priority);
Partitioning: When symbols exceed 500K or edges exceed 2M, partition into
shard SQLite files under shards/ by lang:{language}:bucket:{NN} (stable
hash). Each shard has the same schema. The query helper routes queries to the
correct shard(s) based on the manifest.
Content-Addressed Cache Key
cache_key = sha256(
schema_version +
extractor_name@version +
language +
compile_context_digest +
ordered_input_content_digests +
dependency_or_interface_digest
)
Critical: snapshot_id is NOT in the cache key. It goes in provenance only.
This permits reuse across commits, branches, and snapshots with identical units.
Canonical Symbol IDs
Native IDs (when semantic backend is available):
- SCIP:
scip:{symbol}
- Kythe:
kythe:{uri}
- clangd:
clangd:{usr}
Fallback IDs (when no semantic backend):
fallback:{language}:{file_path}:{sha256(qualified_name|start_line|signature)[:16]}
This distinguishes namespaces, overloads, methods, local functions, and
duplicate names across languages or repositories.
Edge Kinds
Edge kinds are kept explicitly separate — never silently merged:
direct — statically resolved call to a known symbol
indirect — function pointer, closure, callback
virtual — virtual method dispatch (runtime-resolved)
macro — call introduced by macro expansion
unresolved — callee name found but no symbol_id resolved
Per-Language Semantic Units
| Language |
Unit Type |
Compile Context |
Dependency Digest |
| C/C++ |
Compilation unit |
compile_commands.json entry |
sha256 of transitive header interfaces |
| Go |
Package |
go.mod + build tags |
sha256 of imported packages' exported API |
| Rust |
Crate |
Cargo.toml + features |
sha256 of extern crate signatures |
| Java/Kotlin |
Compiler batch |
module path + classpath |
sha256 of imported class signatures |
| TypeScript |
Project |
tsconfig.json |
sha256 of imported module type declarations |
| Fallback |
Individual file |
None |
Empty string |
Fallback Granularity Rule: For the fallback tier (regex, AST parser, or
lightweight crawler without a language build system), each source file MUST be
its own independent semantic unit. Never bundle multiple source files into a
single fallback unit. Per-file unit isolation is essential for content-addressed
cache efficiency — when one file changes in a future snapshot, only that single
file's unit invalidates while all unchanged files hit the cache
(reused = N-1).
Baseline + Delta Overlay
- Static baseline: Built once in CI for the complete snapshot.
- Incremental cache: Immutable unit outputs reused across snapshots.
- Delta overlay: Changed/target files indexed locally, layered over
baseline.
- Remote serving: Optional when global index is too costly locally.
At query time, the query helper merges baseline + overlay results. Overlay
symbols supersede baseline symbols for the same file. The manifest records
provider.kind = "baseline+overlay" with baseline manifest reference and
overlay unit count.
Compatibility JSONL Export
The old structural_index.jsonl format is retained as a compatibility export
only — it is NOT the query contract. Below the threshold (default 10K records),
a complete JSONL export is emitted. Above the threshold, only a provenance
pointer is written. Large consumers MUST use the query interface.
Line 1 — provenance header:
{"_provenance": true, "snapshot_id": "abc123", "tool": "tree-sitter"}
Lines 2+ — structural records (one per line, _type discriminator):
{"_type": "function", "key": "src/parser.c:parse_input", "start_line": 45, "end_line": 120, "signature": "int parse_input(char *buf, size_t len)", "calls": ["malloc", "validate_input", "memcpy"]}
{"_type": "call_edge", "caller": "parse_input", "callee": "malloc", "file": "src/parser.c", "line": 78}
{"_type": "call_edge", "caller": "main", "callee": "parse_input", "file": "src/main.c", "line": 203}
Provenance header fields:
| Field |
Type |
Description |
_provenance |
bool |
Always true — marks this as the provenance header line |
snapshot_id |
string |
SNAPSHOT_ID the index was built against |
tool |
string |
Backend used (e.g. "lsp-clangd", "tree-sitter", "ctags", "regex", "grep") |
Record types:
_type |
Description |
Key fields |
function |
Function definition with boundary and signature |
key, start_line, end_line, signature, calls |
call_edge |
A call from caller to callee at file:line |
caller, callee, file, line |
Snapshot Safety
- Build from
CODE_ROOT, not live tree (when pinned). When the snapshot is
pinned, the structural index is built from the pinned CODE_ROOT, ensuring
it reflects the exact bytes the pipeline is analyzing.
- Reuse-on-match. If
manifest.json already carries the current
SNAPSHOT_ID (and SNAPSHOT_ID is NOT "unknown" — in MODE-OFF, always
rebuild), reuse it — do not rebuild. Rebuild when SNAPSHOT_ID differs or is
"unknown". Individual units may still be reused from the content-addressed
cache. This bounds cost across retries/crash-resume.
- Manifest atomicity. The manifest is written LAST via atomic rename from
tmp/. Interrupted builds leave unreferenced temp objects without corrupting
the last published index. On resume, check manifest.json status and
snapshot_id.
- STALE flag in HALT mode. When
snapshot_pinned is false (HALT), the
index may be built from the unpinned CODE_ROOT but is marked as potentially
stale. Consumers treat structural hints as advisory.
- MODE-OFF: build, do not skip. When
active_snapshot is absent
(MODE-OFF), build against the current directory (cwd) with provenance
snapshot_id set to "unknown". This is the standalone-efficiency case —
the index is still useful for the researcher/planner even without snapshot
pinning. Do NOT return an empty index merely because the snapshot is absent.
Consumption Contract
These are runtime instructions for callers — they define how consumers use the
structural index. The structural index is a HINT-only enhancement; skills that
do not use it behave exactly as they do today.
Query Interface
The query helper IS the contract. All consumers use
query_structural_index.py (or a compatible remote endpoint). The JSONL file is
NOT the query contract — it is a compatibility export only.
Operations:
resolve_symbol(name, language?, file?, namespace?) →
{results, total, ambiguous, coverage}
- Returns ALL matches — no silent selection among ambiguous symbols.
- Caller MUST disambiguate before calling
find_callers / find_callees.
find_callers(symbol_id, limit=100, offset=0) →
{results, total, has_more, coverage}
- Bounded, paginated caller lookup.
- Each result carries
precision, backend, edge_kind.
- Empty results carry
coverage.partition_status.
find_callees(symbol_id, limit=100, offset=0) →
{results, total, has_more, coverage}
- Same shape as
find_callers.
get_function_boundary(file, line) →
{symbol_id, start_line, end_line, signature, precision, backend}
get_coverage(file?) →
{total_files, indexed_files, failed, deferred, partition_status, backends_used}
Key properties:
- Bounded results and pagination (prevents loading entire index into memory).
- Explicit name resolution before graph traversal (no silent selection among
ambiguous symbols).
- Precision and backend attached to every result.
- Coverage attached to empty results.
- Identical operations for local and remote providers (remote via
MANTIS_STRUCTURAL_INDEX_URL env var or manifest provider.kind = "remote").
Coverage on empty results:
partition_status |
Meaning |
Consumer action |
complete |
All files indexed |
"No indexed callers" (still run grep per HINT-only rule) |
partial |
Some files deferred or failed |
"Not fully indexed" — MUST run grep fallback |
empty |
No backend available |
"Not indexed" — MUST run grep fallback |
failed |
Backend attempted but failed |
"Index failed" — MUST run grep fallback |
mantis-plan
- Use the structural index query helper for function-level dependency fan-out.
When planning investigations, call
resolve_symbol() then find_callers() to
identify all functions that call into a target — this broadens the audit set
beyond single-file analysis.
- The structural index decides ORDER of investigations (which functions to audit
first based on call-graph centrality), never MEMBERSHIP. It may broaden the
audit set (safe over-reporting), but must never REMOVE or drop a file; the
planner's existing logic remains the membership floor.
mantis-researcher
- Wave 1 (Rapid Triage): Run a repo-wide grep for the function name to build
the exhaustive set of candidate call-sites — this is the mandatory floor. Then
use the structural index query helper (
resolve_symbol then find_callers)
to RANK and prioritize which call-sites to audit first (the index
distinguishes actual calls from comments/strings/variable names). Audit the
union of both result sets — the structural index may miss macro-based calls,
function pointers, and dynamic dispatch, so grep remains the floor.
- Wave 2 (Deep Audit): Use
get_function_boundary(file, line) to start with
the enclosing function, expanding to callers/callees/file as needed for
cross-function context — this saves context while preserving coverage.
- If the structural index is absent or empty, fall back to grep-based discovery
(today's behavior). The structural index is a coverage HINT only — it improves
audit quality but is never required.
Safety
Non-negotiable invariants:
- Agnostic. Nothing is ever required; no-tool / parse-fail / not-invoked →
empty index → grep fallback = today's behavior byte-for-byte. Optional in the
Pass Lifecycle Contract; a non-conformant harness simply skips it.
- HINT-only / union / never MEMBERSHIP. Consumers audit the union with
grep; a symbol the index misses must still be reachable by the exhaustive
sweep. The structural index decides ORDER, never MEMBERSHIP. The query
contract enforces this:
find_callers returns HINTs, never authoritative
membership — consumers MUST union with grep.
- No verdicts, touches no findings. Cannot violate INV-1 and cannot itself
drop a finding.
- Narrow scope: source cross-reference only. Binary/build-derived
reachability ("is it compiled into production") is explicitly out of scope —
a dev customization, not part of this skill, because absence-from-a-build can
hide a real finding (INV-2). Do not fold build-derived reachability into this
skill.
A reference blueprint is available at
mantis-pipeline-adapter/references/mantis-structural-index.md.
It is a stub that points to this SKILL.md as the single source of truth — do not
duplicate spec content there.
1---2name: mantis-structural-index3description: Builds a content-addressed semantic-unit index from source code for structural context. Use when a pinned or live codebase is available and structural cross-reference data would improve research quality. Don't use for findings analysis, patching, or reporting.4---5
6# Structural Code Index Builder
7
8This is an **optional first-class stage** in the Pass Lifecycle Contract. It
9runs immediately after the snapshot is pinned (Block D), before the first
10code-reading analysis stage (summarize/architecture). It only needs `CODE_ROOT`
11\+ `SNAPSHOT_ID` and must not depend on architecture/KB.
12
13## System Goal
14
15Structural Code Index Builder. Builds a content-addressed semantic-unit index
16from source code using capability-based per-partition backend selection,
17degrading gracefully to grep. Provides `find_callers(symbol)`,
18`get_function_boundary(file, line)`, and call-site awareness to improve LLM
19reasoning quality during discovery — supplementing (never replacing) grep-based
20call-site discovery with structural data. The index is composed from independent
21semantic compilation units, persisted as a manifest + SQLite catalog, and
22exposed through a bounded query helper.
23
24## Command Definition
25
26- **Command:** `/mantis-structural-index`
27- **Description:** Build a content-addressed semantic-unit index from source
28 code under `CODE_ROOT`.
29- **Arguments (optional; supplied by the orchestrator, consumed by Block A):**
30 `--snapshot_root`/`--snapshot_id`/`--state_root`. All absent → MODE-OFF/legacy
31 mode (reads source from the current directory, writes index to
32 `./workspace/kb/structural_index/`).
33
34## Input/Output Contract
35
36- **Reads**:
37 - `workspace/.mantis_state.json` (to read `active_snapshot` for provenance
38 checking and snapshot-aware rebuild logic).
39 - `workspace/kb/structural_index/manifest.json` (to check `snapshot_id` for
40 reuse-on-match idempotency — primary check).
41 - `workspace/kb/structural_index.jsonl` (backward-compat provenance check if
42 manifest absent).
43 - `workspace/kb/structural_index/units/` (content-addressed cache for
44 incremental unit reuse).
45 - `workspace/kb/structural_index/native/` and sidecar `provenance.json` files
46 (prebuilt index attachments and metadata manifests).
47 - CODE_ROOT source files (via generated helper script — the script parses all
48 source files under `CODE_ROOT`).
49- **Writes**:
50 - `workspace/kb/structural_index/manifest.json` (STATE-RELATIVE — atomic
51 commit point, written LAST).
52 - `workspace/kb/structural_index/catalog.sqlite` (STATE-RELATIVE —
53 query-optimized serving store).
54 - `workspace/kb/structural_index/units/` (STATE-RELATIVE — content-addressed
55 immutable unit outputs).
56 - `workspace/kb/structural_index/shards/` (STATE-RELATIVE — partitioned
57 serving data for large corpora).
58 - `workspace/kb/structural_index/native/` (STATE-RELATIVE — prebuilt index
59 attachments: SCIP, Kythe, LSIF).
60 - `workspace/kb/structural_index/tmp/` (STATE-RELATIVE — temporary objects
61 during build).
62 - `workspace/kb/structural_index.jsonl` (STATE-RELATIVE — compatibility
63 pointer; full export below threshold).
64 - `workspace/helpers/build_structural_index.py` (STATE-RELATIVE — the builder
65 helper script).
66 - `workspace/helpers/query_structural_index.py` (STATE-RELATIVE — the query
67 helper script).
68- **Preconditions**:
69 - Source files must exist under `CODE_ROOT`. If `CODE_ROOT` is not resolved
70 (MODE-OFF and no readable `active_snapshot`), build against the current
71 directory with `snapshot_id` set to `"unknown"`. Do NOT skip — this is the
72 standalone-efficiency case.
73- **Inert until wired:** This skill returns an empty index until a caller
74 invokes it (the harness, `mantis-plan`, or `mantis-researcher`). It never
75 fails — it simply returns an empty index if tools are unavailable or source
76 cannot be parsed.
77- **Idempotency Guarantee**:
78 - Read-only on `CODE_ROOT`. Writes only to STATE-RELATIVE paths. Re-running
79 with the same `CODE_ROOT` and `SNAPSHOT_ID` reuses the existing index
80 (manifest `snapshot_id` match) rather than rebuilding — except in MODE-OFF,
81 where it always rebuilds. Individual semantic units are reused across
82 snapshots when their content-addressed cache keys match (incremental
83 rebuild).
84
85## Instructions
86
87### Step 0: Locator Resolution (run first)
88
89```
90LOCATOR RESOLUTION (before reading ANY target code or artifact):
910. ROLE: If this skill NEVER reads target source (report, calibrate, reflect),
92 you are a FINDINGS-ONLY stage: skip steps 2-6; still read active_snapshot from
93 state for provenance/annotation; NEVER stop merely because a code root is unset.
941. Determine CODE_ROOT, in this priority order:
95 a. If --target_root is passed on THIS invocation, CODE_ROOT = --target_root.
96 It is AUTHORITATIVE and OVERRIDES SNAPSHOT_ROOT and the state fallback
97 (used when a caller hands you a prepared tree, e.g. a patched shadow).
98 b. Else if --snapshot_root (or SNAPSHOT_ROOT) is passed, use it.
99 c. Else read state_root/workspace/.mantis_state.json (state_root from
100 --state_root if passed, else ./workspace/... relative to the current dir)
101 -> active_snapshot.root / .snapshot_id / .snapshot_pinned.
102 d. Else (no arg AND no readable active_snapshot): CODE_ROOT = current directory,
103 treat snapshot_pinned = false (MODE-OFF). Do NOT stop.
1042. SENTINEL CHECK (only if snapshot_pinned is true AND you did NOT take path 1a):
105 verify CODE_ROOT/.mantis_snapshot_id exists and equals SNAPSHOT_ID. If missing
106 or different -> STOP "snapshot sentinel mismatch". (A --target_root tree (1a) is
107 deliberately mutated and is sentinel-EXEMPT.)
1083. PATH FIELDS:
109 - SNAPSHOT-RELATIVE (read under CODE_ROOT): code_paths entries; plan target_files
110 that are file paths. Strip ONLY a trailing ":<digits>". A code_paths entry
111 containing "://" is a URL/endpoint, NOT a file read. A code_paths entry that is
112 NOT of the form <existing-path>:<integer> is a non-source LOCATOR
113 (symbol/offset/endpoint): only check that the artifact/symbol exists; skip ALL
114 line-range and line-existence logic.
115 - STATE-RELATIVE (read/write under state_root/workspace, NEVER prefix CODE_ROOT):
116 kb_references, repro_file_path, reattack_file_path, helper scripts, report
117 files, and all state/findings JSON.
1184. Never WRITE under CODE_ROOT when snapshot_pinned is true. Any command that
119 compiles, generates, or writes artifacts MUST run in a PRIVATE SHADOW copy
120 (mktemp -d from CODE_ROOT), never with cwd=CODE_ROOT. Read-only inspection may
121 cd into CODE_ROOT.
1225. VCS-METADATA CARVE-OUT: history-log extraction and any VCS diff/blame command
123 run in the LIVE repository root (which still has .git/.hg/.repo), NOT CODE_ROOT
124 (the snapshot copy strips VCS metadata). Do NOT stop merely because CODE_ROOT
125 lacks .git/.hg/.repo.
1266. Every shell command uses ABSOLUTE paths and sets its own working directory on
127 that call. Do NOT assume the working directory persists between calls.
128```
129
130This is a CODE-READING stage — it reads target source files under CODE_ROOT via
131the helper script. Block A step 0's findings-only skip does NOT apply.
132
133### Step 1: Idempotency / Freshness Check (MANDATORY FIRST)
134
135**MANDATORY FIRST STEP:** Before writing any build scripts, probing backends, or
136extracting symbols, check for MODE-OFF or an existing manifest:
137
1381. **MODE-OFF check (FIRST):** In MODE-OFF (`SNAPSHOT_ID` is `"unknown"` or
139 absent) → **always rebuild**. Do NOT reuse a previous `"unknown"` index,
140 because the live tree is mutable and `"unknown"` is a constant (not a
141 freshness signal). Skip directly to Step 2 (individual units whose content
142 has not changed may still hit the content-addressed cache).
1432. If `workspace/kb/structural_index/manifest.json` exists, read its
144 `snapshot_id` field.
1453. If `snapshot_id` matches the current `SNAPSHOT_ID` (and `SNAPSHOT_ID` is NOT
146 `"unknown"`) → **reuse the index immediately and STOP**. Do NOT invoke Step
147 2, probe backends, or write build scripts. This bounds cost across
148 retries/crash-resume.
1494. If `manifest.json` is absent but `workspace/kb/structural_index.jsonl` exists
150 (backward compat), read its provenance header (`_provenance`, `snapshot_id`
151 keys). If `snapshot_id` matches (and is NOT `"unknown"`) → **reuse and
152 STOP**. Otherwise proceed to rebuild.
1535. If `SNAPSHOT_ID` differs → proceed to rebuild (Steps 2–5). For incremental
154 reuse: before rebuilding a semantic unit, check `units/` for a
155 content-addressed cache hit (see Content-Addressed Cache Key below). A cache
156 hit reuses the unit output without re-extraction.
157
158### Step 2: Select Backend per Partition
159
160Partition the codebase into semantic units (see Per-Language Semantic Units
161below). For each unit, select the most precise backend available in this
162environment using a **capability-based, per-partition decision** — not a single
163global ladder. The following are **examples, not an exhaustive enum**. The
164helper probes each tier per partition and selects the highest available:
165
1661. **Snapshot-matched SCIP / LSIF / Kythe / clangd-static / remote index**
167 (precision: `semantic`) — if a pre-built index matching the current
168 `snapshot_id` or `root_fingerprint` is available. Most precise: full
169 type-aware cross-reference, call hierarchy, and hover/signature data.
1702. **Compiler / typechecker-backed extractor** (precision: `typecheck`) — if
171 `compile_commands.json`, build context, or typechecker is available.
172 Type-accurate symbol resolution and call edges.
1733. **Language-aware AST extraction** (precision: `ast`) — if `tree-sitter`,
174 `ast-grep`, or a language-specific parser is available. Full AST parsing:
175 function boundaries, call expressions, signatures.
1764. **Symbol-only extraction** (precision: `symbol-only`) — if `ctags` or
177 equivalent is on `PATH`. Symbol table only (function definitions, locations —
178 no call graph). Call-site extraction uses a lightweight regex pass within
179 known function boundaries.
1805. **Heuristic fallback** (precision: `heuristic`) — Python stdlib regex pass
181 over source files. Identifies function definitions and call patterns using
182 language-agnostic heuristics. Less precise but zero-dependency.
1836. **Coverage-only manifest + lexical fallback** (precision: `coverage-only`) —
184 grep; no structural index is written. Manifest records `status: "empty"`.
185 Consumers fall back to grep-based discovery (today's behavior byte-for-byte).
186
187**Native Index Probing & Resolution Rules**:
188
189- **Probe instruction**: Before evaluating per-partition backends, probe
190 `workspace/kb/structural_index/native/` and subdirectories
191 `native/{scip,lsif,kythe}/` for prebuilt index files.
192- **Snapshot-declaration convention**: Because native formats (SCIP, LSIF,
193 Kythe) do not embed snapshot identity directly in their binary payload,
194 prebuilt indexes MUST declare their target snapshot using a sidecar
195 `provenance.json` manifest located at
196 `workspace/kb/structural_index/native/provenance.json` or
197 `native/<kind>/provenance.json`. The manifest contains an array of
198 attachments:
199 `[{"kind": "scip|lsif|kythe", "path": "...", "snapshot_id": "...", "root_fingerprint": "...", "language": "...", "indexer": "...", "precision": "semantic", "files": [...]}]`.
200 A native index is matched if its declared `snapshot_id` equals `SNAPSHOT_ID`
201 (when `SNAPSHOT_ID != "unknown"`) or its `root_fingerprint` matches the
202 workspace's calculated root fingerprint. If the `files` array is absent or
203 empty, treat the native index as covering no individual files directly (record
204 in `manifest.native_indexes` but do not update `coverage` rows; fall through
205 to lower tiers for all files).
206- **Record-and-Defer Ingestion Rule**: Parsing raw binary native indexes (e.g.
207 SCIP protobuf) in pure Python without dependencies is costly and complex.
208 Option A builder scripts MUST detect matching prebuilt native indexes, record
209 their entries in `manifest.native_indexes`, and set the `coverage` table
210 `backend` (e.g., `"scip"` or `"scip-clangd"`) for all files listed in the
211 provenance manifest. If `catalog.sqlite` is NOT populated with symbols from
212 the native index (raw binary deferred to harness/MCP readers), set
213 `coverage.status = "deferred"` and `precision = "deferred"` (with
214 `indexed_files = 0`). This prevents the query helper from claiming an
215 un-ingested partition is "authoritative empty" at `semantic` precision,
216 ensuring consumers run the mandatory grep fallback. When `catalog.sqlite` IS
217 populated (e.g., via Option B pre-ingestion or `scip-to-sqlite`), set
218 `precision = "semantic"` and `status = "indexed"`.
219
220**LSP is NOT equivalent to SCIP / LSIF.** LSP is an interactive protocol whose
221workspace state may be partial or mutable. Use it only when the server can
222demonstrate snapshot identity AND complete workspace coverage. A running
223language server does not automatically qualify as a `semantic` backend.
224
225**SCIP merging.** SCIP explicitly permits merging complementary information from
226indexers with different precision levels. Its format records indexer version and
227per-document language metadata. The builder MAY merge results from multiple
228indexers (e.g., a SCIP index for Go + tree-sitter for Python) within a single
229catalog.
230
231The determinism lives in a runtime-generated versioned helper
232(`build_structural_index.py`, `# MANTIS_HELPER_VERSION = 5`, grep-and-regenerate
233on reuse) that probes and selects backends per partition. No shipped binaries;
234air-gapped-safe.
235
236**Deterministic partial coverage.** On very large source trees, a full rebuild
237can dominate stage-0 wall-clock. Replace discovery-order truncation with a
238deterministic priority queue:
239
2401. **Explicit target files and symbols** (from `plan.json`, if available).
2412. **Changed units and known reverse dependencies** (from Block E diff +
242 reverse-dependency edges in the existing catalog).
2433. **Containing packages/modules and direct imports** of tier 1+2 units.
2444. **Remaining units in normalized path order**.
245
246Apply deterministic `max_units` (default: 10000) or `max_source_bytes` (default:
247500MB) bounds to that queue. Persist deferred units in the manifest so another
248invocation can resume. Statuses: `complete`, `partial`, `empty`, `failed`.
249
250A consumer should only interpret "no callers" strongly when the relevant
251partition is complete, snapshot-matched, covered, and semantically precise.
252Otherwise the answer is "no indexed callers", followed by the existing lexical
253fallback. The index is HINT-only, so partial coverage is safe (grep remains
254authoritative).
255
256### Step 3: Write and Run Helper Scripts
257
258Two runtime-generated helpers are used. Both follow the grep-and-regenerate
259pattern: before reuse, grep the first line for the version marker; if absent or
260a different integer, REGENERATE.
261
262#### Builder: `build_structural_index.py`
263
2641. Write the builder to `workspace/helpers/build_structural_index.py`. The FIRST
265 LINE MUST be exactly `# MANTIS_HELPER_VERSION = 5`. Before reusing an
266 existing helper, grep its first lines for `MANTIS_HELPER_VERSION = 5`; if
267 that marker is absent or a different integer, REGENERATE the helper.
2682. The builder partitions the codebase into semantic units (see Per-Language
269 Semantic Units), computes content-addressed cache keys, checks `units/` for
270 reuse, selects a backend per partition (Step 2), extracts symbols + edges,
271 and writes results to `catalog.sqlite`.
2723. The builder must extract:
273 - **Symbols**: `symbol_id`, `name`, `qualified_name`, `namespace`,
274 `language`, `file_path`, `start_line`, `end_line`, `kind`, `signature`,
275 `backend`, `precision`.
276 - **Call edges**: `caller_id`, `callee_id`, `callee_name`, `file_path`,
277 `line`, `edge_kind`.
278 - **Function boundaries**: `symbol_id`, `file_path`, `start_line`,
279 `end_line`, `signature`, `language`.
280 - **Coverage**: `file_path`, `indexed`, `backend`, `precision`,
281 `unit_cache_key`, `status`.
2824. The builder writes the manifest LAST (Step 4) and the compatibility pointer.
2835. The builder MUST use ABSOLUTE paths and set its own working directory (Block
284 A step 6). It MUST NOT write anything under `CODE_ROOT` when
285 `snapshot_pinned` is true (Block A step 4). Backends that produce sidecar
286 files (ctags `tags`, cscope `cscope.out`, clangd cache) MUST be redirected to
287 STATE-RELATIVE paths: `ctags -f <state>/helpers/tags`,
288 `cscope -f <state>/helpers/cscope.out`,
289 `CLANGD_INDEX_STORAGE=<state>/helpers/`. Read-only LSP/SCIP queries to a
290 running server need no redirect.
291
292#### Query helper: `query_structural_index.py`
293
2941. Write the query helper to `workspace/helpers/query_structural_index.py`. The
295 FIRST LINE MUST be exactly `# MANTIS_HELPER_VERSION = 5`. Before reusing an
296 existing helper, grep its first lines for `MANTIS_HELPER_VERSION = 5`; if
297 absent or a different integer, REGENERATE.
2982. The query helper provides bounded, paginated operations against
299 `catalog.sqlite` (or a remote endpoint — identical API). It IS the
300 consumption contract (see Query Interface below).
3013. If `catalog.sqlite` is absent but `structural_index.jsonl` exists, the query
302 helper falls back to linear scanning of the JSONL file (slower but
303 functional). If both are absent, it returns empty results with
304 `coverage.partition_status = "empty"`.
305
306### Step 4: Write Manifest and Commit
307
3081. Write the manifest to `workspace/kb/structural_index/tmp/manifest.json`
309 first. Then atomically rename it to
310 `workspace/kb/structural_index/manifest.json`. This is the atomic commit
311 point — the manifest is written LAST, after all units, catalog, and coverage
312 data are written.
3132. Write a compatibility pointer to `workspace/kb/structural_index.jsonl`
314 (STATE-RELATIVE — NEVER under `CODE_ROOT`). Below a configurable threshold
315 (default: 10K records), emit a complete JSONL export (provenance header + all
316 records). Above the threshold, emit only a provenance header with
317 `compat_pointer.full_export = false` and `compat_pointer.symbol_count` set.
318 Large consumers MUST use the query interface.
3193. The manifest `provider.backend_versions` field records which backend was used
320 per language (e.g.,
321 `{"go": {"backend_name": "scip-clangd", "precision": "semantic"}, "python": {"backend_name": "tree-sitter", "precision": "ast"}}`),
322 so consumers know the precision level.
3234. Interrupted builds leave unreferenced temp objects in `tmp/` without
324 corrupting the last published index. On resume, check `manifest.json` status
325 and `snapshot_id` (Step 1).
326
327### Step 5: Return Results / Notify Caller
328
3291. Return the path to `manifest.json`, `catalog.sqlite`, and the query helper.
330 Include a summary (unit count, symbol count, call-edge count, backends used,
331 coverage status).
3322. If the index is empty (no tools available or no source files found), notify
333 the caller: "Structural index is empty — structural context unavailable."
3343. Do not notify the user directly — this skill is invoked as a sub-agent by the
335 harness, planner, or researcher.
336
337## Index Schema and On-Disk Contract
338
339### On-Disk Layout
340
341```
342workspace/kb/structural_index/
343├── manifest.json # Atomic commit point — written LAST
344├── catalog.sqlite # Query-optimized serving store (both directions indexed)
345├── units/ # Content-addressed immutable unit outputs
346│ └── ab/cd/abcdef... # sha256 prefix sharding (2+2 hex dirs)
347├── shards/ # Partitioned serving data (large corpora)
348│ └── shard_0000.sqlite
349├── native/ # Prebuilt index attachments (SCIP, Kythe, LSIF)
350│ ├── provenance.json # Prebuilt index provenance manifest
351│ ├── scip/
352│ └── kythe/
353└── tmp/ # Temporary objects during build
354
355workspace/helpers/
356├── build_structural_index.py # Builder (MANTIS_HELPER_VERSION = 5)
357└── query_structural_index.py # Query helper (MANTIS_HELPER_VERSION = 5)
358
359workspace/kb/structural_index.jsonl # Compatibility pointer
360```
361
362### Manifest Schema (`manifest.json`)
363
364```json
365{
366 "schema_version": 1,
367 "snapshot_id": "<SNAPSHOT_ID or 'unknown'>",
368 "root_fingerprint": "<sha256 of sorted (path, content_sha256) for all source files>",
369 "status": "complete|partial|empty|failed",
370 "provider": {
371 "kind": "local-build|baseline+overlay|remote",
372 "catalog": "catalog.sqlite",
373 "backend_versions": {
374 "cpp": {"backend_name": "tree-sitter", "backend_version": "0.20.8", "precision": "ast"},
375 "go": {"backend_name": "scip-clangd", "backend_version": "0.2.3", "precision": "semantic"}
376 }
377 },
378 "units": {"total": 0, "reused": 0, "rebuild": 0, "failed": 0},
379 "coverage": {"total_files": 0, "indexed_files": 0, "failed_files": 0, "deferred_files": 0},
380 "shards": [{"id": "", "path": "", "checksum": "", "partition_key": "", "symbol_count": 0, "edge_count": 0}],
381 "deferred_units": [{"unit_id": "", "language": "", "files": [], "priority": 4, "reason": ""}],
382 "native_indexes": [{"kind": "scip", "path": "", "snapshot_id": "", "root_fingerprint": "", "language": "", "indexer": "", "precision": ""}],
383 "baseline": {"source": "ci|local|none", "snapshot_id": "", "manifest_path": ""},
384 "overlay": {"units_added": 0, "units_modified": 0, "files": []},
385 "compat_pointer": {"path": "structural_index.jsonl", "full_export": true, "symbol_count": 0},
386 "created_at": "<ISO 8601>",
387 "build_duration_ms": 0
388}
389```
390
391**Atomic commit**: The manifest is written LAST (atomic rename from `tmp/`).
392Interrupted builds leave unreferenced temp objects without corrupting the last
393published index.
394
395### SQLite Catalog Schema (`catalog.sqlite`)
396
397```sql
398CREATE TABLE IF NOT EXISTS schema_meta (
399 key TEXT PRIMARY KEY, value TEXT NOT NULL
400);
401
402CREATE TABLE IF NOT EXISTS symbols (
403 symbol_id TEXT PRIMARY KEY,
404 name TEXT NOT NULL,
405 qualified_name TEXT NOT NULL,
406 namespace TEXT,
407 language TEXT NOT NULL,
408 file_path TEXT NOT NULL,
409 start_line INTEGER NOT NULL,
410 end_line INTEGER,
411 kind TEXT NOT NULL,
412 signature TEXT,
413 backend TEXT NOT NULL,
414 precision TEXT NOT NULL CHECK (precision IN ('semantic','typecheck','ast','symbol-only','heuristic','deferred','coverage-only')),
415 corpus TEXT DEFAULT 'default',
416 partition_key TEXT,
417 unit_cache_key TEXT,
418 source_layer TEXT NOT NULL DEFAULT 'baseline'
419);
420
421CREATE TABLE IF NOT EXISTS call_edges (
422 edge_id INTEGER PRIMARY KEY AUTOINCREMENT,
423 caller_id TEXT NOT NULL,
424 callee_id TEXT,
425 callee_name TEXT NOT NULL,
426 file_path TEXT NOT NULL,
427 line INTEGER NOT NULL,
428 edge_kind TEXT NOT NULL CHECK (edge_kind IN ('direct','indirect','virtual','macro','unresolved')),
429 corpus TEXT DEFAULT 'default',
430 partition_key TEXT,
431 source_layer TEXT NOT NULL DEFAULT 'baseline',
432 FOREIGN KEY (caller_id) REFERENCES symbols(symbol_id),
433 FOREIGN KEY (callee_id) REFERENCES symbols(symbol_id)
434);
435
436CREATE TABLE IF NOT EXISTS function_boundaries (
437 symbol_id TEXT PRIMARY KEY,
438 file_path TEXT NOT NULL,
439 start_line INTEGER NOT NULL,
440 end_line INTEGER NOT NULL,
441 signature TEXT,
442 language TEXT NOT NULL,
443 FOREIGN KEY (symbol_id) REFERENCES symbols(symbol_id)
444);
445
446CREATE TABLE IF NOT EXISTS coverage (
447 file_path TEXT NOT NULL,
448 indexed INTEGER NOT NULL DEFAULT 0,
449 backend TEXT,
450 precision TEXT,
451 unit_cache_key TEXT,
452 status TEXT NOT NULL DEFAULT 'pending',
453 corpus TEXT DEFAULT 'default',
454 partition_key TEXT,
455 PRIMARY KEY (file_path, corpus)
456);
457
458CREATE TABLE IF NOT EXISTS unit_cache (
459 cache_key TEXT PRIMARY KEY,
460 unit_id TEXT NOT NULL,
461 language TEXT NOT NULL,
462 extractor_name TEXT NOT NULL,
463 extractor_version TEXT NOT NULL,
464 file_count INTEGER NOT NULL,
465 symbol_count INTEGER NOT NULL,
466 edge_count INTEGER NOT NULL,
467 source_bytes INTEGER NOT NULL,
468 created_at TEXT NOT NULL,
469 snapshot_id TEXT NOT NULL
470);
471
472CREATE TABLE IF NOT EXISTS deferred_units (
473 unit_id TEXT PRIMARY KEY,
474 language TEXT NOT NULL,
475 files TEXT NOT NULL,
476 priority INTEGER NOT NULL,
477 reason TEXT NOT NULL,
478 cache_key TEXT,
479 created_at TEXT NOT NULL
480);
481
482-- Indexes: both directions!
483CREATE INDEX IF NOT EXISTS idx_symbols_name ON symbols(name);
484CREATE INDEX IF NOT EXISTS idx_symbols_qualified ON symbols(qualified_name);
485CREATE INDEX IF NOT EXISTS idx_symbols_file ON symbols(file_path, start_line);
486CREATE INDEX IF NOT EXISTS idx_symbols_lang ON symbols(language);
487CREATE INDEX IF NOT EXISTS idx_edges_caller ON call_edges(caller_id);
488CREATE INDEX IF NOT EXISTS idx_edges_callee ON call_edges(callee_id);
489CREATE INDEX IF NOT EXISTS idx_edges_callee_name ON call_edges(callee_name);
490CREATE INDEX IF NOT EXISTS idx_edges_file ON call_edges(file_path, line);
491CREATE INDEX IF NOT EXISTS idx_boundaries_file ON function_boundaries(file_path, start_line, end_line);
492CREATE INDEX IF NOT EXISTS idx_coverage_status ON coverage(status);
493CREATE INDEX IF NOT EXISTS idx_deferred_priority ON deferred_units(priority);
494```
495
496**Partitioning**: When symbols exceed 500K or edges exceed 2M, partition into
497shard SQLite files under `shards/` by `lang:{language}:bucket:{NN}` (stable
498hash). Each shard has the same schema. The query helper routes queries to the
499correct shard(s) based on the manifest.
500
501### Content-Addressed Cache Key
502
503```python
504cache_key = sha256(
505 schema_version +
506 extractor_name@version +
507 language +
508 compile_context_digest +
509 ordered_input_content_digests +
510 dependency_or_interface_digest
511)
512```
513
514**Critical**: `snapshot_id` is NOT in the cache key. It goes in provenance only.
515This permits reuse across commits, branches, and snapshots with identical units.
516
517### Canonical Symbol IDs
518
519**Native IDs** (when semantic backend is available):
520
521- SCIP: `scip:{symbol}`
522- Kythe: `kythe:{uri}`
523- clangd: `clangd:{usr}`
524
525**Fallback IDs** (when no semantic backend):
526
527```
528fallback:{language}:{file_path}:{sha256(qualified_name|start_line|signature)[:16]}
529```
530
531This distinguishes namespaces, overloads, methods, local functions, and
532duplicate names across languages or repositories.
533
534### Edge Kinds
535
536Edge kinds are kept explicitly separate — never silently merged:
537
538- `direct` — statically resolved call to a known symbol
539- `indirect` — function pointer, closure, callback
540- `virtual` — virtual method dispatch (runtime-resolved)
541- `macro` — call introduced by macro expansion
542- `unresolved` — callee name found but no symbol_id resolved
543
544### Per-Language Semantic Units
545
546| Language | Unit Type | Compile Context | Dependency Digest |
547| ----------- | ---------------- | --------------------------- | ------------------------------------------- |
548| C/C++ | Compilation unit | compile_commands.json entry | sha256 of transitive header interfaces |
549| Go | Package | go.mod + build tags | sha256 of imported packages' exported API |
550| Rust | Crate | Cargo.toml + features | sha256 of extern crate signatures |
551| Java/Kotlin | Compiler batch | module path + classpath | sha256 of imported class signatures |
552| TypeScript | Project | tsconfig.json | sha256 of imported module type declarations |
553| Fallback | Individual file | None | Empty string |
554
555**Fallback Granularity Rule:** For the fallback tier (regex, AST parser, or
556lightweight crawler without a language build system), **each source file MUST be
557its own independent semantic unit**. Never bundle multiple source files into a
558single fallback unit. Per-file unit isolation is essential for content-addressed
559cache efficiency — when one file changes in a future snapshot, only that single
560file's unit invalidates while all unchanged files hit the cache
561(`reused = N-1`).
562
563### Baseline + Delta Overlay
564
565- **Static baseline**: Built once in CI for the complete snapshot.
566- **Incremental cache**: Immutable unit outputs reused across snapshots.
567- **Delta overlay**: Changed/target files indexed locally, layered over
568 baseline.
569- **Remote serving**: Optional when global index is too costly locally.
570
571At query time, the query helper merges baseline + overlay results. Overlay
572symbols supersede baseline symbols for the same file. The manifest records
573`provider.kind = "baseline+overlay"` with baseline manifest reference and
574overlay unit count.
575
576### Compatibility JSONL Export
577
578The old `structural_index.jsonl` format is retained as a compatibility export
579only — it is NOT the query contract. Below the threshold (default 10K records),
580a complete JSONL export is emitted. Above the threshold, only a provenance
581pointer is written. Large consumers MUST use the query interface.
582
583Line 1 — provenance header:
584
585```json
586{"_provenance": true, "snapshot_id": "abc123", "tool": "tree-sitter"}
587```
588
589Lines 2+ — structural records (one per line, `_type` discriminator):
590
591```json
592{"_type": "function", "key": "src/parser.c:parse_input", "start_line": 45, "end_line": 120, "signature": "int parse_input(char *buf, size_t len)", "calls": ["malloc", "validate_input", "memcpy"]}
593{"_type": "call_edge", "caller": "parse_input", "callee": "malloc", "file": "src/parser.c", "line": 78}
594{"_type": "call_edge", "caller": "main", "callee": "parse_input", "file": "src/main.c", "line": 203}
595```
596
597**Provenance header fields:**
598
599| Field | Type | Description |
600| ------------- | ------ | ----------------------------------------------------------------------------------- |
601| `_provenance` | bool | Always `true` — marks this as the provenance header line |
602| `snapshot_id` | string | `SNAPSHOT_ID` the index was built against |
603| `tool` | string | Backend used (e.g. `"lsp-clangd"`, `"tree-sitter"`, `"ctags"`, `"regex"`, `"grep"`) |
604
605**Record types:**
606
607| `_type` | Description | Key fields |
608| ----------- | ----------------------------------------------- | ----------------------------------------------------- |
609| `function` | Function definition with boundary and signature | `key`, `start_line`, `end_line`, `signature`, `calls` |
610| `call_edge` | A call from caller to callee at file:line | `caller`, `callee`, `file`, `line` |
611
612## Snapshot Safety
613
6141. **Build from `CODE_ROOT`, not live tree (when pinned).** When the snapshot is
615 pinned, the structural index is built from the pinned `CODE_ROOT`, ensuring
616 it reflects the exact bytes the pipeline is analyzing.
6172. **Reuse-on-match.** If `manifest.json` already carries the current
618 `SNAPSHOT_ID` (and `SNAPSHOT_ID` is NOT `"unknown"` — in MODE-OFF, always
619 rebuild), reuse it — do not rebuild. Rebuild when `SNAPSHOT_ID` differs or is
620 `"unknown"`. Individual units may still be reused from the content-addressed
621 cache. This bounds cost across retries/crash-resume.
6223. **Manifest atomicity.** The manifest is written LAST via atomic rename from
623 `tmp/`. Interrupted builds leave unreferenced temp objects without corrupting
624 the last published index. On resume, check `manifest.json` status and
625 `snapshot_id`.
6264. **STALE flag in HALT mode.** When `snapshot_pinned` is false (HALT), the
627 index may be built from the unpinned `CODE_ROOT` but is marked as potentially
628 stale. Consumers treat structural hints as advisory.
6295. **MODE-OFF: build, do not skip.** When `active_snapshot` is absent
630 (MODE-OFF), build against the current directory (cwd) with provenance
631 `snapshot_id` set to `"unknown"`. This is the standalone-efficiency case —
632 the index is still useful for the researcher/planner even without snapshot
633 pinning. Do NOT return an empty index merely because the snapshot is absent.
634
635## Consumption Contract
636
637These are runtime instructions for callers — they define how consumers use the
638structural index. The structural index is a HINT-only enhancement; skills that
639do not use it behave exactly as they do today.
640
641### Query Interface
642
643**The query helper IS the contract.** All consumers use
644`query_structural_index.py` (or a compatible remote endpoint). The JSONL file is
645NOT the query contract — it is a compatibility export only.
646
647**Operations**:
648
6491. `resolve_symbol(name, language?, file?, namespace?)` →
650 `{results, total, ambiguous, coverage}`
651
652 - Returns ALL matches — no silent selection among ambiguous symbols.
653 - Caller MUST disambiguate before calling `find_callers` / `find_callees`.
654
6552. `find_callers(symbol_id, limit=100, offset=0)` →
656 `{results, total, has_more, coverage}`
657
658 - Bounded, paginated caller lookup.
659 - Each result carries `precision`, `backend`, `edge_kind`.
660 - Empty results carry `coverage.partition_status`.
661
6623. `find_callees(symbol_id, limit=100, offset=0)` →
663 `{results, total, has_more, coverage}`
664
665 - Same shape as `find_callers`.
666
6674. `get_function_boundary(file, line)` →
668 `{symbol_id, start_line, end_line, signature, precision, backend}`
669
6705. `get_coverage(file?)` →
671 `{total_files, indexed_files, failed, deferred, partition_status, backends_used}`
672
673**Key properties**:
674
675- Bounded results and pagination (prevents loading entire index into memory).
676- Explicit name resolution before graph traversal (no silent selection among
677 ambiguous symbols).
678- Precision and backend attached to every result.
679- Coverage attached to empty results.
680- Identical operations for local and remote providers (remote via
681 `MANTIS_STRUCTURAL_INDEX_URL` env var or manifest `provider.kind = "remote"`).
682
683**Coverage on empty results**:
684
685| `partition_status` | Meaning | Consumer action |
686| ------------------ | ----------------------------- | -------------------------------------------------------- |
687| `complete` | All files indexed | "No indexed callers" (still run grep per HINT-only rule) |
688| `partial` | Some files deferred or failed | "Not fully indexed" — MUST run grep fallback |
689| `empty` | No backend available | "Not indexed" — MUST run grep fallback |
690| `failed` | Backend attempted but failed | "Index failed" — MUST run grep fallback |
691
692### mantis-plan
693
694- Use the structural index query helper for function-level dependency fan-out.
695 When planning investigations, call `resolve_symbol()` then `find_callers()` to
696 identify all functions that call into a target — this broadens the audit set
697 beyond single-file analysis.
698- The structural index decides ORDER of investigations (which functions to audit
699 first based on call-graph centrality), never MEMBERSHIP. It may broaden the
700 audit set (safe over-reporting), but must never REMOVE or drop a file; the
701 planner's existing logic remains the membership floor.
702
703### mantis-researcher
704
705- **Wave 1 (Rapid Triage):** Run a repo-wide grep for the function name to build
706 the exhaustive set of candidate call-sites — this is the mandatory floor. Then
707 use the structural index query helper (`resolve_symbol` then `find_callers`)
708 to RANK and prioritize which call-sites to audit first (the index
709 distinguishes actual calls from comments/strings/variable names). Audit the
710 union of both result sets — the structural index may miss macro-based calls,
711 function pointers, and dynamic dispatch, so grep remains the floor.
712- **Wave 2 (Deep Audit):** Use `get_function_boundary(file, line)` to start with
713 the enclosing function, expanding to callers/callees/file as needed for
714 cross-function context — this saves context while preserving coverage.
715- If the structural index is absent or empty, fall back to grep-based discovery
716 (today's behavior). The structural index is a coverage HINT only — it improves
717 audit quality but is never required.
718
719## Safety
720
721Non-negotiable invariants:
722
7231. **Agnostic.** Nothing is ever required; no-tool / parse-fail / not-invoked →
724 empty index → grep fallback = today's behavior byte-for-byte. Optional in the
725 Pass Lifecycle Contract; a non-conformant harness simply skips it.
7262. **HINT-only / union / never MEMBERSHIP.** Consumers audit the union with
727 grep; a symbol the index misses must still be reachable by the exhaustive
728 sweep. The structural index decides ORDER, never MEMBERSHIP. The query
729 contract enforces this: `find_callers` returns HINTs, never authoritative
730 membership — consumers MUST union with grep.
7313. **No verdicts, touches no findings.** Cannot violate INV-1 and cannot itself
732 drop a finding.
7334. **Narrow scope: source cross-reference only.** Binary/build-derived
734 reachability ("is it compiled into production") is explicitly out of scope —
735 a dev customization, not part of this skill, because absence-from-a-build can
736 hide a real finding (INV-2). Do not fold build-derived reachability into this
737 skill.
738
739A reference blueprint is available at
740[mantis-pipeline-adapter/references/mantis-structural-index.md](../mantis-pipeline-adapter/references/mantis-structural-index.md).
741It is a stub that points to this SKILL.md as the single source of truth — do not
742duplicate spec content there.