Skip to content

Commit bd9286e

Browse files
Merge pull request #22 from VioletCranberry/feat/dockerfile-symbols-context
feat: add symbol extraction, context expansion and richer metadata fo…
2 parents acbd85e + 4951194 commit bd9286e

12 files changed

Lines changed: 254 additions & 36 deletions

File tree

CLAUDE.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -76,9 +76,9 @@ uv run cocosearch mcp --project-from-cwd
7676
- **`validation.py`** — Input validation guards: `validate_index_name()` (SQL injection protection for dynamic table names), `validate_query()` (resource exhaustion protection, max 10,000 chars)
7777
- **`mcp/server.py`** — MCP server exposing tools (search_code, analyze_query, index_codebase, etc.) + web dashboard with HTTP API (`/api/stats`, `/api/reindex`, `/api/search`, `/api/project`, `/api/projects`, `/api/index`, `/api/stop-indexing`, `/api/delete-index`, `/api/list`, `/api/analyze`, `/api/languages`, `/api/grammars`, `/api/open-in-editor`, `/api/file-content`, `/health`, `/api/heartbeat` SSE, `/api/ai-chat/*` AI chat)
7878
- **`mcp/project_detection.py`** — Auto-detect project from MCP Roots or CWD
79-
- **`indexer/`** — CocoIndex pipeline: file filtering (`file_filter.py`), Tree-sitter symbol extraction (15 languages via `.scm` queries in `indexer/queries/`), Ollama embedding, tsvector generation, parse health tracking, schema migration, preflight validation (`preflight.py`), progress reporting (`progress.py`)
79+
- **`indexer/`** — CocoIndex pipeline: file filtering (`file_filter.py`), Tree-sitter symbol extraction (16 languages via `.scm` queries in `indexer/queries/`), Ollama embedding, tsvector generation, parse health tracking, schema migration, preflight validation (`preflight.py`), progress reporting (`progress.py`)
8080
- **`indexer/flow.py`** — CocoIndex flow definition (the indexing pipeline)
81-
- **`search/`** — Hybrid search engine: RRF fusion of vector + keyword results, two-level LRU query cache (`cache.py` — exact + semantic similarity at cosine > 0.92), context expansion via Tree-sitter boundaries for 8 languages (`context_expander.py`, exports `CONTEXT_EXPANSION_LANGUAGES`), symbol/language filtering (`filters.py`), auto-detection of code identifiers for hybrid mode (`query_analyzer.py`), interactive REPL (`repl.py`), result formatting (`formatter.py`), pipeline analysis with stage-by-stage diagnostics (`analyze.py`)
81+
- **`search/`** — Hybrid search engine: RRF fusion of vector + keyword results, two-level LRU query cache (`cache.py` — exact + semantic similarity at cosine > 0.92), context expansion via Tree-sitter boundaries for 9 languages (`context_expander.py`, exports `CONTEXT_EXPANSION_LANGUAGES`), symbol/language filtering (`filters.py`), auto-detection of code identifiers for hybrid mode (`query_analyzer.py`), interactive REPL (`repl.py`), result formatting (`formatter.py`), pipeline analysis with stage-by-stage diagnostics (`analyze.py`)
8282
- **`search/db.py`** — PostgreSQL connection pool (singleton) and query execution
8383
- **`config/`** — YAML config with 4-level precedence resolution (CLI > env > file > defaults), `${VAR}` substitution (`env_substitution.py`), Pydantic schema validation (`schema.py` with `extra="forbid"`, `strict=True`), user-friendly error formatting with fuzzy field suggestions (`errors.py`), env var validation (`env_validation.py`)
8484
- **`management/`** — Index lifecycle: discovery (`discovery.py`), stats (`stats.py`), clearing (`clear.py`), git-based naming (`git.py`), metadata with collision detection and status tracking (`metadata.py`), project root detection (`context.py`)

README.md

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,7 @@
5858

