wikifier 4.5.8__tar.gz → 4.5.9__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. {wikifier-4.5.8/wikifier.egg-info → wikifier-4.5.9}/PKG-INFO +1 -1
  2. {wikifier-4.5.8 → wikifier-4.5.9}/tests/test_gap_closure.py +79 -0
  3. {wikifier-4.5.8 → wikifier-4.5.9}/wikifier/__init__.py +1 -1
  4. {wikifier-4.5.8 → wikifier-4.5.9}/wikifier/cli.py +3 -130
  5. {wikifier-4.5.8 → wikifier-4.5.9}/wikifier/import_cache.py +68 -10
  6. {wikifier-4.5.8 → wikifier-4.5.9}/wikifier/parsers/bree.py +2 -2
  7. {wikifier-4.5.8 → wikifier-4.5.9}/wikifier/parsers/javascript.py +1 -1
  8. wikifier-4.5.9/wikifier/project_root.py +144 -0
  9. {wikifier-4.5.8 → wikifier-4.5.9/wikifier.egg-info}/PKG-INFO +1 -1
  10. {wikifier-4.5.8 → wikifier-4.5.9}/wikifier.egg-info/SOURCES.txt +2 -0
  11. {wikifier-4.5.8 → wikifier-4.5.9}/CONTRIBUTING.md +0 -0
  12. {wikifier-4.5.8 → wikifier-4.5.9}/LICENSE +0 -0
  13. {wikifier-4.5.8 → wikifier-4.5.9}/MANIFEST.in +0 -0
  14. {wikifier-4.5.8 → wikifier-4.5.9}/README.md +0 -0
  15. {wikifier-4.5.8 → wikifier-4.5.9}/diagnostics.html +0 -0
  16. {wikifier-4.5.8 → wikifier-4.5.9}/docs/spec.md +0 -0
  17. {wikifier-4.5.8 → wikifier-4.5.9}/index.html +0 -0
  18. {wikifier-4.5.8 → wikifier-4.5.9}/pyproject.toml +0 -0
  19. {wikifier-4.5.8 → wikifier-4.5.9}/setup.cfg +0 -0
  20. {wikifier-4.5.8 → wikifier-4.5.9}/skills/run.md +0 -0
  21. {wikifier-4.5.8 → wikifier-4.5.9}/tests/test_barrel_invalidation.py +0 -0
  22. {wikifier-4.5.8 → wikifier-4.5.9}/tests/test_health.py +0 -0
  23. {wikifier-4.5.8 → wikifier-4.5.9}/tests/test_import_cache.py +0 -0
  24. {wikifier-4.5.8 → wikifier-4.5.9}/tests/test_multi_lang_parsers.py +0 -0
  25. {wikifier-4.5.8 → wikifier-4.5.9}/tests/test_parsers.py +0 -0
  26. {wikifier-4.5.8 → wikifier-4.5.9}/tests/test_selftest_wrappers.py +0 -0
  27. {wikifier-4.5.8 → wikifier-4.5.9}/wikifier/__main__.py +0 -0
  28. {wikifier-4.5.8 → wikifier-4.5.9}/wikifier/contracts.py +0 -0
  29. {wikifier-4.5.8 → wikifier-4.5.9}/wikifier/daemon.py +0 -0
  30. {wikifier-4.5.8 → wikifier-4.5.9}/wikifier/diagnostics.py +0 -0
  31. {wikifier-4.5.8 → wikifier-4.5.9}/wikifier/health.py +0 -0
  32. {wikifier-4.5.8 → wikifier-4.5.9}/wikifier/index.html +0 -0
  33. {wikifier-4.5.8 → wikifier-4.5.9}/wikifier/library.py +0 -0
  34. {wikifier-4.5.8 → wikifier-4.5.9}/wikifier/locking.py +0 -0
  35. {wikifier-4.5.8 → wikifier-4.5.9}/wikifier/mcp/__init__.py +0 -0
  36. {wikifier-4.5.8 → wikifier-4.5.9}/wikifier/mcp/server.py +0 -0
  37. {wikifier-4.5.8 → wikifier-4.5.9}/wikifier/parsers/__init__.py +0 -0
  38. {wikifier-4.5.8 → wikifier-4.5.9}/wikifier/parsers/_edge.py +0 -0
  39. {wikifier-4.5.8 → wikifier-4.5.9}/wikifier/parsers/c_cpp.py +0 -0
  40. {wikifier-4.5.8 → wikifier-4.5.9}/wikifier/parsers/cdia.py +0 -0
  41. {wikifier-4.5.8 → wikifier-4.5.9}/wikifier/parsers/csharp.py +0 -0
  42. {wikifier-4.5.8 → wikifier-4.5.9}/wikifier/parsers/go_lang.py +0 -0
  43. {wikifier-4.5.8 → wikifier-4.5.9}/wikifier/parsers/java.py +0 -0
  44. {wikifier-4.5.8 → wikifier-4.5.9}/wikifier/parsers/python.py +0 -0
  45. {wikifier-4.5.8 → wikifier-4.5.9}/wikifier/parsers/rust.py +0 -0
  46. {wikifier-4.5.8 → wikifier-4.5.9}/wikifier/resolution.py +0 -0
  47. {wikifier-4.5.8 → wikifier-4.5.9}/wikifier/scripts/wikifier.bat +0 -0
  48. {wikifier-4.5.8 → wikifier-4.5.9}/wikifier/scripts/wikifier.ps1 +0 -0
  49. {wikifier-4.5.8 → wikifier-4.5.9}/wikifier/scripts/wikifier.sh +0 -0
  50. {wikifier-4.5.8 → wikifier-4.5.9}/wikifier/serve.py +0 -0
  51. {wikifier-4.5.8 → wikifier-4.5.9}/wikifier.egg-info/dependency_links.txt +0 -0
  52. {wikifier-4.5.8 → wikifier-4.5.9}/wikifier.egg-info/entry_points.txt +0 -0
  53. {wikifier-4.5.8 → wikifier-4.5.9}/wikifier.egg-info/requires.txt +0 -0
  54. {wikifier-4.5.8 → wikifier-4.5.9}/wikifier.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: wikifier
