sourcecode 2.5.15__tar.gz → 2.5.17__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 (162) hide show
  1. {sourcecode-2.5.15 → sourcecode-2.5.17}/CHANGELOG.md +73 -0
  2. {sourcecode-2.5.15 → sourcecode-2.5.17}/PKG-INFO +1 -1
  3. {sourcecode-2.5.15 → sourcecode-2.5.17}/pyproject.toml +1 -1
  4. sourcecode-2.5.17/scripts/perf_harness.py +252 -0
  5. {sourcecode-2.5.15 → sourcecode-2.5.17}/src/sourcecode/__init__.py +1 -1
  6. {sourcecode-2.5.15 → sourcecode-2.5.17}/src/sourcecode/adaptive_scanner.py +12 -36
  7. {sourcecode-2.5.15 → sourcecode-2.5.17}/src/sourcecode/ast_extractor.py +12 -19
  8. {sourcecode-2.5.15 → sourcecode-2.5.17}/src/sourcecode/cli.py +11 -0
  9. {sourcecode-2.5.15 → sourcecode-2.5.17}/src/sourcecode/context_graph.py +35 -0
  10. {sourcecode-2.5.15 → sourcecode-2.5.17}/src/sourcecode/coverage_parser.py +0 -14
  11. {sourcecode-2.5.15 → sourcecode-2.5.17}/src/sourcecode/env_analyzer.py +0 -5
  12. sourcecode-2.5.17/src/sourcecode/perf.py +268 -0
  13. {sourcecode-2.5.15 → sourcecode-2.5.17}/src/sourcecode/repository_ir.py +12 -39
  14. {sourcecode-2.5.15 → sourcecode-2.5.17}/src/sourcecode/scanner.py +55 -37
  15. {sourcecode-2.5.15 → sourcecode-2.5.17}/src/sourcecode/serializer.py +0 -61
  16. {sourcecode-2.5.15 → sourcecode-2.5.17}/src/sourcecode/spring_tx_analyzer.py +0 -24
  17. {sourcecode-2.5.15 → sourcecode-2.5.17}/.github/workflows/build-windows.yml +0 -0
  18. {sourcecode-2.5.15 → sourcecode-2.5.17}/.gitignore +0 -0
  19. {sourcecode-2.5.15 → sourcecode-2.5.17}/.ruff.toml +0 -0
  20. {sourcecode-2.5.15 → sourcecode-2.5.17}/CONTRIBUTING.md +0 -0
  21. {sourcecode-2.5.15 → sourcecode-2.5.17}/LICENSE +0 -0
  22. {sourcecode-2.5.15 → sourcecode-2.5.17}/README.md +0 -0
  23. {sourcecode-2.5.15 → sourcecode-2.5.17}/SECURITY.md +0 -0
  24. {sourcecode-2.5.15 → sourcecode-2.5.17}/raw +0 -0
  25. {sourcecode-2.5.15 → sourcecode-2.5.17}/scripts/compare_integration_engines.py +0 -0
  26. {sourcecode-2.5.15 → sourcecode-2.5.17}/scripts/customer_smoke_test.sh +0 -0
  27. {sourcecode-2.5.15 → sourcecode-2.5.17}/scripts/generate_jdk_exports.py +0 -0
  28. {sourcecode-2.5.15 → sourcecode-2.5.17}/src/sourcecode/archetype.py +0 -0
  29. {sourcecode-2.5.15 → sourcecode-2.5.17}/src/sourcecode/architecture_analyzer.py +0 -0
  30. {sourcecode-2.5.15 → sourcecode-2.5.17}/src/sourcecode/architecture_summary.py +0 -0
  31. {sourcecode-2.5.15 → sourcecode-2.5.17}/src/sourcecode/cache.py +0 -0
  32. {sourcecode-2.5.15 → sourcecode-2.5.17}/src/sourcecode/call_surface.py +0 -0
  33. {sourcecode-2.5.15 → sourcecode-2.5.17}/src/sourcecode/caller_metrics.py +0 -0
  34. {sourcecode-2.5.15 → sourcecode-2.5.17}/src/sourcecode/canonical_ir.py +0 -0
  35. {sourcecode-2.5.15 → sourcecode-2.5.17}/src/sourcecode/cir_graphs.py +0 -0
  36. {sourcecode-2.5.15 → sourcecode-2.5.17}/src/sourcecode/classifier.py +0 -0
  37. {sourcecode-2.5.15 → sourcecode-2.5.17}/src/sourcecode/code_notes_analyzer.py +0 -0
  38. {sourcecode-2.5.15 → sourcecode-2.5.17}/src/sourcecode/confidence_analyzer.py +0 -0
  39. {sourcecode-2.5.15 → sourcecode-2.5.17}/src/sourcecode/context_cache.py +0 -0
  40. {sourcecode-2.5.15 → sourcecode-2.5.17}/src/sourcecode/context_scorer.py +0 -0
  41. {sourcecode-2.5.15 → sourcecode-2.5.17}/src/sourcecode/context_summarizer.py +0 -0
  42. {sourcecode-2.5.15 → sourcecode-2.5.17}/src/sourcecode/contract_model.py +0 -0
  43. {sourcecode-2.5.15 → sourcecode-2.5.17}/src/sourcecode/contract_pipeline.py +0 -0
  44. {sourcecode-2.5.15 → sourcecode-2.5.17}/src/sourcecode/dependency_analyzer.py +0 -0
  45. {sourcecode-2.5.15 → sourcecode-2.5.17}/src/sourcecode/detectors/__init__.py +0 -0
  46. {sourcecode-2.5.15 → sourcecode-2.5.17}/src/sourcecode/detectors/base.py +0 -0
  47. {sourcecode-2.5.15 → sourcecode-2.5.17}/src/sourcecode/detectors/csproj_parser.py +0 -0
  48. {sourcecode-2.5.15 → sourcecode-2.5.17}/src/sourcecode/detectors/dart.py +0 -0
  49. {sourcecode-2.5.15 → sourcecode-2.5.17}/src/sourcecode/detectors/dotnet.py +0 -0
  50. {sourcecode-2.5.15 → sourcecode-2.5.17}/src/sourcecode/detectors/elixir.py +0 -0
  51. {sourcecode-2.5.15 → sourcecode-2.5.17}/src/sourcecode/detectors/go.py +0 -0
  52. {sourcecode-2.5.15 → sourcecode-2.5.17}/src/sourcecode/detectors/heuristic.py +0 -0
  53. {sourcecode-2.5.15 → sourcecode-2.5.17}/src/sourcecode/detectors/hybrid.py +0 -0
  54. {sourcecode-2.5.15 → sourcecode-2.5.17}/src/sourcecode/detectors/java.py +0 -0
  55. {sourcecode-2.5.15 → sourcecode-2.5.17}/src/sourcecode/detectors/jvm_ext.py +0 -0
  56. {sourcecode-2.5.15 → sourcecode-2.5.17}/src/sourcecode/detectors/nodejs.py +0 -0
  57. {sourcecode-2.5.15 → sourcecode-2.5.17}/src/sourcecode/detectors/parsers.py +0 -0
  58. {sourcecode-2.5.15 → sourcecode-2.5.17}/src/sourcecode/detectors/php.py +0 -0
  59. {sourcecode-2.5.15 → sourcecode-2.5.17}/src/sourcecode/detectors/project.py +0 -0
  60. {sourcecode-2.5.15 → sourcecode-2.5.17}/src/sourcecode/detectors/python.py +0 -0
  61. {sourcecode-2.5.15 → sourcecode-2.5.17}/src/sourcecode/detectors/ruby.py +0 -0
  62. {sourcecode-2.5.15 → sourcecode-2.5.17}/src/sourcecode/detectors/rust.py +0 -0
  63. {sourcecode-2.5.15 → sourcecode-2.5.17}/src/sourcecode/detectors/systems.py +0 -0
  64. {sourcecode-2.5.15 → sourcecode-2.5.17}/src/sourcecode/detectors/terraform.py +0 -0
  65. {sourcecode-2.5.15 → sourcecode-2.5.17}/src/sourcecode/detectors/tooling.py +0 -0
  66. {sourcecode-2.5.15 → sourcecode-2.5.17}/src/sourcecode/doc_analyzer.py +0 -0
  67. {sourcecode-2.5.15 → sourcecode-2.5.17}/src/sourcecode/dynamic_argument_surface.py +0 -0
  68. {sourcecode-2.5.15 → sourcecode-2.5.17}/src/sourcecode/endpoint_literals.py +0 -0
  69. {sourcecode-2.5.15 → sourcecode-2.5.17}/src/sourcecode/endpoint_metrics.py +0 -0
  70. {sourcecode-2.5.15 → sourcecode-2.5.17}/src/sourcecode/entrypoint_classifier.py +0 -0
  71. {sourcecode-2.5.15 → sourcecode-2.5.17}/src/sourcecode/error_schema.py +0 -0
  72. {sourcecode-2.5.15 → sourcecode-2.5.17}/src/sourcecode/evidence_provider.py +0 -0
  73. {sourcecode-2.5.15 → sourcecode-2.5.17}/src/sourcecode/explain.py +0 -0
  74. {sourcecode-2.5.15 → sourcecode-2.5.17}/src/sourcecode/file_chunker.py +0 -0
  75. {sourcecode-2.5.15 → sourcecode-2.5.17}/src/sourcecode/file_classifier.py +0 -0
  76. {sourcecode-2.5.15 → sourcecode-2.5.17}/src/sourcecode/format_contract.py +0 -0
  77. {sourcecode-2.5.15 → sourcecode-2.5.17}/src/sourcecode/fqn_utils.py +0 -0
  78. {sourcecode-2.5.15 → sourcecode-2.5.17}/src/sourcecode/git_analyzer.py +0 -0
  79. {sourcecode-2.5.15 → sourcecode-2.5.17}/src/sourcecode/graph_analyzer.py +0 -0
  80. {sourcecode-2.5.15 → sourcecode-2.5.17}/src/sourcecode/graph_evidence.py +0 -0
  81. {sourcecode-2.5.15 → sourcecode-2.5.17}/src/sourcecode/hibernate_strat.py +0 -0
  82. {sourcecode-2.5.15 → sourcecode-2.5.17}/src/sourcecode/jdk_exports.py +0 -0
  83. {sourcecode-2.5.15 → sourcecode-2.5.17}/src/sourcecode/license.py +0 -0
  84. {sourcecode-2.5.15 → sourcecode-2.5.17}/src/sourcecode/mcp/__init__.py +0 -0
  85. {sourcecode-2.5.15 → sourcecode-2.5.17}/src/sourcecode/mcp/onboarding/__init__.py +0 -0
  86. {sourcecode-2.5.15 → sourcecode-2.5.17}/src/sourcecode/mcp/onboarding/applier.py +0 -0
  87. {sourcecode-2.5.15 → sourcecode-2.5.17}/src/sourcecode/mcp/onboarding/backup.py +0 -0
  88. {sourcecode-2.5.15 → sourcecode-2.5.17}/src/sourcecode/mcp/onboarding/detector.py +0 -0
  89. {sourcecode-2.5.15 → sourcecode-2.5.17}/src/sourcecode/mcp/onboarding/planner.py +0 -0
  90. {sourcecode-2.5.15 → sourcecode-2.5.17}/src/sourcecode/mcp/orchestrator.py +0 -0
  91. {sourcecode-2.5.15 → sourcecode-2.5.17}/src/sourcecode/mcp/registry.py +0 -0
  92. {sourcecode-2.5.15 → sourcecode-2.5.17}/src/sourcecode/mcp/runner.py +0 -0
  93. {sourcecode-2.5.15 → sourcecode-2.5.17}/src/sourcecode/mcp/server.py +0 -0
  94. {sourcecode-2.5.15 → sourcecode-2.5.17}/src/sourcecode/mcp_nudge.py +0 -0
  95. {sourcecode-2.5.15 → sourcecode-2.5.17}/src/sourcecode/metrics_analyzer.py +0 -0
  96. {sourcecode-2.5.15 → sourcecode-2.5.17}/src/sourcecode/migrate_check.py +0 -0
  97. {sourcecode-2.5.15 → sourcecode-2.5.17}/src/sourcecode/openapi_surface.py +0 -0
  98. {sourcecode-2.5.15 → sourcecode-2.5.17}/src/sourcecode/output_budget.py +0 -0
  99. {sourcecode-2.5.15 → sourcecode-2.5.17}/src/sourcecode/path_filters.py +0 -0
  100. {sourcecode-2.5.15 → sourcecode-2.5.17}/src/sourcecode/pr_comment_renderer.py +0 -0
  101. {sourcecode-2.5.15 → sourcecode-2.5.17}/src/sourcecode/pr_impact.py +0 -0
  102. {sourcecode-2.5.15 → sourcecode-2.5.17}/src/sourcecode/prepare_context.py +0 -0
  103. {sourcecode-2.5.15 → sourcecode-2.5.17}/src/sourcecode/progress.py +0 -0
  104. {sourcecode-2.5.15 → sourcecode-2.5.17}/src/sourcecode/ranking_engine.py +0 -0
  105. {sourcecode-2.5.15 → sourcecode-2.5.17}/src/sourcecode/reconciliation.py +0 -0
  106. {sourcecode-2.5.15 → sourcecode-2.5.17}/src/sourcecode/redactor.py +0 -0
  107. {sourcecode-2.5.15 → sourcecode-2.5.17}/src/sourcecode/relevance_scorer.py +0 -0
  108. {sourcecode-2.5.15 → sourcecode-2.5.17}/src/sourcecode/rename_refactor.py +0 -0
  109. {sourcecode-2.5.15 → sourcecode-2.5.17}/src/sourcecode/repo_classifier.py +0 -0
  110. {sourcecode-2.5.15 → sourcecode-2.5.17}/src/sourcecode/retrieval/__init__.py +0 -0
  111. {sourcecode-2.5.15 → sourcecode-2.5.17}/src/sourcecode/retrieval/context.py +0 -0
  112. {sourcecode-2.5.15 → sourcecode-2.5.17}/src/sourcecode/retrieval/errors.py +0 -0
  113. {sourcecode-2.5.15 → sourcecode-2.5.17}/src/sourcecode/retrieval/executor.py +0 -0
  114. {sourcecode-2.5.15 → sourcecode-2.5.17}/src/sourcecode/retrieval/planner.py +0 -0
  115. {sourcecode-2.5.15 → sourcecode-2.5.17}/src/sourcecode/retrieval/query.py +0 -0
  116. {sourcecode-2.5.15 → sourcecode-2.5.17}/src/sourcecode/retrieval/request.py +0 -0
  117. {sourcecode-2.5.15 → sourcecode-2.5.17}/src/sourcecode/retrieval/resolution.py +0 -0
  118. {sourcecode-2.5.15 → sourcecode-2.5.17}/src/sourcecode/retrieval/result.py +0 -0
  119. {sourcecode-2.5.15 → sourcecode-2.5.17}/src/sourcecode/retrieval/retriever.py +0 -0
  120. {sourcecode-2.5.15 → sourcecode-2.5.17}/src/sourcecode/retrieval/runtime.py +0 -0
  121. {sourcecode-2.5.15 → sourcecode-2.5.17}/src/sourcecode/retrieval/steps.py +0 -0
  122. {sourcecode-2.5.15 → sourcecode-2.5.17}/src/sourcecode/retrieval/steps_endpoint.py +0 -0
  123. {sourcecode-2.5.15 → sourcecode-2.5.17}/src/sourcecode/retrieval/steps_graph.py +0 -0
  124. {sourcecode-2.5.15 → sourcecode-2.5.17}/src/sourcecode/retrieval/steps_impact.py +0 -0
  125. {sourcecode-2.5.15 → sourcecode-2.5.17}/src/sourcecode/retrieval/steps_intf.py +0 -0
  126. {sourcecode-2.5.15 → sourcecode-2.5.17}/src/sourcecode/retrieval/steps_struct.py +0 -0
  127. {sourcecode-2.5.15 → sourcecode-2.5.17}/src/sourcecode/retrieval/steps_txsec.py +0 -0
  128. {sourcecode-2.5.15 → sourcecode-2.5.17}/src/sourcecode/ris.py +0 -0
  129. {sourcecode-2.5.15 → sourcecode-2.5.17}/src/sourcecode/runtime_classifier.py +0 -0
  130. {sourcecode-2.5.15 → sourcecode-2.5.17}/src/sourcecode/schema.py +0 -0
  131. {sourcecode-2.5.15 → sourcecode-2.5.17}/src/sourcecode/security_config.py +0 -0
  132. {sourcecode-2.5.15 → sourcecode-2.5.17}/src/sourcecode/security_posture.py +0 -0
  133. {sourcecode-2.5.15 → sourcecode-2.5.17}/src/sourcecode/semantic_analyzer.py +0 -0
  134. {sourcecode-2.5.15 → sourcecode-2.5.17}/src/sourcecode/semantic_impact_engine.py +0 -0
  135. {sourcecode-2.5.15 → sourcecode-2.5.17}/src/sourcecode/semantic_integration_engine.py +0 -0
  136. {sourcecode-2.5.15 → sourcecode-2.5.17}/src/sourcecode/semantic_services.py +0 -0
  137. {sourcecode-2.5.15 → sourcecode-2.5.17}/src/sourcecode/spring_event_topology.py +0 -0
  138. {sourcecode-2.5.15 → sourcecode-2.5.17}/src/sourcecode/spring_findings.py +0 -0
  139. {sourcecode-2.5.15 → sourcecode-2.5.17}/src/sourcecode/spring_impact.py +0 -0
  140. {sourcecode-2.5.15 → sourcecode-2.5.17}/src/sourcecode/spring_model.py +0 -0
  141. {sourcecode-2.5.15 → sourcecode-2.5.17}/src/sourcecode/spring_security_audit.py +0 -0
  142. {sourcecode-2.5.15 → sourcecode-2.5.17}/src/sourcecode/spring_semantic.py +0 -0
  143. {sourcecode-2.5.15 → sourcecode-2.5.17}/src/sourcecode/summarizer.py +0 -0
  144. {sourcecode-2.5.15 → sourcecode-2.5.17}/src/sourcecode/telemetry/__init__.py +0 -0
  145. {sourcecode-2.5.15 → sourcecode-2.5.17}/src/sourcecode/telemetry/config.py +0 -0
  146. {sourcecode-2.5.15 → sourcecode-2.5.17}/src/sourcecode/telemetry/consent.py +0 -0
  147. {sourcecode-2.5.15 → sourcecode-2.5.17}/src/sourcecode/telemetry/events.py +0 -0
  148. {sourcecode-2.5.15 → sourcecode-2.5.17}/src/sourcecode/telemetry/filters.py +0 -0
  149. {sourcecode-2.5.15 → sourcecode-2.5.17}/src/sourcecode/telemetry/transport.py +0 -0
  150. {sourcecode-2.5.15 → sourcecode-2.5.17}/src/sourcecode/tree_utils.py +0 -0
  151. {sourcecode-2.5.15 → sourcecode-2.5.17}/src/sourcecode/type_usage_surface.py +0 -0
  152. {sourcecode-2.5.15 → sourcecode-2.5.17}/src/sourcecode/validation_inference.py +0 -0
  153. {sourcecode-2.5.15 → sourcecode-2.5.17}/src/sourcecode/validation_surface.py +0 -0
  154. {sourcecode-2.5.15 → sourcecode-2.5.17}/src/sourcecode/version_check.py +0 -0
  155. {sourcecode-2.5.15 → sourcecode-2.5.17}/src/sourcecode/workspace.py +0 -0
  156. {sourcecode-2.5.15 → sourcecode-2.5.17}/supabase/functions/README.md +0 -0
  157. {sourcecode-2.5.15 → sourcecode-2.5.17}/supabase/functions/get-license/index.ts +0 -0
  158. {sourcecode-2.5.15 → sourcecode-2.5.17}/supabase/functions/lemonsqueezy-webhook/index.ts +0 -0
  159. {sourcecode-2.5.15 → sourcecode-2.5.17}/supabase/functions/telemetry/index.ts +0 -0
  160. {sourcecode-2.5.15 → sourcecode-2.5.17}/supabase/sql/license_event_ordering.sql +0 -0
  161. {sourcecode-2.5.15 → sourcecode-2.5.17}/supabase/sql/licensing_schema.sql +0 -0
  162. {sourcecode-2.5.15 → sourcecode-2.5.17}/supabase/sql/telemetry_events.sql +0 -0
@@ -1,5 +1,78 @@
1
1
  # Changelog
2
2
 
3
+ ## [2.5.17] — 2026-07-19
4
+
5
+ **Semantic Stability cleanup: dead code removed and a scanner fork collapsed, with
6
+ zero behavioural change.** Per `docs/DEVELOPMENT-ROADMAP.md` the 2.5.x line is scoped
7
+ to documentation, cleanup, small bugs and profiling — no new inference. This release
8
+ is pure internal hygiene; every user-facing surface is byte-for-byte unchanged and the
9
+ full suite (3534 passed, 5 skipped) confirms it.
10
+
11
+ ### Removed
12
+
13
+ - **8 dead module-level private helpers** (−151 lines), each verified to have zero
14
+ references across `src` + `tests` (call sites, string names, and dynamic lookups) and
15
+ confirmed long-orphaned (all last touched May 2026, superseded since):
16
+ `repository_ir._resolve_type`, `repository_ir._common_package_prefix`,
17
+ `ast_extractor._find_all`, `ast_extractor._named_children`,
18
+ `coverage_parser._decode_numbits`, `spring_tx_analyzer._extract_method_body`,
19
+ `serializer._tiered_display_score`, `env_analyzer._replace_required`.
20
+ Kept `perf._reset_for_tests` and `migrate_check._find_xml_config_files` — both live
21
+ via tests.
22
+
23
+ ### Changed
24
+
25
+ - **Collapsed the `FileScanner` / `AdaptiveScanner` fork duplication.** AdaptiveScanner
26
+ was forked from FileScanner and carried three byte-identical method bodies
27
+ (`find_manifests`, `_get_or_create_node`, `_is_excluded_by_gitignore`) — one of which
28
+ documented "Identical logic to FileScanner.find_manifests()". The shared logic now
29
+ lives as three module-level free functions in `scanner.py`
30
+ (`find_manifest_paths` / `get_or_create_tree_node` / `match_gitignore`) and both
31
+ classes delegate. No new coupling (AdaptiveScanner already imported from `scanner`),
32
+ no circular import; dropped its now-unused `cast` / `MANIFEST_NAMES` imports.
33
+
34
+ ## [2.5.16] — 2026-07-19
35
+
36
+ **Performance campaign close-out: one real algorithmic win, cost-per-symbol
37
+ measurable, one candidate refuted by measurement.** The ADR-0006 harness localized
38
+ cold wall to the `analysis` phase (90–96% on every non-trivial repo); profiling
39
+ inside it found — and this release fixes — the one structural bottleneck, populates
40
+ the last empty baseline axes, and records a negative result that stopped a
41
+ non-winning change from landing.
42
+
43
+ ### Performance
44
+
45
+ - **Killed an O(files × total_symbols) scan in per-file Java contract projection**
46
+ (`ast_extractor.py`, `context_graph.py`): `_java_contract_from_graph` ran once per
47
+ Java file and called the whole-repo `graph.symbols()`/`relations()` unfiltered, then
48
+ kept only its own file's rows. Added lazy once-built groupings
49
+ `ContextGraph.symbols_in_file(path)` / `relations_from(fqn)` (O(1) after build); the
50
+ projection now walks only edges sourced at its own types. Behavior-equivalent
51
+ (normalized old-vs-new on petclinic + keycloak; 1430 IR/spring/endpoint/impact tests
52
+ green). Cold wall p50, same host, speedup tracking file count as the O(files × N)
53
+ model predicts: neo4j 33.6→16.7s (2.0×), broadleaf 27.9→15.4s (1.8×),
54
+ keycloak 20.8→14.1s (1.5×), openmrs 7.9→5.8s (1.4×), ofbiz 19.4→15.0s (1.3×).
55
+
56
+ ### Added
57
+
58
+ - **`repo.classes` / `repo.methods` populated in the perf baseline** from repo-ir's
59
+ symbol taxonomy (`repository_ir.py`, `scripts/perf_harness.py`): `analysis_meta` now
60
+ emits a deterministic `symbol_kinds` breakdown plus two rollups —
61
+ `classes` = class+interface+enum+annotation+record, `methods` = method+constructor —
62
+ and the harness reads them via one `repo-ir --summary-only` pass off the measured path
63
+ (never in `wall_ms`; repo-ir failure falls back to 0, an honest "unmeasured"). Closes
64
+ the last empty axes in the `perf-baseline-v1` schema, enabling cost-per-symbol.
65
+
66
+ ### Notes
67
+
68
+ - **A body-fact-scanning optimization was investigated and reverted after measurement
69
+ showed no win.** cProfile flagged per-method body re-slicing (`_method_body` 44508× on
70
+ ofbiz) as a lead; a byte-identical single-pass rewrite (0 mismatches / 5555 files)
71
+ measured 7674→7721 ms — no improvement. The profiler had flagged a call-count-heavy but
72
+ wall-cheap region; the real cost is inherent regex/char scanning, already past its
73
+ algorithmic win. The change was dropped rather than accrue complexity on a hot,
74
+ output-critical path for no gain.
75
+
3
76
  ## [2.5.15] — 2026-07-19
4
77
 
5
78
  **Bootstrap detection recovers the real entry point on multi-`main` repos, without
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: sourcecode
3
- Version: 2.5.15
3
+ Version: 2.5.17
4
4
  Summary: Persistent structural context and ultra-fast repeated analysis for AI coding agents
5
5
  License-File: LICENSE
6
6
  Keywords: agents,ai,codebase,context,developer-tools,llm
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "sourcecode"
7
- version = "2.5.15"
7
+ version = "2.5.17"
8
8
  description = "Persistent structural context and ultra-fast repeated analysis for AI coding agents"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.9"
@@ -0,0 +1,252 @@
1
+ #!/usr/bin/env python3
2
+ """Performance harness — runs the reference fleet under the ADR-0006 contract.
3
+
4
+ For every (command x repo x mode) cell it runs the CLI N times, records
5
+ wall-clock, peak RSS and (when the in-process recorder is armed) per-phase
6
+ timings, then writes one ``perf-baseline-v1`` JSON artifact.
7
+
8
+ Methodology is fixed by ADR-0006 §5: cold and warm are separate cells and never
9
+ averaged; the first warm run is discarded as cache fill; central tendency is the
10
+ median; p95 is always reported; cross-machine numbers are recorded, never diffed.
11
+
12
+ Usage:
13
+ python3 scripts/perf_harness.py --quick # petclinic only, 2 runs
14
+ python3 scripts/perf_harness.py # full fleet, 7 runs
15
+ python3 scripts/perf_harness.py --repos keycloak eureka --runs 5
16
+ python3 scripts/perf_harness.py --dry-run # print the plan, run nothing
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import argparse
22
+ import json
23
+ import os
24
+ import resource
25
+ import shutil
26
+ import subprocess
27
+ import sys
28
+ import tempfile
29
+ import time
30
+ from pathlib import Path
31
+
32
+ # Import the frozen collector from the package under test.
33
+ sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "src"))
34
+ from sourcecode import __version__ as TOOL_VERSION # noqa: E402
35
+ from sourcecode import perf # noqa: E402
36
+
37
+ # ── Fleet definition ────────────────────────────────────────────────────────
38
+ # Directory name under the fleet root. Repos are pinned by their checked-out
39
+ # commit at run time (recorded in each artifact), so a size change is never
40
+ # mistaken for a perf change.
41
+ DEFAULT_FLEET_ROOT = Path(
42
+ os.environ.get("PERF_FLEET_ROOT", "/Users/user/Documents/workspace/testing")
43
+ )
44
+ FLEET: dict[str, str] = {
45
+ "petclinic": "spring-petclinic",
46
+ "broadleaf": "BroadleafCommerce",
47
+ "openmrs": "openmrs-core",
48
+ "keycloak": "keycloak",
49
+ "eureka": "eureka",
50
+ "jobrunr": "jobrunr",
51
+ "ofbiz": "ofbiz-framework",
52
+ "alfresco": "alfresco-community-repo",
53
+ "jenkins": "jenkins",
54
+ "neo4j": "neo4j",
55
+ }
56
+
57
+ # Commands to profile. The empty string is the root analysis (`ask <repo>`);
58
+ # named entries are subcommands (`ask <cmd> <repo>`).
59
+ DEFAULT_COMMANDS = [""]
60
+
61
+ ASK_BIN = os.environ.get("ASK_BIN", "ask")
62
+ BYTES_PER_KB = 1024
63
+ # darwin ru_maxrss is bytes; linux reports kilobytes.
64
+ RSS_UNIT = 1 if sys.platform == "darwin" else BYTES_PER_KB
65
+
66
+
67
+ def git_commit(repo_path: Path) -> str:
68
+ try:
69
+ out = subprocess.run(
70
+ ["git", "-C", str(repo_path), "rev-parse", "--short", "HEAD"],
71
+ capture_output=True, text=True, timeout=15,
72
+ )
73
+ return out.stdout.strip() or "unknown"
74
+ except (subprocess.SubprocessError, OSError):
75
+ return "unknown"
76
+
77
+
78
+ def repo_size(repo_path: Path) -> dict[str, int]:
79
+ """Repo scaling axes for cost-per-unit analysis.
80
+
81
+ ``files`` / ``bytes`` are a cheap rglob over every ``*.java`` (the frozen file
82
+ axis — includes tests). ``classes`` / ``methods`` come from repo-ir's
83
+ ``analysis_meta`` rollup (the IR owns the symbol taxonomy). This is one
84
+ ``repo-ir --summary-only`` pass off the measured path; it never touches
85
+ ``wall_ms``. If repo-ir fails the symbol counts fall back to 0 — an honest
86
+ "unmeasured", never a fabricated number.
87
+ """
88
+ files = 0
89
+ total = 0
90
+ for p in repo_path.rglob("*.java"):
91
+ try:
92
+ total += p.stat().st_size
93
+ files += 1
94
+ except OSError:
95
+ continue
96
+ classes, methods = _repo_symbol_counts(repo_path)
97
+ return {"files": files, "bytes": total, "classes": classes, "methods": methods}
98
+
99
+
100
+ def _repo_symbol_counts(repo_path: Path) -> tuple[int, int]:
101
+ """(classes, methods) from ``repo-ir --summary-only``; (0, 0) on any failure."""
102
+ with tempfile.TemporaryDirectory(prefix="perf-ir-") as td:
103
+ out = Path(td) / "ir.json"
104
+ try:
105
+ subprocess.run(
106
+ [ASK_BIN, "repo-ir", str(repo_path), "--summary-only", "-o", str(out)],
107
+ capture_output=True, timeout=1800, check=False,
108
+ )
109
+ meta = json.loads(out.read_text(encoding="utf-8")).get("analysis_meta", {})
110
+ return int(meta.get("classes", 0) or 0), int(meta.get("methods", 0) or 0)
111
+ except (subprocess.SubprocessError, OSError, ValueError):
112
+ return 0, 0
113
+
114
+
115
+ def build_argv(command: str, repo_path: Path, cold: bool) -> list[str]:
116
+ argv = [ASK_BIN]
117
+ if command:
118
+ argv.append(command)
119
+ argv.append(str(repo_path))
120
+ argv += ["--format", "json"]
121
+ if cold:
122
+ argv.append("--no-cache")
123
+ return argv
124
+
125
+
126
+ def run_once(argv: list[str], env: dict[str, str], perf_log: Path) -> tuple[float, float, dict | None]:
127
+ """Run the CLI once. Returns (wall_ms, rss_bytes, phase_ms | None)."""
128
+ if perf_log.exists():
129
+ perf_log.unlink()
130
+ rss_before = resource.getrusage(resource.RUSAGE_CHILDREN).ru_maxrss
131
+ t0 = time.perf_counter()
132
+ subprocess.run(argv, env=env, capture_output=True, timeout=1800)
133
+ wall_ms = (time.perf_counter() - t0) * 1000.0
134
+ rss_after = resource.getrusage(resource.RUSAGE_CHILDREN).ru_maxrss
135
+ # ru_maxrss for RUSAGE_CHILDREN is the peak among reaped children; the delta
136
+ # approximates this run's peak. Approximate by construction — documented.
137
+ rss_bytes = max(rss_after - rss_before, rss_after) * RSS_UNIT if rss_after else 0.0
138
+ phase_ms = _read_phase_log(perf_log)
139
+ return wall_ms, float(rss_bytes), phase_ms
140
+
141
+
142
+ def _read_phase_log(perf_log: Path) -> dict | None:
143
+ if not perf_log.exists():
144
+ return None
145
+ try:
146
+ last = perf_log.read_text(encoding="utf-8").strip().splitlines()[-1]
147
+ return json.loads(last).get("phase_ms") or None
148
+ except (OSError, IndexError, ValueError):
149
+ return None
150
+
151
+
152
+ def measure_cell(command: str, repo_path: Path, mode: str, runs: int) -> dict:
153
+ cold = mode == "cold"
154
+ walls: list[float] = []
155
+ rsss: list[float] = []
156
+ phases: list[dict] = []
157
+
158
+ with tempfile.TemporaryDirectory(prefix="perf-cache-") as cache_dir, \
159
+ tempfile.TemporaryDirectory(prefix="perf-log-") as log_dir:
160
+ perf_log = Path(log_dir) / "phases.jsonl"
161
+ base_env = dict(os.environ)
162
+ base_env[perf.ENV_ENABLE] = "1"
163
+ base_env[perf.ENV_LOG] = str(perf_log)
164
+ base_env["SOURCECODE_CACHE_DIR"] = cache_dir
165
+
166
+ # Warm mode: one un-recorded priming run to populate the cache, then the
167
+ # first measured warm run is still discarded (belt and suspenders).
168
+ n = runs + 1 if mode == "warm" else runs
169
+ for i in range(n):
170
+ argv = build_argv(command, repo_path, cold=cold)
171
+ if cold:
172
+ # Each cold run gets a pristine cache dir.
173
+ for child in Path(cache_dir).iterdir():
174
+ shutil.rmtree(child, ignore_errors=True) if child.is_dir() else child.unlink()
175
+ wall, rss, phase = run_once(argv, base_env, perf_log)
176
+ if mode == "warm" and i == 0:
177
+ continue # discard cache-fill run
178
+ walls.append(wall)
179
+ rsss.append(rss)
180
+ if phase:
181
+ phases.append(phase)
182
+
183
+ return perf.build_baseline_cell(
184
+ tool_version=TOOL_VERSION,
185
+ command=command or "ask",
186
+ repo={"name": repo_path.name, "commit": git_commit(repo_path), **repo_size(repo_path)},
187
+ mode=mode,
188
+ wall_ms_runs=walls,
189
+ rss_bytes_runs=rsss,
190
+ phase_ms_runs=phases or None,
191
+ )
192
+
193
+
194
+ def main() -> int:
195
+ ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
196
+ ap.add_argument("--repos", nargs="*", help="Subset of fleet names (default: all).")
197
+ ap.add_argument("--commands", nargs="*", default=DEFAULT_COMMANDS,
198
+ help="Commands to profile; '' = root analysis (default).")
199
+ ap.add_argument("--modes", nargs="*", default=["cold", "warm"], choices=["cold", "warm"])
200
+ ap.add_argument("--runs", type=int, default=7, help="Measured runs per cell (>=5 per ADR-0006).")
201
+ ap.add_argument("--fleet-root", type=Path, default=DEFAULT_FLEET_ROOT)
202
+ ap.add_argument("--out", type=Path, default=None,
203
+ help="Output dir (default: docs/perf/baselines/<tool_version>).")
204
+ ap.add_argument("--quick", action="store_true", help="petclinic only, 2 runs — smoke test.")
205
+ ap.add_argument("--dry-run", action="store_true", help="Print the plan, run nothing.")
206
+ args = ap.parse_args()
207
+
208
+ if args.quick:
209
+ args.repos = ["petclinic"]
210
+ args.runs = 2
211
+
212
+ repos = args.repos or list(FLEET)
213
+ out_dir = args.out or (Path(__file__).resolve().parent.parent / "docs" / "perf" / "baselines" / TOOL_VERSION)
214
+
215
+ plan = [
216
+ (cmd, name, mode)
217
+ for cmd in args.commands
218
+ for name in repos
219
+ for mode in args.modes
220
+ ]
221
+ print(f"perf-harness {TOOL_VERSION} — {len(plan)} cells, {args.runs} runs each")
222
+ print(f"fleet root: {args.fleet_root}")
223
+ print(f"output: {out_dir}")
224
+ if args.dry_run:
225
+ for cmd, name, mode in plan:
226
+ print(f" [{mode:4}] {name:10} {cmd or 'ask'}")
227
+ return 0
228
+
229
+ out_dir.mkdir(parents=True, exist_ok=True)
230
+ for cmd, name, mode in plan:
231
+ subdir = FLEET.get(name, name)
232
+ repo_path = args.fleet_root / subdir
233
+ if not repo_path.is_dir():
234
+ print(f" SKIP {name}: {repo_path} not found", file=sys.stderr)
235
+ continue
236
+ print(f" [{mode:4}] {name:10} {cmd or 'ask'} ...", end="", flush=True)
237
+ try:
238
+ cell = measure_cell(cmd, repo_path, mode, args.runs)
239
+ except subprocess.TimeoutExpired:
240
+ print(" TIMEOUT", file=sys.stderr)
241
+ continue
242
+ fname = f"{name}__{cmd or 'ask'}__{mode}.json"
243
+ (out_dir / fname).write_text(json.dumps(cell, indent=2) + "\n", encoding="utf-8")
244
+ w = cell["wall_ms"]
245
+ print(f" p50={w['p50']}ms p95={w['p95']}ms -> {fname}")
246
+
247
+ print(f"done. artifacts in {out_dir}")
248
+ return 0
249
+
250
+
251
+ if __name__ == "__main__":
252
+ raise SystemExit(main())
@@ -4,4 +4,4 @@ ASK Engine is the product. ``ask`` is the canonical CLI command; ``sourcecode``
4
4
  the legacy compatibility alias and the Python/PyPI package name. See
5
5
  docs/PRODUCT_IDENTITY.md (normative)."""
6
6
 
7
- __version__ = "2.5.15"
7
+ __version__ = "2.5.17"
@@ -14,12 +14,18 @@ interface, same output format (None = file, dict = directory).
14
14
 
15
15
  import os
16
16
  from pathlib import Path
17
- from typing import Any, Optional, cast
17
+ from typing import Any, Optional
18
18
 
19
19
  from pathspec import GitIgnoreSpec
20
20
 
21
21
  from sourcecode.repo_classifier import RepoTopology
22
- from sourcecode.scanner import DEFAULT_EXCLUDES, EXCLUDED_FILE_SUFFIXES, MANIFEST_NAMES
22
+ from sourcecode.scanner import (
23
+ DEFAULT_EXCLUDES,
24
+ EXCLUDED_FILE_SUFFIXES,
25
+ find_manifest_paths,
26
+ get_or_create_tree_node,
27
+ match_gitignore,
28
+ )
23
29
 
24
30
 
25
31
  class AdaptiveScanner:
@@ -100,9 +106,7 @@ class AdaptiveScanner:
100
106
  return self._gitignore_spec
101
107
 
102
108
  def _is_excluded_by_gitignore(self, rel_path: str, is_dir: bool) -> bool:
103
- spec = self._load_gitignore_spec()
104
- path_to_match = rel_path + "/" if is_dir else rel_path
105
- return spec.match_file(path_to_match)
109
+ return match_gitignore(self._load_gitignore_spec(), rel_path, is_dir)
106
110
 
107
111
  # ------------------------------------------------------------------
108
112
  # Depth budget computation — the core of adaptive traversal
@@ -224,40 +228,12 @@ class AdaptiveScanner:
224
228
  def _get_or_create_node(
225
229
  self, tree: dict[str, Any], parts: tuple[str, ...]
226
230
  ) -> dict[str, Any]:
227
- node = tree
228
- for part in parts:
229
- if part not in node or node[part] is None:
230
- node[part] = {}
231
- node = cast(dict[str, Any], node[part])
232
- return node
231
+ return get_or_create_tree_node(tree, parts)
233
232
 
234
233
  # ------------------------------------------------------------------
235
234
  # Manifest discovery — same interface as FileScanner
236
235
  # ------------------------------------------------------------------
237
236
 
238
237
  def find_manifests(self) -> list[str]:
239
- """Find manifest files at depth 0-1.
240
-
241
- Identical logic to FileScanner.find_manifests() — depth-0 root
242
- manifests plus depth-1 sub-package manifests, hidden dirs excluded.
243
- """
244
- manifests: list[str] = []
245
- for name in MANIFEST_NAMES:
246
- candidate = self.root / name
247
- if candidate.exists() and not candidate.is_symlink():
248
- manifests.append(str(candidate))
249
- try:
250
- for child in self.root.iterdir():
251
- if (
252
- child.is_dir()
253
- and not child.is_symlink()
254
- and child.name not in self._excludes
255
- and not child.name.startswith(".")
256
- ):
257
- for name in MANIFEST_NAMES:
258
- candidate = child / name
259
- if candidate.exists() and not candidate.is_symlink():
260
- manifests.append(str(candidate))
261
- except PermissionError:
262
- pass
263
- return manifests
238
+ """Find manifest files at depth 0-1 (delegates to the shared helper)."""
239
+ return find_manifest_paths(self.root, self._excludes)
@@ -176,18 +176,10 @@ def _find_child(node: Any, *types: str) -> Optional[Any]:
176
176
  return None
