graphitect 0.2.0__py3-none-any.whl
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.
- graphify/__init__.py +30 -0
- graphify/__main__.py +757 -0
- graphify/_minhash.py +107 -0
- graphify/affected.py +318 -0
- graphify/always_on/agents-md.md +12 -0
- graphify/always_on/antigravity-rules.md +14 -0
- graphify/always_on/claude-md.md +9 -0
- graphify/always_on/gemini-md.md +9 -0
- graphify/always_on/kiro-steering.md +5 -0
- graphify/always_on/vscode-instructions.md +17 -0
- graphify/analyze.py +769 -0
- graphify/benchmark.py +152 -0
- graphify/build.py +2300 -0
- graphify/cache.py +1746 -0
- graphify/callflow_html.py +2051 -0
- graphify/cargo_introspect.py +109 -0
- graphify/cli.py +4745 -0
- graphify/cluster.py +409 -0
- graphify/command-kilo.md +15 -0
- graphify/cross_repo_calls.py +216 -0
- graphify/cross_repo_types.py +75 -0
- graphify/csharp_dispatch.py +154 -0
- graphify/dedup.py +1213 -0
- graphify/detect.py +2566 -0
- graphify/diagnostics.py +406 -0
- graphify/export.py +1349 -0
- graphify/exporters/__init__.py +1 -0
- graphify/exporters/base.py +14 -0
- graphify/exporters/graphdb.py +173 -0
- graphify/exporters/html.py +637 -0
- graphify/extract.py +7856 -0
- graphify/extractors/MIGRATION.md +107 -0
- graphify/extractors/__init__.py +66 -0
- graphify/extractors/apex.py +215 -0
- graphify/extractors/base.py +85 -0
- graphify/extractors/bash.py +579 -0
- graphify/extractors/blade.py +53 -0
- graphify/extractors/commonlisp.py +540 -0
- graphify/extractors/csharp.py +448 -0
- graphify/extractors/dart.py +564 -0
- graphify/extractors/dm.py +494 -0
- graphify/extractors/elixir.py +241 -0
- graphify/extractors/engine.py +6509 -0
- graphify/extractors/fortran.py +311 -0
- graphify/extractors/go.py +527 -0
- graphify/extractors/json_config.py +240 -0
- graphify/extractors/julia.py +289 -0
- graphify/extractors/markdown.py +408 -0
- graphify/extractors/models.py +131 -0
- graphify/extractors/objc.py +566 -0
- graphify/extractors/ocaml.py +289 -0
- graphify/extractors/pascal.py +688 -0
- graphify/extractors/pascal_forms.py +196 -0
- graphify/extractors/powershell.py +522 -0
- graphify/extractors/razor.py +192 -0
- graphify/extractors/resolution.py +3584 -0
- graphify/extractors/robot.py +296 -0
- graphify/extractors/rust.py +470 -0
- graphify/extractors/sln.py +92 -0
- graphify/extractors/sql.py +720 -0
- graphify/extractors/terraform.py +181 -0
- graphify/extractors/verilog.py +329 -0
- graphify/extractors/zig.py +181 -0
- graphify/file_slice.py +246 -0
- graphify/global_graph.py +194 -0
- graphify/google_workspace.py +237 -0
- graphify/hooks.py +933 -0
- graphify/ids.py +93 -0
- graphify/ingest.py +358 -0
- graphify/install.py +2366 -0
- graphify/llm.py +3544 -0
- graphify/manifest.py +4 -0
- graphify/manifest_ingest.py +311 -0
- graphify/mcp_ingest.py +386 -0
- graphify/multigraph_compat.py +212 -0
- graphify/pascal_resolution.py +129 -0
- graphify/paths.py +436 -0
- graphify/pg_introspect.py +165 -0
- graphify/prs.py +770 -0
- graphify/querylog.py +80 -0
- graphify/reflect.py +882 -0
- graphify/report.py +346 -0
- graphify/resolver_registry.py +85 -0
- graphify/ruby_resolution.py +242 -0
- graphify/scip_ingest.py +363 -0
- graphify/security.py +460 -0
- graphify/semantic_cleanup.py +336 -0
- graphify/serve.py +2608 -0
- graphify/skill-agents.md +710 -0
- graphify/skill-aider.md +1283 -0
- graphify/skill-amp.md +710 -0
- graphify/skill-claw.md +713 -0
- graphify/skill-codex.md +710 -0
- graphify/skill-copilot.md +713 -0
- graphify/skill-devin.md +1410 -0
- graphify/skill-droid.md +710 -0
- graphify/skill-kilo.md +722 -0
- graphify/skill-kiro.md +713 -0
- graphify/skill-opencode.md +705 -0
- graphify/skill-pi.md +713 -0
- graphify/skill-trae.md +711 -0
- graphify/skill-vscode.md +709 -0
- graphify/skill-windows.md +755 -0
- graphify/skill.md +713 -0
- graphify/skills/agents/references/add-watch.md +56 -0
- graphify/skills/agents/references/exports.md +87 -0
- graphify/skills/agents/references/extraction-spec.md +70 -0
- graphify/skills/agents/references/github-and-merge.md +46 -0
- graphify/skills/agents/references/hooks.md +33 -0
- graphify/skills/agents/references/query.md +311 -0
- graphify/skills/agents/references/transcribe.md +52 -0
- graphify/skills/agents/references/update.md +210 -0
- graphify/skills/amp/references/add-watch.md +56 -0
- graphify/skills/amp/references/exports.md +87 -0
- graphify/skills/amp/references/extraction-spec.md +70 -0
- graphify/skills/amp/references/github-and-merge.md +46 -0
- graphify/skills/amp/references/hooks.md +33 -0
- graphify/skills/amp/references/query.md +311 -0
- graphify/skills/amp/references/transcribe.md +52 -0
- graphify/skills/amp/references/update.md +210 -0
- graphify/skills/claude/references/add-watch.md +56 -0
- graphify/skills/claude/references/exports.md +87 -0
- graphify/skills/claude/references/extraction-spec.md +70 -0
- graphify/skills/claude/references/github-and-merge.md +46 -0
- graphify/skills/claude/references/hooks.md +33 -0
- graphify/skills/claude/references/query.md +311 -0
- graphify/skills/claude/references/transcribe.md +52 -0
- graphify/skills/claude/references/update.md +210 -0
- graphify/skills/claw/references/add-watch.md +56 -0
- graphify/skills/claw/references/exports.md +87 -0
- graphify/skills/claw/references/extraction-spec.md +31 -0
- graphify/skills/claw/references/github-and-merge.md +46 -0
- graphify/skills/claw/references/hooks.md +33 -0
- graphify/skills/claw/references/query.md +311 -0
- graphify/skills/claw/references/transcribe.md +52 -0
- graphify/skills/claw/references/update.md +210 -0
- graphify/skills/codex/references/add-watch.md +56 -0
- graphify/skills/codex/references/exports.md +87 -0
- graphify/skills/codex/references/extraction-spec.md +31 -0
- graphify/skills/codex/references/github-and-merge.md +46 -0
- graphify/skills/codex/references/hooks.md +33 -0
- graphify/skills/codex/references/query.md +311 -0
- graphify/skills/codex/references/transcribe.md +52 -0
- graphify/skills/codex/references/update.md +210 -0
- graphify/skills/copilot/references/add-watch.md +56 -0
- graphify/skills/copilot/references/exports.md +87 -0
- graphify/skills/copilot/references/extraction-spec.md +70 -0
- graphify/skills/copilot/references/github-and-merge.md +46 -0
- graphify/skills/copilot/references/hooks.md +33 -0
- graphify/skills/copilot/references/query.md +311 -0
- graphify/skills/copilot/references/transcribe.md +52 -0
- graphify/skills/copilot/references/update.md +210 -0
- graphify/skills/droid/references/add-watch.md +56 -0
- graphify/skills/droid/references/exports.md +87 -0
- graphify/skills/droid/references/extraction-spec.md +70 -0
- graphify/skills/droid/references/github-and-merge.md +46 -0
- graphify/skills/droid/references/hooks.md +33 -0
- graphify/skills/droid/references/query.md +311 -0
- graphify/skills/droid/references/transcribe.md +52 -0
- graphify/skills/droid/references/update.md +210 -0
- graphify/skills/kilo/references/add-watch.md +56 -0
- graphify/skills/kilo/references/exports.md +87 -0
- graphify/skills/kilo/references/extraction-spec.md +70 -0
- graphify/skills/kilo/references/github-and-merge.md +46 -0
- graphify/skills/kilo/references/hooks.md +33 -0
- graphify/skills/kilo/references/query.md +311 -0
- graphify/skills/kilo/references/transcribe.md +52 -0
- graphify/skills/kilo/references/update.md +210 -0
- graphify/skills/kiro/references/add-watch.md +56 -0
- graphify/skills/kiro/references/exports.md +87 -0
- graphify/skills/kiro/references/extraction-spec.md +31 -0
- graphify/skills/kiro/references/github-and-merge.md +46 -0
- graphify/skills/kiro/references/hooks.md +33 -0
- graphify/skills/kiro/references/query.md +311 -0
- graphify/skills/kiro/references/transcribe.md +52 -0
- graphify/skills/kiro/references/update.md +210 -0
- graphify/skills/opencode/references/add-watch.md +56 -0
- graphify/skills/opencode/references/exports.md +87 -0
- graphify/skills/opencode/references/extraction-spec.md +70 -0
- graphify/skills/opencode/references/github-and-merge.md +46 -0
- graphify/skills/opencode/references/hooks.md +33 -0
- graphify/skills/opencode/references/query.md +311 -0
- graphify/skills/opencode/references/transcribe.md +52 -0
- graphify/skills/opencode/references/update.md +210 -0
- graphify/skills/pi/references/add-watch.md +56 -0
- graphify/skills/pi/references/exports.md +87 -0
- graphify/skills/pi/references/extraction-spec.md +31 -0
- graphify/skills/pi/references/github-and-merge.md +46 -0
- graphify/skills/pi/references/hooks.md +33 -0
- graphify/skills/pi/references/query.md +311 -0
- graphify/skills/pi/references/transcribe.md +52 -0
- graphify/skills/pi/references/update.md +210 -0
- graphify/skills/trae/references/add-watch.md +56 -0
- graphify/skills/trae/references/exports.md +87 -0
- graphify/skills/trae/references/extraction-spec.md +70 -0
- graphify/skills/trae/references/github-and-merge.md +46 -0
- graphify/skills/trae/references/hooks.md +35 -0
- graphify/skills/trae/references/query.md +311 -0
- graphify/skills/trae/references/transcribe.md +52 -0
- graphify/skills/trae/references/update.md +210 -0
- graphify/skills/vscode/references/add-watch.md +56 -0
- graphify/skills/vscode/references/exports.md +87 -0
- graphify/skills/vscode/references/extraction-spec.md +70 -0
- graphify/skills/vscode/references/github-and-merge.md +46 -0
- graphify/skills/vscode/references/hooks.md +33 -0
- graphify/skills/vscode/references/query.md +311 -0
- graphify/skills/vscode/references/transcribe.md +52 -0
- graphify/skills/vscode/references/update.md +210 -0
- graphify/skills/windows/references/add-watch.md +56 -0
- graphify/skills/windows/references/exports.md +87 -0
- graphify/skills/windows/references/extraction-spec.md +70 -0
- graphify/skills/windows/references/github-and-merge.md +46 -0
- graphify/skills/windows/references/hooks.md +33 -0
- graphify/skills/windows/references/query.md +311 -0
- graphify/skills/windows/references/transcribe.md +52 -0
- graphify/skills/windows/references/update.md +210 -0
- graphify/symbol_resolution.py +556 -0
- graphify/transcribe.py +186 -0
- graphify/tree_html.py +603 -0
- graphify/validate.py +95 -0
- graphify/watch.py +2280 -0
- graphify/wiki.py +405 -0
- graphitect/__init__.py +28 -0
- graphitect/__main__.py +4 -0
- graphitect/_vendor/__init__.py +2 -0
- graphitect/_vendor/archify/LICENSE +22 -0
- graphitect/_vendor/archify/SKILL.md +137 -0
- graphitect/_vendor/archify/THIRD_PARTY_NOTICES.md +69 -0
- graphitect/_vendor/archify/assets/JetBrainsMono-OFL.txt +93 -0
- graphitect/_vendor/archify/assets/template.html +14935 -0
- graphitect/_vendor/archify/bin/archify.mjs +2091 -0
- graphitect/_vendor/archify/bin/open-artifact.mjs +86 -0
- graphitect/_vendor/archify/bin/preview.mjs +653 -0
- graphitect/_vendor/archify/bin/visual-check.mjs +829 -0
- graphitect/_vendor/archify/brand-marks/README.md +31 -0
- graphitect/_vendor/archify/brand-marks/catalog.json +131 -0
- graphitect/_vendor/archify/delta/architecture-delta.mjs +1221 -0
- graphitect/_vendor/archify/examples/agent-run.lifecycle.json +60 -0
- graphitect/_vendor/archify/examples/agent-tool-call.workflow.json +94 -0
- graphitect/_vendor/archify/examples/async-job-roundtrip.sequence.json +61 -0
- graphitect/_vendor/archify/examples/brand-aware-delivery.architecture.json +47 -0
- graphitect/_vendor/archify/examples/cache-miss-request.sequence.json +82 -0
- graphitect/_vendor/archify/examples/checkout-platform.base.architecture.json +31 -0
- graphitect/_vendor/archify/examples/checkout-platform.head.architecture.json +31 -0
- graphitect/_vendor/archify/examples/dataflow-product-analytics.html +15045 -0
- graphitect/_vendor/archify/examples/deployment-release.lifecycle.json +49 -0
- graphitect/_vendor/archify/examples/event-stream.dataflow.json +57 -0
- graphitect/_vendor/archify/examples/incident-response.workflow.json +64 -0
- graphitect/_vendor/archify/examples/lifecycle-agent-run.html +14980 -0
- graphitect/_vendor/archify/examples/product-analytics.dataflow.json +76 -0
- graphitect/_vendor/archify/examples/production-deployment.architecture.json +71 -0
- graphitect/_vendor/archify/examples/release-delivery.workflow.json +62 -0
- graphitect/_vendor/archify/examples/sequence-cache-miss-request.html +15060 -0
- graphitect/_vendor/archify/examples/web-app-rendered.html +15009 -0
- graphitect/_vendor/archify/examples/web-app.architecture.json +46 -0
- graphitect/_vendor/archify/examples/workflow-agent-tool-call-rendered.html +15051 -0
- graphitect/_vendor/archify/migrations/workflow-v2.mjs +279 -0
- graphitect/_vendor/archify/package-lock.json +149 -0
- graphitect/_vendor/archify/package.json +39 -0
- graphitect/_vendor/archify/recipes/scenarios.mjs +391 -0
- graphitect/_vendor/archify/references/authoring-contract.md +243 -0
- graphitect/_vendor/archify/references/brand-marks.md +65 -0
- graphitect/_vendor/archify/references/delivery-contract.md +120 -0
- graphitect/_vendor/archify/references/viewer-runtime.md +45 -0
- graphitect/_vendor/archify/renderers/architecture/grid.mjs +62 -0
- graphitect/_vendor/archify/renderers/architecture/render-architecture.mjs +1078 -0
- graphitect/_vendor/archify/renderers/dataflow/README.md +104 -0
- graphitect/_vendor/archify/renderers/dataflow/render-dataflow.mjs +483 -0
- graphitect/_vendor/archify/renderers/lifecycle/README.md +115 -0
- graphitect/_vendor/archify/renderers/lifecycle/render-lifecycle.mjs +561 -0
- graphitect/_vendor/archify/renderers/sequence/README.md +114 -0
- graphitect/_vendor/archify/renderers/sequence/render-sequence.mjs +464 -0
- graphitect/_vendor/archify/renderers/shared/brand-marks.mjs +563 -0
- graphitect/_vendor/archify/renderers/shared/cli.mjs +218 -0
- graphitect/_vendor/archify/renderers/shared/desktop-readability.mjs +26 -0
- graphitect/_vendor/archify/renderers/shared/diagnostics.mjs +127 -0
- graphitect/_vendor/archify/renderers/shared/engineering-profiles.mjs +157 -0
- graphitect/_vendor/archify/renderers/shared/generated-brand-marks.mjs +2003 -0
- graphitect/_vendor/archify/renderers/shared/generated-validators.mjs +13 -0
- graphitect/_vendor/archify/renderers/shared/geometry.mjs +1423 -0
- graphitect/_vendor/archify/renderers/shared/i18n.mjs +595 -0
- graphitect/_vendor/archify/renderers/shared/layout-report.mjs +40 -0
- graphitect/_vendor/archify/renderers/shared/legend.mjs +217 -0
- graphitect/_vendor/archify/renderers/shared/output-path.mjs +340 -0
- graphitect/_vendor/archify/renderers/shared/repository-evidence.mjs +238 -0
- graphitect/_vendor/archify/renderers/shared/repository-location.mjs +58 -0
- graphitect/_vendor/archify/renderers/shared/text-fit.mjs +49 -0
- graphitect/_vendor/archify/renderers/shared/utils.mjs +232 -0
- graphitect/_vendor/archify/renderers/shared/validator.mjs +86 -0
- graphitect/_vendor/archify/renderers/workflow/README.md +223 -0
- graphitect/_vendor/archify/renderers/workflow/render-workflow.mjs +35 -0
- graphitect/_vendor/archify/renderers/workflow/workflow-compiler.mjs +4400 -0
- graphitect/_vendor/archify/renderers/workflow/workflow-migration-geometry.mjs +144 -0
- graphitect/_vendor/archify/schemas/README.md +211 -0
- graphitect/_vendor/archify/schemas/architecture.schema.json +178 -0
- graphitect/_vendor/archify/schemas/common.schema.json +115 -0
- graphitect/_vendor/archify/schemas/dataflow.schema.json +243 -0
- graphitect/_vendor/archify/schemas/lifecycle.schema.json +266 -0
- graphitect/_vendor/archify/schemas/sequence.schema.json +223 -0
- graphitect/_vendor/archify/schemas/workflow.schema.json +428 -0
- graphitect/_vendor/archify/scripts/check-render-output.mjs +836 -0
- graphitect/_vendor/archify/scripts/check-update.mjs +1667 -0
- graphitect/_vendor/archify/scripts/generate-brand-marks.mjs +141 -0
- graphitect/_vendor/archify/scripts/generate-validators.mjs +66 -0
- graphitect/_vendor/archify/scripts/render-examples.mjs +26 -0
- graphitect/_vendor/archify/scripts/update-contract.mjs +182 -0
- graphitect/_vendor/archify/skill-release.json +10 -0
- graphitect/cli.py +981 -0
- graphitect/deliver/__init__.py +5 -0
- graphitect/deliver/archify_adapter.py +1877 -0
- graphitect/deliver/archify_ir.py +160 -0
- graphitect/deliver/archify_repair.py +135 -0
- graphitect/deliver/doc_compiler.py +916 -0
- graphitect/ground/__init__.py +5 -0
- graphitect/ground/describe_source.py +27 -0
- graphitect/ground/fullread_source.py +56 -0
- graphitect/ground/graphify_source.py +107 -0
- graphitect/models.py +118 -0
- graphitect/skill/SKILL.md +80 -0
- graphitect/skill/agents/openai.yaml +4 -0
- graphitect/synthesize/__init__.py +5 -0
- graphitect/synthesize/engine.py +281 -0
- graphitect/synthesize/llm_backend.py +331 -0
- graphitect/synthesize/questions.py +139 -0
- graphitect/synthesize/rubric.py +104 -0
- graphitect-0.2.0.dist-info/METADATA +284 -0
- graphitect-0.2.0.dist-info/RECORD +336 -0
- graphitect-0.2.0.dist-info/WHEEL +5 -0
- graphitect-0.2.0.dist-info/entry_points.txt +2 -0
- graphitect-0.2.0.dist-info/licenses/LICENSE +21 -0
- graphitect-0.2.0.dist-info/licenses/LICENSE-ARCHIFY-MIT +22 -0
- graphitect-0.2.0.dist-info/licenses/LICENSE-GRAPHIFY-APACHE-2.0 +202 -0
- graphitect-0.2.0.dist-info/licenses/LICENSE-GRAPHIFY-MIT +21 -0
- graphitect-0.2.0.dist-info/licenses/NOTICE-ARCHIFY-THIRD-PARTY.md +69 -0
- graphitect-0.2.0.dist-info/licenses/NOTICE-GRAPHIFY +8 -0
- graphitect-0.2.0.dist-info/top_level.txt +2 -0
graphify/report.py
ADDED
|
@@ -0,0 +1,346 @@
|
|
|
1
|
+
# generate GRAPH_REPORT.md - the human-readable audit trail
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
import re
|
|
4
|
+
from datetime import date
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
import networkx as nx
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def _portable_root_label(root: str) -> str:
|
|
10
|
+
"""Portable label for the report header — the project directory basename.
|
|
11
|
+
|
|
12
|
+
GRAPH_REPORT.md is a tracked artifact in practice, so its header must not
|
|
13
|
+
bake the generator host's absolute path into the file: the same graph would
|
|
14
|
+
otherwise produce different bytes on different machines and leak the build
|
|
15
|
+
machine's directory layout into git history (#2628, same class as #2598).
|
|
16
|
+
|
|
17
|
+
Taking the basename strips any leading absolute path without touching the
|
|
18
|
+
filesystem, and makes `graphify update .`, `graphify update ./proj`, and
|
|
19
|
+
`graphify update /abs/path/proj` all label the header `proj`. Only the
|
|
20
|
+
degenerate `.`/``/`..` cases need a cwd resolve to recover the real name;
|
|
21
|
+
if even that fails, fall back to the raw value.
|
|
22
|
+
"""
|
|
23
|
+
raw = str(root).replace("\\", "/")
|
|
24
|
+
name = Path(raw).name
|
|
25
|
+
if name in ("", ".", ".."):
|
|
26
|
+
try:
|
|
27
|
+
name = Path(raw).resolve().name
|
|
28
|
+
except (OSError, RuntimeError):
|
|
29
|
+
name = ""
|
|
30
|
+
return name or raw
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _safe_community_name(label: str) -> str:
|
|
34
|
+
"""Mirrors export.safe_name so community hub filenames and report wikilinks always agree."""
|
|
35
|
+
cleaned = re.sub(r'[\\/*?:"<>|#^[\]]', "", label.replace("\r\n", " ").replace("\r", " ").replace("\n", " ")).strip()
|
|
36
|
+
cleaned = re.sub(r"\.(md|mdx|markdown)$", "", cleaned, flags=re.IGNORECASE)
|
|
37
|
+
return cleaned or "unnamed"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def load_learning_for_report(graph_path) -> dict | None:
|
|
41
|
+
"""Assemble the report's work-memory inputs from sibling artifacts.
|
|
42
|
+
|
|
43
|
+
Reads the ``.graphify_learning.json`` overlay (preferred sources) next to
|
|
44
|
+
``graph_path`` and re-aggregates the memory docs for the query-scoped
|
|
45
|
+
dead-ends. Best-effort: returns None if neither is available, so the report
|
|
46
|
+
simply omits the section. Never raises.
|
|
47
|
+
"""
|
|
48
|
+
from pathlib import Path as _Path
|
|
49
|
+
try:
|
|
50
|
+
gp = _Path(graph_path)
|
|
51
|
+
from graphify.reflect import load_learning_overlay, load_memory_docs, aggregate_lessons
|
|
52
|
+
overlay = load_learning_overlay(gp)
|
|
53
|
+
dead_ends: list[dict] = []
|
|
54
|
+
mem = gp.parent / "memory"
|
|
55
|
+
if mem.is_dir():
|
|
56
|
+
agg = aggregate_lessons(load_memory_docs(mem))
|
|
57
|
+
dead_ends = agg.get("dead_ends", [])
|
|
58
|
+
if not overlay and not dead_ends:
|
|
59
|
+
return None
|
|
60
|
+
return {"overlay": overlay, "dead_ends": dead_ends}
|
|
61
|
+
except Exception:
|
|
62
|
+
return None
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _learning_section(lines: list, learning: dict | None, top_n: int = 10) -> None:
|
|
66
|
+
"""Append the ``## Work-memory lessons`` section, or nothing when empty."""
|
|
67
|
+
if not learning:
|
|
68
|
+
return
|
|
69
|
+
overlay = learning.get("overlay") or {}
|
|
70
|
+
dead_ends = learning.get("dead_ends") or []
|
|
71
|
+
preferred = [
|
|
72
|
+
(nid, e) for nid, e in overlay.items()
|
|
73
|
+
if isinstance(e, dict) and e.get("status") == "preferred"
|
|
74
|
+
]
|
|
75
|
+
# Most-corroborated first (uses desc), then by score, then id for stability.
|
|
76
|
+
preferred.sort(key=lambda kv: (-kv[1].get("uses", 0),
|
|
77
|
+
-float(kv[1].get("score", 0) or 0), kv[0]))
|
|
78
|
+
if not preferred and not dead_ends:
|
|
79
|
+
return
|
|
80
|
+
lines += ["", "## Work-memory lessons"]
|
|
81
|
+
if preferred:
|
|
82
|
+
lines += ["", "**Preferred sources** — corroborated by past sessions; start here."]
|
|
83
|
+
for nid, e in preferred[:top_n]:
|
|
84
|
+
label = e.get("label") or nid
|
|
85
|
+
stale = " _(code changed — re-verify)_" if e.get("stale") else ""
|
|
86
|
+
lines.append(f"- `{label}` ({e.get('uses', 0)}× useful, "
|
|
87
|
+
f"score={e.get('score', 0)}){stale}")
|
|
88
|
+
if dead_ends:
|
|
89
|
+
lines += ["", "**Known dead ends** — questions that led nowhere; don't re-derive."]
|
|
90
|
+
for d in dead_ends:
|
|
91
|
+
nodes = ", ".join(f"`{n}`" for n in d.get("nodes", []))
|
|
92
|
+
lines.append(f"- \"{d.get('question', '')}\""
|
|
93
|
+
+ (f" -> {nodes}" if nodes else ""))
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def generate(
|
|
97
|
+
G: nx.Graph,
|
|
98
|
+
communities: dict[int, list[str]],
|
|
99
|
+
cohesion_scores: dict[int, float],
|
|
100
|
+
community_labels: dict[int, str],
|
|
101
|
+
god_node_list: list[dict],
|
|
102
|
+
surprise_list: list[dict],
|
|
103
|
+
detection_result: dict,
|
|
104
|
+
token_cost: dict,
|
|
105
|
+
root: str,
|
|
106
|
+
suggested_questions: list[dict] | None = None,
|
|
107
|
+
min_community_size: int = 3,
|
|
108
|
+
built_at_commit: str | None = None,
|
|
109
|
+
learning: dict | None = None,
|
|
110
|
+
obsidian: bool = False,
|
|
111
|
+
) -> str:
|
|
112
|
+
today = date.today().isoformat()
|
|
113
|
+
|
|
114
|
+
# JSON deserialization produces string keys; normalize to int so .get(cid) works.
|
|
115
|
+
if community_labels:
|
|
116
|
+
community_labels = {int(k) if isinstance(k, str) else k: v for k, v in community_labels.items()}
|
|
117
|
+
|
|
118
|
+
confidences = [d.get("confidence", "EXTRACTED") for _, _, d in G.edges(data=True)]
|
|
119
|
+
total = len(confidences) or 1
|
|
120
|
+
ext_pct = round(confidences.count("EXTRACTED") / total * 100)
|
|
121
|
+
inf_pct = round(confidences.count("INFERRED") / total * 100)
|
|
122
|
+
amb_pct = round(confidences.count("AMBIGUOUS") / total * 100)
|
|
123
|
+
|
|
124
|
+
inf_edges = [(u, v, d) for u, v, d in G.edges(data=True) if d.get("confidence") == "INFERRED"]
|
|
125
|
+
inf_scores = [d.get("confidence_score", 0.5) for _, _, d in inf_edges]
|
|
126
|
+
inf_avg = round(sum(inf_scores) / len(inf_scores), 2) if inf_scores else None
|
|
127
|
+
|
|
128
|
+
lines = [
|
|
129
|
+
f"# Graph Report - {_portable_root_label(root)} ({today})",
|
|
130
|
+
"",
|
|
131
|
+
"## Corpus Check",
|
|
132
|
+
]
|
|
133
|
+
if detection_result.get("warning"):
|
|
134
|
+
lines.append(f"- {detection_result['warning']}")
|
|
135
|
+
else:
|
|
136
|
+
lines += [
|
|
137
|
+
f"- {detection_result['total_files']} files · ~{detection_result['total_words']:,} words",
|
|
138
|
+
"- Verdict: corpus is large enough that graph structure adds value.",
|
|
139
|
+
]
|
|
140
|
+
|
|
141
|
+
from .analyze import _is_file_node as _ifn
|
|
142
|
+
|
|
143
|
+
def _real_count(nodes) -> int:
|
|
144
|
+
return sum(1 for n in nodes if not _ifn(G, n))
|
|
145
|
+
|
|
146
|
+
non_empty = {cid: nodes for cid, nodes in communities.items()
|
|
147
|
+
if any(not _ifn(G, n) for n in nodes)}
|
|
148
|
+
# One predicate for every figure the report prints about itself (#3148):
|
|
149
|
+
# "thin" is 0 < real < min_community_size, and "shown" is what the render
|
|
150
|
+
# loop below actually renders (real >= min_community_size) - previously
|
|
151
|
+
# shown was total-thin, which also counted communities with ZERO real
|
|
152
|
+
# nodes that the loop skips, overstating the count (#2129's residual).
|
|
153
|
+
thin_count_summary = sum(
|
|
154
|
+
1 for nodes in communities.values()
|
|
155
|
+
if 0 < _real_count(nodes) < min_community_size
|
|
156
|
+
)
|
|
157
|
+
shown_count = sum(
|
|
158
|
+
1 for nodes in communities.values()
|
|
159
|
+
if _real_count(nodes) >= min_community_size
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
lines += [
|
|
163
|
+
"",
|
|
164
|
+
"## Summary",
|
|
165
|
+
f"- {G.number_of_nodes()} nodes · {G.number_of_edges()} edges · {len(communities)} communities"
|
|
166
|
+
+ (f" ({shown_count} shown, {thin_count_summary} thin omitted)" if thin_count_summary else ""),
|
|
167
|
+
f"- Extraction: {ext_pct}% EXTRACTED · {inf_pct}% INFERRED · {amb_pct}% AMBIGUOUS"
|
|
168
|
+
+ (f" · INFERRED: {len(inf_edges)} edges (avg confidence: {inf_avg})" if inf_avg is not None else ""),
|
|
169
|
+
f"- Token cost: {token_cost.get('input', 0):,} input · {token_cost.get('output', 0):,} output",
|
|
170
|
+
]
|
|
171
|
+
|
|
172
|
+
if built_at_commit:
|
|
173
|
+
lines += [
|
|
174
|
+
"",
|
|
175
|
+
"## Graph Freshness",
|
|
176
|
+
f"- Built from commit: `{built_at_commit[:8]}`",
|
|
177
|
+
"- Run `git rev-parse HEAD` and compare to check if the graph is stale.",
|
|
178
|
+
"- Run `graphify update .` after code changes (no API cost).",
|
|
179
|
+
]
|
|
180
|
+
|
|
181
|
+
# Community hub navigation. The `_COMMUNITY_*.md` notes these wikilinks target
|
|
182
|
+
# are only created by the opt-in `--obsidian` export, and the report is written
|
|
183
|
+
# at build time (before any export runs), so emitting wikilinks by default left
|
|
184
|
+
# every link dangling — polluting an Obsidian vault's graph view and rendering as
|
|
185
|
+
# literal brackets everywhere else (#1712). Emit wikilinks only when the caller
|
|
186
|
+
# signals Obsidian output; otherwise a plain list, which navigates nowhere-to-break.
|
|
187
|
+
if non_empty:
|
|
188
|
+
lines += ["", "## Community Hubs (Navigation)"]
|
|
189
|
+
for cid in non_empty:
|
|
190
|
+
label = community_labels.get(cid, f"Community {cid}")
|
|
191
|
+
if obsidian:
|
|
192
|
+
safe = _safe_community_name(label)
|
|
193
|
+
lines.append(f"- [[_COMMUNITY_{safe}|{label}]]")
|
|
194
|
+
else:
|
|
195
|
+
lines.append(f"- {label}")
|
|
196
|
+
|
|
197
|
+
lines += [
|
|
198
|
+
"",
|
|
199
|
+
"## God Nodes (most connected - your core abstractions)",
|
|
200
|
+
]
|
|
201
|
+
for i, node in enumerate(god_node_list, 1):
|
|
202
|
+
lines.append(f"{i}. `{node['label']}` - {node['degree']} edges")
|
|
203
|
+
|
|
204
|
+
lines += ["", "## Surprising Connections (you probably didn't know these)"]
|
|
205
|
+
if surprise_list:
|
|
206
|
+
for s in surprise_list:
|
|
207
|
+
relation = s.get("relation", "related_to")
|
|
208
|
+
note = s.get("note", "")
|
|
209
|
+
files = s.get("source_files", ["", ""])
|
|
210
|
+
conf = s.get("confidence", "EXTRACTED")
|
|
211
|
+
cscore = s.get("confidence_score")
|
|
212
|
+
if conf == "INFERRED" and cscore is not None:
|
|
213
|
+
conf_tag = f"INFERRED {cscore:.2f}"
|
|
214
|
+
else:
|
|
215
|
+
conf_tag = conf
|
|
216
|
+
sem_tag = " [semantically similar]" if relation == "semantically_similar_to" else ""
|
|
217
|
+
lines += [
|
|
218
|
+
f"- `{s['source']}` --{relation}--> `{s['target']}` [{conf_tag}]{sem_tag}",
|
|
219
|
+
f" {files[0]} → {files[1]}" + (f" _{note}_" if note else ""),
|
|
220
|
+
]
|
|
221
|
+
else:
|
|
222
|
+
lines.append("- None detected - all connections are within the same source files.")
|
|
223
|
+
|
|
224
|
+
# Circular imports surfaced from file-level dependency graph. Only meaningful
|
|
225
|
+
# for code — a documents-only corpus has no imports, so the section is pure
|
|
226
|
+
# noise there ("None detected" on every run). Emit it only when the graph
|
|
227
|
+
# actually contains code (#1657).
|
|
228
|
+
_has_code = any(
|
|
229
|
+
d.get("file_type") == "code" for _, d in G.nodes(data=True)
|
|
230
|
+
) or any(
|
|
231
|
+
d.get("relation") in ("imports", "imports_from")
|
|
232
|
+
for *_e, d in G.edges(data=True)
|
|
233
|
+
)
|
|
234
|
+
if _has_code:
|
|
235
|
+
from .analyze import find_import_cycles
|
|
236
|
+
cycles = find_import_cycles(G)
|
|
237
|
+
lines += ["", "## Import Cycles"]
|
|
238
|
+
if cycles:
|
|
239
|
+
for c in cycles:
|
|
240
|
+
cycle = c.get("cycle", [])
|
|
241
|
+
length = c.get("length", len(cycle))
|
|
242
|
+
if not cycle:
|
|
243
|
+
continue
|
|
244
|
+
cycle_path = " -> ".join(cycle + [cycle[0]])
|
|
245
|
+
lines.append(f"- {length}-file cycle: `{cycle_path}`")
|
|
246
|
+
else:
|
|
247
|
+
lines.append("- None detected.")
|
|
248
|
+
|
|
249
|
+
hyperedges = G.graph.get("hyperedges", [])
|
|
250
|
+
if hyperedges:
|
|
251
|
+
lines += ["", "## Hyperedges (group relationships)"]
|
|
252
|
+
for h in hyperedges:
|
|
253
|
+
node_labels = ", ".join(h.get("nodes", []))
|
|
254
|
+
conf = h.get("confidence", "INFERRED")
|
|
255
|
+
cscore = h.get("confidence_score")
|
|
256
|
+
conf_tag = f"{conf} {cscore:.2f}" if cscore is not None else conf
|
|
257
|
+
lines.append(f"- **{h.get('label', h.get('id', ''))}** — {node_labels} [{conf_tag}]")
|
|
258
|
+
|
|
259
|
+
lines += ["", f"## Communities ({len(communities)} total, {thin_count_summary} thin omitted)"]
|
|
260
|
+
for cid, nodes in communities.items():
|
|
261
|
+
label = community_labels.get(cid, f"Community {cid}")
|
|
262
|
+
score = cohesion_scores.get(cid, 0.0)
|
|
263
|
+
# Filter method/function stubs from display - they're structural noise
|
|
264
|
+
real_nodes = [n for n in nodes if not _ifn(G, n)]
|
|
265
|
+
if not real_nodes:
|
|
266
|
+
continue
|
|
267
|
+
if len(real_nodes) < min_community_size:
|
|
268
|
+
continue
|
|
269
|
+
display = [G.nodes[n].get("label", n) for n in real_nodes[:8]]
|
|
270
|
+
suffix = f" (+{len(real_nodes)-8} more)" if len(real_nodes) > 8 else ""
|
|
271
|
+
lines += [
|
|
272
|
+
"",
|
|
273
|
+
f"### Community {cid} - \"{label}\"",
|
|
274
|
+
f"Cohesion: {score:.2f}",
|
|
275
|
+
f"Nodes ({len(real_nodes)}): {', '.join(display)}{suffix}",
|
|
276
|
+
]
|
|
277
|
+
|
|
278
|
+
ambiguous = [(u, v, d) for u, v, d in G.edges(data=True) if d.get("confidence") == "AMBIGUOUS"]
|
|
279
|
+
if ambiguous:
|
|
280
|
+
lines += ["", "## Ambiguous Edges - Review These"]
|
|
281
|
+
for u, v, d in ambiguous:
|
|
282
|
+
ul = G.nodes[u].get("label", u)
|
|
283
|
+
vl = G.nodes[v].get("label", v)
|
|
284
|
+
lines += [
|
|
285
|
+
f"- `{ul}` → `{vl}` [AMBIGUOUS]",
|
|
286
|
+
f" {d.get('source_file', '')} · relation: {d.get('relation', 'unknown')}",
|
|
287
|
+
]
|
|
288
|
+
|
|
289
|
+
# --- Gaps section ---
|
|
290
|
+
from .analyze import _is_file_node, _is_concept_node
|
|
291
|
+
|
|
292
|
+
isolated = [
|
|
293
|
+
n for n in G.nodes()
|
|
294
|
+
if G.degree(n) <= 1
|
|
295
|
+
and not _is_file_node(G, n)
|
|
296
|
+
and not _is_concept_node(G, n)
|
|
297
|
+
and G.nodes[n].get("file_type") != "rationale"
|
|
298
|
+
]
|
|
299
|
+
# Same threshold the Summary and Communities headers used (#3148): this
|
|
300
|
+
# was a hardcoded 3, so with --min-community-size anything else the count
|
|
301
|
+
# here disagreed with the label text beside it, which already printed
|
|
302
|
+
# min_community_size.
|
|
303
|
+
thin_communities = {
|
|
304
|
+
cid: nodes for cid, nodes in communities.items()
|
|
305
|
+
if 0 < sum(1 for n in nodes if not _is_file_node(G, n)) < min_community_size
|
|
306
|
+
}
|
|
307
|
+
gap_count = len(isolated) + len(thin_communities)
|
|
308
|
+
|
|
309
|
+
if gap_count > 0 or amb_pct > 20:
|
|
310
|
+
lines += ["", "## Knowledge Gaps"]
|
|
311
|
+
if isolated:
|
|
312
|
+
isolated_labels = [G.nodes[n].get("label", n) for n in isolated[:5]]
|
|
313
|
+
suffix = f" (+{len(isolated)-5} more)" if len(isolated) > 5 else ""
|
|
314
|
+
raw_isolated = sum(1 for n in G.nodes() if G.degree(n) <= 1)
|
|
315
|
+
lines.append(f"- **{len(isolated)} isolated node(s):** {', '.join(f'`{l}`' for l in isolated_labels)}{suffix}")
|
|
316
|
+
lines.append(
|
|
317
|
+
" These have ≤1 connection - possible missing edges or undocumented components. "
|
|
318
|
+
f"(Counts symbols only; {raw_isolated} node(s) total have ≤1 connection when "
|
|
319
|
+
"file, concept and rationale nodes are included.)"
|
|
320
|
+
)
|
|
321
|
+
if thin_communities:
|
|
322
|
+
lines.append(f"- **{len(thin_communities)} thin communities (<{min_community_size} nodes) omitted from report** — run `graphify query` to explore isolated nodes.")
|
|
323
|
+
if amb_pct > 20:
|
|
324
|
+
lines.append(f"- **High ambiguity: {amb_pct}% of edges are AMBIGUOUS.** Review the Ambiguous Edges section above.")
|
|
325
|
+
|
|
326
|
+
# --- Work-memory lessons (derived overlay) ---
|
|
327
|
+
# Preferred sources come from the .graphify_learning.json sidecar; the
|
|
328
|
+
# query-scoped dead-ends come from the reflect aggregate. Section omitted
|
|
329
|
+
# entirely when neither is present, so a graph with no work-memory is
|
|
330
|
+
# byte-identical to the pre-feature report.
|
|
331
|
+
_learning_section(lines, learning)
|
|
332
|
+
|
|
333
|
+
if suggested_questions:
|
|
334
|
+
lines += ["", "## Suggested Questions"]
|
|
335
|
+
no_signal = len(suggested_questions) == 1 and suggested_questions[0].get("type") == "no_signal"
|
|
336
|
+
if no_signal:
|
|
337
|
+
lines.append(f"_{suggested_questions[0]['why']}_")
|
|
338
|
+
else:
|
|
339
|
+
lines.append("_Questions this graph is uniquely positioned to answer:_")
|
|
340
|
+
lines.append("")
|
|
341
|
+
for q in suggested_questions:
|
|
342
|
+
if q.get("question"):
|
|
343
|
+
lines.append(f"- **{q['question']}**")
|
|
344
|
+
lines.append(f" _{q['why']}_")
|
|
345
|
+
|
|
346
|
+
return "\n".join(lines)
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
"""Registry for cross-file, language-specific resolution passes.
|
|
2
|
+
|
|
3
|
+
Some call/reference edges can only be resolved with language-specific knowledge
|
|
4
|
+
(receiver typing, qualified member calls, framework conventions). Historically
|
|
5
|
+
these ran as a hand-wired sequence of suffix-gated
|
|
6
|
+
``if <lang>_paths: try: _resolve_<lang>(...)`` blocks at the tail of
|
|
7
|
+
``extract.extract()``. That pattern is the de-facto extension point for
|
|
8
|
+
per-language resolution; this module formalizes it so a new language plugs in by
|
|
9
|
+
registering one ``LanguageResolver`` instead of editing ``extract()``'s body.
|
|
10
|
+
|
|
11
|
+
This module deliberately knows nothing about any specific language — languages
|
|
12
|
+
register themselves (see ``extract.py``), keeping the dependency direction one
|
|
13
|
+
way (``extract`` → ``resolver_registry``) and this seam small and reviewable on
|
|
14
|
+
its own, separate from the multi-thousand-line ``extract`` module.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import logging
|
|
20
|
+
from collections.abc import Sequence
|
|
21
|
+
from dataclasses import dataclass
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
from typing import Callable
|
|
24
|
+
|
|
25
|
+
_LOG = logging.getLogger(__name__)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass(frozen=True)
|
|
29
|
+
class LanguageResolver:
|
|
30
|
+
"""One cross-file, language-specific resolution pass.
|
|
31
|
+
|
|
32
|
+
``resolve`` has the signature ``(per_file, all_nodes, all_edges) -> None`` and
|
|
33
|
+
mutates ``all_nodes`` / ``all_edges`` in place, matching the existing
|
|
34
|
+
member-call resolvers. ``suffixes`` gates activation: the pass runs only when
|
|
35
|
+
the corpus contains at least one file with one of these extensions.
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
name: str
|
|
39
|
+
suffixes: frozenset
|
|
40
|
+
resolve: Callable
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
# Module-level registry, populated by callers via register(). Ordered: resolvers
|
|
44
|
+
# run in registration order, preserving any required sequencing between passes.
|
|
45
|
+
_REGISTRY: list[LanguageResolver] = []
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def register(resolver: LanguageResolver) -> LanguageResolver:
|
|
49
|
+
"""Append a resolver to the global registry and return it (for inline use)."""
|
|
50
|
+
_REGISTRY.append(resolver)
|
|
51
|
+
return resolver
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def registered_resolvers() -> list[LanguageResolver]:
|
|
55
|
+
"""Return a copy of the registered resolvers, in registration order."""
|
|
56
|
+
return list(_REGISTRY)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def run_language_resolvers(
|
|
60
|
+
paths: Sequence[Path],
|
|
61
|
+
per_file: list[dict],
|
|
62
|
+
all_nodes: list[dict],
|
|
63
|
+
all_edges: list[dict],
|
|
64
|
+
*,
|
|
65
|
+
resolvers: Sequence[LanguageResolver] | None = None,
|
|
66
|
+
) -> None:
|
|
67
|
+
"""Run every resolver whose suffix appears in ``paths``.
|
|
68
|
+
|
|
69
|
+
Behaviorally identical to the prior hand-wired sequence of suffix-gated,
|
|
70
|
+
try/except-wrapped passes: same activation rule (suffix present), same
|
|
71
|
+
failure handling (log a warning and continue to the next pass), same
|
|
72
|
+
execution order (registration order).
|
|
73
|
+
|
|
74
|
+
``resolvers`` defaults to the global registry; tests pass an explicit list to
|
|
75
|
+
exercise the driver in isolation.
|
|
76
|
+
"""
|
|
77
|
+
active = _REGISTRY if resolvers is None else resolvers
|
|
78
|
+
suffixes_present = {p.suffix for p in paths}
|
|
79
|
+
for resolver in active:
|
|
80
|
+
if not (resolver.suffixes & suffixes_present):
|
|
81
|
+
continue
|
|
82
|
+
try:
|
|
83
|
+
resolver.resolve(per_file, all_nodes, all_edges)
|
|
84
|
+
except Exception as exc:
|
|
85
|
+
_LOG.warning("%s resolution failed, skipping: %s", resolver.name, exc)
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
"""Type-aware cross-file resolution for Ruby member calls.
|
|
2
|
+
|
|
3
|
+
Ruby has no type annotations and reuses method names heavily, so resolving
|
|
4
|
+
``obj.method()`` by globally-unique name is both lossy (drops on collision) and
|
|
5
|
+
unsafe (can attach to the wrong same-named method). This resolver instead uses
|
|
6
|
+
the receiver's *type*, inferred at extraction time from local
|
|
7
|
+
``var = ClassName.new`` bindings and carried on each member-call raw_call as
|
|
8
|
+
``receiver_type``.
|
|
9
|
+
|
|
10
|
+
It resolves two shapes, both at EXTRACTED (1.0) confidence and only when the
|
|
11
|
+
target is certain (single owning class, single owned method) — bail otherwise:
|
|
12
|
+
|
|
13
|
+
* ``Processor.new`` -> a ``calls`` edge to the ``Processor`` class
|
|
14
|
+
* ``p.run`` where ``p`` is a ``Processor`` -> a ``calls`` edge to ``Processor#run``
|
|
15
|
+
|
|
16
|
+
Registered into graphify.resolver_registry and run by extract() after id
|
|
17
|
+
disambiguation, so node ids and raw_call caller_nids are final.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import re
|
|
23
|
+
from typing import Any
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _key(label: str) -> str:
|
|
27
|
+
"""Normalize a class/method label to a comparison key (drop punctuation)."""
|
|
28
|
+
return re.sub(r"[^a-zA-Z0-9]+", "", str(label)).lower()
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
# A Ruby class/module container node is labelled with a constant path, bare or
|
|
32
|
+
# ``::``-qualified (``Processor``, ``Billing::Rounding``); methods end in ``()``
|
|
33
|
+
# and files in ``.rb``. Lets us register method-less containers (a
|
|
34
|
+
# ``Class.new(StandardError)`` error class, an empty module) that have no
|
|
35
|
+
# `method` edge to be found by.
|
|
36
|
+
_BARE_CONST_RE = re.compile(r"^[A-Z][A-Za-z0-9_]*(?:::[A-Z][A-Za-z0-9_]*)*$")
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _ruby_raw_calls(per_file: list[dict]) -> list[dict]:
|
|
40
|
+
calls: list[dict] = []
|
|
41
|
+
for result in per_file:
|
|
42
|
+
if not isinstance(result, dict):
|
|
43
|
+
continue
|
|
44
|
+
for rc in result.get("raw_calls", []):
|
|
45
|
+
if not isinstance(rc, dict):
|
|
46
|
+
continue
|
|
47
|
+
sf = str(rc.get("source_file", ""))
|
|
48
|
+
if sf.endswith((".rb", ".rake")):
|
|
49
|
+
calls.append(rc)
|
|
50
|
+
return calls
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def resolve_ruby_member_calls(
|
|
54
|
+
per_file: list[dict],
|
|
55
|
+
all_nodes: list[dict],
|
|
56
|
+
all_edges: list[dict],
|
|
57
|
+
) -> None:
|
|
58
|
+
"""Resolve Ruby ``Class.new`` and typed ``var.method`` calls by receiver type.
|
|
59
|
+
|
|
60
|
+
Purely additive: only emits edges the shared (name-based) call pass skips
|
|
61
|
+
because they are member calls. Each emission requires a single owning class
|
|
62
|
+
(god-node guard) so an ambiguous class name resolves to nothing rather than a
|
|
63
|
+
wrong edge.
|
|
64
|
+
"""
|
|
65
|
+
node_by_id: dict[str, dict] = {n.get("id"): n for n in all_nodes}
|
|
66
|
+
|
|
67
|
+
# class label key -> [class node ids]; (class_node_id, method_key) -> method id
|
|
68
|
+
class_def_nids: dict[str, list[str]] = {}
|
|
69
|
+
method_index: dict[tuple[str, str], str] = {}
|
|
70
|
+
for e in all_edges:
|
|
71
|
+
if e.get("relation") != "method":
|
|
72
|
+
continue
|
|
73
|
+
src, tgt = e.get("source"), e.get("target")
|
|
74
|
+
cnode = node_by_id.get(src)
|
|
75
|
+
if cnode is not None:
|
|
76
|
+
clabel = str(cnode.get("label", ""))
|
|
77
|
+
class_def_nids.setdefault(_key(clabel), []).append(str(src))
|
|
78
|
+
# A nested/compact declaration labels the node fully qualified
|
|
79
|
+
# (`Billing::Processor`), but its receivers reference the bare last
|
|
80
|
+
# segment (`Processor.new`), so index that too — the unique-match
|
|
81
|
+
# guard below still bails on genuine collisions (#2302).
|
|
82
|
+
if "::" in clabel:
|
|
83
|
+
class_def_nids.setdefault(_key(clabel.split("::")[-1]), []).append(str(src))
|
|
84
|
+
tnode = node_by_id.get(tgt)
|
|
85
|
+
if tnode is not None:
|
|
86
|
+
method_name = str(tnode.get("label", "")).strip("()").lstrip(".")
|
|
87
|
+
method_index[(str(src), method_name)] = str(tgt)
|
|
88
|
+
# Also register class/module container nodes that own no `method` edge — a
|
|
89
|
+
# method-less `Class.new(StandardError)` or an empty module — so a constant
|
|
90
|
+
# receiver still resolves to a real node (#1640/#1634). External base stubs
|
|
91
|
+
# carry an empty source_file, so the `.rb` filter keeps them out.
|
|
92
|
+
for n in all_nodes:
|
|
93
|
+
nid = n.get("id")
|
|
94
|
+
sf = str(n.get("source_file", ""))
|
|
95
|
+
label = str(n.get("label", ""))
|
|
96
|
+
if nid and sf.endswith((".rb", ".rake")) and _BARE_CONST_RE.match(label):
|
|
97
|
+
class_def_nids.setdefault(_key(label), []).append(str(nid))
|
|
98
|
+
if "::" in label:
|
|
99
|
+
class_def_nids.setdefault(_key(label.split("::")[-1]), []).append(str(nid))
|
|
100
|
+
for k in list(class_def_nids):
|
|
101
|
+
class_def_nids[k] = sorted(set(class_def_nids[k]))
|
|
102
|
+
|
|
103
|
+
def _segment_path(label: str) -> list[str]:
|
|
104
|
+
return [s.strip().lower() for s in str(label).split("::") if s.strip()]
|
|
105
|
+
|
|
106
|
+
# Fully-qualified and last-segment views of the same definitions, for the
|
|
107
|
+
# scoped mixin lookup: `include Foo::Bar` must match a `Foo::Bar` label as a
|
|
108
|
+
# whole path, never just its tail.
|
|
109
|
+
fq_label_map: dict[tuple[str, ...], list[str]] = {}
|
|
110
|
+
last_segment_map: dict[str, list[str]] = {}
|
|
111
|
+
for nid in sorted({nid for nids in class_def_nids.values() for nid in nids}):
|
|
112
|
+
segs = _segment_path(str(node_by_id.get(nid, {}).get("label", "")))
|
|
113
|
+
if segs:
|
|
114
|
+
fq_label_map.setdefault(tuple(segs), []).append(nid)
|
|
115
|
+
last_segment_map.setdefault(segs[-1], []).append(nid)
|
|
116
|
+
|
|
117
|
+
existing_pairs = {(e.get("source"), e.get("target")) for e in all_edges}
|
|
118
|
+
|
|
119
|
+
def _unique_class(name: str) -> str | None:
|
|
120
|
+
nids = class_def_nids.get(_key(name), [])
|
|
121
|
+
return nids[0] if len(nids) == 1 else None
|
|
122
|
+
|
|
123
|
+
def _class_by_const_path(raw: str) -> str | None:
|
|
124
|
+
"""Resolve a qualified constant receiver (``Billing::Processor``) to one class.
|
|
125
|
+
|
|
126
|
+
Matches on the constant path rather than its tail: a class qualifies when its
|
|
127
|
+
own label ends with the referenced segments, so ``Billing::Processor`` still
|
|
128
|
+
finds an ``App::Billing::Processor`` while ``ActiveRecord::Base`` no longer
|
|
129
|
+
binds to an unrelated ``Thing::Base`` (#3078). A leading ``::`` pins the
|
|
130
|
+
reference to top level, so it must match the label whole. Ambiguous, or
|
|
131
|
+
matching nothing in the corpus (the usual case for a framework constant) ->
|
|
132
|
+
no edge, never a guess.
|
|
133
|
+
"""
|
|
134
|
+
segs = tuple(_segment_path(raw))
|
|
135
|
+
if not segs:
|
|
136
|
+
return None
|
|
137
|
+
if raw.strip().startswith("::"):
|
|
138
|
+
nids = fq_label_map.get(segs, [])
|
|
139
|
+
return nids[0] if len(nids) == 1 else None
|
|
140
|
+
hits = {nid for path, nids in fq_label_map.items()
|
|
141
|
+
if len(path) >= len(segs) and path[-len(segs):] == segs
|
|
142
|
+
for nid in nids}
|
|
143
|
+
return next(iter(hits)) if len(hits) == 1 else None
|
|
144
|
+
|
|
145
|
+
def _emit(caller: str, target: str, rc: dict[str, Any],
|
|
146
|
+
relation: str = "calls", context: str = "call") -> None:
|
|
147
|
+
if not caller or not target or caller == target:
|
|
148
|
+
return
|
|
149
|
+
if (caller, target) in existing_pairs:
|
|
150
|
+
return
|
|
151
|
+
existing_pairs.add((caller, target))
|
|
152
|
+
all_edges.append({
|
|
153
|
+
"source": caller,
|
|
154
|
+
"target": target,
|
|
155
|
+
"relation": relation,
|
|
156
|
+
"context": context,
|
|
157
|
+
"confidence": "EXTRACTED",
|
|
158
|
+
"confidence_score": 1.0,
|
|
159
|
+
"source_file": rc.get("source_file", ""),
|
|
160
|
+
"source_location": rc.get("source_location"),
|
|
161
|
+
"weight": 1.0,
|
|
162
|
+
})
|
|
163
|
+
|
|
164
|
+
# `include`/`extend`/`prepend <Const>` mixins (#1668): resolve the module
|
|
165
|
+
# reference lexically, the way Ruby constant lookup works (#2302) — try the
|
|
166
|
+
# reference under each enclosing scope of the including class, innermost
|
|
167
|
+
# first, then top level (a leading `::` pins it to top level). A qualified
|
|
168
|
+
# external like `ActiveSupport::Concern` matches no in-corpus path and
|
|
169
|
+
# produces no edge; an unqualified reference with no lexical match falls
|
|
170
|
+
# back to a globally unique last segment, under the same single-definition
|
|
171
|
+
# god-node guard. Ambiguous at any step -> bail, no wrong edge.
|
|
172
|
+
for rc in _ruby_raw_calls(per_file):
|
|
173
|
+
if not rc.get("is_mixin"):
|
|
174
|
+
continue
|
|
175
|
+
caller = str(rc.get("caller_nid", ""))
|
|
176
|
+
module_name = rc.get("callee")
|
|
177
|
+
if not caller or not module_name:
|
|
178
|
+
continue
|
|
179
|
+
raw_ref = str(module_name)
|
|
180
|
+
absolute = raw_ref.startswith("::")
|
|
181
|
+
ref_segs = _segment_path(raw_ref)
|
|
182
|
+
caller_node = node_by_id.get(caller)
|
|
183
|
+
caller_segs = [] if absolute else _segment_path(
|
|
184
|
+
str(caller_node.get("label", "")) if caller_node else "")
|
|
185
|
+
target: str | None = None
|
|
186
|
+
for i in range(len(caller_segs), -1, -1):
|
|
187
|
+
nids = fq_label_map.get(tuple(caller_segs[:i] + ref_segs), [])
|
|
188
|
+
if len(nids) == 1:
|
|
189
|
+
target = nids[0]
|
|
190
|
+
break
|
|
191
|
+
if len(nids) > 1:
|
|
192
|
+
break # reopened/ambiguous definition: bail
|
|
193
|
+
if target is None and len(ref_segs) == 1 and not absolute:
|
|
194
|
+
nids = last_segment_map.get(ref_segs[0], [])
|
|
195
|
+
if len(nids) == 1:
|
|
196
|
+
target = nids[0]
|
|
197
|
+
if target is not None:
|
|
198
|
+
_emit(caller, target, rc, relation="mixes_in", context="mixin")
|
|
199
|
+
|
|
200
|
+
for rc in _ruby_raw_calls(per_file):
|
|
201
|
+
if not rc.get("is_member_call"):
|
|
202
|
+
continue
|
|
203
|
+
caller = str(rc.get("caller_nid", ""))
|
|
204
|
+
callee = rc.get("callee")
|
|
205
|
+
if not caller or not callee:
|
|
206
|
+
continue
|
|
207
|
+
|
|
208
|
+
# Constant receiver: `Processor.new` (instantiation) or `Service.call` /
|
|
209
|
+
# `Model.where` (singleton / class method). The bare method name would
|
|
210
|
+
# collide with unrelated same-named methods, so we resolve by the
|
|
211
|
+
# receiver's class under the single-owning-class god-node guard.
|
|
212
|
+
receiver = rc.get("receiver")
|
|
213
|
+
# `lstrip(":")` so a top-level-pinned `::Processor.call` is still recognised
|
|
214
|
+
# as a constant receiver now that the whole path is captured (#3078).
|
|
215
|
+
if receiver and str(receiver).lstrip(":")[:1].isupper():
|
|
216
|
+
recv_raw = str(receiver)
|
|
217
|
+
class_nid = (_class_by_const_path(recv_raw) if "::" in recv_raw
|
|
218
|
+
else _unique_class(recv_raw))
|
|
219
|
+
if class_nid is not None:
|
|
220
|
+
if callee == "new":
|
|
221
|
+
_emit(caller, class_nid, rc)
|
|
222
|
+
else:
|
|
223
|
+
# Emit to the singleton/instance method the class owns
|
|
224
|
+
# (`def self.call`, which the extractor indexes); otherwise
|
|
225
|
+
# to the class node itself, so inherited/dynamic class methods
|
|
226
|
+
# like ActiveRecord `where`/`find_by` still give correct
|
|
227
|
+
# blast-radius. An ambiguous receiver bails to nothing.
|
|
228
|
+
method_nid = method_index.get((class_nid, str(callee)))
|
|
229
|
+
_emit(caller, method_nid or class_nid, rc)
|
|
230
|
+
continue
|
|
231
|
+
|
|
232
|
+
# `p.run` where p's type is known -> edge to that class's method.
|
|
233
|
+
receiver_type = rc.get("receiver_type")
|
|
234
|
+
if not receiver_type:
|
|
235
|
+
continue
|
|
236
|
+
class_nid = _unique_class(str(receiver_type))
|
|
237
|
+
if class_nid is None:
|
|
238
|
+
continue
|
|
239
|
+
method_nid = method_index.get((class_nid, str(callee)))
|
|
240
|
+
if method_nid is None:
|
|
241
|
+
continue
|
|
242
|
+
_emit(caller, method_nid, rc)
|