3
- Version: 4.5.8
3
+ Version: 4.5.9
4
4
  Summary: Zero-dependency agent-to-agent codebase wiki for LLMs and AI agents. Autonomously maintained via record-change and mark-green for token-efficient lookup of files, dependencies, health, and summaries — across tiny scripts to 50k+ monorepos. Optional MCP server with rich tools for agents.
5
5
  Author-email: Aron Amos <aron@example.com>
6
6
  Maintainer: Aron Amos
@@ -103,6 +103,85 @@ class TestAcsActionableG4(TempProjectTestCase):
103
103
  for p in action
104
104
  ))
105
105
 
106
+ def test_dynamic_literal_noise_demoted_from_actionable(self):
107
+ """ACS v1.2: importlib.import_module(\"pkg\") static dynamics are not actionable."""
108
+ dyn_lit = {
109
+ "raw": "\"wikifier.health\"",
110
+ "resolved": "wikifier.health",
111
+ "resolved_path": None,
112
+ "is_dynamic": True,
113
+ "dynamic_type": "static",
114
+ "confidence_score": 0.05,
115
+ "confidence_reasons": [
116
+ "base:low", "conditional_context", "dynamic",
117
+ "detector:TernaryDetector",
118
+ ],
119
+ "confidence_explanation": (
120
+ 'Base low (0.05). TernaryDetector=importlib.import_module("wikifier.health"). '
121
+ "Recommendation: Runtime optional load is expected noise."
122
+ ),
123
+ "resolution_metadata": {"strategy": "python-dynamic"},
124
+ }
125
+ project_fragile = {
126
+ "raw": "./missing_local",
127
+ "resolved": "missing_local",
128
+ "resolved_path": None,
129
+ "is_dynamic": False,
130
+ "confidence_score": 0.35,
131
+ "confidence_reasons": ["base:low", "no_resolved_path"],
132
+ "confidence_explanation": "Unresolved project-local. Recommendation: fix path.",
133
+ "diagnostic": {"category": "project"},
134
+ "resolution_metadata": {"strategy": "relative-fs"},
135
+ }
136
+ cache = {
137
+ "mod.py": {
138
+ "mtime": 1,
139
+ "resolved_pairs": [dyn_lit, project_fragile],
140
+ }
141
+ }
142
+ self.assertTrue(ic._edge_is_dynamic_literal_noise(dyn_lit))
143
+ self.assertFalse(ic._edge_is_dynamic_literal_noise(project_fragile))
144
+ summary = ic.compute_acs_summary(cache)
145
+ self.assertEqual(summary.get("acs_version"), "1.2")
146
+ self.assertGreaterEqual(summary["low_conf_edges"], 2)
147
+ self.assertEqual(summary["actionable_low_conf_edges"], 1)
148
+ self.assertGreaterEqual(summary.get("dynamic_literal_noise_edges", 0), 1)
149
+ action = ic.get_low_confidence_edges(cache, actionable_only=True)
150
+ self.assertEqual(len(action), 1)
151
+ self.assertIn("missing_local", str(action[0].get("raw") or action[0].get("resolved")))
152
+
153
+
154
+ class TestLoadSafetyNoImportCycle(unittest.TestCase):
155
+ """Load-time safety: core modules import without circular-import failure."""
156
+
157
+ def test_import_wikifier_and_cycle_trio(self):
158
+ import importlib
159
+ import wikifier
160
+ self.assertTrue(hasattr(wikifier, "discover_project_root"))
161
+ # Fresh submodule loads (project_root breaks former cli↔cache↔bree SCC)
162
+ for name in (
163
+ "wikifier.project_root",
164
+ "wikifier.cli",
165
+ "wikifier.import_cache",
166
+ "wikifier.parsers.bree",
167
+ ):
168
+ mod = importlib.import_module(name)
169
+ self.assertIsNotNone(mod)
170
+ from wikifier.project_root import discover_project_root as dpr
171
+ from wikifier.cli import discover_project_root as dpr_cli
172
+ self.assertIs(dpr, dpr_cli)
173
+ root = dpr()
174
+ self.assertTrue(root.exists())
175
+
176
+ def test_bree_does_not_import_cli_at_module_level(self):
177
+ """Static check: bree source must not load-time import wikifier.cli."""
178
+ from pathlib import Path
179
+ bree_src = Path(__file__).resolve().parents[1] / "wikifier" / "parsers" / "bree.py"
180
+ text = bree_src.read_text(encoding="utf-8")
181
+ self.assertNotIn("from ..cli import", text)
182
+ self.assertNotIn("from wikifier.cli import", text)
183
+ self.assertIn("from ..project_root import", text)
184
+
106
185
 
