sourcecode 0.25.0__py3-none-any.whl → 0.27.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
sourcecode/__init__.py CHANGED
@@ -1,3 +1,3 @@
1
1
  """sourcecode — Genera mapas de contexto estructurado para agentes IA."""
2
2
 
3
- __version__ = "0.25.0"
3
+ __version__ = "0.27.0"
@@ -9,6 +9,12 @@ from sourcecode.schema import EntryPoint, SourceMap, StackDetection
9
9
  from sourcecode.tree_utils import flatten_file_tree
10
10
 
11
11
  _TOOLING_PREFIXES = (".claude/", ".vscode/", "bin/")
12
+ _AUXILIARY_PATH_PARTS: frozenset[str] = frozenset({
13
+ "benchmark", "benchmarks", "bench", "benches",
14
+ "demo", "demos", "example", "examples",
15
+ "docs", "doc", "fixture", "fixtures",
16
+ "playground", "playgrounds", "sandbox",
17
+ })
12
18
  _PYTHON_EXTENSIONS = {".py"}
13
19
  _NODE_EXTENSIONS = {".js", ".jsx", ".ts", ".tsx", ".mjs", ".cjs"}
14
20
  _GO_EXTENSIONS = {".go"}
@@ -52,10 +58,12 @@ class ArchitectureSummarizer:
52
58
  # Rich path: use all available signals (stacks, arch analysis, graph)
53
59
  rich_lines = self._build_rich_lines(sm)
54
60
 
55
- # Stack-specific entry point analysis (existing logic)
61
+ # Stack-specific entry point analysis: exclude tooling + auxiliary paths
56
62
  entry_points = [
57
63
  entry for entry in sm.entry_points
58
64
  if not self._is_tooling_path(entry.path)
65
+ and not self._is_auxiliary_path(entry.path)
66
+ and entry.entrypoint_type not in ("benchmark", "example")
59
67
  ]
60
68
  if not entry_points:
61
69
  fallback = self._infer_fallback_entry_points(file_paths, sm.stacks)
@@ -147,7 +155,31 @@ class ArchitectureSummarizer:
147
155
  if primary is None:
148
156
  return ""
149
157
  stack_label = _STACK_LABELS.get(primary.stack, primary.stack)
150
- fw_names = [f.name for s in sm.stacks for f in s.frameworks[:2]][:3]
158
+
159
+ # For monorepos: only show frameworks from runtime-critical packages,
160
+ # not every framework found in every workspace (avoids "React project" misclassification)
161
+ if sm.project_type == "monorepo" and sm.monorepo_packages:
162
+ runtime_roles = {"runtime_core", "plugin_host", "backend_runtime"}
163
+ runtime_paths = {p.path for p in sm.monorepo_packages if p.architectural_role in runtime_roles}
164
+ fw_names = [
165
+ f.name
166
+ for s in sm.stacks
167
+ if not s.workspace or s.workspace in runtime_paths
168
+ for f in s.frameworks[:2]
169
+ ]
170
+ fw_names = list(dict.fromkeys(fw_names))[:3] # dedup, preserve order
171
+ else:
172
+ # Filter out frameworks from auxiliary or tooling stacks (docs/, benchmarks/, etc.)
173
+ fw_names = [
174
+ f.name
175
+ for s in sm.stacks
176
+ if not self._is_tooling_path(s.root or "")
177
+ and not self._is_tooling_path(s.workspace or "")
178
+ and not self._is_auxiliary_path(s.root or "")
179
+ and not self._is_auxiliary_path(s.workspace or "")
180
+ for f in s.frameworks[:2]
181
+ ][:3]
182
+
151
183
  fw_str = f" using {', '.join(fw_names)}" if fw_names else ""
152
184
  if runtime:
153
185
  return f"{stack_label} {runtime.lower()}{fw_str}."
@@ -443,6 +475,12 @@ class ArchitectureSummarizer:
443
475
  except OSError:
444
476
  return None
445
477
 
478
+ def _is_auxiliary_path(self, path: str | None) -> bool:
479
+ if not path:
480
+ return False
481
+ parts = path.replace("\\", "/").strip("/").lower().split("/")
482
+ return any(p in _AUXILIARY_PATH_PARTS for p in parts)
483
+
446
484
  def _is_tooling_path(self, path: str | None) -> bool:
447
485
  if not path:
448
486
  return False