5959
Coco[-S]earch is a local-first hybrid semantic code search tool. It combines vector similarity and keyword matching (via RRF fusion) to find code by meaning, not just text. Powered by [CocoIndex](https://github.com/cocoindex-io/cocoindex) for indexing, [Tree-sitter](https://tree-sitter.github.io/tree-sitter/) for syntax-aware chunking and symbol extraction, [PostgreSQL](https://www.postgresql.org/) with [pgvector](https://github.com/pgvector/pgvector) for storage, and [Ollama](https://ollama.com/) for local embeddings. No external APIs — everything runs on your machine.
6060

61-
Available as a WEB dashboard, CLI, MCP server, or interactive REPL. Incremental indexing, `.gitignore`-aware. Supports 31+ languages with symbol-level filtering for 14+, plus domain-specific grammars for structured config files.
61+
Available as a WEB dashboard, CLI, MCP server, or interactive REPL. Incremental indexing, `.gitignore`-aware. Supports 31+ languages with symbol-level filtering for 15+, plus domain-specific grammars for structured config files.
6262

6363
<details>
6464
<summary>Screenshots</summary>
@@ -114,9 +114,9 @@ This project was originally built for personal use — a solo experiment in loca
114114

115115
- 🔍 **Hybrid search** -- combines semantic similarity (pgvector cosine) and keyword matching (PostgreSQL tsvector) via Reciprocal Rank Fusion. Auto-detects code identifiers (camelCase, snake_case, PascalCase) and enables hybrid mode automatically — or force it with `--hybrid`. Definition symbols (functions, classes) get a 2x score boost. RRF constant k=60.
116116

117-
- 🏷️ **Symbol filtering** -- narrow results to `function`, `class`, `method`, or `interface` with `--symbol-type`; match symbol names with glob patterns (`User*`, `*Handler`) via `--symbol-name`. Supported for 14 languages with Tree-sitter `.scm` queries. Filters apply before RRF fusion for better ranking quality.
117+
- 🏷️ **Symbol filtering** -- narrow results to `function`, `class`, `method`, or `interface` with `--symbol-type`; match symbol names with glob patterns (`User*`, `*Handler`) via `--symbol-name`. Supported for 15 languages with Tree-sitter `.scm` queries. Filters apply before RRF fusion for better ranking quality.
118118

119-
- 📐 **Context expansion** -- results automatically expand to enclosing function/class boundaries using Tree-sitter AST traversal, so you see complete units of code instead of arbitrary line ranges. Supports Python, JavaScript, TypeScript, Go, Rust, and Scala. Hard-capped at 50 lines per result, centered on the match. Disable with `--no-smart` or set explicit line counts with `-B`/`-A`/`-C`.
119+
- 📐 **Context expansion** -- results automatically expand to enclosing function/class boundaries using Tree-sitter AST traversal, so you see complete units of code instead of arbitrary line ranges. Supports Python, JavaScript, TypeScript, Go, Rust, Scala, HCL/Terraform, and Dockerfile. Hard-capped at 50 lines per result, centered on the match. Disable with `--no-smart` or set explicit line counts with `-B`/`-A`/`-C`.
120120

121121
-**Query caching** -- two-level LRU cache (500 entries, 24h TTL): exact-match via SHA-256 hash of all search parameters, plus semantic fallback that finds paraphrased queries by cosine similarity (threshold 0.92, scanning last 50 entries). Cache auto-invalidates on reindex. Bypass with `--no-cache`.
122122

@@ -462,7 +462,7 @@ CocoSearch indexes 31 programming languages. Symbol-aware languages support `--s
462462
│ XML │ .xml │ ✗ │ ✗ │
463463
│ YAML │ .yaml, .yml │ ✗ │ ✗ │
464464
│ Bash │ .sh, .bash, .zsh │ ✓ │ ✗ │
465-
│ Dockerfile │ Dockerfile │
465+
│ Dockerfile │ Dockerfile │
466466
│ HCL │ .hcl │ ✓ │ ✓ │
467467
└────────────┴─────────────────────────────┴─────────┴─────────┘
468468
```
@@ -478,7 +478,7 @@ Chunking strategy depends on the language:
478478

479479
In short: CocoIndex's Tree-sitter tells you _where to cut_; the `.scm` files tell you _what's inside each piece_.
480480

481-
Independently of chunking, CocoSearch runs its own Tree-sitter queries (`.scm` files in `src/cocosearch/indexer/queries/`) to extract symbol metadata — function, class, method, and interface names and signatures. This powers `--symbol-type` and `--symbol-name` filtering. Symbol extraction is available for 14 languages.
481+
Independently of chunking, CocoSearch runs its own Tree-sitter queries (`.scm` files in `src/cocosearch/indexer/queries/`) to extract symbol metadata — function, class, method, and interface names and signatures. This powers `--symbol-type` and `--symbol-name` filtering. Symbol extraction is available for 15 languages.
482482

483483
See [Adding Languages](./docs/adding-languages.md) for details on how these tiers work and how to add new languages or grammars.
484484

src/cocosearch/handlers/dockerfile.py

Lines changed: 72 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -53,19 +53,43 @@ class DockerfileHandler:
5353
r"(?:\s+[Aa][Ss]\s+(\S+))?", # optional AS stage_name (case-insensitive AS)
5454
)
5555

56+
# COPY --from=<stage>
57+
_COPY_FROM_RE = re.compile(r"^COPY\s+--from=(\S+)")
58+
59+
# ARG <name>[=<default>]
60+
_ARG_RE = re.compile(r"^ARG\s+([A-Za-z_][A-Za-z0-9_]*)")
61+
62+
# ENV <key>=<value> or ENV <key> <value>
63+
_ENV_RE = re.compile(r"^ENV\s+([A-Za-z_][A-Za-z0-9_]*)")
64+
65+
# EXPOSE <port>
66+
_EXPOSE_RE = re.compile(r"^EXPOSE\s+(\S+)")
67+
68+
# WORKDIR <path>
69+
_WORKDIR_RE = re.compile(r"^WORKDIR\s+(\S+)")
70+
71+
# LABEL <key>=<value> or LABEL <key> <value>
72+
_LABEL_RE = re.compile(r"^LABEL\s+([A-Za-z_][A-Za-z0-9_./-]*)")
73+
5674
def extract_metadata(self, text: str) -> dict:
5775
"""Extract metadata from Dockerfile chunk.
5876
59-
Matches Dockerfile instructions. For FROM instructions, extracts stage name
60-
(AS clause) or image reference for the hierarchy.
77+
Matches Dockerfile instructions. Extracts hierarchy metadata for:
78+
- FROM: "stage:<name>" or "image:<ref>"
79+
- COPY --from: "from:<stage>"
80+
- ARG: "arg:<name>"
81+
- ENV: "env:<key>"
82+
- EXPOSE: "port:<port>"
83+
- WORKDIR: "workdir:<path>"
84+
- LABEL: "label:<key>"
6185
6286
Args:
6387
text: The chunk text content.
6488
6589
Returns:
6690
Dict with metadata fields:
6791
- block_type: Dockerfile instruction (e.g., "FROM", "RUN")
68-
- hierarchy: For FROM: "stage:name" or "image:ref"; empty for others
92+
- hierarchy: Instruction-specific hierarchy or empty string
6993
- language_id: "dockerfile"
7094
7195
Example:
@@ -82,27 +106,58 @@ def extract_metadata(self, text: str) -> dict:
82106
return {"block_type": "", "hierarchy": "", "language_id": "dockerfile"}
83107

84108
instruction = match.group(1)
109+
hierarchy = self._extract_hierarchy(instruction, stripped)
110+
111+
return {
112+
"block_type": instruction,
113+
"hierarchy": hierarchy,
114+
"language_id": "dockerfile",
115+
}
116+
117+
def _extract_hierarchy(self, instruction: str, stripped: str) -> str:
118+
"""Extract hierarchy string for a Dockerfile instruction.
119+
120+
Args:
121+
instruction: The instruction keyword (e.g., "FROM", "ARG").
122+
stripped: The comment-stripped chunk text.
85123
124+
Returns:
125+
Hierarchy string or empty string if not applicable.
126+
"""
86127
if instruction == "FROM":
87128
from_match = self._FROM_RE.match(stripped)
88129
if from_match:
89130
stage_name = from_match.group(2)
90131
if stage_name:
91-
hierarchy = f"stage:{stage_name}"
92-
else:
93-
image_ref = from_match.group(1)
94-
hierarchy = f"image:{image_ref}"
95-
else:
96-
hierarchy = ""
97-
else:
98-
# Non-FROM instructions get empty hierarchy in v1.2
99-
hierarchy = ""
132+
return f"stage:{stage_name}"
133+
return f"image:{from_match.group(1)}"
134+
return ""
100135

101-
return {
102-
"block_type": instruction,
103-
"hierarchy": hierarchy,
104-
"language_id": "dockerfile",
105-
}
136+
if instruction == "COPY":
137+
m = self._COPY_FROM_RE.match(stripped)
138+
return f"from:{m.group(1)}" if m else ""
139+
140+
if instruction == "ARG":
141+
m = self._ARG_RE.match(stripped)
142+
return f"arg:{m.group(1)}" if m else ""
143+
144+
if instruction == "ENV":
145+
m = self._ENV_RE.match(stripped)
146+
return f"env:{m.group(1)}" if m else ""
147+
148+
if instruction == "EXPOSE":
149+
m = self._EXPOSE_RE.match(stripped)
150+
return f"port:{m.group(1)}" if m else ""
151+
152+
if instruction == "WORKDIR":
153+
m = self._WORKDIR_RE.match(stripped)
154+
return f"workdir:{m.group(1)}" if m else ""
155+
156+
if instruction == "LABEL":
157+
m = self._LABEL_RE.match(stripped)
158+
return f"label:{m.group(1)}" if m else ""
159+
160+
return ""
106161

107162
def _strip_comments(self, text: str) -> str:
108163
"""Strip leading comments from chunk text.
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
;; @doc Dockerfile symbol extraction: build stages (FROM ... AS) and build arguments (ARG)
2+
3+
;; Build stages: FROM ... AS <alias>
4+
(from_instruction
5+
as: (image_alias) @name) @definition.class
6+
7+
;; Build arguments: ARG <name>[=<default>]
8+
(arg_instruction
9+
name: (unquoted_string) @name) @definition.variable

