codingest 0.1.5__tar.gz → 0.1.6__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (84) hide show
  1. {codingest-0.1.5 → codingest-0.1.6}/Cargo.lock +4 -4
  2. {codingest-0.1.5 → codingest-0.1.6}/Cargo.toml +1 -1
  3. {codingest-0.1.5 → codingest-0.1.6}/PKG-INFO +1 -1
  4. {codingest-0.1.5 → codingest-0.1.6}/crates/codingest/src/bin/codingest_bench.rs +5 -0
  5. {codingest-0.1.5 → codingest-0.1.6}/crates/codingest/src/bin/codingest_stats.rs +166 -24
  6. {codingest-0.1.5 → codingest-0.1.6}/crates/codingest/src/builder/call_edges.rs +194 -19
  7. {codingest-0.1.5 → codingest-0.1.6}/crates/codingest/src/builder/load/entity_frames.rs +34 -1
  8. {codingest-0.1.5 → codingest-0.1.6}/crates/codingest/src/builder/other_edges.rs +140 -15
  9. {codingest-0.1.5 → codingest-0.1.6}/crates/codingest/src/builder/routes/django.rs +136 -11
  10. {codingest-0.1.5 → codingest-0.1.6}/crates/codingest/src/docs/mod.rs +255 -29
  11. {codingest-0.1.5 → codingest-0.1.6}/crates/codingest/src/parsers/python.rs +726 -2
  12. {codingest-0.1.5 → codingest-0.1.6}/crates/codingest/src/parsers/shared.rs +88 -1
  13. codingest-0.1.6/crates/codingest/src/parsers/typescript.rs +2764 -0
  14. codingest-0.1.6/crates/codingest/tests/goldens/README.md +146 -0
  15. codingest-0.1.6/crates/codingest/tests/goldens/docs_mdx.sha256 +1 -0
  16. codingest-0.1.6/crates/codingest/tests/goldens/py_nested_defs.sha256 +1 -0
  17. codingest-0.1.6/crates/codingest/tests/goldens/ts_closure_scope.sha256 +1 -0
  18. codingest-0.1.6/crates/codingest/tests/goldens/ts_hof_binding.sha256 +1 -0
  19. {codingest-0.1.5 → codingest-0.1.6}/crates/codingest/tests/parity.rs +42 -0
  20. {codingest-0.1.5 → codingest-0.1.6}/crates/codingest-cli/Cargo.toml +1 -1
  21. {codingest-0.1.5 → codingest-0.1.6}/crates/codingest-mcp/Cargo.toml +1 -1
  22. {codingest-0.1.5 → codingest-0.1.6}/crates/codingest-py/Cargo.toml +3 -3
  23. codingest-0.1.5/crates/codingest/src/parsers/typescript.rs +0 -1281
  24. codingest-0.1.5/crates/codingest/tests/goldens/README.md +0 -80
  25. {codingest-0.1.5 → codingest-0.1.6}/LICENSE +0 -0
  26. {codingest-0.1.5 → codingest-0.1.6}/README.md +0 -0
  27. {codingest-0.1.5 → codingest-0.1.6}/codingest/__init__.py +0 -0
  28. {codingest-0.1.5 → codingest-0.1.6}/codingest/__init__.pyi +0 -0
  29. {codingest-0.1.5 → codingest-0.1.6}/codingest/cli.py +0 -0
  30. {codingest-0.1.5 → codingest-0.1.6}/codingest/mcp_server.py +0 -0
  31. {codingest-0.1.5 → codingest-0.1.6}/crates/codingest/Cargo.toml +0 -0
  32. {codingest-0.1.5 → codingest-0.1.6}/crates/codingest/src/builder/js_workspace.rs +0 -0
  33. {codingest-0.1.5 → codingest-0.1.6}/crates/codingest/src/builder/load/edge_frames.rs +0 -0
  34. {codingest-0.1.5 → codingest-0.1.6}/crates/codingest/src/builder/load.rs +0 -0
  35. {codingest-0.1.5 → codingest-0.1.6}/crates/codingest/src/builder/mod.rs +0 -0
  36. {codingest-0.1.5 → codingest-0.1.6}/crates/codingest/src/builder/routes/fastapi.rs +0 -0
  37. {codingest-0.1.5 → codingest-0.1.6}/crates/codingest/src/builder/routes/flask.rs +0 -0
  38. {codingest-0.1.5 → codingest-0.1.6}/crates/codingest/src/builder/routes/mod.rs +0 -0
  39. {codingest-0.1.5 → codingest-0.1.6}/crates/codingest/src/builder/semantic_edges.rs +0 -0
  40. {codingest-0.1.5 → codingest-0.1.6}/crates/codingest/src/builder/type_edges.rs +0 -0
  41. {codingest-0.1.5 → codingest-0.1.6}/crates/codingest/src/cross_lang.rs +0 -0
  42. {codingest-0.1.5 → codingest-0.1.6}/crates/codingest/src/docs/rst.rs +0 -0
  43. {codingest-0.1.5 → codingest-0.1.6}/crates/codingest/src/lib.rs +0 -0
  44. {codingest-0.1.5 → codingest-0.1.6}/crates/codingest/src/manifest/mod.rs +0 -0
  45. {codingest-0.1.5 → codingest-0.1.6}/crates/codingest/src/models.rs +0 -0
  46. {codingest-0.1.5 → codingest-0.1.6}/crates/codingest/src/parsers/agc.rs +0 -0
  47. {codingest-0.1.5 → codingest-0.1.6}/crates/codingest/src/parsers/cpp.rs +0 -0
  48. {codingest-0.1.5 → codingest-0.1.6}/crates/codingest/src/parsers/csharp.rs +0 -0
  49. {codingest-0.1.5 → codingest-0.1.6}/crates/codingest/src/parsers/css.rs +0 -0
  50. {codingest-0.1.5 → codingest-0.1.6}/crates/codingest/src/parsers/dart.rs +0 -0
  51. {codingest-0.1.5 → codingest-0.1.6}/crates/codingest/src/parsers/go.rs +0 -0
  52. {codingest-0.1.5 → codingest-0.1.6}/crates/codingest/src/parsers/html.rs +0 -0
  53. {codingest-0.1.5 → codingest-0.1.6}/crates/codingest/src/parsers/java.rs +0 -0
  54. {codingest-0.1.5 → codingest-0.1.6}/crates/codingest/src/parsers/mod.rs +0 -0
  55. {codingest-0.1.5 → codingest-0.1.6}/crates/codingest/src/parsers/php.rs +0 -0
  56. {codingest-0.1.5 → codingest-0.1.6}/crates/codingest/src/parsers/registry.rs +0 -0
  57. {codingest-0.1.5 → codingest-0.1.6}/crates/codingest/src/parsers/rust_lang.rs +0 -0
  58. {codingest-0.1.5 → codingest-0.1.6}/crates/codingest/src/parsers/swift.rs +0 -0
  59. {codingest-0.1.5 → codingest-0.1.6}/crates/codingest/src/repo.rs +0 -0
  60. {codingest-0.1.5 → codingest-0.1.6}/crates/codingest/src/rev.rs +0 -0
  61. {codingest-0.1.5 → codingest-0.1.6}/crates/codingest/tests/agc_apollo.rs +0 -0
  62. {codingest-0.1.5 → codingest-0.1.6}/crates/codingest/tests/goldens/agc_basic.sha256 +0 -0
  63. {codingest-0.1.5 → codingest-0.1.6}/crates/codingest/tests/goldens/cross_ts_py.sha256 +0 -0
  64. {codingest-0.1.5 → codingest-0.1.6}/crates/codingest/tests/goldens/dup_minified_assets.sha256 +0 -0
  65. {codingest-0.1.5 → codingest-0.1.6}/crates/codingest/tests/goldens/py_basic.sha256 +0 -0
  66. {codingest-0.1.5 → codingest-0.1.6}/crates/codingest/tests/goldens/py_inheritance.sha256 +0 -0
  67. {codingest-0.1.5 → codingest-0.1.6}/crates/codingest/tests/goldens/rust_xfile.sha256 +0 -0
  68. {codingest-0.1.5 → codingest-0.1.6}/crates/codingest/tests/goldens/ts_callback.sha256 +0 -0
  69. {codingest-0.1.5 → codingest-0.1.6}/crates/codingest/tests/goldens/ts_monorepo.sha256 +0 -0
  70. {codingest-0.1.5 → codingest-0.1.6}/crates/codingest-cli/skills/codingest-code-review/SKILL.md +0 -0
  71. {codingest-0.1.5 → codingest-0.1.6}/crates/codingest-cli/skills/codingest-code-review/references/mcp-upgrade.md +0 -0
  72. {codingest-0.1.5 → codingest-0.1.6}/crates/codingest-cli/skills/codingest-code-review/references/public-repositories.md +0 -0
  73. {codingest-0.1.5 → codingest-0.1.6}/crates/codingest-cli/skills/codingest-code-review/references/queries.md +0 -0
  74. {codingest-0.1.5 → codingest-0.1.6}/crates/codingest-cli/src/code_tree_cli.rs +0 -0
  75. {codingest-0.1.5 → codingest-0.1.6}/crates/codingest-cli/src/lib.rs +0 -0
  76. {codingest-0.1.5 → codingest-0.1.6}/crates/codingest-cli/src/main.rs +0 -0
  77. {codingest-0.1.5 → codingest-0.1.6}/crates/codingest-cli/src/query.rs +0 -0
  78. {codingest-0.1.5 → codingest-0.1.6}/crates/codingest-cli/src/skill.rs +0 -0
  79. {codingest-0.1.5 → codingest-0.1.6}/crates/codingest-cli/src/skill_assets.rs +0 -0
  80. {codingest-0.1.5 → codingest-0.1.6}/crates/codingest-cli/tests/exit_codes.rs +0 -0
  81. {codingest-0.1.5 → codingest-0.1.6}/crates/codingest-mcp/src/lib.rs +0 -0
  82. {codingest-0.1.5 → codingest-0.1.6}/crates/codingest-mcp/src/main.rs +0 -0
  83. {codingest-0.1.5 → codingest-0.1.6}/crates/codingest-py/src/lib.rs +0 -0
  84. {codingest-0.1.5 → codingest-0.1.6}/pyproject.toml +0 -0