sourcecode/cli.py CHANGED
@@ -1,6 +1,7 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import json
4
+ import time
4
5
  from pathlib import Path
5
6
  from typing import Any, Optional, cast
6
7
 
@@ -10,10 +11,53 @@ from sourcecode import __version__
10
11
 
11
12
  app = typer.Typer(
12
13
  name="sourcecode",
13
- help="Genera un mapa de contexto estructurado del proyecto para agentes IA.",
14
+ help="Deterministic codebase context for AI coding agents.",
14
15
  add_completion=False,
15
16
  )
16
17
 
18
+ telemetry_app = typer.Typer(help="Manage anonymous telemetry (opt-in).")
19
+ app.add_typer(telemetry_app, name="telemetry")
20
+
21
+
22
+ def _maybe_ask_consent() -> None:
23
+ """Show first-run consent prompt once, on interactive TTYs only."""
24
+ try:
25
+ from sourcecode.telemetry.config import has_been_asked, mark_asked, set_enabled
26
+ from sourcecode.telemetry.consent import ask_for_consent
27
+ if not has_been_asked():
28
+ enabled = ask_for_consent()
29
+ set_enabled(enabled)
30
+ if enabled:
31
+ typer.echo("Telemetry enabled. Thank you. Disable: sourcecode telemetry disable", err=True)
32
+ else:
33
+ typer.echo("Telemetry disabled. Enable anytime: sourcecode telemetry enable", err=True)
34
+ except Exception:
35
+ pass
36
+
37
+
38
+ def _active_flags(
39
+ dependencies: bool, graph_modules: bool, docs: bool, full_metrics: bool,
40
+ semantics: bool, architecture: bool, git_context: bool, env_map: bool,
41
+ code_notes: bool, agent: bool, compact: bool, tree: bool, no_redact: bool,
42
+ fmt: str,
43
+ ) -> list[str]:
44
+ flags: list[str] = []
45
+ if agent: flags.append("--agent")
46
+ if compact: flags.append("--compact")
47
+ if dependencies: flags.append("--dependencies")
48
+ if graph_modules: flags.append("--graph-modules")
49
+ if docs: flags.append("--docs")
50
+ if full_metrics: flags.append("--full-metrics")
51
+ if semantics: flags.append("--semantics")
52
+ if architecture: flags.append("--architecture")
53
+ if git_context: flags.append("--git-context")
54
+ if env_map: flags.append("--env-map")
55
+ if code_notes: flags.append("--code-notes")
56
+ if tree: flags.append("--tree")
57
+ if no_redact: flags.append("--no-redact")
58
+ if fmt != "json": flags.append("--format")
59
+ return flags
60
+
17
61
  FORMAT_CHOICES = ["json", "yaml"]
18
62
  GRAPH_DETAIL_CHOICES = ["high", "medium", "full"]
19
63
  GRAPH_EDGE_CHOICES = {"imports", "calls", "contains", "extends"}
@@ -167,11 +211,17 @@ def main(
167
211
  help="Modo agente: output estructurado y sin ruido para consumo por IA. Incluye identidad, entrypoints, arquitectura, dependencias clave, señales operacionales y gaps. Sin arbol de ficheros ni secciones vacias.",
168
212
  ),
169
213
  ) -> None:
170
- """Genera un mapa de contexto estructurado del proyecto en formato JSON o YAML."""
171
- # When a subcommand (e.g. prepare-context) is invoked, skip the main analysis.
214
+ """Generate structured codebase context for AI coding agents."""
215
+ # First-run consent (skip for telemetry subcommand itself)
216
+ if ctx.invoked_subcommand != "telemetry":
217
+ _maybe_ask_consent()
218
+
219
+ # When a subcommand is invoked, skip the main analysis.
172
220
  if ctx.invoked_subcommand is not None:
173
221
  return
174
222
 
223
+ _t0 = time.monotonic()
224
+
175
225
  # Validar formato
176
226
  if format not in FORMAT_CHOICES:
177
227
  typer.echo(
@@ -555,6 +605,14 @@ def main(
555
605
  semantic_summary=sem_sum,
556
606
  )
557
607
 
608
+ # Runtime architecture — classify workspace packages for structural summaries
609
+ if workspace_analysis.workspaces:
610
+ from sourcecode.runtime_classifier import RuntimeClassifier
611
+ sm.monorepo_packages = RuntimeClassifier().classify(
612
+ target,
613
+ [ws.path for ws in workspace_analysis.workspaces],
614
+ )
615
+
558
616
  # Phase 9: LLM Output Quality — poblar campos derivados
559
617
  from sourcecode.architecture_summary import ArchitectureSummarizer
560
618
  from sourcecode.summarizer import ProjectSummarizer
@@ -664,7 +722,26 @@ def main(
664
722
  else:
665
723
  content = json.dumps(raw_dict, indent=2, ensure_ascii=False)
666
724
 
667
- # 5. Escribir output (CLI-04)
725
+ # 5. Telemetry (fire-and-forget, never blocks)
726
+ try:
727
+ from sourcecode import telemetry as _tel
728
+ _tel.record(
729
+ "execution_completed",
730
+ cmd="analyze",
731
+ flags=_active_flags(
732
+ dependencies, graph_modules, docs, full_metrics,
733
+ semantics, architecture, git_context, env_map,
734
+ code_notes, agent, compact, tree, no_redact, format,
735
+ ),
736
+ output_fmt=format,
737
+ file_count=len(sm.file_paths),
738
+ duration_s=time.monotonic() - _t0,
739
+ success=True,
740
+ )
741
+ except Exception:
742
+ pass
743
+
744
+ # 6. Escribir output (CLI-04)
668
745
  write_output(content, output=output)
669
746
 
670
747
 
@@ -796,3 +873,42 @@ def prepare_context_cmd(
796
873
  out["llm_prompt"] = builder.render_prompt(output)
797
874
 
798
875
  typer.echo(json.dumps(out, indent=2, ensure_ascii=False))
876
+
877
+
878
+ # ── Telemetry commands ────────────────────────────────────────────────────────
879
+
880
+ @telemetry_app.command("status")
881
+ def telemetry_status() -> None:
882
+ """Show current telemetry setting."""
883
+ from sourcecode.telemetry.config import config_file_path, has_been_asked, is_enabled
884
+ enabled = is_enabled()
885
+ asked = has_been_asked()
886
+ status = "enabled" if enabled else "disabled"
887
+ typer.echo(f"Telemetry: {status}")
888
+ if not asked:
889
+ typer.echo(" (consent not yet shown — will prompt on next run)")
890
+ typer.echo(f" Config: {config_file_path()}")
891
+ typer.echo(" Disable permanently: sourcecode telemetry disable")
892
+ typer.echo(" Or set env var: SOURCECODE_TELEMETRY=0")
893
+
894
+
895
+ @telemetry_app.command("enable")
896
+ def telemetry_enable() -> None:
897
+ """Opt in to anonymous telemetry."""
898
+ from sourcecode.telemetry.config import set_enabled
899
+ from sourcecode import telemetry as _tel
900
+ set_enabled(True)
901
+ typer.echo("Telemetry enabled. Thank you — this helps improve sourcecode.")
902
+ typer.echo("What is collected: version, OS, commands, flags, duration, repo size range, errors.")
903
+ typer.echo("What is never collected: source code, paths, secrets, or any output content.")
904
+ typer.echo("Disable at any time: sourcecode telemetry disable")
905
+ _tel.record("telemetry_enabled", cmd="telemetry")
906
+
907
+
908
+ @telemetry_app.command("disable")
909
+ def telemetry_disable() -> None:
910
+ """Opt out of anonymous telemetry."""
911
+ from sourcecode.telemetry.config import set_enabled
912
+ set_enabled(False)
913
+ typer.echo("Telemetry disabled. No data will be collected or sent.")
914
+ typer.echo("Re-enable at any time: sourcecode telemetry enable")
@@ -117,59 +117,149 @@ class NodejsDetector(AbstractDetector):
117
117
  return package_manager
118
118
  return None
119
119
 
120
+ # Directories that indicate a non-production entry point
121
+ _AUXILIARY_DIRS: frozenset[str] = frozenset({
122
+ "benchmark", "benchmarks", "bench",
123
+ "example", "examples",
124
+ "demo", "demos",
125
+ "playground", "playgrounds",
126
+ "fixture", "fixtures",
127
+ "sandbox", "e2e", "docs",
128
+ })
129
+
120
130
  def _collect_entry_points(
121
131
  self, context: DetectionContext, package_json: dict[str, Any]
122
132
  ) -> list[EntryPoint]:
123
133
  entry_points: list[EntryPoint] = []
124
134
  seen: set[str] = set()
125
- main = package_json.get("main")
126
- if isinstance(main, str) and main.strip():
127
- path = main.strip()
128
- if path_exists_in_tree(context.file_tree, path):
129
- seen.add(path)
130
- entry_points.append(
131
- EntryPoint(
132
- path=path,
133
- stack="nodejs",
134
- kind="server",
135
- source="package.json",
136
- confidence="high",
137
- )
138
- )
139
-
140
- convention_candidates: list[str] = []
135
+
136
+ # Priority 1: package.json scripts — most reliable signal
137
+ scripts = package_json.get("scripts", {})
138
+ if isinstance(scripts, dict):
139
+ for script_name, script_cmd in scripts.items():
140
+ if not isinstance(script_cmd, str):
141
+ continue
142
+ ep_type, kind = self._classify_script(script_name)
143
+ if ep_type is None:
144
+ continue
145
+ # Extract file path from script command
146
+ path = self._extract_script_path(script_cmd, context)
147
+ if path and path not in seen and path_exists_in_tree(context.file_tree, path):
148
+ seen.add(path)
149
+ if not self._is_auxiliary_path(path):
150
+ entry_points.append(EntryPoint(
151
+ path=path,
152
+ stack="nodejs",
153
+ kind=kind,
154
+ source="package.json#scripts",
155
+ confidence="high",
156
+ reason=f"script:{script_name}",
157
+ evidence=f"scripts.{script_name} = {script_cmd!r:.80}",
158
+ entrypoint_type=ep_type,
159
+ ))
160
+
161
+ # Priority 2: package.json bin — CLI production entry points
141
162
  bin_field = package_json.get("bin")
163
+ bin_paths: list[str] = []
142
164
  if isinstance(bin_field, str) and bin_field.strip():
143
- convention_candidates.append(bin_field.strip())
165
+ bin_paths.append(bin_field.strip())
144
166
  elif isinstance(bin_field, dict):
145
- convention_candidates.extend(
146
- str(value).strip() for value in bin_field.values() if isinstance(value, str) and value.strip()
167
+ bin_paths.extend(
168
+ str(v).strip() for v in bin_field.values()
169
+ if isinstance(v, str) and v.strip()
147
170
  )
171
+ for path in bin_paths:
172
+ if path not in seen and path_exists_in_tree(context.file_tree, path):
173
+ seen.add(path)
174
+ entry_points.append(EntryPoint(
175
+ path=path,
176
+ stack="nodejs",
177
+ kind="cli",
178
+ source="package.json#bin",
179
+ confidence="high",
180
+ reason="bin",
181
+ evidence="declared in package.json bin field",
182
+ entrypoint_type="production",
183
+ ))
148
184
 
149
- convention_candidates.extend(
150
- [
151
- "server.js",
152
- "src/index.js",
153
- "src/index.ts",
154
- "src/main.js",
155
- "src/main.ts",
156
- "src/main.tsx",
157
- "app/page.tsx",
158
- "pages/index.js",
159
- ]
160
- )
185
+ # Priority 3: package.json main (library/module entry)
186
+ main = package_json.get("main")
187
+ if isinstance(main, str) and main.strip():
188
+ path = main.strip()
189
+ if path not in seen and path_exists_in_tree(context.file_tree, path):
190
+ seen.add(path)
191
+ entry_points.append(EntryPoint(
192
+ path=path,
193
+ stack="nodejs",
194
+ kind="server",
195
+ source="package.json",
196
+ confidence="high",
197
+ entrypoint_type="production",
198
+ ))
161
199
 
162
- for path in unique_strings(convention_candidates):
200
+ # Priority 4: filename conventions (last resort — penalize auxiliary dirs)
201
+ for path in [
202
+ "server.js", "server.ts",
203
+ "src/index.js", "src/index.ts",
204
+ "src/main.js", "src/main.ts", "src/main.tsx",
205
+ "app/page.tsx", "pages/index.js",
206
+ ]:
163
207
  if path in seen or not path_exists_in_tree(context.file_tree, path):
164
208
  continue
209
+ ep_type = self._path_entrypoint_type(path)
165
210
  kind = "web" if path.startswith(("app/", "pages/")) else "server"
166
- entry_points.append(
167
- EntryPoint(
168
- path=path,
169
- stack="nodejs",
170
- kind=kind,
171
- source="convention",
172
- confidence="medium",
173
- )
174
- )
211
+ entry_points.append(EntryPoint(
212
+ path=path,
213
+ stack="nodejs",
214
+ kind=kind,
215
+ source="convention",
216
+ confidence="medium",
217
+ reason="convention",
218
+ entrypoint_type=ep_type,
219
+ ))
220
+
175
221
  return entry_points
222
+
223
+ def _classify_script(self, script_name: str) -> tuple[str | None, str]:
224
+ """Map script name → (entrypoint_type, kind). Returns (None, '') to skip."""
225
+ lower = script_name.lower()
226
+ if lower in ("start", "serve"):
227
+ return "production", "server"
228
+ if lower in ("dev", "develop", "watch"):
229
+ return "development", "server"
230
+ if lower in ("cli", "bin"):
231
+ return "production", "cli"
232
+ if "benchmark" in lower or lower == "bench":
233
+ return "benchmark", "script"
234
+ if lower.startswith("example") or lower.startswith("demo"):
235
+ return "example", "script"
236
+ return None, ""
237
+
238
+ def _extract_script_path(self, cmd: str, context: DetectionContext) -> str | None:
239
+ """Extract a likely source file path from a script command string."""
240
+ import shlex
241
+ try:
242
+ parts = shlex.split(cmd)
243
+ except ValueError:
244
+ parts = cmd.split()
245
+ # Skip executor prefixes: node, ts-node, tsx, nodemon, npx, etc.
246
+ _SKIP = {"node", "ts-node", "tsx", "nodemon", "npx", "pnpm", "yarn", "npm", "bun",
247
+ "--inspect", "--inspect-brk", "--require", "-r", "run", "exec"}
248
+ for part in parts:
249
+ if part.startswith("-") or part in _SKIP or "=" in part:
250
+ continue
251
+ # It looks like a file path (has slash or known extension)
252
+ p = part.strip("'\"")
253
+ if ("/" in p or p.endswith((".js", ".ts", ".mjs", ".cjs"))) and not p.startswith("@"):
254
+ return p
255
+ return None
256
+
257
+ def _is_auxiliary_path(self, path: str) -> bool:
258
+ norm = path.replace("\\", "/")
259
+ parts = norm.split("/")
260
+ return any(p.lower() in self._AUXILIARY_DIRS for p in parts)
261
+
262
+ def _path_entrypoint_type(self, path: str) -> str:
263
+ if self._is_auxiliary_path(path):
264
+ return "example"
265
+ return "production"
@@ -15,6 +15,44 @@ GraphDetail = Literal["high", "medium", "full"]
15
15
  GraphImportance = Literal["high", "medium", "low"]
16
16
 
17
17
 
18
+ # Node.js stdlib modules — omit from graph unless --graph-detail full
19
+ _NODE_STDLIB: frozenset[str] = frozenset({
20
+ "fs", "fs/promises", "path", "os", "child_process", "process",
21
+ "net", "http", "https", "http2", "stream", "events", "util",
22
+ "crypto", "buffer", "url", "querystring", "string_decoder",
23
+ "readline", "repl", "assert", "dns", "dgram", "vm",
24
+ "worker_threads", "cluster", "module", "v8", "perf_hooks",
25
+ "async_hooks", "inspector", "tty", "zlib", "domain",
26
+ "timers", "timers/promises", "console", "constants",
27
+ "node:fs", "node:path", "node:os", "node:crypto", "node:buffer",
28
+ "node:stream", "node:events", "node:util", "node:http", "node:https",
29
+ "node:child_process", "node:process", "node:worker_threads",
30
+ })
31
+
32
+ # Python stdlib modules — omit from graph unless --graph-detail full
33
+ _PYTHON_STDLIB: frozenset[str] = frozenset({
34
+ "os", "sys", "re", "io", "abc", "ast", "copy", "csv",
35
+ "enum", "functools", "itertools", "json", "logging", "math",
36
+ "pathlib", "random", "shutil", "string", "subprocess",
37
+ "threading", "time", "typing", "uuid", "warnings",
38
+ "collections", "contextlib", "dataclasses", "datetime",
39
+ "decimal", "difflib", "email", "glob", "hashlib", "hmac",
40
+ "http", "importlib", "inspect", "operator", "pickle",
41
+ "platform", "pprint", "queue", "signal", "socket",
42
+ "sqlite3", "stat", "struct", "tempfile", "textwrap",
43
+ "traceback", "unicodedata", "unittest", "urllib", "weakref",
44
+ "xml", "zipfile", "zlib",
45
+ })
46
+
47
+ # External packages that add noise without structural signal
48
+ _NODE_NOISE_PACKAGES: frozenset[str] = frozenset({
49
+ "lodash", "moment", "date-fns", "uuid", "chalk", "debug",
50
+ "yargs", "commander", "dotenv", "cross-env",
51
+ "eslint", "prettier", "typescript", "ts-node",
52
+ "@types/node", "@types/react",
53
+ })
54
+
55
+
18
56
  class GraphAnalyzer:
19
57
  """Construye un grafo estructural parcial y seguro del proyecto."""
20
58
 
@@ -908,7 +946,14 @@ class GraphAnalyzer:
908
946
  )
909
947
  )
910
948
  else:
911
- limitations.append(f"node_external:{relative_path}:{spec}")
949
+ # Silently skip stdlib and noise packages — they add no structural signal
950
+ bare = spec.removeprefix("node:")
951
+ root_pkg = bare.split("/")[0]
952
+ if (spec not in _NODE_STDLIB
953
+ and bare not in _NODE_STDLIB
954
+ and root_pkg not in _NODE_STDLIB
955
+ and root_pkg not in _NODE_NOISE_PACKAGES):
956
+ limitations.append(f"node_external:{relative_path}:{spec}")
912
957
 
913
958
  return nodes, edges, limitations
914
959
 
@@ -1121,6 +1166,10 @@ class GraphAnalyzer:
1121
1166
  def _resolve_python_import(
1122
1167
  self, module_name: str, module_map: dict[str, str]
1123
1168
  ) -> Optional[str]:
1169
+ # Skip stdlib modules — they add no structural signal to the project graph
1170
+ root_module = module_name.split(".")[0]
1171
+ if root_module in _PYTHON_STDLIB:
1172
+ return None
1124
1173
  for path, candidate in module_map.items():
1125
1174
  if candidate == module_name:
1126
1175
  return path
@@ -359,10 +359,15 @@ class TaskContextBuilder:
359
359
 
360
360
  # ── 2. Detect stacks + entry points ───────────────────────────────
361
361
  from sourcecode.detectors import ProjectDetector, build_default_detectors
362
+ from sourcecode.workspace import WorkspaceAnalyzer
362
363
 
363
364
  detector = ProjectDetector(build_default_detectors())
365
+ workspace_analysis = WorkspaceAnalyzer().analyze(self.root, manifests)
364
366
  stacks, entry_points, _ = detector.detect(self.root, file_tree, manifests)
365
- stacks, project_type = detector.classify_results(file_tree, stacks, entry_points)
367
+ stacks, project_type = detector.classify_results(
368
+ file_tree, stacks, entry_points,
369
+ project_type_override="monorepo" if workspace_analysis.is_monorepo else None,
370
+ )
366
371
 
367
372
  # ── 3. Summarize ───────────────────────────────────────────────────
368
373
  from sourcecode.schema import AnalysisMetadata, SourceMap
@@ -378,6 +383,14 @@ class TaskContextBuilder:
378
383
  )
379
384
  sm.file_paths = all_paths
380
385
 
386
+ # Classify workspace packages for structural context
387
+ if workspace_analysis.workspaces:
388
+ from sourcecode.runtime_classifier import RuntimeClassifier
389
+ sm.monorepo_packages = RuntimeClassifier().classify(
390
+ self.root,
391
+ [ws.path for ws in workspace_analysis.workspaces],
392
+ )
393
+
381
394
  project_summary = ProjectSummarizer(self.root).generate(sm)
382
395
  architecture_summary = ArchitectureSummarizer(self.root).generate(sm)
383
396
 
@@ -436,13 +449,31 @@ class TaskContextBuilder:
436
449
  for p, n in sorted(counts2.items(), key=lambda x: -x[1])[:8]
437
450
  ]
438
451
 
452
+ # ── 5b. Git signals for ranking ────────────────────────────────────
453
+ git_hotspots: dict[str, int] = {}
454
+ uncommitted_files: set[str] = set()
455
+ try:
456
+ from sourcecode.git_analyzer import GitAnalyzer
457
+ _gc = GitAnalyzer().analyze(self.root, depth=30, days=90)
458
+ _bad = {"no_git_repo", "git_not_found", "git_timeout"}
459
+ if _gc and not (_bad & set(_gc.limitations)):
460
+ git_hotspots = {h.file: h.commit_count for h in _gc.change_hotspots}
461
+ if _gc.uncommitted_changes:
462
+ _uc = _gc.uncommitted_changes
463
+ uncommitted_files = set(_uc.staged) | set(_uc.unstaged)
464
+ except Exception:
465
+ pass
466
+
439
467
  # ── 6. Rank files ──────────────────────────────────────────────────
440
468
  entry_set = {ep.path for ep in entry_points}
441
469
  test_set = {p for p in all_paths if self._is_test(p)}
442
470
  source_set = {p for p in all_paths if not self._is_test(p) and self._is_source(p)}
443
471
 
444
472
  relevant_files = self._rank_files(
445
- task_name, spec, all_paths, entry_set, test_set
473
+ task_name, spec, all_paths, entry_set, test_set,
474
+ monorepo_packages=sm.monorepo_packages if sm.monorepo_packages else None,
475
+ git_hotspots=git_hotspots,
476
+ uncommitted_files=uncommitted_files,
446
477
  )
447
478
 
448
479
  # ── 7. Test gaps (generate-tests only) ────────────────────────────
@@ -590,7 +621,21 @@ class TaskContextBuilder:
590
621
  all_paths: list[str],
591
622
  entry_set: set[str],
592
623
  test_set: set[str],
624
+ monorepo_packages: Optional[list] = None,
625
+ git_hotspots: Optional[dict[str, int]] = None,
626
+ uncommitted_files: Optional[set[str]] = None,
593
627
  ) -> list[RelevantFile]:
628
+ from sourcecode.relevance_scorer import RelevanceScorer
629
+ scorer = RelevanceScorer(monorepo_packages or [])
630
+
631
+ # Auxiliary entry points (benchmark, docs, examples) must not get
632
+ # the production entry boost — they are not runtime signals.
633
+ runtime_entry_set = {ep for ep in entry_set if not scorer.is_auxiliary(ep)}
634
+
635
+ _hotspots = git_hotspots or {}
636
+ _uncommitted = uncommitted_files or set()
637
+ _max_churn = max(_hotspots.values(), default=1)
638
+
594
639
  scored: list[tuple[float, RelevantFile]] = []
595
640
 
596
641
  for path in all_paths:
@@ -599,6 +644,10 @@ class TaskContextBuilder:
599
644
  if any(pen in path for pen in spec.ranking_penalties):
600
645
  continue
601
646
 
647
+ # Hard filter: tooling/config noise
648
+ if scorer.is_noise(path):
649
+ continue
650
+
602
651
  is_test = path in test_set
603
652
  if is_test and task_name != "generate-tests":
604
653
  continue
@@ -606,7 +655,8 @@ class TaskContextBuilder:
606
655
  score = 0.0
607
656
  reasons: list[str] = []
608
657
 
609
- if path in entry_set:
658
+ # Only runtime entry points get the production boost
659
+ if path in runtime_entry_set:
610
660
  score += 3.0
611
661
  reasons.append("entry point")
612
662
 
@@ -625,11 +675,30 @@ class TaskContextBuilder:
625
675
  if not reasons:
626
676
  reasons.append("source file")
627
677
 
678
+ # Operational relevance boost/penalty from package role
679
+ rel = scorer.score(path)
680
+ score += (rel - 0.3) * 2.0 # center around 0.3 baseline
681
+
682
+ # Suppress auxiliary dirs (benchmarks, docs, examples, demos)
683
+ if scorer.is_auxiliary(path):
684
+ score -= 2.0
685
+
686
+ # Git churn: frequently changed files are high-signal for active work
687
+ churn = _hotspots.get(path, 0)
688
+ if churn > 0:
689
+ score += (churn / _max_churn) * 1.5
690
+ reasons.append(f"git churn ({churn})")
691
+
692
+ # Uncommitted changes: files actively being edited rank highest
693
+ if path in _uncommitted:
694
+ score += 1.0
695
+ reasons.append("uncommitted changes")
696
+
628
697
  if score <= 0:
629
698
  continue
630
699
 
631
700
  role = (
632
- "entrypoint" if path in entry_set
701
+ "entrypoint" if path in runtime_entry_set
633
702
  else ("test" if is_test else "source")
634
703
  )
635
704
  scored.append((score, RelevantFile(