Skip to content
Merged
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,14 @@

Full release notes with details on each version: [GitHub Releases](https://github.com/safishamsi/graphify/releases)

## Unreleased

- Feat: work-memory overlay — `graphify reflect` now projects the verdicts it distills (preferred / tentative / contested, recency-weighted) into a `.graphify_learning.json` sidecar next to graph.json, and `graphify explain` / `query` / `GRAPH_REPORT.md` / the HTML viewer surface them where you look (a `Lesson:` hint, a colored node ring). Builds on the idea in #1441/#1542 (thanks @TPAteeq), implemented as a sidecar rather than stamping graph.json: structural truth stays separate (no `learning_*` in graph.json or GraphML exports, no rebuild churn). Each verdict carries the source questions that produced it (provenance) and a content fingerprint of the cited code, so a verdict on a file that has changed since is flagged "code changed — re-verify" instead of shown as still-authoritative. Dead-ends stay query-scoped (a report section, never a node attribute). Letting verdicts influence query traversal is deliberately deferred (it needs propensity correction + exploration to avoid a self-reinforcing feedback loop).
- Feat: type-aware `this.field.method()` resolution for TypeScript/JS (#1316, thanks @guyoron1). A member call through a constructor-injected dependency (`constructor(private db: Database)` then `this.db.query()`) now produces a `calls` edge to the field type's method, resolved by the field's declared type and gated by the single-definition god-node guard (an ambiguous or untyped field produces no edge — no global name-match fan-out). EXTRACTED confidence; constructor parameter-property injection scope.
- Feat: resolve TypeScript wildcard path aliases (#1544, thanks @oleksii-tumanov). A `compilerOptions.paths` pattern like `@app/*` or `@*/interfaces` now captures the matched segment and substitutes it into each target in order, honoring tsc's longest-prefix / exact-wins specificity, baseUrl, and the first-existing-target fallback. Extends the #1531 resolver.
- Feat: resolve JS namespace re-export bindings (#1552, thanks @oleksii-tumanov). `export * as ns from './mod'` now creates a real symbol node for `ns`, registers it as a named export (so a downstream `import { ns }` resolves to it), and emits a file-level `re_exports` edge — treated as a single opaque binding, so `ns.member` accesses don't fan out into false per-symbol edges. Includes cycle and deep-chain guards.
- Feat: Objective-C dot-syntax property accesses and `@selector()` call edges (#1475, #1543, thanks @guyoron1). `self.product.name` now emits an `accesses` edge and `@selector(method)` a `calls` edge, each resolved only to an unambiguous in-scope definition by exact method-id match (a sibling of the same class for dot-syntax; exactly one method by exact selector name for `@selector`) — so `self.name` can't mis-resolve to a `-surname` sibling and same-named methods across classes don't fan out. Completes the #1475 ObjC follow-ups.

## 0.9.2 (2026-06-29)

- Feat: type-aware Ruby member-call resolution (#1499, thanks @vamsipavanmahesh). `p.run` is now resolved by the inferred type of the receiver (`p = Processor.new` ⇒ `Processor#run`) instead of by globally-unique method name, so the edge survives name collisions (an unrelated `Worker#run` no longer makes it ambiguous) and never points at the wrong method. Introduces a small resolver-registry framework that the existing Swift (#1356) and Python (#1446) cross-file passes register into. Receiver types are inferred only from unambiguous local `var = ClassName.new` bindings; a call whose receiver type can't be proven resolves to nothing rather than to a guess — a deliberate precision-over-recall change for Ruby member calls.
Expand Down
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -550,7 +550,9 @@ graphify save-result --question "Q" --answer "A" --nodes Foo Bar --outcome usefu
graphify reflect # aggregate graphify-out/memory/ outcomes into reflections/LESSONS.md
graphify reflect --if-stale # no-op when LESSONS.md is already newer than every input (cheap to run each session)
graphify reflect --out docs/LESSONS.md # write the lessons doc somewhere else
graphify reflect --graph graphify-out/graph.json # also group lessons by community
graphify reflect --graph graphify-out/graph.json # group lessons by community + write the work-memory overlay (.graphify_learning.json)
# the overlay tags nodes preferred/tentative/contested (recency-weighted, with provenance);
# graphify explain / query then show a "Lesson:" hint, flagged "code changed — re-verify" when the source moved on

graphify uninstall # remove from all platforms in one shot
graphify uninstall --purge # also delete graphify-out/
Expand Down
28 changes: 27 additions & 1 deletion graphify/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -3186,6 +3186,30 @@ def main() -> None:
)
print(f" Type: {d.get('file_type', '')}")
print(f" Community: {d.get('community_name') or d.get('community', '')}")
# Work-memory overlay: a derived experiential hint from `graphify reflect`,
# merged in display-only from the .graphify_learning.json sidecar next to
# graph.json. No line when the node has no overlay entry.
try:
from graphify.reflect import load_learning_overlay as _llo
from graphify.security import sanitize_label as _sl
_overlay = _llo(gp)
_entry = _overlay.get(str(nid))
if _entry:
_status = _sl(str(_entry.get("status", "")))
if _status == "contested":
_line = (f" Lesson: contested (useful {_entry.get('uses', 0)} / "
f"dead-end {_entry.get('neg', 0)})")
elif _status == "preferred":
_line = (f" Lesson: preferred source (start here) — "
f"{_entry.get('uses', 0)} useful, score={_entry.get('score', 0)}")
else:
_line = (f" Lesson: {_status or 'tentative'} — "
f"{_entry.get('uses', 0)} useful, score={_entry.get('score', 0)}")
if _entry.get("stale"):
_line += " [code changed since — re-verify]"
print(_line)
except Exception:
pass
print(f" Degree: {G.degree(nid)}")
from graphify.build import edge_data
connections: list[tuple[str, str, dict]] = [] # (direction, neighbor_id, edge_data)
Expand Down Expand Up @@ -3529,10 +3553,12 @@ def main() -> None:
tokens = {"input": 0, "output": 0}
from graphify.export import _git_head as _gh
_commit = _gh()
from graphify.report import load_learning_for_report as _llfr
report = generate(G, communities, cohesion, labels, gods, surprises,
{"warning": "cluster-only mode — file stats not available"},
tokens, str(watch_path), suggested_questions=questions,
min_community_size=min_community_size, built_at_commit=_commit)
min_community_size=min_community_size, built_at_commit=_commit,
learning=_llfr(out / "graph.json"))
(out / "GRAPH_REPORT.md").write_text(report, encoding="utf-8")
stages.mark("report")
from graphify.export import backup_if_protected as _backup
Expand Down
51 changes: 49 additions & 2 deletions graphify/export.py
Original file line number Diff line number Diff line change
Expand Up @@ -636,6 +636,7 @@ def to_html(
community_labels: dict[int, str] | None = None,
member_counts: dict[int, int] | None = None,
node_limit: int | None = None,
learning_overlay: dict | None = None,
) -> None:
"""Generate an interactive vis.js HTML visualization of the graph.

Expand Down Expand Up @@ -713,6 +714,21 @@ def to_html(
max_deg = max(degree.values(), default=1) or 1
max_mc = (max(member_counts.values(), default=1) or 1) if member_counts else 1

# Work-memory overlay (derived sidecar). When not passed explicitly, load it
# best-effort from the sibling .graphify_learning.json next to the output
# graph.html (which lives beside graph.json). Empty/missing => no learning
# fields, so the un-annotated render is byte-identical to pre-feature.
if learning_overlay is None:
learning_overlay = {}
try:
from graphify.reflect import load_learning_overlay as _llo
learning_overlay = _llo(Path(output_path))
except Exception:
learning_overlay = {}
# Status -> ring color. preferred=green, contested=amber. Tentative gets no
# ring (it's not yet trustworthy enough to highlight in the map).
_RING = {"preferred": "#22c55e", "contested": "#f59e0b"}

# Build nodes list for vis.js
vis_nodes = []
for node_id, data in G.nodes(data=True):
Expand All @@ -728,7 +744,7 @@ def to_html(
size = 10 + 30 * (deg / max_deg)
# Only show label for high-degree nodes by default; others show on hover
font_size = 12 if deg >= max_deg * 0.15 else 0
vis_nodes.append({
node = {
"id": node_id,
"label": label,
"color": {"background": color, "border": color, "highlight": {"background": "#ffffff", "border": color}},
Expand All @@ -740,7 +756,38 @@ def to_html(
"source_file": sanitize_label(str(data.get("source_file") or "")),
"file_type": data.get("file_type", ""),
"degree": deg,
})
}
# Conditional learning fields — only present for annotated nodes, so
# un-annotated output keeps the exact pre-feature node dict shape.
entry = learning_overlay.get(str(node_id)) if learning_overlay else None
if entry:
status = sanitize_label(str(entry.get("status", "")))
stale = bool(entry.get("stale"))
node["learning_status"] = status
node["learning_stale"] = stale
ring = _RING.get(status)
if ring:
# Status-colored ring via the border; stale => desaturated +
# dashed (vis.js supports per-node `shapeProperties.borderDashes`).
if stale:
ring = "#9ca3af"
node["shapeProperties"] = {"borderDashes": [4, 4]}
node["borderWidth"] = 3
node["color"] = {
"background": color, "border": ring,
"highlight": {"background": "#ffffff", "border": ring},
}
# Lesson line appended to the hover title.
if status == "contested":
lesson = f"Lesson: contested (useful {entry.get('uses', 0)} / dead-end {entry.get('neg', 0)})"
elif status == "preferred":
lesson = f"Lesson: preferred source ({entry.get('uses', 0)} useful, score={entry.get('score', 0)})"
else:
lesson = f"Lesson: {status} ({entry.get('uses', 0)} useful)"
if stale:
lesson += " [code changed — re-verify]"
node["title"] = _html.escape(label) + "\n" + _html.escape(sanitize_label(lesson))
vis_nodes.append(node)

# Build edges list. Restore original edge direction from _src/_tgt
# (stashed by build.py for exactly this reason): undirected NetworkX
Expand Down
Loading
Loading