src/cocosearch/indexer/symbols.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,8 @@ class SymbolMetadata:
100100
# CSS
101101
"css": "css",
102102
"scss": "css",
103+
# Dockerfile
104+
"dockerfile": "dockerfile",
103105
}
104106

105107
# ============================================================================

src/cocosearch/search/context_expander.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@
5353
},
5454
"hcl": {"block"},
5555
"terraform": {"block"},
56+
"dockerfile": {"from_instruction"},
5657
}
5758

5859
# Languages that support smart context expansion (derived from DEFINITION_NODE_TYPES)
@@ -82,6 +83,8 @@
8283
".tf": "terraform",
8384
".hcl": "hcl",
8485
".tfvars": "terraform",
86+
# Dockerfile
87+
".dockerfile": "dockerfile",
8588
}
8689

8790

src/cocosearch/search/query.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,7 @@ class SearchResult:
109109
"bash",
110110
"scala",
111111
"css",
112+
"dockerfile",
112113
}
113114

114115
# Handler language canonical names mapped to language_id values in the database

tests/unit/handlers/test_dockerfile.py

Lines changed: 76 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -120,21 +120,58 @@ def test_run_instruction_empty_hierarchy(self):
120120
assert m["language_id"] == "dockerfile"
121121

122122
def test_copy_instruction_empty_hierarchy(self):
123-
"""COPY instruction produces empty hierarchy."""
123+
"""COPY without --from produces empty hierarchy."""
124124
handler = DockerfileHandler()
125125
m = handler.extract_metadata("COPY . /app")
126126
assert m["block_type"] == "COPY"
127127
assert m["hierarchy"] == ""
128128
assert m["language_id"] == "dockerfile"
129129

130-
def test_env_instruction_empty_hierarchy(self):
131-
"""ENV instruction produces empty hierarchy."""
130+
def test_copy_from_produces_hierarchy(self):
131+
"""COPY --from produces from:<stage> hierarchy."""
132+
handler = DockerfileHandler()
133+
m = handler.extract_metadata("COPY --from=builder /app /app")
134+
assert m["block_type"] == "COPY"
135+
assert m["hierarchy"] == "from:builder"
136+
assert m["language_id"] == "dockerfile"
137+
138+
def test_copy_from_numeric_stage(self):
139+
"""COPY --from with numeric stage produces from:<number> hierarchy."""
140+
handler = DockerfileHandler()
141+
m = handler.extract_metadata("COPY --from=0 /app /app")
142+
assert m["block_type"] == "COPY"
143+
assert m["hierarchy"] == "from:0"
144+
145+
def test_arg_produces_hierarchy(self):
146+
"""ARG instruction produces arg:<name> hierarchy."""
147+
handler = DockerfileHandler()
148+
m = handler.extract_metadata("ARG VERSION=dev")
149+
assert m["block_type"] == "ARG"
150+
assert m["hierarchy"] == "arg:VERSION"
151+
assert m["language_id"] == "dockerfile"
152+
153+
def test_arg_without_default(self):
154+
"""ARG without default value produces arg:<name> hierarchy."""
155+
handler = DockerfileHandler()
156+
m = handler.extract_metadata("ARG COMMIT_SHA")
157+
assert m["block_type"] == "ARG"
158+
assert m["hierarchy"] == "arg:COMMIT_SHA"
159+
160+
def test_env_produces_hierarchy(self):
161+
"""ENV instruction produces env:<key> hierarchy."""
132162
handler = DockerfileHandler()
133163
m = handler.extract_metadata("ENV NODE_ENV=production")
134164
assert m["block_type"] == "ENV"
135-
assert m["hierarchy"] == ""
165+
assert m["hierarchy"] == "env:NODE_ENV"
136166
assert m["language_id"] == "dockerfile"
137167

168+
def test_env_space_separated(self):
169+
"""ENV with space-separated key/value produces env:<key> hierarchy."""
170+
handler = DockerfileHandler()
171+
m = handler.extract_metadata("ENV PATH /usr/local/bin")
172+
assert m["block_type"] == "ENV"
173+
assert m["hierarchy"] == "env:PATH"
174+
138175
def test_add_instruction_empty_hierarchy(self):
139176
"""ADD instruction produces empty hierarchy."""
140177
handler = DockerfileHandler()
@@ -143,14 +180,46 @@ def test_add_instruction_empty_hierarchy(self):
143180
assert m["hierarchy"] == ""
144181
assert m["language_id"] == "dockerfile"
145182

146-
def test_expose_instruction_empty_hierarchy(self):
147-
"""EXPOSE instruction produces empty hierarchy."""
183+
def test_expose_produces_hierarchy(self):
184+
"""EXPOSE instruction produces port:<port> hierarchy."""
148185
handler = DockerfileHandler()
149186
m = handler.extract_metadata("EXPOSE 8080")
150187
assert m["block_type"] == "EXPOSE"
151-
assert m["hierarchy"] == ""
188+
assert m["hierarchy"] == "port:8080"
189+
assert m["language_id"] == "dockerfile"
190+
191+
def test_expose_with_protocol(self):
192+
"""EXPOSE with protocol produces port:<port/proto> hierarchy."""
193+
handler = DockerfileHandler()
194+
m = handler.extract_metadata("EXPOSE 8080/tcp")
195+
assert m["block_type"] == "EXPOSE"
196+
assert m["hierarchy"] == "port:8080/tcp"
197+
198+
def test_workdir_produces_hierarchy(self):
199+
"""WORKDIR instruction produces workdir:<path> hierarchy."""
200+
handler = DockerfileHandler()
201+
m = handler.extract_metadata("WORKDIR /app")
202+
assert m["block_type"] == "WORKDIR"
203+
assert m["hierarchy"] == "workdir:/app"
152204
assert m["language_id"] == "dockerfile"
153205

206+
def test_label_produces_hierarchy(self):
207+
"""LABEL instruction produces label:<key> hierarchy."""
208+
handler = DockerfileHandler()
209+
m = handler.extract_metadata('LABEL maintainer="me"')
210+
assert m["block_type"] == "LABEL"
211+
assert m["hierarchy"] == "label:maintainer"
212+
assert m["language_id"] == "dockerfile"
213+
214+
def test_label_dotted_key(self):
215+
"""LABEL with dotted key produces label:<key> hierarchy."""
216+
handler = DockerfileHandler()
217+
m = handler.extract_metadata(
218+
'LABEL org.opencontainers.image.title="CocoSearch"'
219+
)
220+
assert m["block_type"] == "LABEL"
221+
assert m["hierarchy"] == "label:org.opencontainers.image.title"
222+
154223
def test_comment_before_instruction(self):
155224
"""Comment line before instruction is correctly skipped."""
156225
handler = DockerfileHandler()

0 commit comments

Comments
 (0)