107
186
  class TestGhostAndDeletionG7(TempProjectTestCase):
108
187
  def test_find_ghost_and_record_deletion(self):
@@ -71,4 +71,4 @@ from .contracts import (
71
71
  compute_acs_confidence,
72
72
  )
73
73
 
74
- __version__ = "4.5.8"
74
+ __version__ = "4.5.9"
@@ -21,136 +21,9 @@ from pathlib import Path
21
21
  from typing import Any, Dict, List, Optional
22
22
  from contextlib import nullcontext as _nullcontext
23
23
 
24
-
25
- def discover_project_root() -> Path:
26
- """
27
- Unified discovery helper for the *target project root* (user codebase / monorepo).
28
-
29
- This is the canonical implementation for CLI, MCP, direct Python use, and
30
- is mirrored (same priority + markers) by the shell script's discover_project_root().
31
-
32
- Highest-leverage fix for External / Packaged Full-Update Robustness (Gap #1):
33
- Makes `pip install wikifier` + `wikifier ...` (or direct packaged sh) reliable
34
- from any external monorepo cwd without requiring manual WIKIFIER_PROJECT_ROOT
35
- or --target on every invocation. Zero-dependency.
36
-
37
- Wave 3 hardening (symlinks + pnpm/yarn stores):
38
- - Consults $PWD (and $OLDPWD) logical cwd in addition to Path.cwd() / .resolve().
39
- - Walks parent chains from *logical* start points first; this ensures that when
40
- cwd is deep inside a pnpm symlinked store layout (e.g. monorepo/node_modules/.pnpm/pkg@1/.../pkg
41
- whose physical target is /global-store/...), the logical ancestor chain still reaches
42
- the real project root markers (.git, package.json etc.) outside the store.
43
- - Falls back to resolved physical; returns the outermost marker root found.
44
- - Prevents packaged/parser/daemon from accidentally using a store dir as PROJECT_ROOT.
45
-
46
- Wave 4 further hardening for complex monorepo layouts:
47
- - Collects *all* candidate marker roots across logical/physical/realpath chains (no early return).
48
- - Selects the outermost (shallowest fs path) preferring .git roots, then lockfiles (pnpm/yarn), then other.
49
- This correctly picks true monorepo root even when deep sub-packages have their own package.json / yarn workspaces.
50
- - Expanded common markers: pnpm-lock.yaml, yarn.lock, lerna.json, nx.json, turbo.json, rush.json, workspace yamls.
51
- - Explicit skipping of node_modules/.pnpm / .yarn / .pnp internals as candidate roots (even on logical paths).
52
- - Additional os.path.realpath chains for nested/broken symlink monorepo layouts.
53
- - Daemon, parsers (via _get_*_fallback), run_full_update all inherit automatically.
54
- """
55
- # 1. Explicit env var (highest priority, supports all the R6 --target flows)
56
- env_root = os.environ.get("WIKIFIER_PROJECT_ROOT")
57
- if env_root:
58
- p = Path(env_root).expanduser().resolve()
59
- if p.exists():
60
- return p
61
-
62
- # Wave 3/4: logical cwd (PWD/OLDPWD) + physical + realpath fallbacks for symlink/pnpm/yarn/nested
63
- cwd = Path.cwd()
64
- logical_cwd_str = os.environ.get("PWD") or os.environ.get("OLDPWD") or str(cwd)
65
- try:
66
- logical_cwd = Path(logical_cwd_str).expanduser()
67
- except Exception:
68
- logical_cwd = cwd
69
-
70
- start_points: List[Path] = []
71
- for start in (logical_cwd, cwd, cwd.resolve()):
72
- if start and start not in start_points:
73
- start_points.append(start)
74
-
75
- # Wave 4: realpath variants for deeper symlink nest robustness (e.g. git submodules + pnpm links)
76
- for sp in list(start_points):
77
- try:
78
- rp = Path(os.path.realpath(str(sp)))
79
- if rp not in start_points:
80
- start_points.append(rp)
81
- except Exception:
82
- continue
83
-
84
- # 2/3. Markers: wikifier-specific + expanded common monorepo (Wave 4)
85
- wikifier_specific = [".wikifier", "monitored_paths.txt", ".wikifier/config"]
86
- common_markers = [
87
- ".git", "package.json", "pyproject.toml", "setup.py", "setup.cfg",
88
- "Cargo.toml", "go.mod", ".hg",
89
- # Wave 4 monorepo root indicators (for workspace/subpkg layouts + yarn/pnpm detection)
90
- "pnpm-lock.yaml", "yarn.lock", "lerna.json", "nx.json", "turbo.json",
91
- "rush.json", "workspace.json", "pnpm-workspace.yaml", ".yarnrc.yml"
92
- ]
93
- store_indicators = (".pnpm", ".yarn", ".pnp", ".pnp.cjs", "virtual-store", ".store")
94
-
95
- candidates: List[Path] = []
96
- for start in start_points:
97
- try:
98
- parent_chain = [start] + list(start.parents)
99
- except Exception:
100
- parent_chain = [start]
101
-
102
- # Wikifier markers (post-init)
103
- for parent in parent_chain:
104
- try:
105
- pstr = str(parent)
106
- if any(ind in pstr for ind in store_indicators) and "node_modules" in pstr:
107
- continue # never treat deep pnpm/yarn store paths as project root
108
- if ((parent / ".wikifier").is_dir() or
109
- (parent / "monitored_paths.txt").exists() or
110
- (parent / ".wikifier/config").exists()):
111
- if parent not in candidates:
112
- candidates.append(parent)
113
- except Exception:
114
- continue
115
-
116
- # Common + monorepo markers (any-scale external)
117
- for parent in parent_chain:
118
- try:
119
- pstr = str(parent)
120
- if any(ind in pstr for ind in store_indicators) and "node_modules" in pstr:
121
- continue
122
- for marker in common_markers:
123
- if (parent / marker).exists():
124
- if parent not in candidates:
125
- candidates.append(parent)
126
- except Exception:
127
- continue
128
-
129
- if candidates:
130
- # Wave 4: pick outermost/shallowest, with strong preference for .git (true monorepo root)
131
- # even if a deeper packages/*/package.json also matches. Solves workspace subdir case.
132
- def _root_key(p: Path):
133
- try:
134
- rp = p.resolve()
135
- has_git = (rp / ".git").exists() or (rp / ".git").is_file() # worktree support
136
- depth = len(rp.parts)
137
- return (0 if has_git else 1, depth, str(rp))
138
- except Exception:
139
- return (99, 99999, str(p))
140
-
141
- best = min(candidates, key=_root_key)
142
- try:
143
- return best.resolve() if best.exists() else best
144
- except Exception:
145
- return best
146
-
147
- # 4. Last-resort sensible default: the directory the user is running from.
148
- # This is the key robustness fix vs. old "fall back to package dir".
149
- # Prefer resolved for the final fallback.
150
- try:
151
- return cwd.resolve()
152
- except Exception:
153
- return cwd
24
+ # Canonical discovery lives in project_root (no cli↔cache↔bree load cycle).
25
+ # Re-export for public API: `from wikifier.cli import discover_project_root`.
26
+ from .project_root import discover_project_root # noqa: F401
154
27
 