@@ -313,7 +313,7 @@ dependencies = [
313
313
 
314
314
  [[package]]
315
315
  name = "codingest"
316
- version = "0.1.5"
316
+ version = "0.1.6"
317
317
  dependencies = [
318
318
  "aho-corasick",
319
319
  "base64",
@@ -345,7 +345,7 @@ dependencies = [
345
345
 
346
346
  [[package]]
347
347
  name = "codingest-cli"
348
- version = "0.1.5"
348
+ version = "0.1.6"
349
349
  dependencies = [
350
350
  "anyhow",
351
351
  "clap",
@@ -357,7 +357,7 @@ dependencies = [
357
357
 
358
358
  [[package]]
359
359
  name = "codingest-mcp"
360
- version = "0.1.5"
360
+ version = "0.1.6"
361
361
  dependencies = [
362
362
  "anyhow",
363
363
  "codingest",
@@ -366,7 +366,7 @@ dependencies = [
366
366
 
367
367
  [[package]]
368
368
  name = "codingest-py"
369
- version = "0.1.5"
369
+ version = "0.1.6"
370
370
  dependencies = [
371
371
  "codingest",
372
372
  "codingest-cli",
@@ -3,7 +3,7 @@ members = ["crates/codingest", "crates/codingest-cli", "crates/codingest-mcp", "
3
3
  resolver = "2"
4
4
 
5
5
  [workspace.package]
6
- version = "0.1.5"
6
+ version = "0.1.6"
7
7
  edition = "2021"
8
8
  rust-version = "1.88"
9
9
  license = "MIT"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: codingest
3
- Version: 0.1.5
3
+ Version: 0.1.6
4
4
  Classifier: Development Status :: 3 - Alpha
5
5
  Classifier: Intended Audience :: Developers
6
6
  Classifier: Programming Language :: Python :: 3
@@ -486,6 +486,11 @@ fn main() {
486
486
  "corpus_files": corpus.sha256.as_ref().map(|_| corpus.files),
487
487
  "corpus_bytes": corpus.sha256.as_ref().map(|_| corpus.bytes),
488
488
  "corpus_sha256": corpus.sha256,
489
+ // Fixed build configuration, echoed so a recorded row states it.
490
+ // Unlike codingest_stats, this harness has always built docs-ON,
491
+ // so the docs pass IS in its build times and node counts.
492
+ "include_tests": false,
493
+ "include_docs": true,
489
494
  "nodes": nodes,
490
495
  "edges": edges,
491
496
  "build_a_secs": (build_a_secs * 1000.0).round() / 1000.0,
@@ -15,10 +15,21 @@
15
15
  //! Usage:
16
16
  //! cargo run -p codingest --bin codingest_stats --release -- <path>
17
17
  //! cargo run -p codingest --bin codingest_stats --release -- <path> --include-tests
18
+ //! cargo run -p codingest --bin codingest_stats --release -- <path> --include-docs
18
19
  //! cargo run -p codingest --bin codingest_stats --release -- <path> --function-metrics
19
20
  //! cargo run -p codingest --bin codingest_stats --release -- <path> --edge-breakdown
20
21
  //! cargo run -p codingest --bin codingest_stats --release -- <path> --dump-calls a,b,c
21
22
  //!
23
+ //! `--include-docs` turns the docs pass on. It is **off by default**, which is
24
+ //! the configuration every historical row in `dev-docs/bench/results/` was
25
+ //! measured at, so the default is the comparable one; without the flag `:Doc`,
26
+ //! `:MENTIONS` and `:DOCUMENTS` are structurally absent and no docs-pass
27
+ //! regression — in node counts or in build time — can be seen here. Both the
28
+ //! tests and the docs configuration are echoed into the emitted JSON so a
29
+ //! recorded row states the configuration it was taken under, and an unknown
30
+ //! flag is a usage error rather than a silently-ignored typo that would report
31
+ //! a measurement under a mode it never ran in.
32
+ //!
22
33
  //! `--edge-breakdown` appends a per-connection-type edge histogram to the JSON
23
34
  //! object, with `IMPORTS` split by endpoint node type (`IMPORTS(File->File)`
24
35
  //! vs `IMPORTS(File->Module)`) — the two are the same connection type but
@@ -231,37 +242,76 @@ fn dump_calls(graph: &DirGraph, names: &BTreeSet<String>) -> Vec<CallRow> {
231
242
  rows
232
243
  }
233
244
 
245
+ const USAGE: &str = "usage: codingest_stats <path> [--include-tests] [--include-docs] \
246
+ [--function-metrics] [--edge-breakdown] [--dump-calls name1,name2,...]";
247
+
248
+ /// Everything the command line can say, parsed once so the configuration a run
249
+ /// was taken under is a value that can be echoed and tested rather than a
250
+ /// scatter of `any(|a| a == …)` predicates.
251
+ #[derive(Debug, Default, PartialEq, Eq)]
252
+ struct Options {
253
+ include_tests: bool,
254
+ include_docs: bool,
255
+ function_metrics: bool,
256
+ edge_breakdown: bool,
257
+ dump_calls: Option<BTreeSet<String>>,
258
+ }
259
+
260
+ /// Parse the arguments after `<path>`. Rejects anything unrecognised: a
261
+ /// typo'd `--include-dcos` must not report a docs-off measurement as a docs-on
262
+ /// one, which is the same reason `codingest_bench` rejects unknown flags.
263
+ fn parse_options(args: &[String]) -> Result<Options, String> {
264
+ let mut out = Options::default();
265
+ let mut i = 0;
266
+ while i < args.len() {
267
+ match args[i].as_str() {
268
+ "--include-tests" => out.include_tests = true,
269
+ "--include-docs" => out.include_docs = true,
270
+ "--function-metrics" => out.function_metrics = true,
271
+ "--edge-breakdown" => out.edge_breakdown = true,
272
+ "--dump-calls" => {
273
+ let list = args.get(i + 1).ok_or_else(|| {
274
+ "--dump-calls requires a comma-separated name list".to_string()
275
+ })?;
276
+ out.dump_calls = Some(
277
+ list.split(',')
278
+ .map(str::trim)
279
+ .filter(|s| !s.is_empty())
280
+ .map(str::to_string)
281
+ .collect(),
282
+ );
283
+ i += 1;
284
+ }
285
+ other => return Err(format!("unknown argument `{other}`")),
286
+ }
287
+ i += 1;
288
+ }
289
+ Ok(out)
290
+ }
291
+
234
292
  fn main() {
235
293
  let mut args = std::env::args().skip(1);
236
294
  let path = match args.next() {
237
295
  Some(p) => p,
238
296
  None => {
239
- eprintln!(
240
- "usage: codingest_stats <path> [--include-tests] [--function-metrics] \
241
- [--edge-breakdown] [--dump-calls name1,name2,...]"
242
- );
297
+ eprintln!("{USAGE}");
243
298
  std::process::exit(2);
244
299
  }
245
300
  };
246
- let options: Vec<_> = args.collect();
247
- let include_tests = options.iter().any(|a| a == "--include-tests");
248
- let emit_function_metrics = options.iter().any(|a| a == "--function-metrics");
249
- let emit_edge_breakdown = options.iter().any(|a| a == "--edge-breakdown");
250
- let dump_call_names: Option<BTreeSet<String>> = options
251
- .iter()
252
- .position(|a| a == "--dump-calls")
253
- .map(|i| match options.get(i + 1) {
254
- Some(list) => list
255
- .split(',')
256
- .map(str::trim)
257
- .filter(|s| !s.is_empty())
258
- .map(str::to_string)
259
- .collect(),
260
- None => {
261
- eprintln!("--dump-calls requires a comma-separated name list");
262
- std::process::exit(2);
263
- }
264
- });
301
+ let rest: Vec<String> = args.collect();
302
+ let options = match parse_options(&rest) {
303
+ Ok(options) => options,
304
+ Err(e) => {
305
+ eprintln!("{e}");
306
+ eprintln!("{USAGE}");
307
+ std::process::exit(2);
308
+ }
309
+ };
310
+ let include_tests = options.include_tests;
311
+ let include_docs = options.include_docs;
312
+ let emit_function_metrics = options.function_metrics;
313
+ let emit_edge_breakdown = options.edge_breakdown;
314
+ let dump_call_names = options.dump_calls.clone();
265
315
 
266
316
  let t = Instant::now();
267
317
  let (graph, stats) = match codingest::builder::run_with_options_stats(
@@ -270,7 +320,7 @@ fn main() {
270
320
  include_tests,
271
321
  None,
272
322
  None,
273
- false,
323
+ include_docs,
274
324
  ) {
275
325
  Ok(pair) => pair,
276
326
  Err(e) => {
@@ -306,6 +356,7 @@ fn main() {
306
356
  let mut out = serde_json::json!({
307
357
  "path": path,
308
358
  "include_tests": include_tests,
359
+ "include_docs": include_docs,
309
360
  "build_secs": (build_secs * 1000.0).round() / 1000.0,
310
361
  "nodes": graph.graph.node_count(),
311
362
  "edges": graph.graph.edge_count(),
@@ -343,6 +394,97 @@ mod tests {
343
394
  .expect("corpus build")
344
395
  }
345
396
 
397
+ fn opts(args: &[&str]) -> Result<Options, String> {
398
+ parse_options(&args.iter().map(|a| a.to_string()).collect::<Vec<_>>())
399
+ }
400
+
401
+ #[test]
402
+ fn parse_options_defaults_to_the_historical_configuration() {
403
+ // Every row in dev-docs/bench/results/ predating --include-docs was
404
+ // measured tests-off/docs-off; the default must stay that way or old
405
+ // and new rows silently stop being comparable.
406
+ let parsed = opts(&[]).expect("empty argv parses");
407
+ assert_eq!(parsed, Options::default());
408
+ assert!(!parsed.include_tests);
409
+ assert!(!parsed.include_docs);
410
+ }
411
+
412
+ #[test]
413
+ fn parse_options_reads_every_flag_including_docs() {
414
+ let parsed = opts(&[
415
+ "--include-tests",
416
+ "--include-docs",
417
+ "--edge-breakdown",
418
+ "--function-metrics",
419
+ "--dump-calls",
420
+ "run, helper ,,other",
421
+ ])
422
+ .expect("all flags parse");
423
+ assert!(parsed.include_tests);
424
+ assert!(parsed.include_docs);
425
+ assert!(parsed.edge_breakdown);
426
+ assert!(parsed.function_metrics);
427
+ assert_eq!(
428
+ parsed.dump_calls,
429
+ Some(BTreeSet::from([
430
+ "run".to_string(),
431
+ "helper".to_string(),
432
+ "other".to_string()
433
+ ])),
434
+ "the name list is split, trimmed and empties dropped"
435
+ );
436
+ }
437
+
438
+ #[test]
439
+ fn parse_options_rejects_an_unknown_flag_but_not_a_dump_calls_value() {
440
+ // A typo must be a usage error: silently ignoring it would report a
441
+ // measurement under a configuration the run never used.
442
+ assert_eq!(
443
+ opts(&["--include-dcos"]),
444
+ Err("unknown argument `--include-dcos`".to_string())
445
+ );
446
+ assert_eq!(
447
+ opts(&["--dump-calls"]),
448
+ Err("--dump-calls requires a comma-separated name list".to_string())
449
+ );
450
+ // The value that follows --dump-calls is consumed, not re-inspected as
451
+ // a flag — even when it looks like one.
452
+ let parsed = opts(&["--dump-calls", "--include-docs", "--include-tests"])
453
+ .expect("the value after --dump-calls is a value");
454
+ assert!(!parsed.include_docs, "the value was not read as a flag");
455
+ assert!(parsed.include_tests);
456
+ }
457
+
458
+ #[test]
459
+ fn include_docs_is_what_makes_doc_nodes_visible() {
460
+ // The gap this flag closes: with the docs pass off — the hard-coded
461
+ // behaviour before it existed — a :Doc node cannot appear in any
462
+ // codingest_stats number, so no docs-pass regression can fail a gate.
463
+ fn doc_nodes(include_docs: bool) -> usize {
464
+ let graph = codingest::builder::run_with_options(
465
+ &corpus("docs_mdx"),
466
+ false,
467
+ true,
468
+ None,
469
+ None,
470
+ include_docs,
471
+ )
472
+ .expect("corpus build");
473
+ let params = HashMap::new();
474
+ let options = session::ExecuteOptions::eager(&params);
475
+ session::execute_read(&graph, "MATCH (d:Doc) RETURN d.name", &options)
476
+ .expect("query docs")
477
+ .result
478
+ .rows
479
+ .len()
480
+ }
481
+ assert_eq!(doc_nodes(false), 0, "docs off means no Doc node exists");
482
+ assert!(
483
+ doc_nodes(true) > 0,
484
+ "docs on must surface the docs_mdx corpus's Doc nodes"
485
+ );
486
+ }
487
+
346
488
  #[test]
347
489
  fn short_name_takes_the_rightmost_separator_of_any_flavour() {
348
490
  assert_eq!(short_name("pkg.util.helper"), "helper");
@@ -229,6 +229,41 @@ fn qname_starts_with_any(qname: &str, scopes: &[String]) -> bool {
229
229
  false
230
230
  }
231
231
 
232
+ /// D3: a closure-scoped definition — one the parser tagged with a non-zero
233
+ /// `nesting_depth` — is lexically reachable only inside its enclosing scope
234
+ /// unless it escapes. Every bare-name index over `functions` has to keep it
235
+ /// out of the global tier and offer it to its own file only.
236
+ pub(super) fn is_nested_function(function: &FunctionInfo) -> bool {
237
+ function
238
+ .metadata
239
+ .get("nesting_depth")
240
+ .and_then(|value| value.as_u64())
241
+ .is_some_and(|depth| depth > 0)
242
+ }
243
+
244
+ /// D3 candidate assembly: the globally visible targets for a call name plus
245
+ /// the nested (closure-scoped) targets declared in the *caller's own file*.
246
+ ///
247
+ /// Borrows both sides untouched in the common case — `storage` is filled only
248
+ /// when a name has candidates on both sides, which is rare.
249
+ pub(super) fn merge_candidates<'a, 'b>(
250
+ global: Option<&'b Vec<&'a str>>,
251
+ local: Option<&'b Vec<&'a str>>,
252
+ storage: &'b mut Vec<&'a str>,
253
+ ) -> &'b [&'a str] {
254
+ match (global, local) {
255
+ (Some(g), None) => g.as_slice(),
256
+ (None, Some(l)) => l.as_slice(),
257
+ (None, None) => &[],
258
+ (Some(g), Some(l)) => {
259
+ storage.reserve(g.len() + l.len());
260
+ storage.extend_from_slice(g);
261
+ storage.extend_from_slice(l);
262
+ storage.as_slice()
263
+ }
264
+ }
265
+ }
266
+
232
267
  fn infer_lang_group(qname: &str) -> &'static str {
233
268
  if qname.contains("::") {
234
269
  "rust_cpp"
@@ -262,29 +297,62 @@ pub fn build_call_edges(
262
297
  ) -> (Vec<CallEdge>, CallResolutionStats) {
263
298
  let verbose = std::env::var_os("KGLITE_CODE_TREE_VERBOSE").is_some();
264
299
  let t0 = std::time::Instant::now();
265
- // Bare name → every qualified_name that matches.
300
+ // D3 — a closure-scoped definition (`nesting_depth > 0`) is lexically
301
+ // callable only inside its enclosing scope unless it escapes. It
302
+ // therefore never joins the global lookups; it is offered only to
303
+ // callers in its own file. Without this the ~2 270 nested names opencode
304
+ // gains would flood the bare-name index: the Phase 1 spike measured
305
+ // multi-candidate names going 664 → 1 562 and 293 currently-unique names
306
+ // turning ambiguous, which is exactly the false-positive class v0.1.5's
307
+ // resolution metadata was built to keep out. Escape analysis (which
308
+ // nested functions really do leak cross-file) is the deliberate
309
+ // non-goal; same-file is the conservative approximation. The predicate
310
+ // itself is `is_nested_function` above — `build_references_fn_edges` and
311
+ // `build_decorates_edges` are bare-name indexes over the same population
312
+ // and gate on it too.
313
+ let is_nested = is_nested_function;
314
+
315
+ // Bare name → every qualified_name that matches. Top-level definitions
316
+ // only; nested ones go to `nested_names` below, keyed by file.
266
317
  let mut name_lookup: HashMap<&str, Vec<&str>> = HashMap::new();
318
+ let mut nested_names: HashMap<&str, HashMap<&str, Vec<&str>>> = HashMap::new();
267
319
  for fn_info in functions {
268
- name_lookup
269
- .entry(fn_info.name.as_str())
270
- .or_default()
271
- .push(fn_info.qualified_name.as_str());
320
+ let target = if is_nested(fn_info) {
321
+ nested_names
322
+ .entry(fn_info.file_path.as_str())
323
+ .or_default()
324
+ .entry(fn_info.name.as_str())
325
+ .or_default()
326
+ } else {
327
+ name_lookup.entry(fn_info.name.as_str()).or_default()
328
+ };
329
+ target.push(fn_info.qualified_name.as_str());
272
330
  }
273
331
  // Exact qualified call text → one ordinary function or every overload in
274
332
  // that same-scope group. Parser call sites keep the source-level base ID,
275
333
  // while graph identities carry a signature discriminator.
276
334
  let mut qualified_lookup: HashMap<&str, Vec<&str>> = HashMap::new();
335
+ let mut nested_qualified: HashMap<&str, HashMap<&str, Vec<&str>>> = HashMap::new();
277
336
  for function in functions {
278
337
  let qualified_name = function.qualified_name.as_str();
279
- qualified_lookup
280
- .entry(qualified_name)
281
- .or_default()
282
- .push(qualified_name);
338
+ let mut keys: Vec<&str> = vec![qualified_name];
283
339
  if let Some(base) = super::overload_base_qualified_name(qualified_name) {
284
- qualified_lookup
285
- .entry(base)
286
- .or_default()
287
- .push(qualified_name);
340
+ keys.push(base);
341
+ }
342
+ for key in keys {
343
+ if is_nested(function) {
344
+ nested_qualified
345
+ .entry(function.file_path.as_str())
346
+ .or_default()
347
+ .entry(key)
348
+ .or_default()
349
+ .push(qualified_name);
350
+ } else {
351
+ qualified_lookup
352
+ .entry(key)
353
+ .or_default()
354
+ .push(qualified_name);
355
+ }
288
356
  }
289
357
  }
290
358
 
@@ -348,6 +416,10 @@ pub fn build_call_edges(
348
416
  let caller_prefix = qname_to_prefix.get(caller_qn).copied();
349
417
  let caller_owner = qname_to_owner.get(caller_qn).copied();
350
418
  let caller_file = fn_info.file_path.as_str();
419
+ // D3: the nested definitions this caller may see — its own file's,
420
+ // and nothing else's.
421
+ let local_names = nested_names.get(caller_file);
422
+ let local_qualified = nested_qualified.get(caller_file);
351
423
 
352
424
  let mut out: Vec<(&str, &str, u32, Resolution, usize)> = Vec::new();
353
425
  let mut counts = Counts::default();
@@ -359,16 +431,22 @@ pub fn build_call_edges(
359
431
  counts.excluded += 1;
360
432
  continue;
361
433
  }
362
- if let Some(targets) = qualified_lookup.get(called_name.as_str()) {
434
+ let mut exact_storage: Vec<&str> = Vec::new();
435
+ let exact: &[&str] = merge_candidates(
436
+ qualified_lookup.get(called_name.as_str()),
437
+ local_qualified.and_then(|m| m.get(called_name.as_str())),
438
+ &mut exact_storage,
439
+ );
440
+ if !exact.is_empty() {
363
441
  counts.resolved += 1;
364
- for &target in targets {
442
+ for &target in exact {
365
443
  if target != caller_qn {
366
444
  out.push((
367
445
  caller_qn,
368
446
  target,
369
447
  *line,
370
448
  Resolution::ExactQualified,
371
- targets.len(),
449
+ exact.len(),
372
450
  ));
373
451
  }
374
452
  }
@@ -379,10 +457,16 @@ pub fn build_call_edges(
379
457
  None => (None, called_name.as_str()),
380
458
  };
381
459
 
382
- let Some(candidates) = name_lookup.get(method_name) else {
460
+ let mut name_storage: Vec<&str> = Vec::new();
461
+ let candidates: &[&str] = merge_candidates(
462
+ name_lookup.get(method_name),
463
+ local_names.and_then(|m| m.get(method_name)),
464
+ &mut name_storage,
465
+ );
466
+ if candidates.is_empty() {
383
467
  counts.no_candidate += 1;
384
468
  continue;
385
- };
469
+ }
386
470
 
387
471
  if candidates.len() == 1 {
388
472
  counts.resolved += 1;
@@ -393,7 +477,7 @@ pub fn build_call_edges(
393
477
  continue;
394
478
  }
395
479
 
396
- let mut targets: &[&str] = candidates.as_slice();
480
+ let mut targets: &[&str] = candidates;
397
481
  let mut filtered: Vec<&str>;
398
482
 
399
483
  // Tier 0: receiver-type filter. Two sources of hints —
@@ -682,6 +766,97 @@ mod stats_tests {
682
766
  rows
683
767
  }
684
768
 
769
+ /// D3: mark a function as closure-scoped, the way the TS scope walk does.
770
+ fn nested(mut f: FunctionInfo, depth: u64) -> FunctionInfo {
771
+ f.metadata
772
+ .insert("nesting_depth".into(), serde_json::json!(depth));
773
+ f
774
+ }
775
+
776
+ /// D3 — a `nesting_depth > 0` definition never joins the global bare-name
777
+ /// lookup. Naively merging opencode's ~2 270 nested names into it took
778
+ /// multi-candidate names from 664 to 1 562 and flipped 293 unique names
779
+ /// ambiguous (Phase 1 spike), which is exactly the false-positive class
780
+ /// v0.1.5's resolution metadata exists to keep out.
781
+ #[test]
782
+ fn a_nested_definition_is_invisible_to_a_caller_in_another_file() {
783
+ let functions = vec![
784
+ func("b.consume", "b.ts", &[("helper", 3)]),
785
+ nested(func("a.outer.helper", "a.ts", &[]), 1),
786
+ ];
787
+ let (edges, _) = build_call_edges(
788
+ &functions,
789
+ &[],
790
+ &std::collections::HashSet::new(),
791
+ 5,
792
+ &[],
793
+ &no_imports(),
794
+ );
795
+ assert!(
796
+ edges.is_empty(),
797
+ "leaked cross-file edges: {:?}",
798
+ edges
799
+ .iter()
800
+ .map(|e| (&e.caller, &e.callee))
801
+ .collect::<Vec<_>>()
802
+ );
803
+ }
804
+
805
+ /// …but a caller in its own file resolves it, top-level or nested.
806
+ #[test]
807
+ fn a_nested_definition_resolves_for_callers_in_its_own_file() {
808
+ let functions = vec![
809
+ func("a.outer", "a.ts", &[("helper", 3)]),
810
+ nested(func("a.outer.helper", "a.ts", &[("deeper", 5)]), 1),
811
+ nested(func("a.outer.helper.deeper", "a.ts", &[]), 2),
812
+ ];
813
+ let (edges, _) = build_call_edges(
814
+ &functions,
815
+ &[],
816
+ &std::collections::HashSet::new(),
817
+ 5,
818
+ &[],
819
+ &no_imports(),
820
+ );
821
+ let mut pairs: Vec<_> = edges
822
+ .iter()
823
+ .map(|e| (e.caller.as_str(), e.callee.as_str()))
824
+ .collect();
825
+ pairs.sort();
826
+ assert_eq!(
827
+ pairs,
828
+ vec![
829
+ ("a.outer", "a.outer.helper"),
830
+ ("a.outer.helper", "a.outer.helper.deeper"),
831
+ ]
832
+ );
833
+ }
834
+
835
+ /// The gating is by *name visibility*, not by dropping the node: a
836
+ /// nested definition in one file must not shadow or suppress an
837
+ /// identically named top-level export in another.
838
+ #[test]
839
+ fn a_nested_name_does_not_disturb_the_global_lookup() {
840
+ let functions = vec![
841
+ func("b.consume", "b.ts", &[("helper", 3)]),
842
+ func("c.helper", "c.ts", &[]),
843
+ nested(func("a.outer.helper", "a.ts", &[]), 1),
844
+ ];
845
+ let (edges, _) = build_call_edges(
846
+ &functions,
847
+ &[],
848
+ &std::collections::HashSet::new(),
849
+ 5,
850
+ &[],
851
+ &no_imports(),
852
+ );
853
+ assert_eq!(
854
+ meta(&edges),
855
+ vec![("c.helper", "unique_name", 1, false)],
856
+ "the cross-file caller must see exactly the top-level `helper`"
857
+ );
858
+ }
859
+
685
860
  #[test]
686
861
  fn unique_name_tier_is_labelled_and_counted() {
687
862
  let functions = vec![
@@ -463,7 +463,40 @@ pub(super) fn functions_df(
463
463
  ),
464
464
  ),
465
465
  ];
466
- for key in ["symbol_kind", "role_hint"] {
466
+ // `nesting_depth` — 0 == top level. Written by the parsers only when it
467
+ // is non-zero, so this column appears only in a build that actually
468
+ // contains closure-scoped definitions, and NULL reads as "top level".
469
+ // Together with `parent_scope` below it replaces a Function→Function
470
+ // containment edge, which would have lied about node types: the
471
+ // enclosing scope is very often a `Constant` (an Effect-TS `layer`) or
472
+ // an anonymous literal, not a Function.
473
+ if fns
474
+ .iter()
475
+ .any(|function| function.metadata.contains_key("nesting_depth"))
476
+ {
477
+ columns.push((
478
+ "nesting_depth",
479
+ ColumnType::Int64,
480
+ int_col(
481
+ fns.iter()
482
+ .map(|function| {
483
+ function
484
+ .metadata
485
+ .get("nesting_depth")
486
+ .and_then(|value| value.as_i64())
487
+ })
488
+ .collect(),
489
+ ),
490
+ ));
491
+ }
492
+ // Sparse string metadata, promoted only when some function in the build
493
+ // actually carries the key — so a corpus without it keeps its exact
494
+ // column set (and therefore its golden digest). `wrapped_by` is the
495
+ // TS/JS factory-unwrap label (`Effect.fn`, `Layer.effect`, `memoize`);
496
+ // `parent_scope` is the qualified name of the nearest named enclosing
497
+ // binding, so containment queries read `WHERE n.parent_scope = '…'`.
498
+ // Without promotion the parser would be writing metadata nothing reads.
499
+ for key in ["symbol_kind", "role_hint", "wrapped_by", "parent_scope"] {
467
500
  if fns.iter().any(|function| {
468
501
  function
469
502
  .metadata