Skip to content

Commit 289c9bb

Browse files
authored
Merge pull request #15 from Eilodon/claude/ci-feature-feasibility-kcr1ox
Claude/ci feature feasibility kcr1ox
2 parents 4a13b19 + 07e44d3 commit 289c9bb

9 files changed

Lines changed: 590 additions & 6 deletions

File tree

Cargo.lock

Lines changed: 43 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,9 @@ tree-sitter-bash = "0.25"
3737
stack-graphs = "0.14"
3838
tree-sitter-stack-graphs = "0.10"
3939
tree-sitter-stack-graphs-python = "0.3"
40+
# =0.23.2 pin (tree-sitter-typescript) comes from this crate's own Cargo.toml —
41+
# already the exact version tree-sitter-typescript resolves to workspace-wide.
42+
tree-sitter-stack-graphs-typescript = "0.4"
4043
serde = { version = "1", features = ["derive"] }
4144
serde_json = "1"
4245
toml = "0.8"

README.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -87,7 +87,8 @@ agent: "tôi cần sửa hàm getUserByEmail"
8787
xuất symbol bằng line-scan regex; không có call-graph hay import resolution — built-in, không cần
8888
feature flag.
8989
- **Call graph có độ tin cậy** — mỗi edge được gắn nhãn `resolved` / `inferred` / `formal` /
90-
`textual` tuỳ vào mức độ chắc chắn khi resolve. `formal` (Tier-3, StackGraph) hiện hỗ trợ Python.
90+
`textual` tuỳ vào mức độ chắc chắn khi resolve. `formal` (Tier-3, StackGraph) hiện hỗ trợ Python
91+
và TypeScript/TSX.
9192
- **Import graph** — file-level dependency graph cho tool `dependencies`.
9293
- **Graph metrics**`coreness` (k-core) và `is_hub` để nhận diện symbol trung tâm trước khi sửa.
9394
`repo_overview.core_symbols` dùng lại chính `coreness` này để vẽ "khung xương kiến trúc" ngay từ

crates/ci-core/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ tree-sitter-go = { workspace = true }
2525
stack-graphs = { workspace = true }
2626
tree-sitter-stack-graphs = { workspace = true }
2727
tree-sitter-stack-graphs-python = { workspace = true }
28+
tree-sitter-stack-graphs-typescript = { workspace = true }
2829
model2vec-rs = { version = "0.2.1", optional = true }
2930
tree-sitter-c = { workspace = true, optional = true }
3031
tree-sitter-cpp = { workspace = true, optional = true }

crates/ci-core/src/indexer/imports.rs

Lines changed: 89 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,13 @@ fn import_node_types(language: &str) -> &'static [&'static str] {
2727
"python" => &["import_statement", "import_from_statement"],
2828
"rust" => &["use_declaration"],
2929
"go" => &["import_spec"],
30-
"javascript" | "typescript" => &["import_statement"],
30+
// `variable_declarator` also catches CommonJS `require()` — see
31+
// `parse_js_require`. It's the same node kind `assignment_nodes()`
32+
// (resolver/lang_constants.rs) already walks for alias tracking; the
33+
// two extractions look for different shapes in the same nodes and
34+
// don't conflict (alias tracking wants a bare-identifier RHS,
35+
// `parse_js_require` wants a `require(...)` call RHS).
36+
"javascript" | "typescript" => &["import_statement", "variable_declarator"],
3137
"java" => &["import_declaration"],
3238
_ => &[],
3339
}
@@ -199,6 +205,10 @@ fn parse_go_import(text: &str) -> Option<ParsedImport> {
199205
}
200206

201207
fn parse_js_import(text: &str) -> Option<ParsedImport> {
208+
parse_js_esm_import(text).or_else(|| parse_js_require(text))
209+
}
210+
211+
fn parse_js_esm_import(text: &str) -> Option<ParsedImport> {
202212
// import { a, b as c } from 'mod'; import x from 'mod'; import * as ns from 'mod';
203213
let (clause, module) = text.split_once(" from ")?;
204214
let module = module
@@ -227,6 +237,47 @@ fn parse_js_import(text: &str) -> Option<ParsedImport> {
227237
})
228238
}
229239

