Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 27 additions & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -149,8 +149,34 @@ jobs:
run: |
set -euo pipefail
cd dist
shasum -a 256 rgctl-* > SHA256SUMS.txt
# Artifacts may land flat (merge-multiple) or under per-target dirs.
mapfile -t archives < <(
find . -type f \( -name 'rgctl-*.tar.gz' -o -name 'rgctl-*.zip' \) | sort
)
if [[ ${#archives[@]} -eq 0 ]]; then
echo "error: no rgctl archives under dist/; refusing empty SHA256SUMS.txt" >&2
find . -type f | head -50 >&2
exit 1
fi
# Flatten into dist/ so softprops uploads a single flat file set.
for f in "${archives[@]}"; do
base="$(basename "$f")"
if [[ "$f" != "./$base" ]]; then
mv -f "$f" "./$base"
fi
done
: > SHA256SUMS.txt
for f in rgctl-*.tar.gz rgctl-*.zip; do
[[ -f "$f" ]] || continue
shasum -a 256 "$f" >> SHA256SUMS.txt
done
if [[ ! -s SHA256SUMS.txt ]]; then
echo "error: SHA256SUMS.txt is empty" >&2
exit 1
fi
echo "--- SHA256SUMS.txt ---"
cat SHA256SUMS.txt
echo "lines=$(wc -l < SHA256SUMS.txt)"

- name: Resolve release tag
id: meta
Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
.cursor/
.claude/
.opencode/
.agent
# OpenSpec change proposals (local only — do not commit)
openspec/
.scratch/
Expand Down
5 changes: 2 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,16 +29,15 @@ The LLM reasons on **summaries and facts**, not raw repo grep — fewer tokens,

## Quick Start

**1. Install** from [GitHub Releases](https://github.com/sshaaf/rgctl/releases/latest) (binary **`rgctl`**) or build from source ([Installation docs](docs/installation.md)):
**1. Install** from [GitHub Releases](https://github.com/sshaaf/rgctl/releases/latest) (binary **`rgctl`**) or build from source ([Installation docs](docs/installation.md) — glibc / Ubuntu 22.04 caveat, Rust **1.88+**, and `--no-default-features` if ONNX/`ort` link fails):

```bash
git clone https://github.com/sshaaf/rgctl.git
cd rgctl
git lfs pull # only if you use `semantic index --embedder code-daemon` (~206 MB)
cargo build --release --bin rgctl

# If ort-sys fails: cargo build --release --bin rgctl --no-default-features
```

**2. Discover (Index your repo):**
Run this once to build the graph and reachability caches. Artifacts land in `{repo}/.rgctl/`.

Expand Down
27 changes: 17 additions & 10 deletions crates/rgctl-analysis/src/ast_skeleton.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@

use crate::language_profile::{function_kinds_for, parse_source};
use rgctl_error::{Error, Result};
use rgctl_plugin_helpers::extract_name_from_node;
use rgctl_plugin_helpers::{ecmascript_function_symbol_name, extract_name_from_node};
use serde::{Deserialize, Serialize};
use std::fs;
use std::path::{Path, PathBuf};
Expand Down Expand Up @@ -140,11 +140,13 @@ pub fn build_function_skeleton(
let bytes = source.as_bytes();
let tree = parse_source(language, bytes)?;
let kinds = function_kinds_for(language)?;
let func = find_function(tree.root_node(), bytes, function_name, kinds).ok_or_else(|| {
Error::NotFound(format!(
"function '{function_name}' not found for AST skeleton"
))
})?;
let func = find_function(tree.root_node(), bytes, function_name, kinds, language).ok_or_else(
|| {
Error::NotFound(format!(
"function '{function_name}' not found for AST skeleton"
))
},
)?;
let mut nodes = Vec::new();
let root_id = 0u32;
nodes.push(AstSkeletonNode {
Expand All @@ -169,17 +171,22 @@ fn find_function<'a>(
source: &[u8],
name: &str,
kinds: &[&str],
language: &str,
) -> Option<Node<'a>> {
if kinds.contains(&node.kind()) {
if let Ok(Some(n)) = extract_name_from_node(node, source) {
if n == name {
return Some(node);
let resolved = match language {
"javascript" | "js" | "typescript" | "ts" => {
ecmascript_function_symbol_name(node, source)
}
_ => extract_name_from_node(node, source).ok().flatten(),
};
if resolved.as_deref() == Some(name) {
return Some(node);
}
}
let mut cursor = node.walk();
for child in node.children(&mut cursor) {
if let Some(found) = find_function(child, source, name, kinds) {
if let Some(found) = find_function(child, source, name, kinds, language) {
return Some(found);
}
}
Expand Down
57 changes: 50 additions & 7 deletions crates/rgctl-analysis/src/cfg_builder.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ use crate::cfg::{BasicBlock, BlockId, CfgEdgeType, ControlFlowGraph, Statement,
use crate::def_use::extract_def_use;
use crate::language_profile::{function_kinds_for, parse_source};
use rgctl_error::{Error, Result};
use rgctl_plugin_helpers::extract_name_from_node;
use rgctl_plugin_helpers::{ecmascript_function_symbol_name, extract_name_from_node};
use smallvec::SmallVec;
use std::collections::{HashMap, HashSet};
use tracing::warn;
Expand All @@ -23,7 +23,7 @@ pub fn build_cfg_for_function(
let tree = parse_language(language, bytes)?;
let function_kinds = function_kinds_for(language)?;
let func_node =
find_function_by_name(tree.root_node(), bytes, function_name, function_kinds)
find_function_by_name(tree.root_node(), bytes, function_name, function_kinds, language)
.ok_or_else(|| Error::NotFound(format!("function '{function_name}' not found")))?;
build_cfg_from_function_node(language, func_node, bytes, function_name)
}
Expand Down Expand Up @@ -83,8 +83,9 @@ pub fn build_cfg_for_function_in_tree(
function_name: &str,
) -> Result<ControlFlowGraph> {
let function_kinds = function_kinds_for(language)?;
let func_node = find_function_by_name(tree.root_node(), source, function_name, function_kinds)
.ok_or_else(|| Error::NotFound(format!("function '{function_name}' not found")))?;
let func_node =
find_function_by_name(tree.root_node(), source, function_name, function_kinds, language)
.ok_or_else(|| Error::NotFound(format!("function '{function_name}' not found")))?;
build_cfg_from_function_node(language, func_node, source, function_name)
}

Expand All @@ -96,20 +97,21 @@ pub fn index_function_locations(
let tree = parse_language(language, source)?;
let function_kinds = function_kinds_for(language)?;
let mut index = HashMap::new();
collect_function_locations(tree.root_node(), source, function_kinds, &mut index);
collect_function_locations(tree.root_node(), source, function_kinds, language, &mut index);
Ok((tree, index))
}

fn collect_function_locations(
root: Node<'_>,
source: &[u8],
function_kinds: &[&str],
language: &str,
out: &mut HashMap<String, FunctionLocation>,
) {
let mut stack = vec![root];
while let Some(node) = stack.pop() {
if function_kinds.contains(&node.kind()) {
if let Ok(Some(func_name)) = extract_name_from_node(node, source) {
if let Some(func_name) = callable_name_for_cfg(node, source, language) {
out.entry(func_name).or_insert(FunctionLocation {
start_byte: node.start_byte(),
end_byte: node.end_byte(),
Expand All @@ -127,11 +129,22 @@ fn parse_language(language: &str, source: &[u8]) -> Result<Tree> {
parse_source(language, source)
}

/// Resolve the Function name CFG should look up, matching language-plugin naming.
fn callable_name_for_cfg(node: Node<'_>, source: &[u8], language: &str) -> Option<String> {
match language {
"javascript" | "js" | "typescript" | "ts" => {
ecmascript_function_symbol_name(node, source)
}
_ => extract_name_from_node(node, source).ok().flatten(),
}
}

fn find_function_by_name<'a>(
node: Node<'a>,
source: &[u8],
name: &str,
function_kinds: &[&str],
language: &str,
) -> Option<Node<'a>> {
// Java instance initializer blocks are bare `block` children of `class_body`,
// extracted as synthetic `<initblock>N` functions (not a distinct CST kind).
Expand All @@ -157,7 +170,7 @@ fn find_function_by_name<'a>(
let mut stack = vec![node];
while let Some(node) = stack.pop() {
if function_kinds.contains(&node.kind()) {
if let Ok(Some(func_name)) = extract_name_from_node(node, source) {
if let Some(func_name) = callable_name_for_cfg(node, source, language) {
if func_name == name {
return Some(node);
}
Expand Down Expand Up @@ -6026,6 +6039,36 @@ function classify(v) {
assert!(cfg.blocks.len() >= 5);
}

#[test]
fn test_typescript_bound_arrow_cfg() {
let code = r#"
export const dup = (n: number): number => n + 1;
export const uniqueOne = (n: number): number => dup(n);

export function dupDecl(n: number): number { return n + 1; }
export function uniqueDecl(n: number): number { return dupDecl(n); }
"#;
let arrow_cfg = build_cfg_for_function("typescript", code, "uniqueOne").unwrap();
assert!(
arrow_cfg.blocks.len() >= 2,
"bound arrow should have a CFG, got {} blocks",
arrow_cfg.blocks.len()
);
let decl_cfg = build_cfg_for_function("typescript", code, "uniqueDecl").unwrap();
assert!(decl_cfg.blocks.len() >= 2);
}

#[test]
fn test_javascript_bound_arrow_cfg() {
let code = r#"
const tickDown = (n) => n - 1;
function tickDecl(n) { return n - 1; }
"#;
let cfg = build_cfg_for_function("javascript", code, "tickDown").unwrap();
assert!(cfg.blocks.len() >= 2);
let _ = build_cfg_for_function("javascript", code, "tickDecl").unwrap();
}

#[test]
fn test_unsupported_language() {
let result = build_cfg_for_function("brainfuck", "+++", "main");
Expand Down
2 changes: 2 additions & 0 deletions crates/rgctl-analysis/src/language_profile.rs
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,7 @@ const PROFILES: &[LanguageAnalysisProfile] = &[
extensions: &["js", "jsx", "mjs", "cjs"],
function_kinds: &[
"function_declaration",
"function_expression",
"method_definition",
"arrow_function",
],
Expand All @@ -107,6 +108,7 @@ const PROFILES: &[LanguageAnalysisProfile] = &[
extensions: &["ts", "tsx"],
function_kinds: &[
"function_declaration",
"function_expression",
"method_definition",
"arrow_function",
],
Expand Down
37 changes: 32 additions & 5 deletions crates/rgctl-analysis/src/macro_call_lookup.rs
Original file line number Diff line number Diff line change
Expand Up @@ -92,11 +92,7 @@ pub fn parse_fqn_symbol(
let scope_hint = &input[..idx];
let target_name = &input[idx + 2..];

if scope_hint.contains('/')
|| scope_hint.contains('\\')
|| scope_hint.ends_with(".java")
|| scope_hint.ends_with(".rs")
{
if looks_like_file_scope(scope_hint) {
ParsedSymbol {
target_name: target_name.to_string(),
class_filter: explicit_class,
Expand All @@ -118,6 +114,19 @@ pub fn parse_fqn_symbol(
}
}

fn looks_like_file_scope(scope: &str) -> bool {
if scope.contains('/') || scope.contains('\\') {
return true;
}
matches!(
scope.rsplit_once('.').map(|(_, ext)| ext),
Some(
"java" | "rs" | "ts" | "tsx" | "js" | "jsx" | "mjs" | "cjs" | "py" | "go" | "php"
| "cs" | "c" | "h" | "cpp" | "cc" | "cxx" | "hpp" | "hh" | "rb"
)
)
}

/// Extract lowercase language id from graph node metadata or file extension.
pub fn language_from_node(node: &Node) -> String {
node.get_property("language")
Expand Down Expand Up @@ -829,6 +838,24 @@ mod tests {
assert_eq!(parsed.file_filter.as_deref(), Some("src/Foo.java"));
}

#[test]
fn parse_fqn_typescript_file_scope() {
let parsed = parse_fqn_symbol("fixtures/caseB_one.ts::dup", None, None);
assert_eq!(parsed.target_name, "dup");
assert_eq!(
parsed.file_filter.as_deref(),
Some("fixtures/caseB_one.ts")
);
assert!(parsed.class_filter.is_none());
}

#[test]
fn parse_fqn_bare_ts_filename_is_file_scope() {
let parsed = parse_fqn_symbol("caseB_one.ts::dup", None, None);
assert_eq!(parsed.file_filter.as_deref(), Some("caseB_one.ts"));
assert!(parsed.class_filter.is_none());
}

#[test]
fn resolve_symbol_uuid_filters_by_class() {
let candidates = vec![
Expand Down
Loading