From 57469641cd6930a464dffd1e7f6360c34d8cd508 Mon Sep 17 00:00:00 2001 From: oleksii-tumanov Date: Tue, 30 Jun 2026 09:41:50 +0100 Subject: [PATCH 1/8] feat(js): resolve TypeScript wildcard path aliases (#1544) Extends the tsconfig path-alias resolver (#1531) with single-`*` wildcard capture and substitution: a pattern like `@app/*` or `@*/interfaces` captures the matched segment and substitutes it into each target in declared order, honoring baseUrl and tsc's longest-prefix / exact-wins specificity rules, and preserving #1531's first-existing-target-wins fallback (no false edge when nothing resolves). Builds on the _resolve_tsconfig_alias helper rather than reintroducing inline loops; multi-star patterns remain out of scope. Co-Authored-By: Claude Opus 4.8 (1M context) --- graphify/extract.py | 96 ++++++++++++----- tests/test_js_import_resolution.py | 165 +++++++++++++++++++++++++++++ 2 files changed, 236 insertions(+), 25 deletions(-) diff --git a/graphify/extract.py b/graphify/extract.py index 7d45a6329..2f4f9c1bc 100644 --- a/graphify/extract.py +++ b/graphify/extract.py @@ -249,17 +249,18 @@ def _read_tsconfig_aliases(tsconfig: Path, base_dir: Path, seen: set) -> dict[st for alias, targets in paths.items(): if not targets: continue - alias_prefix = alias.rstrip("/*") # Keep ALL targets in declared order — tsc tries each until one resolves # on disk. Discarding the fallbacks (#1531) misresolved/dropped imports - # whose file lived at a non-first target. Empty/non-string entries skipped. - target_bases = [ - str(os.path.normpath(paths_base / t.rstrip("/*"))) + # whose file lived at a non-first target. Preserve wildcard tokens in + # both sides until the resolver substitutes the captured segment, then + # normalizes the concrete path (#927). Empty/non-string entries are skipped. + target_patterns = [ + str(paths_base / t) for t in targets if isinstance(t, str) and t ] - if target_bases: - aliases[alias_prefix] = target_bases + if target_patterns: + aliases[alias] = target_patterns return aliases @@ -268,8 +269,8 @@ def _load_tsconfig_aliases(start_dir: Path) -> dict[str, list[str]]: """Walk up from start_dir to find tsconfig.json and return compilerOptions.paths aliases. Follows extends chains so SvelteKit/Nuxt/NestJS inherited aliases are included. - Returns a dict mapping alias prefix (e.g. "@") to an ordered list of resolved - base dirs (e.g. ["src"]) — tsc tries each in declared order (#1531). + Returns a dict mapping alias patterns to ordered resolved target patterns; + wildcard tokens remain intact for substitution during resolution (#927). Result is cached by tsconfig path string. """ current = start_dir.resolve() @@ -283,26 +284,71 @@ def _load_tsconfig_aliases(start_dir: Path) -> dict[str, list[str]]: return {} -def _resolve_tsconfig_alias(raw: str, aliases: dict[str, list[str]]) -> "Path | None": - """Resolve `raw` against tsconfig path aliases. Try each target in declared - order; return the first whose candidate resolves to a real file (tsc parity). - If none exist, return the first candidate (no false edge fabricated, prior - single-target behavior preserved). Returns a Path or None if no alias matches.""" - for alias_prefix, alias_bases in aliases.items(): - if raw == alias_prefix or raw.startswith(alias_prefix + "/"): - rest = raw[len(alias_prefix):].lstrip("/") - first = None - for base in alias_bases: - cand = Path(os.path.normpath(Path(base) / rest)) - resolved = _resolve_js_import_path(cand) - if resolved.is_file(): - return resolved - if first is None: - first = cand - return first +def _match_tsconfig_alias(raw: str, pattern: str) -> "tuple[tuple[int, int], str, bool] | None": + """Return (specificity, captured text, is_wildcard) when pattern matches raw. + + Exact aliases win first. Wildcard aliases follow TypeScript's longest-prefix + rule. The final branch preserves Graphify's existing support for treating a + non-wildcard alias as a directory prefix, but only after real wildcard matches. + """ + if "*" in pattern: + if pattern.count("*") != 1: + return None + prefix, suffix = pattern.split("*", 1) + if not raw.startswith(prefix) or not raw.endswith(suffix): + return None + end = len(raw) - len(suffix) if suffix else len(raw) + if end < len(prefix): + return None + return (1, -len(prefix)), raw[len(prefix):end], True + + if raw == pattern: + return (0, -len(pattern)), "", False + + prefix = pattern.rstrip("/") + if prefix and raw.startswith(prefix + "/"): + return (2, -len(prefix)), raw[len(prefix):].lstrip("/"), False return None +def _resolve_tsconfig_alias(raw: str, aliases: dict[str, list[str]]) -> "Path | None": + """Resolve `raw` against the most specific matching tsconfig alias pattern. + + Within that pattern, try targets in declared order and return the first whose + candidate resolves to a real file. If none exist, return the first candidate + so existing phantom/external-edge behavior stays unchanged. + """ + best: "tuple[tuple[int, int], str, bool, list[str]] | None" = None + for pattern, targets in aliases.items(): + match = _match_tsconfig_alias(raw, pattern) + if match is None: + continue + specificity, captured, is_wildcard = match + if best is None or specificity < best[0]: + best = specificity, captured, is_wildcard, targets + + if best is None: + return None + + _, captured, is_wildcard, targets = best + first = None + for target in targets: + if is_wildcard: + # TypeScript substitutes only when the matched star is non-empty. + substituted = target.replace("*", captured, 1) if captured else target + cand = Path(os.path.normpath(substituted)) + else: + cand = Path(target) + if captured: + cand = Path(os.path.normpath(cand / captured)) + resolved = _resolve_js_import_path(cand) + if resolved.is_file(): + return resolved + if first is None: + first = cand + return first + + def _find_workspace_root(start_dir: Path) -> Path | None: current = start_dir.resolve() for candidate in [current, *current.parents]: diff --git a/tests/test_js_import_resolution.py b/tests/test_js_import_resolution.py index 231520e26..5872f10d5 100644 --- a/tests/test_js_import_resolution.py +++ b/tests/test_js_import_resolution.py @@ -885,6 +885,171 @@ def test_tsconfig_alias_none_exist_creates_no_false_edge(tmp_path: Path): assert not _has_edge(result, "src/routes/page.ts", "src/lib/utils.ts") +# ── #927: wildcard tsconfig path patterns ──────────────────────────────────── + + +def test_tsconfig_wildcard_alias_substitutes_captured_path(tmp_path, monkeypatch): + _write( + tmp_path / "tsconfig.json", + json.dumps({ + "compilerOptions": { + "baseUrl": ".", + "paths": {"@*": ["features/*/src/"]}, + } + }), + ) + _write( + tmp_path / "features/communicate/documentv2/src/index.ts", + "export const FileChipComponent = {}\n", + ) + _write( + tmp_path / "src/routes/page.ts", + "import { FileChipComponent } from '@communicate/documentv2'\n", + ) + + monkeypatch.chdir(tmp_path) + result = extract( + [ + Path("features/communicate/documentv2/src/index.ts"), + Path("src/routes/page.ts"), + ], + cache_root=Path("."), + ) + + assert _has_edge( + result, + "src/routes/page.ts", + "features/communicate/documentv2/src/index.ts", + ) + + +def test_tsconfig_wildcard_alias_substitutes_before_suffix(tmp_path: Path): + _write( + tmp_path / "tsconfig.json", + json.dumps({ + "compilerOptions": { + "baseUrl": ".", + "paths": {"@*/interfaces": ["features/*/src/interfaces.ts"]}, + } + }), + ) + target = _write( + tmp_path / "features/communicate/src/interfaces.ts", + "export interface Message { id: string }\n", + ) + importer = _write( + tmp_path / "src/routes/page.ts", + "import type { Message } from '@communicate/interfaces'\n", + ) + + result = _extract_for([target, importer], tmp_path) + + assert _has_edge( + result, + "src/routes/page.ts", + "features/communicate/src/interfaces.ts", + ) + + +def test_tsconfig_wildcard_alias_substitutes_before_normalizing_target(tmp_path: Path): + _write( + tmp_path / "tsconfig.json", + json.dumps({ + "compilerOptions": { + "baseUrl": ".", + "paths": {"@/*": ["generated/*/../shared"]}, + } + }), + ) + target = _write( + tmp_path / "generated/feature/shared/index.ts", + "export const shared = 1\n", + ) + importer = _write( + tmp_path / "src/routes/page.ts", + "import { shared } from '@/feature/nested'\n", + ) + + result = _extract_for([target, importer], tmp_path) + + assert _has_edge( + result, + "src/routes/page.ts", + "generated/feature/shared/index.ts", + ) + + +def test_tsconfig_wildcard_alias_allows_empty_capture(tmp_path: Path): + _write( + tmp_path / "tsconfig.json", + json.dumps({ + "compilerOptions": { + "baseUrl": ".", + "paths": {"app*": ["src/config/index.ts"]}, + } + }), + ) + target = _write(tmp_path / "src/config/index.ts", "export const config = {}\n") + importer = _write( + tmp_path / "src/routes/page.ts", + "import { config } from 'app'\n", + ) + + result = _extract_for([target, importer], tmp_path) + + assert _has_edge(result, "src/routes/page.ts", "src/config/index.ts") + + +def test_tsconfig_wildcard_alias_prefers_longest_matching_prefix(tmp_path: Path): + _write( + tmp_path / "tsconfig.json", + json.dumps({ + "compilerOptions": { + "baseUrl": ".", + "paths": { + "@/*": ["fallback/*"], + "@/common/integration/*": ["preferred/*"], + }, + } + }), + ) + fallback = _write( + tmp_path / "fallback/common/integration/foo.ts", + "export const Foo = 1\n", + ) + preferred = _write(tmp_path / "preferred/foo.ts", "export const Foo = 2\n") + importer = _write( + tmp_path / "src/routes/page.ts", + "import { Foo } from '@/common/integration/foo'\n", + ) + + result = _extract_for([fallback, preferred, importer], tmp_path) + + assert _has_edge(result, "src/routes/page.ts", "preferred/foo.ts") + assert not _has_edge(result, "src/routes/page.ts", "fallback/common/integration/foo.ts") + + +def test_tsconfig_exact_alias_still_resolves(tmp_path: Path): + _write( + tmp_path / "tsconfig.json", + json.dumps({ + "compilerOptions": { + "baseUrl": ".", + "paths": {"app-config": ["src/config/index.ts"]}, + } + }), + ) + target = _write(tmp_path / "src/config/index.ts", "export const config = {}\n") + importer = _write( + tmp_path / "src/routes/page.ts", + "import { config } from 'app-config'\n", + ) + + result = _extract_for([target, importer], tmp_path) + + assert _has_edge(result, "src/routes/page.ts", "src/config/index.ts") + + # ── #1529: alias/workspace import targets orphaned by the full-path migration ── From c8c604d08cd717d84eeb9da1fcb6e50b49f35748 Mon Sep 17 00:00:00 2001 From: oleksii-tumanov Date: Tue, 30 Jun 2026 09:42:49 +0100 Subject: [PATCH 2/8] feat(js): resolve namespace re-export bindings (export * as ns from) (#1552) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `export * as ns from './mod'` now creates a real symbol node for the namespace binding `ns`, registers it as a named export (so a downstream `import { ns }` resolves to it), and emits a file-level `re_exports` edge to the target module. The binding is treated as a single opaque symbol — `ns.member` accesses are deliberately NOT expanded into per-member name-matching, avoiding the over-linking that would fan false edges. Includes re-export cycle and deep-chain recursion guards. Co-Authored-By: Claude Opus 4.8 (1M context) --- graphify/extract.py | 62 ++++++++++++++++++++- tests/test_js_import_resolution.py | 86 ++++++++++++++++++++++++++++++ 2 files changed, 147 insertions(+), 1 deletion(-) diff --git a/graphify/extract.py b/graphify/extract.py index 2f4f9c1bc..ee2712775 100644 --- a/graphify/extract.py +++ b/graphify/extract.py @@ -8087,6 +8087,14 @@ class _StarExportFact: line: int +@dataclass(frozen=True) +class _NamespaceExportFact: + file_path: Path + exported_name: str + target_path: Path + line: int + + @dataclass(frozen=True) class _SymbolUseFact: file_path: Path @@ -8104,6 +8112,7 @@ class _SymbolResolutionFacts: aliases: list[_SymbolAliasFact] = field(default_factory=list) exports: list[_SymbolExportFact] = field(default_factory=list) star_exports: list[_StarExportFact] = field(default_factory=list) + namespace_exports: list[_NamespaceExportFact] = field(default_factory=list) uses: list[_SymbolUseFact] = field(default_factory=list) # File-to-file submodule imports from `from pkg import submod` (#1146). # Each entry is (importing_file, submodule_file, line). @@ -8124,6 +8133,7 @@ def _apply_symbol_resolution_facts( or facts.aliases or facts.exports or facts.star_exports + or facts.namespace_exports or facts.uses or facts.module_imports ): @@ -8228,6 +8238,36 @@ def add_edge(source: str, target: str, relation: str, context: str, line: int, s star_fact.file_path, ) + for namespace_fact in facts.namespace_exports: + source_path = namespace_fact.file_path.resolve() + target_path = namespace_fact.target_path.resolve() + namespace_id = ensure_symbol_node( + namespace_fact.file_path, + namespace_fact.exported_name, + namespace_fact.line, + ) + named_exports_by_file.setdefault(source_path, {})[ + namespace_fact.exported_name + ] = (source_path, namespace_fact.exported_name) + source_id = source_file_id.get(source_path) + if source_id is not None: + add_edge( + source_id, + namespace_id, + "contains", + "namespace_export", + namespace_fact.line, + namespace_fact.file_path, + ) + add_edge( + source_id, + _make_id(str(path_by_resolved.get(target_path, target_path))), + "re_exports", + "export", + namespace_fact.line, + namespace_fact.file_path, + ) + for export_fact in facts.exports: file_path = export_fact.file_path.resolve() origin: tuple[Path, str] | None = None @@ -8409,6 +8449,16 @@ def _js_export_statement_is_star(node) -> bool: return any(child.type == "*" for child in node.children) +def _js_namespace_export_name(node, source: bytes) -> str | None: + for child in node.children: + if child.type != "namespace_export": + continue + for sub in child.children: + if sub.type == "identifier": + return _read_text(sub, source) or None + return None + + def _js_lexical_aliases(node, source: bytes) -> list[tuple[str, str]]: aliases: list[tuple[str, str]] = [] if node.type != "lexical_declaration": @@ -8773,7 +8823,17 @@ def _collect_js_symbol_resolution_facts(paths: list[Path], facts: _SymbolResolut if target_path is None: continue target_path = target_path.resolve() - if _js_export_statement_is_star(node): + namespace_name = _js_namespace_export_name(node, source) + if namespace_name is not None: + facts.namespace_exports.append( + _NamespaceExportFact( + path, + namespace_name, + target_path, + node.start_point[0] + 1, + ) + ) + elif _js_export_statement_is_star(node): facts.star_exports.append( _StarExportFact(path, target_path, node.start_point[0] + 1) ) diff --git a/tests/test_js_import_resolution.py b/tests/test_js_import_resolution.py index 5872f10d5..f5f892c6f 100644 --- a/tests/test_js_import_resolution.py +++ b/tests/test_js_import_resolution.py @@ -3,6 +3,8 @@ import json from pathlib import Path +import pytest + from graphify.extract import _file_node_id, _file_stem, _make_id, extract @@ -138,6 +140,90 @@ def test_ts_export_star_from_index_resolves_imported_symbol_to_origin(tmp_path: assert _has_symbol_edge(result, "src/routes/page.ts", "src/lib/foo.ts", "Foo") +@pytest.mark.parametrize("suffix", ["ts", "js"]) +def test_js_namespace_reexport_import_targets_real_binding( + tmp_path: Path, + monkeypatch, + suffix: str, +): + monkeypatch.chdir(tmp_path) + target = _write(Path(f"src/lib/foo.{suffix}"), "export class Foo { id = '' }\n") + barrel = _write(Path(f"src/lib/index.{suffix}"), "export * as ns from './foo'\n") + consumer = _write( + Path(f"src/routes/page.{suffix}"), + "import { ns } from '../lib/index'\nexport const use = () => ns.Foo\n", + ) + + result = _extract_for([target, barrel, consumer], Path(".")) + + namespace_id = _make_id(_file_stem(Path(f"src/lib/index.{suffix}")), "ns") + node_ids = {node["id"] for node in result["nodes"]} + assert namespace_id in node_ids + assert _has_symbol_edge( + result, + f"src/routes/page.{suffix}", + f"src/lib/index.{suffix}", + "ns", + ) + assert _has_edge( + result, + f"src/lib/index.{suffix}", + f"src/lib/foo.{suffix}", + "re_exports", + ) + assert ( + _file_node_id(Path(f"src/lib/index.{suffix}")), + namespace_id, + "contains", + ) in { + (edge["source"], edge["target"], edge["relation"]) + for edge in result["edges"] + } + assert not [ + edge + for edge in result["edges"] + if edge["source"] not in node_ids or edge["target"] not in node_ids + ] + + +def test_ts_reexport_cycle_resolves_symbol_from_non_cycle_branch(tmp_path: Path): + target = _write(tmp_path / "src/lib/foo.ts", "export class Foo { id = '' }\n") + first = _write( + tmp_path / "src/lib/first.ts", + "export * from './second'\nexport * from './foo'\n", + ) + second = _write(tmp_path / "src/lib/second.ts", "export * from './first'\n") + consumer = _write( + tmp_path / "src/routes/page.ts", + "import type { Foo } from '../lib/first'\nexport type X = Foo\n", + ) + + result = _extract_for([target, first, second, consumer], tmp_path) + + assert _has_symbol_edge(result, "src/routes/page.ts", "src/lib/foo.ts", "Foo") + + +def test_ts_reexport_chain_beyond_sixteen_hops_resolves_origin(tmp_path: Path): + target = _write(tmp_path / "src/lib/foo.ts", "export class Foo { id = '' }\n") + barrels: list[Path] = [] + previous = "foo" + for index in range(20): + barrel = _write( + tmp_path / f"src/lib/barrel_{index}.ts", + f"export * from './{previous}'\n", + ) + barrels.append(barrel) + previous = f"barrel_{index}" + consumer = _write( + tmp_path / "src/routes/page.ts", + "import type { Foo } from '../lib/barrel_19'\nexport type X = Foo\n", + ) + + result = extract([target, *barrels, consumer], cache_root=tmp_path, parallel=False) + + assert _has_symbol_edge(result, "src/routes/page.ts", "src/lib/foo.ts", "Foo") + + def test_ts_import_alias_then_reexport_alias_resolves_imported_symbol_to_origin(tmp_path: Path): target = _write(tmp_path / "src/lib/foo.ts", "export class Foo { id = '' }\n") barrel = _write( From 1801da0634e728533811ddcb648b38d5d4a900eb Mon Sep 17 00:00:00 2001 From: guy oron Date: Tue, 30 Jun 2026 09:46:16 +0100 Subject: [PATCH 3/8] feat(ts): resolve this.field.method() calls via constructor-injection types (#1316) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A member call through a constructor-injected dependency (`constructor(private db: Database)` ... `this.db.query()`) now produces a calls edge to the field type's method. The field->type map is captured from constructor parameter-properties, and resolution reuses the existing single-definition god-node guard (like the Swift/Python/Ruby member-call resolvers): the edge is emitted only when the field's type name resolves to exactly one class definition that owns the method, so an ambiguous or unknown/untyped field produces no edge — no global name-match fan-out. Edges are EXTRACTED (the type is explicit from the annotation). TS/JS-only and additive; scope is constructor parameter-property injection. Adds the decisive regression tests the implementation needed: two classes defining the same method name where the injected field is typed to one of them (must resolve to that one only), and an ambiguous type-name case (must emit no edge). Co-Authored-By: Claude Opus 4.8 (1M context) --- graphify/extract.py | 138 +++++++++++++++++++++++- tests/fixtures/typescript_advanced.ts | 4 + tests/test_languages.py | 148 ++++++++++++++++++++++++++ 3 files changed, 286 insertions(+), 4 deletions(-) diff --git a/graphify/extract.py b/graphify/extract.py index ee2712775..9cf619fd2 100644 --- a/graphify/extract.py +++ b/graphify/extract.py @@ -2193,6 +2193,7 @@ def _swift_extra_walk(node, source: bytes, file_nid: str, stem: str, str_path: s call_function_field="function", call_accessor_node_types=frozenset({"member_expression"}), call_accessor_field="property", + call_accessor_object_field="object", function_boundary_types=frozenset({"function_declaration", "arrow_function", "method_definition"}), import_handler=_import_js, ) @@ -2207,12 +2208,13 @@ def _swift_extra_walk(node, source: bytes, file_nid: str, stem: str, str_path: s "enum_declaration", # named enums "type_alias_declaration", # named type aliases }), - function_types=frozenset({"function_declaration", "method_definition"}), + function_types=frozenset({"function_declaration", "method_definition", "method_signature"}), import_types=frozenset({"import_statement", "export_statement"}), call_types=frozenset({"call_expression", "new_expression"}), call_function_field="function", call_accessor_node_types=frozenset({"member_expression"}), call_accessor_field="property", + call_accessor_object_field="object", function_boundary_types=frozenset({"function_declaration", "arrow_function", "method_definition"}), import_handler=_import_js, ) @@ -2231,6 +2233,7 @@ def _swift_extra_walk(node, source: bytes, file_nid: str, stem: str, str_path: s call_function_field=_TS_CONFIG.call_function_field, call_accessor_node_types=_TS_CONFIG.call_accessor_node_types, call_accessor_field=_TS_CONFIG.call_accessor_field, + call_accessor_object_field=_TS_CONFIG.call_accessor_object_field, function_boundary_types=_TS_CONFIG.function_boundary_types, import_handler=_TS_CONFIG.import_handler, ) @@ -3600,6 +3603,31 @@ def _emit_java_parent_type(type_node, rel: str, at_line: int) -> None: if target_nid != func_nid: add_edge(func_nid, target_nid, "references", line, context=ctx) + if (config.ts_module in ("tree_sitter_javascript", "tree_sitter_typescript") + and func_name == "constructor"): + params_node = node.child_by_field_name("parameters") + if params_node is not None: + for p in params_node.children: + if p.type != "required_parameter": + continue + has_modifier = any( + c.type in ("accessibility_modifier", "readonly") + for c in p.children + ) + if not has_modifier: + continue + name_n = p.child_by_field_name("pattern") + type_n = p.child_by_field_name("type") + if name_n is None or type_n is None: + continue + pname = _read_text(name_n, source) + for tc in type_n.children: + if tc.type == "type_identifier": + ptype = _read_text(tc, source) + if pname and ptype: + type_table[pname] = ptype + break + if config.ts_module in ("tree_sitter_c", "tree_sitter_cpp"): collect = (_cpp_collect_type_refs if config.ts_module == "tree_sitter_cpp" else _c_collect_type_refs) @@ -3791,6 +3819,7 @@ def walk_calls(node, caller_nid: str) -> None: callee_name: str | None = None is_member_call: bool = False + is_this_field_call: bool = False swift_receiver: str | None = None member_receiver: str | None = None @@ -3927,10 +3956,20 @@ def walk_calls(node, caller_nid: str) -> None: # Capture a simple-identifier receiver (e.g. `ClassName` # in `ClassName.method()`) so cross-file member-call # resolution can resolve qualified class-method calls - # (#1446). Chained receivers (`a.b.method()`) are skipped. + # (#1446). Chained receivers (`a.b.method()`) are skipped + # UNLESS the chain is `this.field.method()` (#1316). obj = func_node.child_by_field_name(config.call_accessor_object_field) if obj is not None and obj.type == "identifier": member_receiver = _read_text(obj, source) + elif (obj is not None + and obj.type in config.call_accessor_node_types + and config.call_accessor_object_field): + inner_obj = obj.child_by_field_name(config.call_accessor_object_field) + if inner_obj is not None and inner_obj.type == "this": + inner_prop = obj.child_by_field_name(config.call_accessor_field) + if inner_prop is not None: + member_receiver = _read_text(inner_prop, source) + is_this_field_call = True else: # Try reading the node directly (e.g. Java name field is the callee) callee_name = _read_text(func_node, source) @@ -3942,7 +3981,7 @@ def walk_calls(node, caller_nid: str) -> None: # viewset action delegates to a same-named service action — which would # match `tgt_nid == caller_nid` and silently drop the call (#1446). The # captured receiver is resolved later in _resolve_python_member_calls. - if is_member_call and member_receiver and member_receiver[:1].isupper(): + if is_member_call and member_receiver and (member_receiver[:1].isupper() or is_this_field_call): tgt_nid = None else: tgt_nid = label_to_nid.get(callee_name) @@ -4153,7 +4192,10 @@ def walk_calls(node, caller_nid: str) -> None: if swift_extensions: result["swift_extensions"] = swift_extensions if type_table: - result["swift_type_table"] = {"path": str_path, "table": type_table} + if config.ts_module == "tree_sitter_swift": + result["swift_type_table"] = {"path": str_path, "table": type_table} + elif config.ts_module in ("tree_sitter_javascript", "tree_sitter_typescript"): + result["ts_type_table"] = {"path": str_path, "table": type_table} return result @@ -9783,6 +9825,91 @@ def _key(label: str) -> str: }) +def _resolve_typescript_member_calls( + per_file: list[dict], + all_nodes: list[dict], + all_edges: list[dict], +) -> None: + """Resolve cross-file TS/JS member calls via constructor-injection type tables (#1316). + + ``this.repo.findById()`` drops out in the shared cross-file pass because bare + ``findById`` collides across the corpus (god-node guard). TS constructors with + parameter-property modifiers (``private repo: IUserRepository``) produce a + per-file type table mapping field names to their declared types. This pass + looks up the receiver field's type, finds a single-definition class/interface + owning a method with the callee name, and emits an EXTRACTED ``calls`` edge. + """ + type_table_by_file: dict[str, dict[str, str]] = {} + for result in per_file: + tt = result.get("ts_type_table") + if tt and tt.get("path"): + type_table_by_file[tt["path"]] = tt.get("table", {}) + if not type_table_by_file: + return + + def _key(label: str) -> str: + return re.sub(r"[^a-zA-Z0-9]+", "", str(label)).lower() + + contained = {e.get("target") for e in all_edges if e.get("relation") == "contains"} + + type_def_nids: dict[str, list[str]] = {} + node_by_id: dict[str, dict] = {} + for n in all_nodes: + node_by_id[n.get("id")] = n + if n.get("source_file") and n.get("id") in contained and _is_type_like_definition(n): + type_def_nids.setdefault(_key(n.get("label", "")), []).append(n["id"]) + + method_index: dict[tuple[str, str], str] = {} + for e in all_edges: + if e.get("relation") != "method": + continue + src, tgt = e.get("source"), e.get("target") + tnode = node_by_id.get(tgt) + if tnode is not None: + method_index[(src, _key(tnode.get("label", "")))] = tgt + + all_raw_calls: list[dict] = [] + for result in per_file: + all_raw_calls.extend(result.get("raw_calls", [])) + + existing_pairs = {(e.get("source"), e.get("target")) for e in all_edges} + for rc in all_raw_calls: + if not rc.get("is_member_call"): + continue + receiver = rc.get("receiver") + callee = rc.get("callee") + caller = rc.get("caller_nid") + if not receiver or not callee or not caller: + continue + if receiver[:1].isupper(): + type_name = receiver + else: + type_name = type_table_by_file.get(rc.get("source_file", ""), {}).get(receiver) + if not type_name: + continue + type_defs = type_def_nids.get(_key(type_name), []) + if len(type_defs) != 1: + continue + type_nid = type_defs[0] + method_nid = method_index.get((type_nid, _key(callee))) + target = method_nid or type_nid + relation = "calls" if method_nid else "references" + if target == caller or (caller, target) in existing_pairs: + continue + existing_pairs.add((caller, target)) + all_edges.append({ + "source": caller, + "target": target, + "relation": relation, + "context": "call", + "confidence": "EXTRACTED", + "confidence_score": 1.0, + "source_file": rc.get("source_file", ""), + "source_location": rc.get("source_location"), + "weight": 1.0, + }) + + # Register the cross-file, language-specific member-call resolvers into the shared # registry (framework lives in graphify.resolver_registry). A new language plugs in # by adding one register() call below — no edits to extract()'s body. Order @@ -9798,6 +9925,9 @@ def _key(label: str) -> str: register_language_resolver( LanguageResolver("ruby_member_calls", frozenset({".rb"}), resolve_ruby_member_calls) ) +register_language_resolver( + LanguageResolver("typescript_member_calls", frozenset({".ts", ".tsx", ".js", ".jsx"}), _resolve_typescript_member_calls) +) def extract_objc(path: Path) -> dict: diff --git a/tests/fixtures/typescript_advanced.ts b/tests/fixtures/typescript_advanced.ts index bd271c1dd..076d10b52 100644 --- a/tests/fixtures/typescript_advanced.ts +++ b/tests/fixtures/typescript_advanced.ts @@ -57,6 +57,10 @@ export class UserService { bulkCreate(names: string[]): User[] { return names.map((n) => new User(n)); } + + getById(id: string): Promise { + return this.repo.findById(id); + } } @Module({ diff --git a/tests/test_languages.py b/tests/test_languages.py index a7d3ea9aa..98e522ca2 100644 --- a/tests/test_languages.py +++ b/tests/test_languages.py @@ -1717,6 +1717,154 @@ def test_ts_local_const_does_not_emit_phantom_node(tmp_path): assert "topLevel" in labels, f"module-level TS const 'topLevel' missing: {labels}" +def test_ts_constructor_injection_calls_edge(tmp_path): + """this.repo.findById() in a class with constructor(private repo: IUserRepository) + must produce a calls edge from getUser() to findById() (#1316).""" + from graphify.extract import extract + repo_ts = tmp_path / "repo.ts" + repo_ts.write_text( + "export interface IUserRepository {\n" + " findById(id: string): Promise;\n" + " save(user: any): Promise;\n" + "}\n" + ) + svc_ts = tmp_path / "service.ts" + svc_ts.write_text( + "import { IUserRepository } from './repo';\n" + "\n" + "export class UserService {\n" + " constructor(private repo: IUserRepository) {}\n" + "\n" + " getUser(id: string) {\n" + " return this.repo.findById(id);\n" + " }\n" + "}\n" + ) + r = extract([repo_ts, svc_ts], cache_root=tmp_path / "cache") + edge_triples = { + (e["source"], e["relation"], e["target"]) + for e in r["edges"] + } + labels_by_id = {n["id"]: n["label"] for n in r["nodes"]} + label_triples = { + (labels_by_id.get(s, s), rel, labels_by_id.get(t, t)) + for s, rel, t in edge_triples + } + calls_from_get_user = [ + (s, rel, t) for s, rel, t in label_triples + if "getUser" in s and rel == "calls" + ] + assert any("findById" in t for _, _, t in calls_from_get_user), ( + f"expected getUser()->findById() calls edge, got: {calls_from_get_user}" + ) + + +def test_ts_this_field_receiver_not_same_file_collision(tmp_path): + """this.db.query() should NOT match an unrelated query() in the same file (#1316).""" + f = tmp_path / "collision.ts" + f.write_text( + "function query() { return 'global'; }\n" + "\n" + "export class Service {\n" + " constructor(private db: Database) {}\n" + "\n" + " run() {\n" + " return this.db.query();\n" + " }\n" + "}\n" + ) + r = extract_js(f) + calls_edges = [ + e for e in r["edges"] + if e["relation"] == "calls" + ] + caller_labels = {n["id"]: n["label"] for n in r["nodes"]} + run_to_query = [ + e for e in calls_edges + if "run" in caller_labels.get(e["source"], "") + and "query" in caller_labels.get(e["target"], "") + ] + assert len(run_to_query) == 0, ( + f"this.db.query() should NOT resolve to bare query() in same file: {run_to_query}" + ) + + +def _ts_label_calls(r, src_sub): + labels = {n["id"]: n["label"] for n in r["nodes"]} + return [ + labels.get(e["target"], e["target"]) + for e in r["edges"] + if e["relation"] == "calls" and src_sub in labels.get(e["source"], e["source"]) + ] + + +def test_ts_injected_field_resolves_to_typed_class_not_same_named_collision(tmp_path): + """The decisive #1316 guardrail: two classes each define `query`, but the + injected field is typed `Database`, so `this.db.query()` must resolve to + Database.query ONLY — never HttpClient.query (no global name-match fan-out).""" + from graphify.extract import extract + (tmp_path / "database.ts").write_text( + "export class Database {\n query(sql: string) { return sql; }\n}\n" + ) + (tmp_path / "http.ts").write_text( + "export class HttpClient {\n query(url: string) { return url; }\n}\n" + ) + (tmp_path / "service.ts").write_text( + "import { Database } from './database';\n" + "export class Service {\n" + " constructor(private db: Database) {}\n" + " run() { return this.db.query('x'); }\n" + "}\n" + ) + r = extract( + [tmp_path / "database.ts", tmp_path / "http.ts", tmp_path / "service.ts"], + cache_root=tmp_path / "cache", + ) + labels = {n["id"]: n["label"] for n in r["nodes"]} + # Find the run()->query calls edge and confirm its target is owned by Database. + method_owner = { + e["target"]: e["source"] + for e in r["edges"] if e["relation"] == "method" + } + run_query_targets = [ + e["target"] for e in r["edges"] + if e["relation"] == "calls" + and "run" in labels.get(e["source"], "") + and "query" in labels.get(e["target"], "") + ] + assert run_query_targets, "expected this.db.query() to resolve to a query method" + for tgt in run_query_targets: + owner = method_owner.get(tgt) + assert owner is not None and labels.get(owner) == "Database", ( + f"this.db.query() must resolve to Database.query, got owner {labels.get(owner)}" + ) + + +def test_ts_injected_field_ambiguous_type_emits_no_edge(tmp_path): + """If the injected field's type name is ambiguous (two classes named Database), + the god-node guard bails — no calls edge rather than a guess (#1316).""" + from graphify.extract import extract + (tmp_path / "a" ).mkdir() + (tmp_path / "b").mkdir() + (tmp_path / "a" / "database.ts").write_text( + "export class Database {\n query(sql: string) { return sql; }\n}\n" + ) + (tmp_path / "b" / "database.ts").write_text( + "export class Database {\n query(sql: string) { return sql; }\n}\n" + ) + (tmp_path / "service.ts").write_text( + "export class Service {\n" + " constructor(private db: Database) {}\n" + " run() { return this.db.query('x'); }\n" + "}\n" + ) + r = extract(sorted(tmp_path.rglob("*.ts")), cache_root=tmp_path / "cache") + # `query` resolution must bail (2 Database defs) -> no run()->query calls edge. + assert not [t for t in _ts_label_calls(r, "run") if "query" in t], ( + "ambiguous Database type must not produce a this.db.query() edge" + ) + + # ── Markdown ───────────────────────────────────────────────────────────────── from graphify.extract import extract_markdown From 0792b419fcdff4db4f02c065da753bf4dc2c8591 Mon Sep 17 00:00:00 2001 From: guy oron Date: Tue, 30 Jun 2026 09:54:28 +0100 Subject: [PATCH 4/8] feat(objc): dot-syntax property accesses and @selector() call edges (#1475, #1543) `self.product.name` dot-syntax now emits an `accesses` edge and `@selector(method)` emits a `calls` edge, both resolved only to an unambiguous in-scope definition (a sibling method of the same class for dot-syntax; exactly one method by exact selector name for @selector) so no false-edge fan-out occurs when multiple classes share a name. Hardened over the original PR: resolution now matches the method node id EXACTLY (a method id is _make_id(container, name)) rather than by `endswith` suffix. The substring match would mis-resolve `self.name` to a sibling `-surname` (false positive) and, when a substring-colliding sibling existed, suppress the correct edge (false negative); exact matching fixes both. Adds substring-collision regression tests (`-name`/`-surname`, `-doThing`/`-reallyDoThing`). Completes the #1475 ObjC follow-ups (Bug 5 dot-syntax accesses, Bug 6b @selector target-action). Co-Authored-By: Claude Opus 4.8 (1M context) --- graphify/extract.py | 51 +++++++++++++++- tests/test_languages.py | 130 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 178 insertions(+), 3 deletions(-) diff --git a/graphify/extract.py b/graphify/extract.py index 9cf619fd2..7accab315 100644 --- a/graphify/extract.py +++ b/graphify/extract.py @@ -9959,7 +9959,7 @@ def extract_objc(path: Path) -> dict: nodes: list[dict] = [] edges: list[dict] = [] seen_ids: set[str] = set() - method_bodies: list[tuple[str, Any]] = [] + method_bodies: list[tuple[str, Any, str]] = [] def add_node(nid: str, label: str, line: int) -> None: if nid not in seen_ids: @@ -10173,7 +10173,7 @@ def walk(node, parent_nid: str | None = None) -> None: add_node(method_nid, f"{prefix}{method_name}", line) add_edge(container, method_nid, "method", line) if t == "method_definition": - method_bodies.append((method_nid, node)) + method_bodies.append((method_nid, node, container)) return for child in node.children: @@ -10183,8 +10183,13 @@ def walk(node, parent_nid: str | None = None) -> None: # Second pass: resolve calls inside method bodies all_method_nids = {n["id"] for n in nodes if n["id"] != file_nid} + class_method_nids: dict[str, set[str]] = {} + for m_nid, _, container_nid in method_bodies: + class_method_nids.setdefault(container_nid, set()).add(m_nid) seen_calls: set[tuple[str, str]] = set() - for caller_nid, body_node in method_bodies: + for caller_nid, body_node, container_nid in method_bodies: + sibling_nids = class_method_nids.get(container_nid, set()) + def walk_calls(n) -> None: if n.type == "message_expression": # `[[Foo alloc] init]` is a message_expression whose method is the @@ -10228,6 +10233,46 @@ def walk_calls(n) -> None: seen_calls.add(pair) add_edge(caller_nid, candidate, "calls", n.start_point[0] + 1, confidence="EXTRACTED", weight=1.0, context="call") + elif n.type == "field_expression": + # self.name / self.product.name — dot-syntax sugar for [self name]. + # Resolve to a sibling method of the SAME class, matched by EXACT + # node id (a method id is _make_id(container, name)). A suffix + # substring match would mis-resolve self.name -> -surname and would + # let a substring-colliding sibling (-surname) suppress the real + # -name edge, so it must be an exact match (#1475). + for child in n.children: + if child.type == "field_identifier": + field_name = _read(child) + target = _make_id(container_nid, field_name) + if target in sibling_nids and target != caller_nid: + pair = (caller_nid, target) + if pair not in seen_calls: + seen_calls.add(pair) + add_edge(caller_nid, target, "accesses", + n.start_point[0] + 1, + confidence="EXTRACTED", weight=1.0) + elif n.type == "selector_expression": + # @selector(doSomething:withParam:) — compile-time method ref. + # Match the selector name EXACTLY (a method id is + # _make_id(container, name)) against every class's methods, and emit + # only when exactly one method matches, to avoid ambiguous fan-out. + # Exact match (not a suffix) keeps -doThing distinct from + # -reallyDoThing (#1475). + sel_parts = [_read(c) for c in n.children if c.type == "identifier"] + sel_name = "".join(sel_parts) + if sel_name: + matches = sorted({ + m for m, _, cont in method_bodies + if m == _make_id(cont, sel_name) and m != caller_nid + }) + if len(matches) == 1: + pair = (caller_nid, matches[0]) + if pair not in seen_calls: + seen_calls.add(pair) + add_edge(caller_nid, matches[0], "calls", + n.start_point[0] + 1, + confidence="EXTRACTED", weight=1.0, + context="call") for child in n.children: walk_calls(child) walk_calls(body_node) diff --git a/tests/test_languages.py b/tests/test_languages.py index 98e522ca2..fe000d8ee 100644 --- a/tests/test_languages.py +++ b/tests/test_languages.py @@ -1214,6 +1214,136 @@ def test_objc_alloc_init_unknown_class_no_resolved_edge(tmp_path): assert e["target"] not in sourced_ids, f"unexpected resolved ref: {e}" +def test_objc_dot_syntax_property_accesses_edge(tmp_path): + """self.name dot-syntax resolves to an accesses edge within the same class.""" + p = tmp_path / "Dog.m" + p.write_text( + "@implementation Dog\n" + "- (NSString *)name { return @\"Rex\"; }\n" + "- (void)greet { NSLog(@\"%@\", self.name); }\n" + "@end\n" + ) + r = extract_objc(p) + accesses = [(e["source"], e["target"]) for e in r["edges"] + if e["relation"] == "accesses"] + nid2label = {n["id"]: n["label"] for n in r["nodes"]} + assert len(accesses) == 1 + assert nid2label[accesses[0][1]] == "-name" + + +def test_objc_dot_syntax_no_fanout_two_same_named_properties(tmp_path): + """Two classes each declaring -name: self.name in A must NOT fan out to B's -name.""" + p = tmp_path / "AB.m" + p.write_text( + "@implementation A\n" + "- (NSString *)name { return @\"A\"; }\n" + "- (void)show { NSLog(@\"%@\", self.name); }\n" + "@end\n" + "@implementation B\n" + "- (NSString *)name { return @\"B\"; }\n" + "- (void)show { NSLog(@\"%@\", self.name); }\n" + "@end\n" + ) + r = extract_objc(p) + accesses = [e for e in r["edges"] if e["relation"] == "accesses"] + assert len(accesses) == 2, f"expected 2 scoped accesses, got {len(accesses)}: {accesses}" + nid2label = {n["id"]: n["label"] for n in r["nodes"]} + for e in accesses: + src_label = nid2label[e["source"]] + tgt_label = nid2label[e["target"]] + assert src_label == "-show" and tgt_label == "-name" + + +def test_objc_dot_syntax_unresolvable_property_zero_edges(tmp_path): + """Accessing a property not defined in the current class produces zero accesses edges.""" + p = tmp_path / "X.m" + p.write_text( + "@implementation X\n" + "- (void)run { NSLog(@\"%@\", self.missing); }\n" + "@end\n" + ) + r = extract_objc(p) + accesses = [e for e in r["edges"] if e["relation"] == "accesses"] + assert len(accesses) == 0 + + +def test_objc_selector_expression_calls_edge(tmp_path): + """@selector(uniqueMethod) with exactly one match produces a calls edge.""" + p = tmp_path / "Sched.m" + p.write_text( + "@implementation Sched\n" + "- (void)fetch { }\n" + "- (void)schedule { [self performSelector:@selector(fetch)]; }\n" + "@end\n" + ) + r = extract_objc(p) + nid2label = {n["id"]: n["label"] for n in r["nodes"]} + sel_calls = [(nid2label.get(e["source"]), nid2label.get(e["target"])) + for e in r["edges"] + if e["relation"] == "calls" and e.get("context") == "call"] + assert ("-schedule", "-fetch") in sel_calls + + +def test_objc_selector_no_fanout_two_same_named_methods(tmp_path): + """@selector(doThing) with two doThing methods must emit zero calls edges.""" + p = tmp_path / "Dual.m" + p.write_text( + "@implementation A\n" + "- (void)doThing { }\n" + "- (void)run { [self performSelector:@selector(doThing)]; }\n" + "@end\n" + "@implementation B\n" + "- (void)doThing { }\n" + "@end\n" + ) + r = extract_objc(p) + nid2label = {n["id"]: n["label"] for n in r["nodes"]} + sel_edges = [e for e in r["edges"] + if e["relation"] == "calls" + and nid2label.get(e["target"], "").endswith("doThing")] + assert len(sel_edges) == 0, f"expected 0 selector edges with ambiguous name, got {sel_edges}" + + +def test_objc_dot_syntax_substring_sibling_exact_match(tmp_path): + """A substring-colliding sibling must neither be falsely matched nor suppress + the real match: `self.name` with both `-name` and `-surname` present resolves + to `-name` ONLY (exact id, not a `endswith` suffix) (#1475).""" + p = tmp_path / "Person.m" + p.write_text( + "@implementation Person\n" + "- (NSString *)name { return @\"n\"; }\n" + "- (NSString *)surname { return @\"s\"; }\n" + "- (void)show { NSLog(@\"%@\", self.name); }\n" + "@end\n" + ) + r = extract_objc(p) + nid2label = {n["id"]: n["label"] for n in r["nodes"]} + accesses = [(nid2label.get(e["source"]), nid2label.get(e["target"])) + for e in r["edges"] if e["relation"] == "accesses"] + assert ("-show", "-name") in accesses, f"self.name must resolve to -name: {accesses}" + assert ("-show", "-surname") not in accesses, f"self.name must NOT match -surname: {accesses}" + + +def test_objc_selector_substring_method_exact_match(tmp_path): + """@selector(doThing) must resolve to `-doThing` exactly, not be suppressed by + a substring-colliding `-reallyDoThing` (exact match, not suffix) (#1475).""" + p = tmp_path / "Worker.m" + p.write_text( + "@implementation Worker\n" + "- (void)doThing { }\n" + "- (void)reallyDoThing { }\n" + "- (void)run { [self performSelector:@selector(doThing)]; }\n" + "@end\n" + ) + r = extract_objc(p) + nid2label = {n["id"]: n["label"] for n in r["nodes"]} + sel_calls = [(nid2label.get(e["source"]), nid2label.get(e["target"])) + for e in r["edges"] + if e["relation"] == "calls" and e.get("context") == "call"] + assert ("-run", "-doThing") in sel_calls, f"@selector(doThing) must resolve to -doThing: {sel_calls}" + assert ("-run", "-reallyDoThing") not in sel_calls + + # --------------------------------------------------------------------------- # Go # --------------------------------------------------------------------------- From 5779767fd31b26de9d3045c7b564f7306b08668a Mon Sep 17 00:00:00 2001 From: safishamsi Date: Tue, 30 Jun 2026 11:19:54 +0100 Subject: [PATCH 5/8] =?UTF-8?q?feat(reflect):=20work-memory=20overlay=20?= =?UTF-8?q?=E2=80=94=20surface=20learned=20verdicts=20as=20a=20graph=20sid?= =?UTF-8?q?ecar=20(#1441)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Projects the verdicts `graphify reflect` already distills (preferred / tentative / contested, exponential time-decayed) into a derived experiential layer the read surfaces consume, so accumulated agent experience actually shows up where you look — without polluting the structural graph. Design (grounded in agent-memory + provenance literature; a redesign of the #1542 approach): - SIDECAR, not graph.json stamping. `reflect` writes `.graphify_learning.json` next to graph.json (an additional output, so the git hooks produce it automatically). graph.json stays purely structural; nothing leaks into GraphML; no graph.json churn. Mirrors the named-graph / event-sourcing separation of durable truth from a derived layer. - Reuses the existing reflect aggregate (its `_decay` is the recency-weighted exponential model; `_finalize_sources` the classification) — no new scoring. - PROVENANCE: each verdict carries the source questions/dates that produced it (cap 5, most-recent first). - STALENESS: each verdict stores the node's file fingerprint; on read, a changed source file flags the verdict stale ("code changed since — re-verify") rather than presenting a confident lesson on rewritten code. - CONTESTED surfaced distinctly (useful N / dead-end M), not averaged away. - DEAD-ENDS stay QUERY-SCOPED — never a node-level status; they appear only in the report as question -> nodes. - Read surfaces (explain / query+MCP / GRAPH_REPORT / graph.html) merge the overlay at read time, sanitized; un-annotated graphs are byte-identical. Deferred (logged): letting verdicts influence query/seed traversal — the recommender feedback-loop / Matthew-effect risk means that needs propensity correction + exploration, not naive biasing. Builds on the idea in #1441/#1542 (thanks @TPAteeq). Co-Authored-By: Claude Opus 4.8 (1M context) --- graphify/__main__.py | 28 +++- graphify/export.py | 51 +++++++- graphify/reflect.py | 266 +++++++++++++++++++++++++++++++++++++- graphify/report.py | 64 +++++++++ graphify/serve.py | 27 +++- graphify/watch.py | 3 +- tests/test_explain_cli.py | 43 ++++++ tests/test_export.py | 69 ++++++++++ tests/test_reflect.py | 170 ++++++++++++++++++++++++ tests/test_report.py | 44 +++++++ tests/test_serve.py | 48 +++++++ 11 files changed, 804 insertions(+), 9 deletions(-) diff --git a/graphify/__main__.py b/graphify/__main__.py index 312f3eefe..def633fe6 100644 --- a/graphify/__main__.py +++ b/graphify/__main__.py @@ -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) @@ -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 diff --git a/graphify/export.py b/graphify/export.py index 29676036f..a281422e3 100644 --- a/graphify/export.py +++ b/graphify/export.py @@ -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. @@ -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): @@ -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}}, @@ -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 diff --git a/graphify/reflect.py b/graphify/reflect.py index 00bbd3d28..ed63121c6 100644 --- a/graphify/reflect.py +++ b/graphify/reflect.py @@ -38,6 +38,14 @@ _UNCATEGORIZED = "Uncategorized" +# Derived experiential layer written alongside graph.json (a SIDECAR, kept +# separate from the durable structural truth in graph.json — no learning_* +# fields are ever stamped into the graph itself). Read-surface annotations are +# merged in at display time from this file. +LEARNING_SIDECAR_NAME = ".graphify_learning.json" +_LEARNING_SCHEMA_VERSION = 1 +_PROVENANCE_CAP = 5 # most-recent (question, date, outcome) entries per node + # Scoring defaults (both exposed as CLI flags). _DEFAULT_HALF_LIFE_DAYS = 30.0 # a signal's weight halves every 30 days _DEFAULT_MIN_CORROBORATION = 2 # distinct useful results needed to "prefer" a node @@ -289,13 +297,18 @@ def _empty_bucket() -> dict[str, Any]: "node_neg": Counter(), # node -> most recent event date seen (for the contested verdict line) "node_last": {}, + # node -> list of (date, question, outcome) for useful/corrected citations. + # Feeds the sidecar overlay's per-node provenance; never read by LESSONS.md, + # so it doesn't touch the aggregate's public shape. + "node_provenance": {}, "dead_ends": [], "corrections": [], } def _record_node(bucket: dict[str, Any], node: str, sign: int, - weight: float, date: str) -> None: + weight: float, date: str, *, outcome: str | None = None, + question: str = "") -> None: bucket["node_score"][node] = bucket["node_score"].get(node, 0.0) + sign * weight if sign > 0: bucket["node_pos"][node] += 1 @@ -303,6 +316,11 @@ def _record_node(bucket: dict[str, Any], node: str, sign: int, bucket["node_neg"][node] += 1 if date > bucket["node_last"].get(node, ""): bucket["node_last"][node] = date + # Provenance: only useful/corrected events are recorded (the experiential + # trail an agent cares about — what cited this node, and how it turned out). + if outcome in ("useful", "corrected"): + bucket["node_provenance"].setdefault(node, []).append( + (date, question, outcome)) def _finalize_sources(bucket: dict[str, Any], @@ -382,7 +400,8 @@ def aggregate_lessons(docs: list[dict[str, Any]], target["counts"][outcome if outcome in OUTCOMES else "unmarked"] += 1 if sign: for n in nodes: - _record_node(target, n, sign, weight, date) + _record_node(target, n, sign, weight, date, + outcome=outcome, question=doc.get("question", "")) if outcome == "dead_end": target["dead_ends"].append( {"question": doc.get("question", ""), "nodes": nodes, "date": date}) @@ -411,6 +430,10 @@ def aggregate_lessons(docs: list[dict[str, Any]], "dead_ends": _dedupe_by_question(overall["dead_ends"]), "corrections": _dedupe_by_question(overall["corrections"]), "by_community": community_out, + # Private: per-node (date, question, outcome) trail for the sidecar + # overlay's provenance. Underscore-prefixed and not rendered by + # render_lessons_md, so the public aggregate shape is unchanged. + "_node_provenance": overall["node_provenance"], } @@ -574,4 +597,243 @@ def reflect(memory_dir: Path, out_path: Path, out_path = Path(out_path) out_path.parent.mkdir(parents=True, exist_ok=True) out_path.write_text(render_lessons_md(agg), encoding="utf-8") + + # Also project a derived experiential sidecar next to graph.json when a graph + # is in hand. Best-effort: a sidecar failure must never break LESSONS.md. + if graph_path is not None: + try: + write_learning_sidecar(agg, Path(graph_path), now=now) + except Exception: + pass + return out_path, agg + + +# --- work-memory overlay sidecar ------------------------------------------------ +# +# A derived, experiential projection of the reflect aggregate, written next to +# graph.json as ``.graphify_learning.json``. It carries which nodes have proven +# preferred/tentative/contested, a code fingerprint for staleness detection, and +# a short provenance trail. graph.json (durable structural truth) is never +# touched — read surfaces merge this overlay in only at display time. + + +def _build_id_label_maps(graph_path: Path) -> tuple[dict[str, str], dict[str, list[str]], + dict[str, dict[str, Any]]]: + """From graph.json build: + + - ``id_set``: id -> id (every node id, so an id-form citation resolves to itself) + - ``label_to_ids``: label -> [ids] (so a label-form citation can be resolved, + and ambiguity — one label, many ids — can be detected and skipped) + - ``node_by_id``: id -> node dict (for source_file lookup) + + Best-effort; an unreadable/garbage graph yields empty maps. + """ + id_set: dict[str, str] = {} + label_to_ids: dict[str, list[str]] = {} + node_by_id: dict[str, dict[str, Any]] = {} + try: + data = json.loads(Path(graph_path).read_text(encoding="utf-8")) + except (OSError, ValueError): + return id_set, label_to_ids, node_by_id + for n in data.get("nodes", []): + if not isinstance(n, dict) or n.get("id") is None: + continue + nid = str(n["id"]) + id_set[nid] = nid + node_by_id[nid] = n + label = n.get("label") + if label is not None: + label_to_ids.setdefault(str(label), []).append(nid) + return id_set, label_to_ids, node_by_id + + +def _resolve_canonical_id(cited: str, id_set: dict[str, str], + label_to_ids: dict[str, list[str]]) -> str | None: + """Resolve a cited node (a label OR an id) to a single canonical node id. + + Returns None if the citation is unresolved (stale — gone from the graph) or + ambiguous (a label shared by >1 node id). Such citations can't be displayed + against a single node, so the caller skips them. + """ + if cited in id_set: + return id_set[cited] + ids = label_to_ids.get(cited) + if ids and len(ids) == 1: + return ids[0] + return None + + +def _code_fingerprint(node: dict[str, Any] | None, root: Path) -> str: + """File-level content hash of the node's ``source_file``, or '' if unavailable. + + Coarse on purpose — a file-level hash over-flags (any edit to the file marks + every node in it stale) rather than under-flags, which is the safe direction + for a "re-verify" hint. + """ + if not node: + return "" + src = node.get("source_file") + if not src: + return "" + try: + from graphify.cache import file_hash + p = Path(src) + if not p.is_absolute(): + p = (Path(root) / p) + return file_hash(p, root) + except Exception: + return "" + + +def _provenance_for(node: str, prov_map: dict[str, list], + fallback_outcome: str) -> list[dict[str, str]]: + """Most-recent-first, capped provenance entries for a node. + + ``prov_map`` is the aggregate's private per-node (date, question, outcome) + trail. ``fallback_outcome`` covers an entry with no recorded trail (shouldn't + happen for preferred/tentative/contested, which all have ≥1 positive event). + """ + events = prov_map.get(node, []) + # Sort recent-first; (date desc, then question for a stable tiebreak). + ordered = sorted(events, key=lambda e: (e[0], e[1]), reverse=True) + out: list[dict[str, str]] = [] + for date, question, outcome in ordered[:_PROVENANCE_CAP]: + out.append({"q": question, "date": date, "outcome": outcome}) + return out + + +def build_learning_overlay(agg: dict[str, Any], graph_path: Path, + *, now: datetime | None = None) -> dict[str, Any]: + """Project the reflect aggregate into the sidecar's ``{version, generated_at, + nodes}`` structure, keyed by canonical node id. + + Built from preferred + tentative + contested (NOT dead_ends — those stay + query-scoped, surfaced only in the report). Citations that don't resolve to + exactly one node id are skipped. + """ + if now is None: + now = datetime.now(timezone.utc) + elif now.tzinfo is None: + now = now.replace(tzinfo=timezone.utc) + + graph_path = Path(graph_path) + root = graph_path.parent + id_set, label_to_ids, node_by_id = _build_id_label_maps(graph_path) + prov_map = agg.get("_node_provenance", {}) + + # id -> entry; a canonical id can be cited under both its id and label form, + # but the aggregate dedups per node string, so collisions here are benign and + # resolved deterministically by iteration order (preferred, tentative, contested). + nodes_out: dict[str, dict[str, Any]] = {} + + def _add(entry_src: dict[str, Any], status: str) -> None: + cited = entry_src["node"] + cid = _resolve_canonical_id(cited, id_set, label_to_ids) + if cid is None: + return # ambiguous or stale — can't display against a single node + if cid in nodes_out: + return # first status wins (preferred > tentative > contested order) + node = node_by_id.get(cid) + out: dict[str, Any] = { + "status": status, + "score": entry_src["score"], + "uses": entry_src.get("n", entry_src.get("pos", 0)), + "last": entry_src.get("last", ""), + "label": str(node.get("label", cited)) if node else str(cited), + "source_file": str(node.get("source_file") or "") if node else "", + "code_fingerprint": _code_fingerprint(node, root), + "provenance": _provenance_for(cited, prov_map, status), + } + if status == "contested": + out["verdict"] = entry_src.get("verdict", "even") + out["neg"] = entry_src.get("neg", 0) + else: + # preferred/tentative carry no contested verdict; derive `last` from + # provenance if the finalizer didn't (positive-only buckets do track it + # via node_last for contested only). + if not out["last"] and out["provenance"]: + out["last"] = out["provenance"][0]["date"] + nodes_out[cid] = out + + for e in agg.get("preferred", []): + _add(e, "preferred") + for e in agg.get("tentative", []): + _add(e, "tentative") + for e in agg.get("contested", []): + _add(e, "contested") + + return { + "version": _LEARNING_SCHEMA_VERSION, + "generated_at": now.isoformat(), + "nodes": nodes_out, + } + + +def write_learning_sidecar(agg: dict[str, Any], graph_path: Path, + *, now: datetime | None = None) -> Path: + """Write ``.graphify_learning.json`` next to ``graph_path`` deterministically. + + Sorted keys + indent=2 so re-runs on identical input (and a fixed ``now``) + are byte-identical. Returns the sidecar path. + """ + overlay = build_learning_overlay(agg, graph_path, now=now) + sidecar = Path(graph_path).parent / LEARNING_SIDECAR_NAME + sidecar.write_text( + json.dumps(overlay, indent=2, sort_keys=True, ensure_ascii=False) + "\n", + encoding="utf-8", + ) + return sidecar + + +def load_learning_overlay(graph_path: Path) -> dict[str, dict[str, Any]]: + """Load the sidecar next to ``graph_path`` and return ``{node_id -> entry}`` + with a recomputed ``stale: bool`` per entry. Best-effort -> {} on any error. + + Staleness: recompute ``file_hash(source_file)`` and compare to the entry's + stored ``code_fingerprint``. Matching fingerprints -> not stale. Differing, + missing-but-recomputable, or a vanished file -> stale (the safe, over-flagging + direction). An entry with no stored fingerprint AND no current file is not + marked stale (nothing to re-verify). + """ + sidecar = Path(graph_path).parent / LEARNING_SIDECAR_NAME + try: + data = json.loads(sidecar.read_text(encoding="utf-8")) + except (OSError, ValueError): + return {} + nodes = data.get("nodes") + if not isinstance(nodes, dict): + return {} + root = Path(graph_path).parent + out: dict[str, dict[str, Any]] = {} + for nid, entry in nodes.items(): + if not isinstance(entry, dict): + continue + merged = dict(entry) + merged["stale"] = _is_stale(entry, root) + out[str(nid)] = merged + return out + + +def _is_stale(entry: dict[str, Any], root: Path) -> bool: + """True if the node's source file changed (or vanished) since the fingerprint + was taken.""" + stored = entry.get("code_fingerprint", "") + src = entry.get("source_file", "") + if not src: + # No file to track. Stale only if a fingerprint was stored yet there's + # nothing to compare against — treat as not stale (nothing to re-verify). + return False + p = Path(src) + if not p.is_absolute(): + p = root / p + if not p.exists(): + return True # file gone — definitely re-verify + try: + from graphify.cache import file_hash + current = file_hash(p, root) + except Exception: + return bool(stored) # couldn't recompute; flag iff we had something to compare + if not stored: + return True # had a file but never fingerprinted it -> can't trust -> stale + return current != stored diff --git a/graphify/report.py b/graphify/report.py index f0210897d..1a1d9a365 100644 --- a/graphify/report.py +++ b/graphify/report.py @@ -12,6 +12,62 @@ def _safe_community_name(label: str) -> str: return cleaned or "unnamed" +def load_learning_for_report(graph_path) -> dict | None: + """Assemble the report's work-memory inputs from sibling artifacts. + + Reads the ``.graphify_learning.json`` overlay (preferred sources) next to + ``graph_path`` and re-aggregates the memory docs for the query-scoped + dead-ends. Best-effort: returns None if neither is available, so the report + simply omits the section. Never raises. + """ + from pathlib import Path as _Path + try: + gp = _Path(graph_path) + from graphify.reflect import load_learning_overlay, load_memory_docs, aggregate_lessons + overlay = load_learning_overlay(gp) + dead_ends: list[dict] = [] + mem = gp.parent / "memory" + if mem.is_dir(): + agg = aggregate_lessons(load_memory_docs(mem)) + dead_ends = agg.get("dead_ends", []) + if not overlay and not dead_ends: + return None + return {"overlay": overlay, "dead_ends": dead_ends} + except Exception: + return None + + +def _learning_section(lines: list, learning: dict | None, top_n: int = 10) -> None: + """Append the ``## Work-memory lessons`` section, or nothing when empty.""" + if not learning: + return + overlay = learning.get("overlay") or {} + dead_ends = learning.get("dead_ends") or [] + preferred = [ + (nid, e) for nid, e in overlay.items() + if isinstance(e, dict) and e.get("status") == "preferred" + ] + # Most-corroborated first (uses desc), then by score, then id for stability. + preferred.sort(key=lambda kv: (-kv[1].get("uses", 0), + -float(kv[1].get("score", 0) or 0), kv[0])) + if not preferred and not dead_ends: + return + lines += ["", "## Work-memory lessons"] + if preferred: + lines += ["", "**Preferred sources** — corroborated by past sessions; start here."] + for nid, e in preferred[:top_n]: + label = e.get("label") or nid + stale = " _(code changed — re-verify)_" if e.get("stale") else "" + lines.append(f"- `{label}` ({e.get('uses', 0)}× useful, " + f"score={e.get('score', 0)}){stale}") + if dead_ends: + lines += ["", "**Known dead ends** — questions that led nowhere; don't re-derive."] + for d in dead_ends: + nodes = ", ".join(f"`{n}`" for n in d.get("nodes", [])) + lines.append(f"- \"{d.get('question', '')}\"" + + (f" -> {nodes}" if nodes else "")) + + def generate( G: nx.Graph, communities: dict[int, list[str]], @@ -25,6 +81,7 @@ def generate( suggested_questions: list[dict] | None = None, min_community_size: int = 3, built_at_commit: str | None = None, + learning: dict | None = None, ) -> str: today = date.today().isoformat() @@ -202,6 +259,13 @@ def generate( if amb_pct > 20: lines.append(f"- **High ambiguity: {amb_pct}% of edges are AMBIGUOUS.** Review the Ambiguous Edges section above.") + # --- Work-memory lessons (derived overlay) --- + # Preferred sources come from the .graphify_learning.json sidecar; the + # query-scoped dead-ends come from the reflect aggregate. Section omitted + # entirely when neither is present, so a graph with no work-memory is + # byte-identical to the pre-feature report. + _learning_section(lines, learning) + if suggested_questions: lines += ["", "## Suggested Questions"] no_signal = len(suggested_questions) == 1 and suggested_questions[0].get("type") == "no_signal" diff --git a/graphify/serve.py b/graphify/serve.py index f096f1075..c2cffbe54 100644 --- a/graphify/serve.py +++ b/graphify/serve.py @@ -42,9 +42,18 @@ def _load_graph(graph_path: str) -> nx.Graph: except Exception: pass try: - return json_graph.node_link_graph(data, edges="links") + G = json_graph.node_link_graph(data, edges="links") except TypeError: - return json_graph.node_link_graph(data) + G = json_graph.node_link_graph(data) + # Attach the work-memory overlay (derived sidecar next to graph.json) so + # the query/MCP read surface can annotate NODE lines display-only. Empty + # when no sidecar exists, leaving un-annotated output byte-identical. + try: + from graphify.reflect import load_learning_overlay as _llo + G.graph["_learning_overlay"] = _llo(resolved) + except Exception: + G.graph["_learning_overlay"] = {} + return G except (ValueError, FileNotFoundError) as exc: print(f"error: {exc}", file=sys.stderr) sys.exit(1) @@ -503,6 +512,9 @@ def _subgraph_to_text(G: nx.Graph, nodes: set[str], edges: list[tuple], token_bu """ char_budget = token_budget * 3 lines = [] + # Work-memory overlay (derived sidecar) stashed on the graph at load time. + # Empty when no sidecar exists, so un-annotated output stays byte-identical. + overlay = getattr(G, "graph", {}).get("_learning_overlay", {}) or {} seed_set = set(seeds or []) ordered = [n for n in (seeds or []) if n in nodes] + \ sorted(nodes - seed_set, key=lambda n: G.degree(n), reverse=True) @@ -513,11 +525,20 @@ def _subgraph_to_text(G: nx.Graph, nodes: set[str], edges: list[tuple], token_bu # corpus document can otherwise inject ANSI escapes, fake graphify-out # log lines, or prompt-injection markup into the model's context via # source_file / source_location / community. + # The learning= suffix is appended INSIDE the bracket and BEFORE the + # budget check below, so it counts in char_budget accounting. + entry = overlay.get(str(nid)) + learning_suffix = "" + if entry: + status = sanitize_label(str(entry.get("status", ""))) + if status: + learning_suffix = f" learning={status}{':stale' if entry.get('stale') else ''}" line = ( f"NODE {sanitize_label(d.get('label', nid))} " f"[src={sanitize_label(str(d.get('source_file', '')))} " f"loc={sanitize_label(str(d.get('source_location', '')))} " - f"community={sanitize_label(str(d.get('community_name') or d.get('community', '')))}]" + f"community={sanitize_label(str(d.get('community_name') or d.get('community', '')))}" + f"{learning_suffix}]" ) lines.append(line) for u, v in edges: diff --git a/graphify/watch.py b/graphify/watch.py index 7d059e1ad..c8770362e 100644 --- a/graphify/watch.py +++ b/graphify/watch.py @@ -807,9 +807,10 @@ def _edge_evicted(e: dict) -> bool: if cid not in labels: labels[cid] = "Community " + str(cid) questions = suggest_questions(G, communities, labels) + from graphify.report import load_learning_for_report as _llfr report = generate(G, communities, cohesion, labels, gods, surprises, detection, {"input": 0, "output": 0}, report_root, suggested_questions=questions, - built_at_commit=commit) + built_at_commit=commit, learning=_llfr(out / "graph.json")) report_path = out / "GRAPH_REPORT.md" labels_json = json.dumps({str(k): v for k, v in sorted(labels.items())}, ensure_ascii=False, indent=2) + "\n" graph_tmp = out / ".graph.tmp.json" diff --git a/tests/test_explain_cli.py b/tests/test_explain_cli.py index a77ac3eca..7759f30e3 100644 --- a/tests/test_explain_cli.py +++ b/tests/test_explain_cli.py @@ -80,3 +80,46 @@ def test_explain_source_file_path_prefers_file_level_node(monkeypatch, tmp_path, assert "ID: example_route" in out assert f"Source: {source_file} L1" in out assert "Node: GET()" not in out + + +# --- work-memory overlay Lesson line ------------------------------------------ + +def _write_sidecar(tmp_path, nodes): + (tmp_path / ".graphify_learning.json").write_text( + json.dumps({"version": 1, "generated_at": "2026-06-01T00:00:00+00:00", + "nodes": nodes}), + encoding="utf-8", + ) + + +def test_explain_shows_preferred_lesson_line(monkeypatch, tmp_path, capsys): + p = _write_graph(tmp_path) + _write_sidecar(tmp_path, { + "validate": {"status": "preferred", "score": 2.4, "uses": 3, + "label": "validateSanitySession()", "source_file": "", + "code_fingerprint": "", "provenance": []}, + }) + out = _run(monkeypatch, p, "validateSanitySession", capsys) + assert "Lesson: preferred source (start here) — 3 useful, score=2.4" in out + assert "code changed" not in out + + +def test_explain_shows_contested_and_stale_lesson(monkeypatch, tmp_path, capsys): + p = _write_graph(tmp_path) + # source_file points at a path that does not exist -> loader marks it stale. + _write_sidecar(tmp_path, { + "validate": {"status": "contested", "score": -0.1, "uses": 2, "neg": 1, + "verdict": "dead end", "label": "validateSanitySession()", + "source_file": "server/sanity-validate-session.ts", + "code_fingerprint": "deadbeef", "provenance": []}, + }) + out = _run(monkeypatch, p, "validateSanitySession", capsys) + assert "Lesson: contested (useful 2 / dead-end 1)" in out + assert "[code changed since — re-verify]" in out + + +def test_explain_no_lesson_line_for_unannotated_node(monkeypatch, tmp_path, capsys): + """No sidecar => no Lesson line; output identical to pre-feature.""" + p = _write_graph(tmp_path) + out = _run(monkeypatch, p, "validateSanitySession", capsys) + assert "Lesson:" not in out diff --git a/tests/test_export.py b/tests/test_export.py index fb5481f22..be4743bc5 100644 --- a/tests/test_export.py +++ b/tests/test_export.py @@ -184,6 +184,75 @@ def test_to_html_member_counts_accepted(): assert out.exists() +def _vis_nodes_from_html(content: str) -> list: + """Extract the RAW_NODES JSON array embedded in the generated HTML.""" + m = re.search(r"const RAW_NODES = (\[.*?\]);", content, re.DOTALL) + assert m, "RAW_NODES not found in HTML" + return json.loads(m.group(1).replace("<\\/", " None: + """Write a minimal graph.json under ``out`` with the given node dicts.""" + out.mkdir(parents=True, exist_ok=True) + graph = {"directed": True, "multigraph": False, "graph": {}, + "nodes": nodes, "links": []} + (out / "graph.json").write_text(json.dumps(graph), encoding="utf-8") + + +def _overlay_corpus(mem: Path) -> None: + """A corpus with: a PREFERRED node (2 useful), a TENTATIVE node (1 useful), + a CONTESTED node (useful + dead_end), and a DEAD-END-ONLY node.""" + _write_raw_doc(mem, "p1.md", "2026-05-01", outcome="useful", + question="how do I auth?", nodes=["login()"]) + _write_raw_doc(mem, "p2.md", "2026-05-10", outcome="useful", + question="auth again", nodes=["login()"]) + _write_raw_doc(mem, "t1.md", "2026-05-02", outcome="useful", + question="cache?", nodes=["RedisClient"]) + _write_raw_doc(mem, "c1.md", "2026-05-03", outcome="useful", + question="contested useful", nodes=["Contested"]) + _write_raw_doc(mem, "c2.md", "2026-05-04", outcome="dead_end", + question="contested dead", nodes=["Contested"]) + _write_raw_doc(mem, "d1.md", "2026-05-05", outcome="dead_end", + question="led nowhere", nodes=["DeadEnd"]) + + +def test_sidecar_write_classifies_and_keys_by_canonical_id(tmp_path): + """reflect with a graph writes .graphify_learning.json next to graph.json with + the preferred/tentative/contested nodes keyed by canonical node id; the + dead-end-only node is NOT present; score/uses/provenance are carried.""" + out = tmp_path / "graphify-out" + src = tmp_path / "auth.py" + src.write_text("def login(): pass\n", encoding="utf-8") + _overlay_graph(out, [ + {"id": "auth_login", "label": "login()", "source_file": str(src), "community": 0}, + {"id": "redis_client", "label": "RedisClient", "source_file": "", "community": 0}, + {"id": "contested_node", "label": "Contested", "source_file": "", "community": 0}, + {"id": "deadend_node", "label": "DeadEnd", "source_file": "", "community": 0}, + ]) + mem = out / "memory" + _overlay_corpus(mem) + + reflect(mem, out / "reflections" / "LESSONS.md", + graph_path=out / "graph.json", now=_NOW) + sidecar = json.loads((out / LEARNING_SIDECAR_NAME).read_text(encoding="utf-8")) + + assert sidecar["version"] == 1 + assert sidecar["generated_at"] == _NOW.isoformat() + nodes = sidecar["nodes"] + # Keyed by canonical node id, not label. + assert nodes["auth_login"]["status"] == "preferred" + assert nodes["auth_login"]["uses"] == 2 + assert nodes["auth_login"]["label"] == "login()" + assert isinstance(nodes["auth_login"]["score"], float) + assert nodes["auth_login"]["provenance"] # captured during aggregation + assert nodes["redis_client"]["status"] == "tentative" + assert nodes["contested_node"]["status"] == "contested" + assert nodes["contested_node"]["verdict"] in ("useful", "dead end", "even") + # Dead-end-only node stays query-scoped — never in the overlay. + assert "deadend_node" not in nodes + # And learning_* is NOT stamped into graph.json (durable truth untouched). + graph = json.loads((out / "graph.json").read_text(encoding="utf-8")) + for n in graph["nodes"]: + assert not any(k.startswith("learning") for k in n) + + +def test_sidecar_is_byte_identical_across_runs(tmp_path): + """Two reflect runs on identical input + fixed `now` produce a byte-identical + sidecar (sorted keys, stable indent).""" + out = tmp_path / "graphify-out" + src = tmp_path / "auth.py" + src.write_text("def login(): pass\n", encoding="utf-8") + _overlay_graph(out, [ + {"id": "auth_login", "label": "login()", "source_file": str(src), "community": 0}, + ]) + mem = out / "memory" + _write_raw_doc(mem, "a.md", "2026-05-01", outcome="useful", nodes=["login()"]) + _write_raw_doc(mem, "b.md", "2026-05-10", outcome="useful", nodes=["login()"]) + + reflect(mem, out / "reflections" / "LESSONS.md", + graph_path=out / "graph.json", now=_NOW) + first = (out / LEARNING_SIDECAR_NAME).read_bytes() + reflect(mem, out / "reflections" / "LESSONS.md", + graph_path=out / "graph.json", now=_NOW) + second = (out / LEARNING_SIDECAR_NAME).read_bytes() + assert first == second + + +def test_loader_marks_entry_stale_when_source_file_changes(tmp_path): + """load_learning_overlay recomputes the file fingerprint: unchanged source => + stale=False; an edit to that source => stale=True.""" + out = tmp_path / "graphify-out" + src = tmp_path / "auth.py" + src.write_text("def login(): pass\n", encoding="utf-8") + _overlay_graph(out, [ + {"id": "auth_login", "label": "login()", "source_file": str(src), "community": 0}, + ]) + mem = out / "memory" + _write_raw_doc(mem, "a.md", "2026-05-01", outcome="useful", nodes=["login()"]) + _write_raw_doc(mem, "b.md", "2026-05-10", outcome="useful", nodes=["login()"]) + reflect(mem, out / "reflections" / "LESSONS.md", + graph_path=out / "graph.json", now=_NOW) + + fresh = load_learning_overlay(out / "graph.json") + assert fresh["auth_login"]["stale"] is False + + src.write_text("def login(): return 1 # changed\n", encoding="utf-8") + after = load_learning_overlay(out / "graph.json") + assert after["auth_login"]["stale"] is True + + +def test_provenance_capped_to_five_most_recent(tmp_path): + """A node cited by >5 useful results keeps exactly the 5 most-recent in + provenance (recent-first).""" + out = tmp_path / "graphify-out" + src = tmp_path / "auth.py" + src.write_text("x\n", encoding="utf-8") + _overlay_graph(out, [ + {"id": "auth_login", "label": "login()", "source_file": str(src), "community": 0}, + ]) + mem = out / "memory" + for i in range(7): + _write_raw_doc(mem, f"u{i}.md", f"2026-05-{10 + i:02d}", + outcome="useful", question=f"q{i}", nodes=["login()"]) + reflect(mem, out / "reflections" / "LESSONS.md", + graph_path=out / "graph.json", now=_NOW) + sidecar = json.loads((out / LEARNING_SIDECAR_NAME).read_text(encoding="utf-8")) + prov = sidecar["nodes"]["auth_login"]["provenance"] + assert len(prov) == 5 + # Most-recent first. + assert prov[0]["date"] == "2026-05-16" + assert prov[-1]["date"] == "2026-05-12" + + +def test_ambiguous_or_unresolved_citation_is_skipped(tmp_path): + """A label shared by >1 node id (ambiguous) or absent from the graph + (unresolved) is skipped — it can't be displayed against a single node.""" + out = tmp_path / "graphify-out" + _overlay_graph(out, [ + {"id": "dup_a", "label": "Dup", "source_file": "", "community": 0}, + {"id": "dup_b", "label": "Dup", "source_file": "", "community": 0}, + {"id": "solo", "label": "Solo", "source_file": "", "community": 0}, + ]) + mem = out / "memory" + _write_raw_doc(mem, "a.md", "2026-05-01", outcome="useful", nodes=["Dup"]) + _write_raw_doc(mem, "b.md", "2026-05-02", outcome="useful", nodes=["Dup"]) + _write_raw_doc(mem, "c.md", "2026-05-03", outcome="useful", nodes=["Solo"]) + _write_raw_doc(mem, "d.md", "2026-05-04", outcome="useful", nodes=["Solo"]) + reflect(mem, out / "reflections" / "LESSONS.md", + graph_path=out / "graph.json", now=_NOW) + nodes = json.loads((out / LEARNING_SIDECAR_NAME).read_text(encoding="utf-8"))["nodes"] + # Ambiguous "Dup" skipped; only the unambiguous "Solo" survives. + assert "dup_a" not in nodes and "dup_b" not in nodes + assert "solo" in nodes diff --git a/tests/test_report.py b/tests/test_report.py index a5b3916a1..d8c4ad4fd 100644 --- a/tests/test_report.py +++ b/tests/test_report.py @@ -61,3 +61,47 @@ def test_report_shows_raw_cohesion_scores(): assert "Cohesion:" in report assert "✓" not in report assert "⚠" not in report + + +# --- work-memory lessons section ---------------------------------------------- + +def test_report_work_memory_section_present_with_overlay_and_dead_ends(): + """When a work-memory overlay (preferred sources) and query-scoped dead-ends + are supplied, the report grows a `## Work-memory lessons` section listing the + preferred sources and, separately, the dead-ends as question -> nodes.""" + G, communities, cohesion, labels, gods, surprises, detection, tokens = make_inputs() + learning = { + "overlay": { + "auth_login": {"status": "preferred", "uses": 3, "score": 2.4, + "label": "login()", "stale": False}, + "redis": {"status": "tentative", "uses": 1, "score": 0.5, + "label": "RedisClient", "stale": False}, + }, + "dead_ends": [ + {"question": "does it use websockets?", "nodes": ["WSServer"], "date": "2026-05-01"}, + ], + } + report = generate(G, communities, cohesion, labels, gods, surprises, detection, + tokens, "./project", learning=learning) + assert "## Work-memory lessons" in report + assert "**Preferred sources**" in report + assert "`login()`" in report + # Tentative is not listed in the report's preferred block. + assert "RedisClient" not in report + # Dead-ends are query-scoped: question -> nodes, NOT a node-level status. + assert "**Known dead ends**" in report + assert "does it use websockets?" in report + assert "`WSServer`" in report + + +def test_report_work_memory_section_absent_without_overlay(): + """No learning input => no section; report identical to pre-feature.""" + G, communities, cohesion, labels, gods, surprises, detection, tokens = make_inputs() + before = generate(G, communities, cohesion, labels, gods, surprises, detection, + tokens, "./project") + assert "## Work-memory lessons" not in before + # Explicit empty learning also omits the section. + empty = generate(G, communities, cohesion, labels, gods, surprises, detection, + tokens, "./project", learning={"overlay": {}, "dead_ends": []}) + assert "## Work-memory lessons" not in empty + assert before == empty diff --git a/tests/test_serve.py b/tests/test_serve.py index 4cd477d65..74bfffb93 100644 --- a/tests/test_serve.py +++ b/tests/test_serve.py @@ -379,6 +379,54 @@ def test_subgraph_to_text_includes_edge_context(): assert "context=call" in text +# --- work-memory overlay annotation on NODE lines ----------------------------- + +def test_subgraph_to_text_annotates_node_with_learning_status(): + """An annotated node gets a `learning=` suffix inside its NODE + bracket; an un-annotated node gets none.""" + G = _make_graph() + G.graph["_learning_overlay"] = { + "n1": {"status": "preferred", "stale": False}, + } + text = _subgraph_to_text(G, {"n1", "n2"}, [("n1", "n2")]) + lines = {l.split()[1]: l for l in text.splitlines() if l.startswith("NODE ")} + assert "learning=preferred]" in lines["extract"] + assert "learning=" not in lines["cluster"] # un-annotated node + + +def test_subgraph_to_text_marks_stale_status(): + G = _make_graph() + G.graph["_learning_overlay"] = {"n1": {"status": "contested", "stale": True}} + text = _subgraph_to_text(G, {"n1"}, []) + assert "learning=contested:stale]" in text + + +def test_subgraph_to_text_learning_suffix_counts_against_budget(): + """The learning= suffix is part of the NODE line BEFORE the budget cut, so it + is included in the char_budget accounting (a budget tight enough to fit the + bare line but not the suffixed line forces truncation).""" + G = _make_graph() + bare = _subgraph_to_text(G, {"n1", "n2", "n3"}, []) + # token_budget chosen so the un-annotated render fits without truncation... + budget = (len(bare) // 3) + 1 + assert "truncated" not in _subgraph_to_text(G, {"n1", "n2", "n3"}, [], + token_budget=budget) + # ...but once every node carries a learning= suffix, the same budget overflows. + G.graph["_learning_overlay"] = { + n: {"status": "preferred", "stale": False} for n in ("n1", "n2", "n3") + } + annotated = _subgraph_to_text(G, {"n1", "n2", "n3"}, [], token_budget=budget) + assert "learning=preferred" in annotated + assert "truncated" in annotated + + +def test_subgraph_to_text_no_overlay_is_unchanged(): + """With no overlay on the graph, NODE lines carry no learning= suffix.""" + G = _make_graph() + text = _subgraph_to_text(G, {"n1", "n2"}, [("n1", "n2")]) + assert "learning=" not in text + + def test_query_graph_text_explicit_context_filter_changes_traversal(): G = _make_graph() text = _query_graph_text(G, "extract", mode="bfs", depth=2, token_budget=2000, context_filters=["call"]) From 00e00a0b5f7cbf80a123d878e6e3c40c78c680e6 Mon Sep 17 00:00:00 2001 From: safishamsi Date: Tue, 30 Jun 2026 12:40:20 +0100 Subject: [PATCH 6/8] fix(reflect): work-memory staleness false-positive on relative source paths MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The overlay fingerprint resolved a node's source_file against graph_path.parent (the graphify-out/ dir), but source_file is stored relative to the PROJECT root — so graphify-out/auth.py never existed and _is_stale flagged EVERY verdict "code changed since — re-verify" the moment it was written. (The original staleness test used an absolute source_file, which masked it.) Fix: resolve the file by trying the likely roots in order (.graphify_root marker, graphify-out's parent, graph.json's own dir, cwd) and use the first that exists — the same search at write and read — and fingerprint file CONTENT only (sha256 of bytes, no path mixed in) so the hash is root-independent and a committed sidecar stays valid across checkouts. Drops the brittle directory-name-based root guess. Adds a regression test with a relative source_file under the graphify-out layout (stale=False right after reflect, True after an edit). Co-Authored-By: Claude Opus 4.8 (1M context) --- graphify/reflect.py | 97 ++++++++++++++++++++++++++++--------------- tests/test_reflect.py | 26 ++++++++++++ 2 files changed, 90 insertions(+), 33 deletions(-) diff --git a/graphify/reflect.py b/graphify/reflect.py index ed63121c6..61a122d46 100644 --- a/graphify/reflect.py +++ b/graphify/reflect.py @@ -664,8 +664,57 @@ def _resolve_canonical_id(cited: str, id_set: dict[str, str], return None -def _code_fingerprint(node: dict[str, Any] | None, root: Path) -> str: - """File-level content hash of the node's ``source_file``, or '' if unavailable. +def _resolve_source_path(src: str, graph_path: Path) -> Path | None: + """Locate a node's ``source_file`` on disk, returning an existing file or None. + + ``source_file`` is stored relative to the PROJECT root, but graph.json may + live in ``/graphify-out/`` (so its own dir is not the root) or directly + at the root (``extract --out .``). Rather than guess the root from a directory + name (brittle: a ``GRAPHIFY_OUT`` override changes it), try the likely roots in + order and return the first where the file actually exists. The same candidate + search runs at write and read time, so the writer and reader resolve to the + same file. Order: the committed ``.graphify_root`` marker (#686), then the + graphify-out-parent, then graph.json's own dir, then the cwd. + """ + if not src: + return None + p = Path(src) + if p.is_absolute(): + return p if p.is_file() else None + gp = Path(graph_path) + candidates: list[Path] = [] + marker = gp.parent / ".graphify_root" + try: + if marker.is_file(): + candidates.append(Path(marker.read_text(encoding="utf-8").strip())) + except OSError: + pass + candidates += [gp.parent.parent, gp.parent, Path(".")] + seen: set[str] = set() + for base in candidates: + key = str(base) + if key in seen: + continue + seen.add(key) + cand = base / p + if cand.is_file(): + return cand + return None + + +def _content_hash(path: Path) -> str: + """SHA256 of file CONTENT only (no path mixed in), so the fingerprint is + independent of which root resolved the file — write and read agree, and a + committed sidecar stays valid across machines/checkouts.""" + import hashlib + try: + return hashlib.sha256(path.read_bytes()).hexdigest() + except OSError: + return "" + + +def _code_fingerprint(node: dict[str, Any] | None, graph_path: Path) -> str: + """Content hash of the node's ``source_file``, or '' if unavailable. Coarse on purpose — a file-level hash over-flags (any edit to the file marks every node in it stale) rather than under-flags, which is the safe direction @@ -673,17 +722,8 @@ def _code_fingerprint(node: dict[str, Any] | None, root: Path) -> str: """ if not node: return "" - src = node.get("source_file") - if not src: - return "" - try: - from graphify.cache import file_hash - p = Path(src) - if not p.is_absolute(): - p = (Path(root) / p) - return file_hash(p, root) - except Exception: - return "" + sp = _resolve_source_path(node.get("source_file") or "", graph_path) + return _content_hash(sp) if sp is not None else "" def _provenance_for(node: str, prov_map: dict[str, list], @@ -718,7 +758,6 @@ def build_learning_overlay(agg: dict[str, Any], graph_path: Path, now = now.replace(tzinfo=timezone.utc) graph_path = Path(graph_path) - root = graph_path.parent id_set, label_to_ids, node_by_id = _build_id_label_maps(graph_path) prov_map = agg.get("_node_provenance", {}) @@ -742,7 +781,7 @@ def _add(entry_src: dict[str, Any], status: str) -> None: "last": entry_src.get("last", ""), "label": str(node.get("label", cited)) if node else str(cited), "source_file": str(node.get("source_file") or "") if node else "", - "code_fingerprint": _code_fingerprint(node, root), + "code_fingerprint": _code_fingerprint(node, graph_path), "provenance": _provenance_for(cited, prov_map, status), } if status == "contested": @@ -804,36 +843,28 @@ def load_learning_overlay(graph_path: Path) -> dict[str, dict[str, Any]]: nodes = data.get("nodes") if not isinstance(nodes, dict): return {} - root = Path(graph_path).parent out: dict[str, dict[str, Any]] = {} for nid, entry in nodes.items(): if not isinstance(entry, dict): continue merged = dict(entry) - merged["stale"] = _is_stale(entry, root) + merged["stale"] = _is_stale(entry, graph_path) out[str(nid)] = merged return out -def _is_stale(entry: dict[str, Any], root: Path) -> bool: +def _is_stale(entry: dict[str, Any], graph_path: Path) -> bool: """True if the node's source file changed (or vanished) since the fingerprint - was taken.""" - stored = entry.get("code_fingerprint", "") + was taken. Uses the same file resolution + content hash as the writer, so a + freshly-written verdict on unchanged code is never spuriously stale.""" src = entry.get("source_file", "") if not src: - # No file to track. Stale only if a fingerprint was stored yet there's - # nothing to compare against — treat as not stale (nothing to re-verify). + # No file to track — nothing to re-verify. return False - p = Path(src) - if not p.is_absolute(): - p = root / p - if not p.exists(): - return True # file gone — definitely re-verify - try: - from graphify.cache import file_hash - current = file_hash(p, root) - except Exception: - return bool(stored) # couldn't recompute; flag iff we had something to compare + sp = _resolve_source_path(src, graph_path) + if sp is None: + return True # file gone / unfindable — re-verify + stored = entry.get("code_fingerprint", "") if not stored: return True # had a file but never fingerprinted it -> can't trust -> stale - return current != stored + return _content_hash(sp) != stored diff --git a/tests/test_reflect.py b/tests/test_reflect.py index a5304c921..61e0dd280 100644 --- a/tests/test_reflect.py +++ b/tests/test_reflect.py @@ -837,6 +837,32 @@ def test_loader_marks_entry_stale_when_source_file_changes(tmp_path): assert after["auth_login"]["stale"] is True +def test_relative_source_file_not_spuriously_stale_in_graphify_out_layout(tmp_path): + """Regression: with a RELATIVE source_file and graph.json under graphify-out/, + a freshly-written verdict must NOT be flagged stale. The fingerprint resolves + the file relative to the PROJECT root (tmp_path), not graph.json's own dir + (graphify-out/) — otherwise every node looked unfindable and was marked stale. + The edit case must still flip stale=True.""" + out = tmp_path / "graphify-out" # graph.json lives here + (tmp_path / "auth.py").write_text("def login(): pass\n", encoding="utf-8") + _overlay_graph(out, [ + # source_file is RELATIVE to the project root (tmp_path), as `extract` writes it + {"id": "auth_login", "label": "login()", "source_file": "auth.py", "community": 0}, + ]) + mem = out / "memory" + _write_raw_doc(mem, "a.md", "2026-05-01", outcome="useful", nodes=["login()"]) + _write_raw_doc(mem, "b.md", "2026-05-10", outcome="useful", nodes=["login()"]) + reflect(mem, out / "reflections" / "LESSONS.md", + graph_path=out / "graph.json", now=_NOW) + + fresh = load_learning_overlay(out / "graph.json") + assert fresh["auth_login"]["status"] == "preferred" + assert fresh["auth_login"]["stale"] is False # the bug: was spuriously True + + (tmp_path / "auth.py").write_text("def login(): return 1 # changed\n", encoding="utf-8") + assert load_learning_overlay(out / "graph.json")["auth_login"]["stale"] is True + + def test_provenance_capped_to_five_most_recent(tmp_path): """A node cited by >5 useful results keeps exactly the 5 most-recent in provenance (recent-first).""" From 2cdc212b43e5043b144d579518c0036917d81747 Mon Sep 17 00:00:00 2001 From: safishamsi Date: Tue, 30 Jun 2026 12:42:25 +0100 Subject: [PATCH 7/8] docs: README work-memory overlay note + Unreleased changelog MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit README: document that `reflect --graph` writes the .graphify_learning.json overlay and that explain/query surface a Lesson hint (with the code-changed staleness flag). CHANGELOG: add an Unreleased section for the post-0.9.2 work — the work-memory overlay (#1441/#1542), this.field.method() injected-field resolution (#1316), TS wildcard path aliases (#1544), JS namespace re-exports (#1552), and the ObjC dot-syntax/@selector edges (#1475/#1543). Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 8 ++++++++ README.md | 4 +++- 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f2bf99e92..a918232a4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/README.md b/README.md index 1f81ae07b..ea3201335 100644 --- a/README.md +++ b/README.md @@ -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/ From c865a3c9b0feea421b391b28340de153ec3b9269 Mon Sep 17 00:00:00 2001 From: safishamsi Date: Tue, 30 Jun 2026 12:57:33 +0100 Subject: [PATCH 8/8] fix(reflect): layout-ordered source resolution for overlay staleness (#1558) Refines the staleness file resolution (00e00a0) by folding in the two genuine merits of @TPAteeq's parallel fix (#1558), which independently and correctly diagnosed the same root-mismatch bug: - Layout-ordered candidates: try the layout-appropriate root FIRST (the graphify-out parent for the standard layout, graph.json's own dir for a flat layout) before the other. The prior order tried the grandparent first unconditionally, which in a flat layout (graph.json at the project root) could fingerprint a same-named file one directory up. Existence checking is kept on top, so a defeated name heuristic or a stale .graphify_root marker still falls through to the real file. - Adds @TPAteeq's .graphify_root-marker-driven regression test, plus a flat-layout test that pins the ordering (editing the real file flips stale; editing the same-named decoy one dir up does not). Co-Authored-By: tpateeq Co-Authored-By: Claude Opus 4.8 (1M context) --- graphify/reflect.py | 36 ++++++++++++++++++++----------- tests/test_reflect.py | 50 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 74 insertions(+), 12 deletions(-) diff --git a/graphify/reflect.py b/graphify/reflect.py index 61a122d46..4e04e0589 100644 --- a/graphify/reflect.py +++ b/graphify/reflect.py @@ -35,6 +35,7 @@ from typing import Any from graphify.ingest import OUTCOMES +from graphify.paths import GRAPHIFY_OUT_NAME _UNCATEGORIZED = "Uncategorized" @@ -669,12 +670,17 @@ def _resolve_source_path(src: str, graph_path: Path) -> Path | None: ``source_file`` is stored relative to the PROJECT root, but graph.json may live in ``/graphify-out/`` (so its own dir is not the root) or directly - at the root (``extract --out .``). Rather than guess the root from a directory - name (brittle: a ``GRAPHIFY_OUT`` override changes it), try the likely roots in - order and return the first where the file actually exists. The same candidate - search runs at write and read time, so the writer and reader resolve to the - same file. Order: the committed ``.graphify_root`` marker (#686), then the - graphify-out-parent, then graph.json's own dir, then the cwd. + at the root (``extract --out .``). Resolve the root in the most-likely order + and return the first candidate where the file actually exists, so a defeated + heuristic or a stale marker can never strand the file (every node would then + look "changed"). The same search runs at write and read time, so the writer + and reader resolve to the same file. + + Order: the committed ``.graphify_root`` marker (#686/#1423 — authoritative for + an absolute/elsewhere ``GRAPHIFY_OUT`` override); then the layout-appropriate + root *first* — graph.json's parent's parent for the ``graphify-out`` layout, + or graph.json's own dir for a flat layout — which avoids matching a same-named + file one directory up; then the other of the two; then the cwd. """ if not src: return None @@ -682,14 +688,20 @@ def _resolve_source_path(src: str, graph_path: Path) -> Path | None: if p.is_absolute(): return p if p.is_file() else None gp = Path(graph_path) + out_dir = gp.parent candidates: list[Path] = [] - marker = gp.parent / ".graphify_root" try: - if marker.is_file(): - candidates.append(Path(marker.read_text(encoding="utf-8").strip())) - except OSError: - pass - candidates += [gp.parent.parent, gp.parent, Path(".")] + recorded = (out_dir / ".graphify_root").read_text(encoding="utf-8").strip() + if recorded: + candidates.append(Path(recorded)) + except (OSError, ValueError): + pass # unreadable/non-UTF-8 marker -> fall through (best-effort) + # Layout-appropriate root first (precision), then the other (robustness). + if out_dir.name == GRAPHIFY_OUT_NAME: + candidates += [out_dir.parent, out_dir] + else: + candidates += [out_dir, out_dir.parent] + candidates.append(Path(".")) seen: set[str] = set() for base in candidates: key = str(base) diff --git a/tests/test_reflect.py b/tests/test_reflect.py index 61e0dd280..c24cacefd 100644 --- a/tests/test_reflect.py +++ b/tests/test_reflect.py @@ -863,6 +863,56 @@ def test_relative_source_file_not_spuriously_stale_in_graphify_out_layout(tmp_pa assert load_learning_overlay(out / "graph.json")["auth_login"]["stale"] is True +def test_relative_source_file_resolved_via_graphify_root_marker(tmp_path): + """When a committed .graphify_root marker records the project root (e.g. a + GRAPHIFY_OUT override pointing the output dir elsewhere), the fingerprint + resolves source_file against that root, not graph.json's own dir.""" + proj = tmp_path / "project" + proj.mkdir() + (proj / "auth.py").write_text("def login(): pass\n", encoding="utf-8") + out = tmp_path / "elsewhere-out" # output dir NOT under the project + _overlay_graph(out, [ + {"id": "auth_login", "label": "login()", "source_file": "auth.py", "community": 0}, + ]) + (out / ".graphify_root").write_text(str(proj), encoding="utf-8") # the marker + mem = out / "memory" + _write_raw_doc(mem, "a.md", "2026-05-01", outcome="useful", nodes=["login()"]) + _write_raw_doc(mem, "b.md", "2026-05-10", outcome="useful", nodes=["login()"]) + reflect(mem, out / "reflections" / "LESSONS.md", + graph_path=out / "graph.json", now=_NOW) + assert load_learning_overlay(out / "graph.json")["auth_login"]["stale"] is False + + +def test_flat_layout_does_not_match_same_named_file_one_dir_up(tmp_path): + """In a flat layout (graph.json at the project root), the resolver must use the + graph's own dir, not its parent — otherwise a same-named file one level up + would be fingerprinted instead, producing a wrong staleness verdict.""" + proj = tmp_path / "proj" + proj.mkdir() + (proj / "util.py").write_text("REAL = 1\n", encoding="utf-8") + # A decoy same-named file in the parent dir (tmp_path / util.py). + (tmp_path / "util.py").write_text("DECOY = 2\n", encoding="utf-8") + # Flat layout: graph.json sits directly in proj/ (not a graphify-out subdir). + proj.joinpath("graph.json").write_text(json.dumps({ + "nodes": [{"id": "util", "label": "util.py", "source_file": "util.py", + "source_location": "L1", "community": 0}], + "links": [], + }), encoding="utf-8") + mem = proj / "memory" + _write_raw_doc(mem, "a.md", "2026-05-01", outcome="useful", nodes=["util.py"]) + _write_raw_doc(mem, "b.md", "2026-05-10", outcome="useful", nodes=["util.py"]) + reflect(mem, proj / "reflections" / "LESSONS.md", + graph_path=proj / "graph.json", now=_NOW) + # Not stale on a clean build... + assert load_learning_overlay(proj / "graph.json")["util"]["stale"] is False + # ...and editing the REAL file (proj/util.py) flips it, while editing the + # decoy (parent) does not — proving the resolver bound to the right file. + (tmp_path / "util.py").write_text("DECOY = 999\n", encoding="utf-8") + assert load_learning_overlay(proj / "graph.json")["util"]["stale"] is False + (proj / "util.py").write_text("REAL = 999\n", encoding="utf-8") + assert load_learning_overlay(proj / "graph.json")["util"]["stale"] is True + + def test_provenance_capped_to_five_most_recent(tmp_path): """A node cited by >5 useful results keeps exactly the 5 most-recent in provenance (recent-first)."""