240+
/// CommonJS `require()`, still common in real Node.js code (older packages,
241+
/// TypeScript compiled to CommonJS) but structurally a call expression, not
242+
/// an `import_statement` — this is fed `variable_declarator` text instead
243+
/// (`NAME = require(...)` or `{ a, b as c } = require(...)`, no trailing
244+
/// `;`, no `const`/`let`/`var` keyword — that's the parent node).
245+
///
246+
/// Only a literal string argument resolves to a module — `require(path)`
247+
/// with a computed argument can't be statically attributed, so it's left
248+
/// unresolved (`None`) rather than guessed at.
249+
fn parse_js_require(text: &str) -> Option<ParsedImport> {
250+
let (lhs, rhs) = text.split_once('=')?;
251+
let after_require = rhs.trim().strip_prefix("require(")?.trim_start();
252+
let quote = after_require.chars().next()?;
253+
if quote != '"' && quote != '\'' {
254+
return None;
255+
}
256+
let rest = &after_require[quote.len_utf8()..];
257+
let end = rest.find(quote)?;
258+
let module = rest[..end].to_string();
259+
if module.is_empty() {
260+
return None;
261+
}
262+
263+
let lhs = lhs.trim();
264+
let mut names = Vec::new();
265+
if let Some(start) = lhs.find('{')
266+
&& let Some(end) = lhs.find('}')
267+
{
268+
for seg in lhs[start + 1..end].split(',') {
269+
names.extend(bound_name(seg));
270+
}
271+
} else {
272+
names.extend(ident(lhs));
273+
}
274+
275+
Some(ParsedImport {
276+
module_name: module,
277+
imported_names: names,
278+
})
279+
}
280+
230281
fn parse_java_import(text: &str) -> Option<ParsedImport> {
231282
// import a.b.C; import static a.b.C.m; import a.b.*;
232283
let rest = text
@@ -299,6 +350,43 @@ mod tests {
299350
assert_eq!(i.imported_names, vec!["a", "c"]);
300351
}
301352

353+
#[test]
354+
fn js_require_default() {
355+
let i = one("const foo = require('./foo');\n", "javascript");
356+
assert_eq!(i.module_name, "./foo");
357+
assert_eq!(i.imported_names, vec!["foo"]);
358+
}
359+
360+
#[test]
361+
fn ts_require_double_quoted() {
362+
let i = one("const foo = require(\"./foo\");\n", "typescript");
363+
assert_eq!(i.module_name, "./foo");
364+
assert_eq!(i.imported_names, vec!["foo"]);
365+
}
366+
367+
#[test]
368+
fn js_require_destructure() {
369+
let i = one("const { a, b: c } = require('./mod');\n", "javascript");
370+
assert_eq!(i.module_name, "./mod");
371+
assert_eq!(i.imported_names, vec!["a", "c"]);
372+
}
373+
374+
/// A computed argument can't be statically attributed to a module —
375+
/// must not guess (see `parse_js_require`'s literal-only contract).
376+
#[test]
377+
fn js_require_with_computed_path_yields_no_import() {
378+
let v = extract_imports("const x = require(somePath);\n", "javascript");
379+
assert!(v.is_empty(), "expected no import, got {v:?}");
380+
}
381+
382+
/// A plain (non-`require`) assignment must not be mistaken for an
383+
/// import now that `variable_declarator` is walked for JS/TS.
384+
#[test]
385+
fn js_plain_assignment_yields_no_import() {
386+
let v = extract_imports("const x = 5;\n", "javascript");
387+
assert!(v.is_empty(), "expected no import, got {v:?}");
388+
}
389+
302390
#[test]
303391
fn java_import() {
304392
let i = one("import a.b.C;\n", "java");

crates/ci-core/src/indexer/pipeline.rs

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -658,6 +658,7 @@ pub fn run_indexing_pipeline(
658658
// ConservativeResolver only.
659659
let mut formal = crate::resolver::formal::FormalResolver::new();
660660
let _ = formal.load_python(); // non-fatal: falls back silently on error
661+
let _ = formal.load_typescript(); // non-fatal: falls back silently on error
661662

662663
let mut files = Vec::new();
663664
collect_source_files(project_root, &ignore_patterns, &mut files);
@@ -729,6 +730,7 @@ pub fn reindex_changed(
729730

730731
let mut formal = crate::resolver::formal::FormalResolver::new();
731732
let _ = formal.load_python();
733+
let _ = formal.load_typescript();
732734

733735
let existing: HashMap<String, String> = {
734736
let mut stmt = conn.prepare("SELECT path, hash FROM file_index")?;
@@ -1117,6 +1119,58 @@ mod tests {
11171119
let _ = std::fs::remove_dir_all(&dir);
11181120
}
11191121

1122+
/// CommonJS `require()` (real Node.js code, not just ES `import`) must
1123+
/// feed `import_map`/`import_edges` exactly like `import ... from ...`
1124+
/// does — see `indexer::imports::parse_js_require`.
1125+
#[test]
1126+
fn test_commonjs_require_cross_file_resolved_confidence() {
1127+
let dir = std::env::temp_dir().join(format!("ci_idx_require_{}", std::process::id()));
1128+
let _ = std::fs::remove_dir_all(&dir);
1129+
std::fs::create_dir_all(&dir).unwrap();
1130+
std::fs::write(
1131+
dir.join("helper.js"),
1132+
"function helper() {}\nmodule.exports = { helper };\n",
1133+
)
1134+
.unwrap();
1135+
std::fs::write(
1136+
dir.join("main.js"),
1137+
"const { helper } = require('./helper');\n\nfunction run() {\n helper();\n}\n",
1138+
)
1139+
.unwrap();
1140+
1141+
let mut conn = Connection::open_in_memory().unwrap();
1142+
init_db(&conn).unwrap();
1143+
run_indexing_pipeline(&mut conn, &dir, dummy_phase()).unwrap();
1144+
1145+
let (to_path, module): (String, String) = conn
1146+
.query_row(
1147+
"SELECT COALESCE(to_path,''), module_name FROM import_edges WHERE from_path = 'main.js'",
1148+
[],
1149+
|r| Ok((r.get(0)?, r.get(1)?)),
1150+
)
1151+
.unwrap();
1152+
assert_eq!(module, "./helper");
1153+
assert_eq!(
1154+
to_path, "helper.js",
1155+
"require() target resolved to in-project file"
1156+
);
1157+
1158+
let confidence: String = conn
1159+
.query_row(
1160+
"SELECT edge_confidence FROM call_edges \
1161+
WHERE from_symbol = 'main.js::run' AND to_symbol = 'helper.js::helper'",
1162+
[],
1163+
|r| r.get(0),
1164+
)
1165+
.unwrap();
1166+
assert_eq!(
1167+
confidence, "resolved",
1168+
"call through require() should be resolved, not textual"
1169+
);
1170+
1171+
let _ = std::fs::remove_dir_all(&dir);
1172+
}
1173+
11201174
/// Regression: `Type::method()` (a scoped-path call, no `.` receiver) must
11211175
/// resolve *only* against `Type`, not fan out to every same-named symbol
11221176
/// project-wide. Two structs (`StructA`, `StructB`) each define `fn new()`;

0 commit comments

Comments
 (0)