155
28
 
156
29
  # =============================================================================
@@ -993,6 +993,54 @@ def _edge_is_external_noise(pair: Dict[str, Any]) -> bool:
993
993
  return False
994
994
 
995
995
 
996
+ def _edge_is_dynamic_literal_noise(pair: Dict[str, Any]) -> bool:
997
+ """True for dynamic imports of static string literals (not agent actionable risk).
998
+
999
+ ACS v1.2: demote importlib.import_module(\"pkg\"), __import__(\"pkg\"), and other
1000
+ is_dynamic + dynamic_type=static string-literal edges. These are intentional runtime
1001
+ loads (often optional/try fallbacks), not unresolved project graph holes.
1002
+
1003
+ Keep as telemetry (still in low_conf_edges) but exclude from actionable_low_conf_edges.
1004
+ """
1005
+ if not isinstance(pair, dict):
1006
+ return False
1007
+ reasons = pair.get("confidence_reasons") or []
1008
+ reason_l = " ".join(str(r).lower() for r in reasons if isinstance(r, str))
1009
+ is_dyn = bool(pair.get("is_dynamic")) or ("dynamic" in reason_l)
1010
+ if not is_dyn:
1011
+ return False
1012
+
1013
+ dtype = str(pair.get("dynamic_type") or "").lower()
1014
+ raw = str(pair.get("raw") or pair.get("raw_module") or pair.get("module") or "").strip()
1015
+ expl = str(pair.get("confidence_explanation") or "")
1016
+ resolved = str(pair.get("resolved") or "").strip()
1017
+
1018
+ # Explicit importlib / __import__ traces → always non-actionable for ACS
1019
+ # (includes static string loads and parser traces that mention the call form).
1020
+ if "import_module(" in expl or "__import__(" in expl or "importlib.import_module" in expl:
1021
+ return True
1022
+
1023
+ # dynamic_type=static means LDSI recovered a concrete string literal
1024
+ if dtype in ("static", "string", "literal"):
1025
+ # Quoted raw like "\"wikifier.health\"" or '"./x"'
1026
+ if (raw.startswith("\"") and raw.endswith("\"")) or (raw.startswith("'") and raw.endswith("'")):
1027
+ return True
1028
+ # Unquoted but resolved/raw is a dotted package name (no path variables)
1029
+ cand = raw or resolved
1030
+ if cand and "${" not in cand and "+" not in cand:
1031
+ inner = cand.strip().strip("\"'")
1032
+ if inner and not any(ch in inner for ch in ("/", "\\", " ", "(", ")")):
1033
+ # pure module id literal (e.g. wikifier.health) — not a computed path
1034
+ if "." in inner or inner.isidentifier():
1035
+ return True
1036
+ return False
1037
+
1038
+
1039
+ def _edge_is_non_actionable_noise(pair: Dict[str, Any]) -> bool:
1040
+ """Union of external/stdlib noise + dynamic-literal noise (ACS actionable filter)."""
1041
+ return _edge_is_external_noise(pair) or _edge_is_dynamic_literal_noise(pair)
1042
+
1043
+
996
1044
  def compute_acs_summary(
997
1045
  cache: Dict[str, Any],
998
1046
  max_samples: int = 5,
@@ -1013,6 +1061,9 @@ def compute_acs_summary(
1013
1061
  - external_noise_edges: count of scored edges classified as external noise
1014
1062
  - sample_actionable_low_conf_explanations: samples for agent action only
1015
1063
  Agents should prefer actionable_* for next-steps; low_conf_edges remains full telemetry.
1064
+
1065
+ ACS v1.2: also demotes dynamic string-literal noise (importlib.import_module(\"…\"),
1066
+ is_dynamic+dynamic_type=static) from actionable counts. Full low_conf telemetry unchanged.
1016
1067
  """
1017
1068
  # Phase 5e (66): compute_acs_summary + get_acs_summary promoted first-class default (O(k) bounded samples via ACS/CIABRE, deque-style in practice) for 20k+ creative; format=summary paths in MCP/CLI/health default to this + barrel summary (per 48/58/50/57, crit2/5 long-term WS A).
1018
1069
  t0 = time.time()
@@ -1021,6 +1072,7 @@ def compute_acs_summary(
1021
1072
  low_count = 0
1022
1073
  actionable_low = 0
1023
1074
  external_noise = 0
1075
+ dynamic_literal_noise = 0
1024
1076
  reason_counts: Dict[str, int] = {}
1025
1077
  samples: List[str] = [] # full expls for lowest-risk (prioritized)
1026
1078
 
@@ -1037,9 +1089,13 @@ def compute_acs_summary(
1037
1089
  sc = p.get("confidence_score")
1038
1090
  expl = p.get("confidence_explanation") or ""
1039
1091
  reasons = p.get("confidence_reasons") or []
1040
- is_noise = _edge_is_external_noise(p)
1041
- if is_noise:
1092
+ is_ext = _edge_is_external_noise(p)
1093
+ is_dyn_lit = _edge_is_dynamic_literal_noise(p)
1094
+ is_noise = is_ext or is_dyn_lit
1095
+ if is_ext:
1042
1096
  external_noise += 1
1097
+ if is_dyn_lit:
1098
+ dynamic_literal_noise += 1
1043
1099
  if isinstance(sc, (int, float)):
1044
1100
  scf = float(sc)
1045
1101
  sum_score += scf
@@ -1073,13 +1129,14 @@ def compute_acs_summary(
1073
1129
  top_reasons = sorted(reason_counts.items(), key=lambda x: -x[1])[:6]
1074
1130
 
1075
1131
  return {
1076
- "acs_version": "1.1",
1132
+ "acs_version": "1.2",
1077
1133
  "generated_at": datetime.now(timezone.utc).isoformat(),
1078
1134
  "total_scored_edges": total,
1079
1135
  "avg_confidence": avg,
1080
1136
  "low_conf_edges": low_count,
1081
1137
  "actionable_low_conf_edges": actionable_low,
1082
1138
  "external_noise_edges": external_noise,
1139
+ "dynamic_literal_noise_edges": dynamic_literal_noise,
1083
1140
  "low_conf_threshold": low_threshold,
1084
1141
  "top_risk_reasons": dict(top_reasons),
1085
1142
  "sample_low_conf_explanations": samples, # full Recommendation text for agents
@@ -1116,12 +1173,12 @@ def ensure_acs_summary_persisted(
1116
1173
  always-available oracle in primary surfaces without requiring explicit update first.
1117
1174
  """
1118
1175
  acs = get_acs_summary(cache)
1119
- # G4: recompute when missing OR pre-1.1 summaries (no actionable_low_conf_edges)
1176
+ # G4/v1.2: recompute when missing OR pre-1.2 summaries (dynamic-literal demotion)
1120
1177
  needs = (
1121
1178
  not acs
1122
1179
  or acs.get("total_scored_edges", 0) == 0
1123
1180
  or "actionable_low_conf_edges" not in acs
1124
- or str(acs.get("acs_version") or "") < "1.1"
1181
+ or str(acs.get("acs_version") or "") < "1.2"
1125
1182
  )
1126
1183
  if needs:
1127
1184
  acs = compute_acs_summary(cache)
@@ -1705,8 +1762,8 @@ def get_low_confidence_edges(
1705
1762
  Complements get_unresolved_imports; used for ACS-style hotspots.
1706
1763
  Includes full provenance/diagnostic when present (from python/JS parity).
1707
1764
 
1708
- actionable_only=True (G4): skip external/bare/stdlib noise so agents do not treat
1709
- `import json` as a wiki/refactor action item.
1765
+ actionable_only=True (G4/v1.2): skip external/bare/stdlib + dynamic-literal noise so
1766
+ agents do not treat `import json` or importlib.import_module(\"pkg\") as action items.
1710
1767
  """
1711
1768
  results: List[Dict[str, Any]] = []
1712
1769
  for rel, data in cache.items():
@@ -1714,7 +1771,7 @@ def get_low_confidence_edges(
1714
1771
  for p in (data.get("resolved_pairs") or []):
1715
1772
  if not isinstance(p, dict):
1716
1773
  continue
1717
- if actionable_only and _edge_is_external_noise(p):
1774
+ if actionable_only and _edge_is_non_actionable_noise(p):
1718
1775
  continue
1719
1776
  score = p.get("confidence_score")
1720
1777
  conf_str = (p.get("confidence") or p.get("resolution_confidence") or "").lower()
@@ -1727,7 +1784,7 @@ def get_low_confidence_edges(
1727
1784
  except Exception:
1728
1785
  is_low = conf_str in ("low", "unresolved")
1729
1786
  if is_low or not p.get("resolved_path"):
1730
- if actionable_only and not p.get("resolved_path") and _edge_is_external_noise(p):
1787
+ if actionable_only and not p.get("resolved_path") and _edge_is_non_actionable_noise(p):
1731
1788
  continue
1732
1789
  entry = dict(p)
1733
1790
  entry.setdefault("src", rel)
@@ -1855,7 +1912,8 @@ def generate_update_events(
1855
1912
  # Defensive root
1856
1913
  if root is None:
1857
1914
  try:
1858
- from .cli import discover_project_root
1915
+ # Load-safe: project_root (not cli) avoids import_cache→cli→import_cache cycle
1916
+ from .project_root import discover_project_root
1859
1917
  root = discover_project_root()
1860
1918
  except Exception:
1861
1919
  root = Path(".").resolve()
@@ -121,8 +121,8 @@ def _get_project_root_fallback(default: Optional[Union[str, Path]] = None) -> Pa
121
121
  from subdirs of user monorepos. Safe, zero-dep, never raises.
122
122
  """
123
123
  try:
124
- # parsers/ subpackage -> ..cli
125
- from ..cli import discover_project_root
124
+ # Load-safe: project_root (not cli) — avoids bree→cli→import_cache→bree cycle
125
+ from ..project_root import discover_project_root
126
126
  root = discover_project_root()
127
127
  if root:
128
128
  return Path(root).resolve()
@@ -216,7 +216,7 @@ def _get_project_root_fallback(default: Optional[Union[str, Path]] = None) -> Pa
216
216
 
217
217
  try:
218
218
  # inside parsers/ -> ..cli sibling
219
- from ..cli import discover_project_root
219
+ from ..project_root import discover_project_root
220
220
  root = discover_project_root()
221
221
  if root:
222
222
  r = Path(root).resolve()
@@ -0,0 +1,144 @@
1
+ """
2
+ Canonical project-root discovery (load-time safe).
3
+
4
+ AGENT MAP:
5
+ discover_project_root() — env → markers → cwd (no imports of cli/import_cache/bree)
6
+ Used by cli (re-export), import_cache, bree, daemon, serve, mcp
7
+ Extracted so bree/import_cache never import cli (breaks cyclic SCC).
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import os
13
+ from pathlib import Path
14
+ from typing import List
15
+
16
+
17
+ def discover_project_root() -> Path:
18
+ """
19
+ Unified discovery helper for the *target project root* (user codebase / monorepo).
20
+
21
+ This is the canonical implementation for CLI, MCP, direct Python use, and
22
+ is mirrored (same priority + markers) by the shell script's discover_project_root().
23
+
24
+ Highest-leverage fix for External / Packaged Full-Update Robustness (Gap #1):
25
+ Makes `pip install wikifier` + `wikifier ...` (or direct packaged sh) reliable
26
+ from any external monorepo cwd without requiring manual WIKIFIER_PROJECT_ROOT
27
+ or --target on every invocation. Zero-dependency.
28
+
29
+ Wave 3 hardening (symlinks + pnpm/yarn stores):
30
+ - Consults $PWD (and $OLDPWD) logical cwd in addition to Path.cwd() / .resolve().
31
+ - Walks parent chains from *logical* start points first; this ensures that when
32
+ cwd is deep inside a pnpm symlinked store layout (e.g. monorepo/node_modules/.pnpm/pkg@1/.../pkg
33
+ whose physical target is /global-store/...), the logical ancestor chain still reaches
34
+ the real project root markers (.git, package.json etc.) outside the store.
35
+ - Falls back to resolved physical; returns the outermost marker root found.
36
+ - Prevents packaged/parser/daemon from accidentally using a store dir as PROJECT_ROOT.
37
+
38
+ Wave 4 further hardening for complex monorepo layouts:
39
+ - Collects *all* candidate marker roots across logical/physical/realpath chains (no early return).
40
+ - Selects the outermost (shallowest fs path) preferring .git roots, then lockfiles (pnpm/yarn), then other.
41
+ This correctly picks true monorepo root even when deep sub-packages have their own package.json / yarn workspaces.
42
+ - Expanded common markers: pnpm-lock.yaml, yarn.lock, lerna.json, nx.json, turbo.json, rush.json, workspace yamls.
43
+ - Explicit skipping of node_modules/.pnpm / .yarn / .pnp internals as candidate roots (even on logical paths).
44
+ - Additional os.path.realpath chains for nested/broken symlink monorepo layouts.
45
+ - Daemon, parsers (via _get_*_fallback), run_full_update all inherit automatically.
46
+ """
47
+ # 1. Explicit env var (highest priority, supports all the R6 --target flows)
48
+ env_root = os.environ.get("WIKIFIER_PROJECT_ROOT")
49
+ if env_root:
50
+ p = Path(env_root).expanduser().resolve()
51
+ if p.exists():
52
+ return p
53
+
54
+ # Wave 3/4: logical cwd (PWD/OLDPWD) + physical + realpath fallbacks for symlink/pnpm/yarn/nested
55
+ cwd = Path.cwd()
56
+ logical_cwd_str = os.environ.get("PWD") or os.environ.get("OLDPWD") or str(cwd)
57
+ try:
58
+ logical_cwd = Path(logical_cwd_str).expanduser()
59
+ except Exception:
60
+ logical_cwd = cwd
61
+
62
+ start_points: List[Path] = []
63
+ for start in (logical_cwd, cwd, cwd.resolve()):
64
+ if start and start not in start_points:
65
+ start_points.append(start)
66
+
67
+ # Wave 4: realpath variants for deeper symlink nest robustness (e.g. git submodules + pnpm links)
68
+ for sp in list(start_points):
69
+ try:
70
+ rp = Path(os.path.realpath(str(sp)))
71
+ if rp not in start_points:
72
+ start_points.append(rp)
73
+ except Exception:
74
+ continue
75
+
76
+ # 2/3. Markers: wikifier-specific + expanded common monorepo (Wave 4)
77
+ common_markers = [
78
+ ".git", "package.json", "pyproject.toml", "setup.py", "setup.cfg",
79
+ "Cargo.toml", "go.mod", ".hg",
80
+ # Wave 4 monorepo root indicators (for workspace/subpkg layouts + yarn/pnpm detection)
81
+ "pnpm-lock.yaml", "yarn.lock", "lerna.json", "nx.json", "turbo.json",
82
+ "rush.json", "workspace.json", "pnpm-workspace.yaml", ".yarnrc.yml"
83
+ ]
84
+ store_indicators = (".pnpm", ".yarn", ".pnp", ".pnp.cjs", "virtual-store", ".store")
85
+
86
+ candidates: List[Path] = []
87
+ for start in start_points:
88
+ try:
89
+ parent_chain = [start] + list(start.parents)
90
+ except Exception:
91
+ parent_chain = [start]
92
+
93
+ # Wikifier markers (post-init)
94
+ for parent in parent_chain:
95
+ try:
96
+ pstr = str(parent)
97
+ if any(ind in pstr for ind in store_indicators) and "node_modules" in pstr:
98
+ continue # never treat deep pnpm/yarn store paths as project root
99
+ if ((parent / ".wikifier").is_dir() or
100
+ (parent / "monitored_paths.txt").exists() or
101
+ (parent / ".wikifier/config").exists()):
102
+ if parent not in candidates:
103
+ candidates.append(parent)
104
+ except Exception:
105
+ continue
106
+
107
+ # Common + monorepo markers (any-scale external)
108
+ for parent in parent_chain:
109
+ try:
110
+ pstr = str(parent)
111
+ if any(ind in pstr for ind in store_indicators) and "node_modules" in pstr:
112
+ continue
113
+ for marker in common_markers:
114
+ if (parent / marker).exists():
115
+ if parent not in candidates:
116
+ candidates.append(parent)
117
+ except Exception:
118
+ continue
119
+
120
+ if candidates:
121
+ # Wave 4: pick outermost/shallowest, with strong preference for .git (true monorepo root)
122
+ # even if a deeper packages/*/package.json also matches. Solves workspace subdir case.
123
+ def _root_key(p: Path):
124
+ try:
125
+ rp = p.resolve()
126
+ has_git = (rp / ".git").exists() or (rp / ".git").is_file() # worktree support
127
+ depth = len(rp.parts)
128
+ return (0 if has_git else 1, depth, str(rp))
129
+ except Exception:
130
+ return (99, 99999, str(p))
131
+
132
+ best = min(candidates, key=_root_key)
133
+ try:
134
+ return best.resolve() if best.exists() else best
135
+ except Exception:
136
+ return best
137
+
138
+ # 4. Last-resort sensible default: the directory the user is running from.
139
+ # This is the key robustness fix vs. old "fall back to package dir".
140
+ # Prefer resolved for the final fallback.
141
+ try:
142
+ return cwd.resolve()
143
+ except Exception:
144
+ return cwd
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: wikifier
3
- Version: 4.5.8
3
+ Version: 4.5.9
4
4
  Summary: Zero-dependency agent-to-agent codebase wiki for LLMs and AI agents. Autonomously maintained via record-change and mark-green for token-efficient lookup of files, dependencies, health, and summaries — across tiny scripts to 50k+ monorepos. Optional MCP server with rich tools for agents.
5
5
  Author-email: Aron Amos <aron@example.com>
6
6
  Maintainer: Aron Amos
@@ -16,6 +16,7 @@ pyproject.toml
16
16
  ./wikifier/index.html
17
17
  ./wikifier/library.py
18
18
  ./wikifier/locking.py
19
+ ./wikifier/project_root.py
19
20
  ./wikifier/resolution.py
20
21
  ./wikifier/serve.py
21
22
  ./wikifier/mcp/__init__.py
@@ -54,6 +55,7 @@ wikifier/import_cache.py
54
55
  wikifier/index.html
55
56
  wikifier/library.py
56
57
  wikifier/locking.py
58
+ wikifier/project_root.py
57
59
  wikifier/resolution.py
58
60
  wikifier/serve.py
59
61
  wikifier.egg-info/PKG-INFO
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes