Skip to content

Commit 63a591d

Browse files
CosmoHacclaude
andcommitted
Merge origin/main into holive/improve-mcp
Resolve README.md conflict: keep 95 commands (main) + 61 MCP tools (PR). Update comparison table tool count from 48 to 61. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2 parents 331f76d + 4358b70 commit 63a591d

6 files changed

Lines changed: 114 additions & 43 deletions

File tree

README.md

Lines changed: 38 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44

55
**The architectural intelligence layer for AI coding agents. Structural graph, architecture governance, multi-agent orchestration, vulnerability mapping, runtime analysis -- one CLI, zero API keys.**
66

7-
*94 commands · 26 languages · architecture OS · 100% local*
7+
*95 commands · 26 languages · architecture OS · 100% local*
88

99
[![PyPI version](https://img.shields.io/pypi/v/roam-code?style=flat-square&color=blue)](https://pypi.org/project/roam-code/)
1010
[![GitHub stars](https://img.shields.io/github/stars/Cranot/roam-code?style=flat-square)](https://github.com/Cranot/roam-code/stargazers)
@@ -87,7 +87,7 @@ $ roam diff # blast radius of uncommitted changes
8787

8888
**Fully local.** No API keys, telemetry, or network calls. Works in air-gapped environments.
8989

90-
**Algorithm-aware.** Built-in catalog of 23 anti-patterns. Detects suboptimal algorithms (quadratic loops, N+1 queries, unbounded recursion) and suggests fixes with Big-O improvements and confidence scores.
90+
**Algorithm-aware.** Built-in catalog of 23 anti-patterns. Detects suboptimal algorithms (quadratic loops, N+1 queries, unbounded recursion) and suggests fixes with Big-O improvements and confidence scores. Receiver-aware loop-invariant analysis minimizes false positives.
9191

9292
**CI-ready.** `--json` output, `--gate` quality gates, GitHub Action, SARIF 2.1.0.
9393

@@ -181,7 +181,7 @@ roam health
181181

182182
## Commands
183183

184-
The [5 core commands](#core-commands) shown above cover ~80% of agent workflows. 94 commands are organized into 7 categories.
184+
The [5 core commands](#core-commands) shown above cover ~80% of agent workflows. 95 commands are organized into 7 categories.
185185

186186
<details>
187187
<summary><strong>Full command reference</strong></summary>
@@ -198,6 +198,7 @@ The [5 core commands](#core-commands) shown above cover ~80% of agent workflows.
198198
| `roam minimap [--update] [-o FILE] [--init-notes]` | Compact annotated codebase snapshot for CLAUDE.md injection: stack, annotated directory tree, key symbols by PageRank, high fan-in symbols to avoid touching, hotspots, conventions. Sentinel-based in-place updates |
199199
| `roam config [KEY [VALUE]]` | View or set configuration options |
200200
| `roam map [-n N] [--full] [--budget N]` | Project skeleton: files, languages, entry points, top symbols by PageRank. `--budget` caps output to N tokens |
201+
| `roam schema [--diff] [--version V]` | JSON envelope schema versioning: view, diff, and validate output schemas |
201202

202203
### Daily Workflow
203204

@@ -643,6 +644,21 @@ roam describe --agent-prompt # compact ~500-token prompt (append to any c
643644
roam minimap --update # inject/refresh annotated codebase minimap in CLAUDE.md
644645
```
645646

647+
**Agent not using Roam correctly?** If your agent is ignoring Roam and falling back to grep/read exploration, it likely doesn't have the instructions. Run:
648+
649+
```bash
650+
roam describe --write # writes instructions to your agent's config (CLAUDE.md, AGENTS.md, etc.)
651+
```
652+
653+
If you already have a config file and don't want to overwrite it:
654+
655+
```bash
656+
roam describe --agent-prompt # prints a compact prompt — copy-paste into your existing config
657+
roam minimap --update # injects an annotated codebase snapshot into CLAUDE.md (won't touch other content)
658+
```
659+
660+
This teaches the agent which Roam command to use for each situation (e.g., `roam preflight` before changes, `roam context` for files to read, `roam diagnose` for debugging).
661+
646662
<details>
647663
<summary><strong>Copy-paste agent instructions</strong></summary>
648664

@@ -1160,10 +1176,13 @@ Roam is **not** a replacement for your linter, LSP, or SonarQube. It fills a dif
11601176
|------|-------------|------------------|
11611177
| **ctags / cscope** | Symbol index for editors | Roam adds graph metrics, git signals, architecture analysis, and AI-optimized output |
11621178
| **LSP (pyright, gopls)** | Real-time type checking | LSP requires a running server and file:line:col queries. Roam is offline, exploratory, and cross-language |
1163-
| **Sourcegraph** | Code search + AI | Requires hosted deployment. Roam is local-only, MIT-licensed |
1164-
| **Aider repo map** | Tree-sitter + PageRank | Context selection for chat. Roam adds git signals, 94 architecture commands, CI gates, multi-agent orchestration |
1165-
| **CodeScene** | Behavioral code analysis | Commercial SaaS. Roam is free, local, uses peer-reviewed algorithms (Mann-Kendall, NPMI, Personalized PageRank) |
1166-
| **SonarQube** | Code quality + security | Heavy server. Roam's cognitive complexity follows SonarSource spec |
1179+
| **Sourcegraph / Cody** | Code search + AI | Requires hosted deployment. Roam is local-only, MIT-licensed, zero infrastructure |
1180+
| **Aider repo map** | Tree-sitter + PageRank | Context selection for chat. Roam adds git signals, 95 architecture commands, CI gates, multi-agent orchestration |
1181+
| **CodeScene** | Behavioral code analysis | Commercial SaaS ($20-60k/yr). Roam is free, local, uses peer-reviewed algorithms (Mann-Kendall, NPMI, Personalized PageRank) |
1182+
| **SonarQube** | Code quality + security | Heavy server ($15-45k/yr). Roam's cognitive complexity follows SonarSource spec |
1183+
| **Serena MCP** | LSP-based symbol navigation | 6 MCP tools for navigation. Roam has 61 MCP tools covering architecture, governance, simulation, and orchestration |
1184+
| **Repomix / code2prompt** | Codebase packing for LLMs | Flat file packing with no graph intelligence. Roam gives structural queries, not raw file dumps |
1185+
| **Augment Code** | Cloud context engine | Cloud-hosted, enterprise-priced. Roam is 100% local, air-gapped, MIT-licensed |
11671186
| **grep / ripgrep** | Text search | No semantic understanding. Can't distinguish definitions from usage |
11681187
11691188
## FAQ
@@ -1230,7 +1249,7 @@ Delete `.roam/` from your project root to clean up local data.
12301249
git clone https://github.com/Cranot/roam-code.git
12311250
cd roam-code
12321251
pip install -e ".[dev]" # includes pytest, ruff
1233-
pytest tests/ # 2654 tests, Python 3.9-3.13
1252+
pytest tests/ # 2656 tests, Python 3.9-3.13
12341253

12351254
# Or use Make targets:
12361255
make dev # install with dev extras
@@ -1247,7 +1266,7 @@ roam-code/
12471266
├── action.yml # Reusable GitHub Action
12481267
├── src/roam/
12491268
│ ├── __init__.py # Version (from pyproject.toml)
1250-
│ ├── cli.py # Click CLI (94 commands, 7 categories)
1269+
│ ├── cli.py # Click CLI (95 commands, 7 categories)
12511270
│ ├── mcp_server.py # MCP server (61 tools, 2 resources)
12521271
│ ├── db/
12531272
│ │ ├── connection.py # SQLite (WAL, pragmas, batched IN)
@@ -1360,17 +1379,25 @@ Optional: [fastmcp](https://github.com/jlowin/fastmcp) >= 2.0 (MCP server — in
13601379
- [x] Governance DSL: `roam rules` with `.roam/rules/` YAML plugin system (v9.1)
13611380
- [x] Topology fingerprinting: `roam fingerprint` with cross-repo comparison (v9.1)
13621381
- [x] 30+ new commands: simulate, orchestrate, mutate, closure, adversarial, plan, invariants, bisect, intent, cut, effects, dark-matter, capsule, forecast, path-coverage, fingerprint, rules, vuln-map, vuln-reach, ingest-trace, hotspots, and more (v9.1)
1382+
- [x] MCP lite mode: `ROAM_MCP_LITE=1` for 15 core tools (v10.0)
1383+
- [x] YAML/HCL Tier 1 support: CI/CD pipelines, Terraform configs (v10.0)
1384+
- [x] Compact annotated minimap for CLAUDE.md injection (v10.0)
1385+
- [x] Algorithm detection false-positive reduction: receiver-aware loop-invariant analysis (v10.0)
13631386
- [ ] Terminal demo GIF
13641387
- [ ] Docker image for CI
1365-
- [ ] VS Code extension
1388+
- [ ] VS Code extension (CodeLens for callers/callees, inline health indicators)
1389+
- [ ] File-system watch mode for sub-second incremental re-indexing
1390+
- [ ] Embedding-based semantic search via local models (Ollama integration)
1391+
- [ ] Official GitHub Action marketplace listing
1392+
- [ ] Token budget management (`--max-tokens` flag for context-aware output)
13661393

13671394
## Contributing
13681395

13691396
```bash
13701397
git clone https://github.com/Cranot/roam-code.git
13711398
cd roam-code
13721399
pip install -e .
1373-
pytest tests/ # All 2654 tests must pass
1400+
pytest tests/ # All 2656 tests must pass
13741401
```
13751402

13761403
Good first contributions: add a [Tier 1 language](src/roam/languages/) (see `go_lang.py` or `php_lang.py` as templates), improve reference resolution, add benchmark repos, extend SARIF converters, add MCP tools.

benchmarks/agent-eval/compare.py

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -70,9 +70,10 @@ def print_task_table(task: str, lookup: dict):
7070
print(f"{'=' * 80}")
7171

7272
# Header
73-
header = f"{'Agent':<14} {'Mode':<10}"
73+
header_parts = [f"{'Agent':<14} {'Mode':<10}"]
7474
for _, label, _, _ in SCORE_COLUMNS:
75-
header += f" {label:>8}"
75+
header_parts.append(f" {label:>8}")
76+
header = "".join(header_parts)
7677
print(header)
7778
print("-" * len(header))
7879

@@ -83,19 +84,19 @@ def print_task_table(task: str, lookup: dict):
8384
if not scores:
8485
continue
8586

86-
row = f"{agent:<14} {mode:<10}"
87+
row_parts = [f"{agent:<14} {mode:<10}"]
8788
for field, _, fmt, _ in SCORE_COLUMNS:
8889
val = scores.get(field)
8990
if val is None:
90-
row += f" {'N/A':>8}"
91+
row_parts.append(f" {'N/A':>8}")
9192
elif isinstance(val, bool):
92-
row += f" {'PASS' if val else 'FAIL':>8}"
93+
row_parts.append(f" {'PASS' if val else 'FAIL':>8}")
9394
else:
9495
try:
95-
row += f" {fmt.format(val):>8}"
96+
row_parts.append(f" {fmt.format(val):>8}")
9697
except (ValueError, TypeError):
97-
row += f" {str(val)[:8]:>8}"
98-
print(row)
98+
row_parts.append(f" {str(val)[:8]:>8}")
99+
print("".join(row_parts))
99100

100101

101102
def print_agent_summary(agent: str, lookup: dict):

src/roam/catalog/detectors.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -822,9 +822,29 @@ def detect_loop_invariant_call(conn):
822822

823823
# Calls that are intentionally per-iteration (suppress)
824824
_INTENTIONAL_CALLS = {
825+
# Logging / output
825826
"print", "log", "debug", "info", "warn", "warning", "error",
827+
# Collection mutation
826828
"append", "add", "push", "extend", "write", "send",
829+
"get", "values", "items", "keys", "update", "pop", "remove",
830+
"insert", "setdefault", "discard",
831+
# String methods (inherently per-item)
832+
"startswith", "endswith", "replace", "format", "strip", "split",
833+
"join", "lower", "upper", "lstrip", "rstrip", "encode", "decode",
834+
"ljust", "rjust", "center", "zfill",
835+
# Event / tracking
827836
"emit", "track", "record", "increment", "decrement",
837+
# Iteration helpers / builtins
838+
"enumerate", "zip", "range", "sorted", "reversed",
839+
"list", "dict", "set", "tuple", "len", "str", "int", "float",
840+
"bool", "bytes", "type",
841+
# Math / comparison builtins (per-item reductions)
842+
"max", "min", "sum", "abs", "round", "pow",
843+
"isinstance", "issubclass", "hasattr", "getattr", "setattr",
844+
# File / IO
845+
"resolve", "execute", "fetchone", "fetchall", "read_text",
846+
"read_bytes", "open",
847+
# Control flow
828848
"sleep", "yield",
829849
}
830850

src/roam/commands/cmd_orphan_routes.py

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -360,6 +360,12 @@ def _determine_confidence(classified: dict) -> str:
360360
# Controller method analysis
361361
# ---------------------------------------------------------------------------
362362

363+
_RE_PUBLIC_METHOD = re.compile(
364+
r"public\s+function\s+([A-Za-z_][A-Za-z0-9_]*)\s*\(",
365+
re.IGNORECASE,
366+
)
367+
368+
363369
def _extract_controller_public_methods(project_root: Path,
364370
controller_name: str) -> list[str]:
365371
"""Find public methods in a controller PHP file.
@@ -383,12 +389,7 @@ def _extract_controller_public_methods(project_root: Path,
383389
except OSError:
384390
continue
385391

386-
# Extract public function names
387-
method_re = re.compile(
388-
r"public\s+function\s+([A-Za-z_][A-Za-z0-9_]*)\s*\(",
389-
re.IGNORECASE,
390-
)
391-
methods = method_re.findall(source)
392+
methods = _RE_PUBLIC_METHOD.findall(source)
392393
# Filter out magic methods
393394
return [m for m in methods if not m.startswith("__")]
394395

src/roam/index/complexity.py

Lines changed: 32 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -693,17 +693,13 @@ def _extract_loop_vars(loop_node, source: bytes) -> set[str]:
693693

694694

695695
def _call_uses_loop_vars(call_node, source: bytes, loop_vars: set[str]) -> bool:
696-
"""Check if any argument of a call expression references a loop variable."""
697-
# Find argument list node
698-
args_node = None
699-
for child in call_node.children:
700-
if child.type in ("argument_list", "arguments", "template_string"):
701-
args_node = child
702-
break
703-
if args_node is None:
704-
return False
696+
"""Check if a call expression depends on a loop variable.
697+
698+
Checks both arguments AND the call receiver (the object before ``.method()``).
699+
For example, ``loop_var.method()`` and ``dict[loop_var].call()`` are both
700+
loop-dependent even if the argument list is empty or constant.
701+
"""
705702

706-
# Walk all identifiers in the arguments
707703
def _has_loop_var(node) -> bool:
708704
if node.type == "identifier":
709705
name = source[node.start_byte:node.end_byte].decode(
@@ -715,7 +711,32 @@ def _has_loop_var(node) -> bool:
715711
return True
716712
return False
717713

718-
return _has_loop_var(args_node)
714+
# Check the call receiver (object before .method())
715+
for child in call_node.children:
716+
if child.type in ("member_expression", "attribute",
717+
"field_expression"):
718+
# Check the object part (everything except the method name)
719+
for sub in child.children:
720+
if sub.type not in ("identifier", "property_identifier",
721+
"field_identifier", "."):
722+
if _has_loop_var(sub):
723+
return True
724+
# Also check if the direct object identifier is a loop var
725+
for sub in child.children:
726+
if sub.type == "identifier":
727+
name = source[sub.start_byte:sub.end_byte].decode(
728+
"utf-8", errors="replace")
729+
if name in loop_vars:
730+
return True
731+
break # only check the first identifier (the object)
732+
733+
# Check argument list
734+
for child in call_node.children:
735+
if child.type in ("argument_list", "arguments", "template_string"):
736+
if _has_loop_var(child):
737+
return True
738+
739+
return False
719740

720741

721742
def _is_bounded_loop(loop_node, source: bytes) -> bool:

src/roam/rules/engine.py

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -165,33 +165,34 @@ def _matches_glob(file_path: str, pattern: str) -> bool:
165165
return fnmatch.fnmatch(norm, pat)
166166

167167
# Convert glob pattern with ** to regex
168-
regex = ""
168+
parts: list[str] = []
169169
i = 0
170170
while i < len(pat):
171171
c = pat[i]
172172
if c == "*":
173173
if i + 1 < len(pat) and pat[i + 1] == "*":
174174
if i + 2 < len(pat) and pat[i + 2] == "/":
175-
regex += "(?:.+/)?"
175+
parts.append("(?:.+/)?")
176176
i += 3
177177
continue
178178
else:
179-
regex += ".*"
179+
parts.append(".*")
180180
i += 2
181181
continue
182182
else:
183-
regex += "[^/]*"
183+
parts.append("[^/]*")
184184
i += 1
185185
elif c == "?":
186-
regex += "[^/]"
186+
parts.append("[^/]")
187187
i += 1
188188
elif c in r".+^${}()|[]":
189-
regex += "\\" + c
189+
parts.append("\\" + c)
190190
i += 1
191191
else:
192-
regex += c
192+
parts.append(c)
193193
i += 1
194194

195+
regex = "".join(parts)
195196
return re.match("^" + regex + "$", norm) is not None
196197

197198

0 commit comments

Comments
 (0)