177
177
 
178
178
 
179
- def _find_all(node: Any, *types: str) -> list[Any]:
180
- return [child for child in node.children if child.type in types]
181
-
182
-
183
179
  def _text(node: Any, src: bytes) -> str:
184
180
  return src[node.start_byte:node.end_byte].decode("utf-8", errors="replace")
185
181
 
186
182
 
187
- def _named_children(node: Any) -> list[Any]:
188
- return [c for c in node.children if c.is_named]
189
-
190
-
191
183
  # ---------------------------------------------------------------------------
192
184
  # Tree-sitter TS/JS extraction
193
185
  # ---------------------------------------------------------------------------
@@ -967,25 +959,26 @@ def _java_contract_from_graph(rel_path: str, graph: "Any") -> FileContract:
967
959
  nodes/edges instead of re-parsing source. Method signatures are the graph's
968
960
  canonical compact form (`(Type)->Return`); annotations are node-grounded.
969
961
  """
970
- file_syms = [s for s in graph.symbols() if s.source_file == rel_path]
962
+ file_syms = graph.symbols_in_file(rel_path)
971
963
  type_syms = [s for s in file_syms if s.kind in ("class", "interface", "enum", "annotation")]
972
964
  type_fqns = {s.fqn for s in type_syms}
973
965
 
974
966
  # extends / implements edges out of this file's types (targets are simple
975
967
  # names for out-of-repo supertypes, resolved FQNs for in-repo ones — the
976
- # same forms the old regex captured for the unresolved case).
968
+ # same forms the old regex captured for the unresolved case). Only edges
969
+ # sourced at this file's types matter, so index straight to them instead of
970
+ # scanning the whole-repo relation set once per file.
977
971
  extends_of: dict[str, list[str]] = {}
978
972
  implements_of: dict[str, list[str]] = {}
979
973
  import_targets: set[str] = set()
980
- for r in graph.relations():
981
- if r.source not in type_fqns:
982
- continue
983
- if r.kind == "extends":
984
- extends_of.setdefault(r.source, []).append(r.target)
985
- elif r.kind == "implements":
986
- implements_of.setdefault(r.source, []).append(r.target)
987
- elif r.kind == "imports":
988
- import_targets.add(r.target)
974
+ for fqn in type_fqns:
975
+ for r in graph.relations_from(fqn):
976
+ if r.kind == "extends":
977
+ extends_of.setdefault(r.source, []).append(r.target)
978
+ elif r.kind == "implements":
979
+ implements_of.setdefault(r.source, []).append(r.target)
980
+ elif r.kind == "imports":
981
+ import_targets.add(r.target)
989
982
 
990
983
  exports: list[ExportRecord] = []
991
984
  types: list[TypeDefinition] = []
@@ -15,6 +15,7 @@ from sourcecode import __version__
15
15
  from sourcecode.error_schema import INVALID_INPUT_CODE, build_error_envelope
16
16
  from sourcecode.entrypoint_classifier import is_production_entry_point, normalize_entry_point
17
17
  from sourcecode.progress import Progress
18
+ from sourcecode import perf
18
19
  from sourcecode.caller_metrics import CALLER_METRIC_RECONCILIATION
19
20
  from sourcecode.repository_ir import extract_java_endpoints as _extract_java_endpoints
20
21
 
@@ -1703,6 +1704,7 @@ def main(
1703
1704
  _progress = Progress()
1704
1705
  _progress.start("scanning files")
1705
1706
 
1707
+ _perf_discovery = perf.start()
1706
1708
  scanner = AdaptiveScanner(target, topology=_topology, base_depth=effective_depth,
1707
1709
  extra_excludes=_extra_excludes)
1708
1710
  raw_tree = scanner.scan_tree()
@@ -1710,6 +1712,8 @@ def main(
1710
1712
  _progress.update("parsing manifests")
1711
1713
  # 2. Filter .env and *.secret entries from file tree (SEC-02, all levels)
1712
1714
  file_tree = filter_sensitive_files(raw_tree, redactor)
1715
+ perf.stop("discovery", _perf_discovery)
1716
+ _perf_detection = perf.start()
1713
1717
  detector = ProjectDetector(build_default_detectors())
1714
1718
  workspace_analysis = WorkspaceAnalyzer().analyze(target, manifests)
1715
1719
 
@@ -1951,6 +1955,8 @@ def main(
1951
1955
  else None
1952
1956
  )
1953
1957
 
1958
+ perf.stop("detection", _perf_detection)
1959
+ _perf_analysis = perf.start()
1954
1960
  # 3. Build schema
1955
1961
  # Compute analyzer fingerprints: short hashes of each analyzer's key rule
1956
1962
  # constants so that a rule change is always visible in the output, regardless
@@ -2411,8 +2417,10 @@ def main(
2411
2417
  err=True,
2412
2418
  )
2413
2419
 
2420
+ perf.stop("analysis", _perf_analysis)
2414
2421
  # 4. Serialize
2415
2422
  _progress.update("serializing output")
2423
+ _perf_serialize = perf.start()
2416
2424
  if _is_contract_mode and not agent:
2417
2425
  from sourcecode.serializer import contract_view as _contract_view
2418
2426
  _depth = _CONTRACT_DEPTH.get(mode, "minimal")
@@ -2455,6 +2463,9 @@ def main(
2455
2463
  raw_dict = redact_dict(raw_dict)
2456
2464
  content = _serialize_dict(raw_dict, format)
2457
2465
 
2466
+ perf.stop("serialize", _perf_serialize)
2467
+ perf.flush_recorder()
2468
+
2458
2469
  # 5. Telemetry (fire-and-forget, never blocks)
2459
2470
  try:
2460
2471
  from sourcecode import telemetry as _tel
@@ -408,6 +408,7 @@ class ContextGraph:
408
408
  "_cir", "_nodes_by_fqn", "_nodes", "_build_ms",
409
409
  "_body_index", "_literal_index", "_guard_index", "_span_index",
410
410
  "_class_type_index", "_relations_all", "_symbols_sorted",
411
+ "_symbols_by_file", "_relations_by_source",
411
412
  )
412
413
 
413
414
  def __init__(self, cir: CanonicalRepositoryIR, *, build_ms: float = 0.0) -> None:
@@ -439,6 +440,12 @@ class ContextGraph:
439
440
  # filtered cheaply thereafter.
440
441
  self._relations_all: Optional[tuple[Relation, ...]] = None
441
442
  self._symbols_sorted: Optional[tuple[Symbol, ...]] = None
443
+ # Per-key groupings for per-file consumers (`_java_contract_from_graph`
444
+ # runs once per file). Without these, each file filtered the whole-repo
445
+ # symbol/relation set — the residual O(files × total) scan that the
446
+ # memoized projections above did not remove. Built once, O(1) lookup.
447
+ self._symbols_by_file: Optional[dict[str, list[Symbol]]] = None
448
+ self._relations_by_source: Optional[dict[str, list[Relation]]] = None
442
449
 
443
450
  # -- construction -------------------------------------------------------
444
451
 
@@ -518,6 +525,34 @@ class ContextGraph:
518
525
  and (name_contains is None or name_contains in s.fqn)
519
526
  ]
520
527
 
528
+ def symbols_in_file(self, source_file: str) -> list[Symbol]:
529
+ """All symbols declared in one file, FQN-sorted — the same order and set
530
+ a no-filter ``symbols()`` scan filtered to this file would produce, but
531
+ O(1) after a one-time O(total) grouping instead of O(total) per call.
532
+ """
533
+ if self._symbols_by_file is None:
534
+ if self._symbols_sorted is None:
535
+ self._symbols_sorted = tuple(sorted(self._nodes, key=lambda s: s.fqn))
536
+ idx: dict[str, list[Symbol]] = {}
537
+ for s in self._symbols_sorted:
538
+ idx.setdefault(s.source_file, []).append(s)
539
+ self._symbols_by_file = idx
540
+ return self._symbols_by_file.get(source_file, [])
541
+
542
+ def relations_from(self, source_fqn: str) -> list[Relation]:
543
+ """All relations whose source is ``source_fqn``, in the IR's stable edge
544
+ order. O(1) after a one-time O(edges) grouping — replaces per-file scans
545
+ of the whole relation set.
546
+ """
547
+ if self._relations_by_source is None:
548
+ if self._relations_all is None:
549
+ self._relations_all = tuple(_edge_to_relation(e) for e in self._cir.call_graph)
550
+ idx: dict[str, list[Relation]] = {}
551
+ for r in self._relations_all:
552
+ idx.setdefault(r.source, []).append(r)
553
+ self._relations_by_source = idx
554
+ return self._relations_by_source.get(source_fqn, [])
555
+
521
556
  def types(self) -> list[Symbol]:
522
557
  """All class/interface/enum/annotation symbols, sorted by FQN."""
523
558
  return sorted(
@@ -39,20 +39,6 @@ def _safe_int(value: Optional[str]) -> Optional[int]:
39
39
  return None
40
40
 
41
41
 
42
- def _decode_numbits(blob: bytes) -> list[int]:
43
- """Decodifica bitset little-endian de coverage.py .coverage SQLite.
44
-
45
- Byte i, bit j set => linea (i*8 + j + 1) ejecutada.
46
- Ejemplo: bytes([0b00000101]) => [1, 3]
47
- """
48
- return [
49
- i * 8 + j + 1
50
- for i, byte in enumerate(blob)
51
- for j in range(8)
52
- if byte & (1 << j)
53
- ]
54
-
55
-
56
42
  # ---------------------------------------------------------------------------
57
43
  # CoverageParser
58
44
  # ---------------------------------------------------------------------------
@@ -534,8 +534,3 @@ class EnvAnalyzer:
534
534
  def _replace_description(record, description: str):
535
535
  from dataclasses import replace
536
536
  return replace(record, description=description)
537
-
538
-
539
- def _replace_required(record, required: bool, default: Optional[str]):
540
- from dataclasses import replace
541
- return replace(record, required=required, default=default if not record.default else record.default)