sourcecode 0.25.0__py3-none-any.whl → 0.26.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.26.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
@@ -555,6 +555,14 @@ def main(
555
555
  semantic_summary=sem_sum,
556
556
  )
557
557
 
558
+ # Runtime architecture — classify workspace packages for structural summaries
559
+ if workspace_analysis.workspaces:
560
+ from sourcecode.runtime_classifier import RuntimeClassifier
561
+ sm.monorepo_packages = RuntimeClassifier().classify(
562
+ target,
563
+ [ws.path for ws in workspace_analysis.workspaces],
564
+ )
565
+
558
566
  # Phase 9: LLM Output Quality — poblar campos derivados
559
567
  from sourcecode.architecture_summary import ArchitectureSummarizer
560
568
  from sourcecode.summarizer import ProjectSummarizer
@@ -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(
@@ -0,0 +1,182 @@
1
+ from __future__ import annotations
2
+
3
+ """Operational relevance scoring for files and directories.
4
+
5
+ Returns a (relevance, noise) pair per path:
6
+ relevance: 0.0–1.0 — how useful is this for an agent modifying code
7
+ noise: bool — should this be suppressed unless explicitly requested
8
+
9
+ Key principle: signal over inventory. Tooling, config, and auxiliary content
10
+ score low. Runtime core, entrypoints, and central modules score high.
11
+ """
12
+
13
+ import re
14
+ from pathlib import Path
15
+ from typing import Optional
16
+
17
+ from sourcecode.schema import MonorepoPackageInfo
18
+
19
+ # --------------------------------------------------------------------------
20
+ # Noise path patterns — suppress unless explicitly needed
21
+ # --------------------------------------------------------------------------
22
+
23
+ _NOISE_PREFIXES: frozenset[str] = frozenset({
24
+ "node_modules/",
25
+ ".git/",
26
+ "__pycache__/",
27
+ ".venv/",
28
+ "venv/",
29
+ ".mypy_cache/",
30
+ ".pytest_cache/",
31
+ "dist/",
32
+ "build/",
33
+ ".turbo/",
34
+ ".next/",
35
+ ".nuxt/",
36
+ "coverage/",
37
+ ".nyc_output/",
38
+ })
39
+
40
+ _NOISE_DIRS: frozenset[str] = frozenset({
41
+ "node_modules", "__pycache__", ".git", "dist", "build",
42
+ ".turbo", "coverage", ".nyc_output", ".next", ".nuxt",
43
+ ".venv", "venv", ".mypy_cache", ".pytest_cache",
44
+ })
45
+
46
+ _NOISE_SUFFIXES: frozenset[str] = frozenset({
47
+ ".lock", ".log", ".map", ".min.js", ".min.css",
48
+ ".snap", ".d.ts.map",
49
+ })
50
+
51
+ _TOOLING_FILENAMES: frozenset[str] = frozenset({
52
+ ".eslintrc", ".eslintrc.js", ".eslintrc.json", ".eslintrc.yaml", ".eslintrc.yml",
53
+ ".prettierrc", ".prettierrc.js", ".prettierrc.json",
54
+ "prettier.config.js", "prettier.config.ts",
55
+ "eslint.config.js", "eslint.config.ts",
56
+ ".editorconfig", ".gitignore", ".gitattributes",
57
+ "commitlint.config.js", "stylelint.config.js",
58
+ "jest.config.js", "jest.config.ts", "jest.config.json",
59
+ "vitest.config.ts", "vitest.config.js",
60
+ "webpack.config.js", "webpack.config.ts",
61
+ "vite.config.ts", "vite.config.js",
62
+ "rollup.config.js", "rollup.config.ts",
63
+ "babel.config.js", "babel.config.json",
64
+ ".babelrc", ".babelrc.js",
65
+ "tsconfig.json", "tsconfig.base.json",
66
+ ".dockerignore", "Makefile",
67
+ "lerna.json", "nx.json", "turbo.json",
68
+ "CHANGELOG.md", "LICENSE", "LICENSE.md",
69
+ })
70
+
71
+ _AUXILIARY_DIR_PATTERNS: list[re.Pattern[str]] = [
72
+ re.compile(r"(?:^|/)benchmark[s]?(?:/|$)"),
73
+ re.compile(r"(?:^|/)example[s]?(?:/|$)"),
74
+ re.compile(r"(?:^|/)demo[s]?(?:/|$)"),
75
+ re.compile(r"(?:^|/)playground[s]?(?:/|$)"),
76
+ re.compile(r"(?:^|/)fixture[s]?(?:/|$)"),
77
+ re.compile(r"(?:^|/)sandbox(?:/|$)"),
78
+ re.compile(r"(?:^|/)docs?(?:/|$)"),
79
+ re.compile(r"(?:^|/)\.github(?:/|$)"),
80
+ re.compile(r"(?:^|/)\.claude(?:/|$)"),
81
+ re.compile(r"(?:^|/)\.vscode(?:/|$)"),
82
+ re.compile(r"(?:^|/)scripts?(?:/|$)"),
83
+ re.compile(r"(?:^|/)tools?(?:/|$)"),
84
+ re.compile(r"(?:^|/)ci(?:/|$)"),
85
+ ]
86
+
87
+ _HIGH_VALUE_SUFFIXES: frozenset[str] = frozenset({
88
+ ".py", ".ts", ".tsx", ".js", ".jsx", ".mjs",
89
+ ".go", ".java", ".kt", ".rs", ".rb", ".cs",
90
+ })
91
+
92
+ _ENTRYPOINT_STEMS: frozenset[str] = frozenset({
93
+ "main", "cli", "app", "server", "index", "__main__",
94
+ "application", "bootstrap", "entry",
95
+ })
96
+
97
+
98
+ class RelevanceScorer:
99
+ """Scores file paths by operational relevance for AI agents."""
100
+
101
+ def __init__(self, monorepo_packages: Optional[list[MonorepoPackageInfo]] = None) -> None:
102
+ self._pkg_roles: dict[str, str] = {} # path_prefix → role
103
+ if monorepo_packages:
104
+ for pkg in monorepo_packages:
105
+ prefix = pkg.path.rstrip("/") + "/"
106
+ self._pkg_roles[prefix] = pkg.architectural_role
107
+
108
+ def score(self, path: str) -> float:
109
+ """Return operational relevance 0.0–1.0. Higher = more useful for agents."""
110
+ norm = path.replace("\\", "/").lstrip("/")
111
+
112
+ if self.is_noise(norm):
113
+ return 0.0
114
+
115
+ base = 0.3
116
+
117
+ # Package role boost
118
+ role = self._package_role(norm)
119
+ role_boost = {
120
+ "runtime_core": 0.4,
121
+ "plugin_host": 0.35,
122
+ "backend_runtime": 0.3,
123
+ "frontend_runtime": 0.25,
124
+ "composition_layer": 0.2,
125
+ "plugin_package": 0.15,
126
+ "infrastructure_layer": 0.15,
127
+ "tooling_layer": -0.1,
128
+ "docs_layer": -0.15,
129
+ "test_layer": 0.05,
130
+ "benchmark_layer": -0.2,
131
+ }.get(role, 0.0)
132
+ base += role_boost
133
+
134
+ # Source file boost
135
+ suffix = Path(norm).suffix.lower()
136
+ if suffix in _HIGH_VALUE_SUFFIXES:
137
+ base += 0.1
138
+
139
+ # Entrypoint stem boost
140
+ stem = Path(norm).stem.lower()
141
+ if stem in _ENTRYPOINT_STEMS:
142
+ base += 0.15
143
+
144
+ # Penalize auxiliary dirs
145
+ if self._is_auxiliary(norm):
146
+ base -= 0.2
147
+
148
+ return max(0.0, min(1.0, base))
149
+
150
+ def is_noise(self, path: str) -> bool:
151
+ """True if this file should be suppressed from default agent output."""
152
+ norm = path.replace("\\", "/").lstrip("/")
153
+
154
+ if any(norm.startswith(p) for p in _NOISE_PREFIXES):
155
+ return True
156
+
157
+ parts = norm.split("/")
158
+ if any(p in _NOISE_DIRS for p in parts):
159
+ return True
160
+
161
+ filename = Path(norm).name
162
+ if filename in _TOOLING_FILENAMES:
163
+ return True
164
+
165
+ for suffix in _NOISE_SUFFIXES:
166
+ if norm.endswith(suffix):
167
+ return True
168
+
169
+ return False
170
+
171
+ def is_auxiliary(self, path: str) -> bool:
172
+ """True if file is in a benchmark/example/demo/docs directory."""
173
+ return self._is_auxiliary(path.replace("\\", "/"))
174
+
175
+ def _is_auxiliary(self, norm: str) -> bool:
176
+ return any(p.search(norm) for p in _AUXILIARY_DIR_PATTERNS)
177
+
178
+ def _package_role(self, norm: str) -> str:
179
+ for prefix, role in self._pkg_roles.items():
180
+ if norm.startswith(prefix):
181
+ return role
182
+ return "unknown"
@@ -0,0 +1,331 @@
1
+ from __future__ import annotations
2
+
3
+ """Classifies workspace packages into architectural roles using weighted inference.
4
+
5
+ Signal priority (descending):
6
+ scripts.start/serve → runtime_core/backend_runtime
7
+ scripts.cli/bin → runtime_core (CLI tool)
8
+ runtime dep imports → backend_runtime / frontend_runtime
9
+ name/path patterns → specialized roles
10
+ fan-in → weak corroboration only, NEVER sole driver
11
+ """
12
+
13
+ import json
14
+ import re
15
+ from pathlib import Path
16
+ from typing import Any
17
+
18
+ from sourcecode.schema import MonorepoPackageInfo
19
+
20
+ # --------------------------------------------------------------------------
21
+ # Noise / negative patterns — packages matching these are NOT runtime_core
22
+ # regardless of fan-in.
23
+ # --------------------------------------------------------------------------
24
+
25
+ _NEGATIVE_RUNTIME_CORE: list[re.Pattern[str]] = [
26
+ re.compile(r"(?:^|/)utils?(?:/|$)"),
27
+ re.compile(r"(?:^|/)shared(?:/|$)"),
28
+ re.compile(r"(?:^|/)types?(?:/|$)"),
29
+ re.compile(r"(?:^|/)config(?:s)?(?:/|$)"),
30
+ re.compile(r"(?:^|/)common(?:/|$)"),
31
+ re.compile(r"(?:^|/)helpers?(?:/|$)"),
32
+ re.compile(r"(?:^|/)constants?(?:/|$)"),
33
+ re.compile(r"(?:^|/)fixture[s]?(?:/|$)"),
34
+ re.compile(r"(?:^|/)tooling(?:/|$)"),
35
+ re.compile(r"(?:^|/)preset[s]?(?:/|$)"),
36
+ re.compile(r"(?:^|/)theme[s]?(?:/|$)"),
37
+ re.compile(r"(?:^|/)i18n(?:/|$)"),
38
+ re.compile(r"(?:^|/)locale[s]?(?:/|$)"),
39
+ ]
40
+
41
+ _NEGATIVE_NAME_RUNTIME_CORE: list[re.Pattern[str]] = [
42
+ re.compile(r"\butils?\b"),
43
+ re.compile(r"\bshared\b"),
44
+ re.compile(r"\btypes?\b"),
45
+ re.compile(r"\bconfig\b"),
46
+ re.compile(r"\bcommon\b"),
47
+ re.compile(r"\bhelpers?\b"),
48
+ re.compile(r"\bconstants?\b"),
49
+ re.compile(r"\bfixtures?\b"),
50
+ re.compile(r"\btooling\b"),
51
+ re.compile(r"\btheme[s]?\b"),
52
+ re.compile(r"\bi18n\b"),
53
+ re.compile(r"\blocale[s]?\b"),
54
+ re.compile(r"\bpreset[s]?\b"),
55
+ re.compile(r"eslint|prettier|lint|format"),
56
+ ]
57
+
58
+ # --------------------------------------------------------------------------
59
+ # Role-determining pattern tables
60
+ # --------------------------------------------------------------------------
61
+
62
+ _PATH_SIGNALS: list[tuple[re.Pattern[str], str, int]] = [
63
+ (re.compile(r"benchmark|bench"), "benchmark_layer", 4),
64
+ (re.compile(r"(?:^|/)docs?(?:/|$)"), "docs_layer", 4),
65
+ (re.compile(r"(?:^|/)tests?(?:/|$)|(?:^|/)specs?(?:/|$)|(?:^|/)e2e(?:/|$)"), "test_layer", 4),
66
+ (re.compile(r"example|demo|playground|fixture|sandbox"), "benchmark_layer", 3),
67
+ (re.compile(r"(?:^|/)plugins?(?:/|$)"), "plugin_package", 3),
68
+ (re.compile(r"(?:^|/)presets?(?:/|$)"), "composition_layer", 3),
69
+ (re.compile(r"infra(?:structure)?"), "infrastructure_layer", 2),
70
+ (re.compile(r"(?:^|/)client(?:/|$)|(?:^|/)web(?:/|$)|(?:^|/)ui(?:/|$)"), "frontend_runtime", 2),
71
+ (re.compile(r"(?:^|/)server(?:/|$)"), "backend_runtime", 3),
72
+ (re.compile(r"(?:^|/)app(?:/|$)"), "backend_runtime", 2),
73
+ ]
74
+
75
+ _NAME_SIGNALS: list[tuple[re.Pattern[str], str, int]] = [
76
+ (re.compile(r"benchmark|bench"), "benchmark_layer", 4),
77
+ (re.compile(r"\bplugin"), "plugin_package", 3),
78
+ (re.compile(r"\bpreset"), "composition_layer", 3),
79
+ (re.compile(r"client|web|react|frontend|\bui\b"), "frontend_runtime", 2),
80
+ (re.compile(r"\bserver\b|\bapi\b|\bbackend\b"), "backend_runtime", 3),
81
+ (re.compile(r"database|db|storage|cache|queue|redis|pg|sql"), "infrastructure_layer", 2),
82
+ (re.compile(r"\bcore\b|\bkernel\b|\bruntime\b|\bengine\b|\bapp\b"), "runtime_core", 2),
83
+ (re.compile(r"test|spec|e2e"), "test_layer", 3),
84
+ (re.compile(r"lint|format|build|webpack|vite|eslint|prettier|tsconfig"), "tooling_layer", 3),
85
+ (re.compile(r"docs?$"), "docs_layer", 3),
86
+ ]
87
+
88
+ # Scripts that provide hard runtime evidence
89
+ _RUNTIME_SCRIPTS: frozenset[str] = frozenset({"start", "serve", "server"})
90
+ _DEV_SCRIPTS: frozenset[str] = frozenset({"dev", "develop", "watch"})
91
+ _CLI_SCRIPTS: frozenset[str] = frozenset({"cli", "bin", "run"})
92
+
93
+ _SCRIPT_SIGNALS: dict[str, tuple[str, int]] = {
94
+ "start": ("backend_runtime", 4),
95
+ "serve": ("backend_runtime", 4),
96
+ "server": ("backend_runtime", 4),
97
+ "cli": ("backend_runtime", 3),
98
+ "dev": ("backend_runtime", 2),
99
+ "develop": ("backend_runtime", 2),
100
+ "benchmark": ("benchmark_layer", 4),
101
+ "bench": ("benchmark_layer", 4),
102
+ "test": ("test_layer", 3),
103
+ "lint": ("tooling_layer", 2),
104
+ "format": ("tooling_layer", 2),
105
+ }
106
+
107
+ _DEP_SIGNALS: list[tuple[re.Pattern[str], str, int]] = [
108
+ (re.compile(r"^react$|^vue$|^@angular/core$|^svelte$"), "frontend_runtime", 3),
109
+ (re.compile(r"^express$|^fastify$|^koa$|^@nestjs/core$|^hono$|^elysia$"), "backend_runtime", 3),
110
+ (re.compile(r"^sequelize$|^prisma$|^typeorm$|^mongoose$|^knex$|^pg$"),"infrastructure_layer",2),
111
+ (re.compile(r"^jest$|^mocha$|^vitest$|^cypress$|^playwright$"), "test_layer", 3),
112
+ (re.compile(r"^eslint$|^prettier$"), "tooling_layer", 2),
113
+ ]
114
+
115
+ _ROLE_PRIORITY: dict[str, int] = {
116
+ "runtime_core": 10,
117
+ "plugin_host": 9,
118
+ "backend_runtime": 8,
119
+ "composition_layer": 7,
120
+ "frontend_runtime": 6,
121
+ "plugin_package": 5,
122
+ "infrastructure_layer": 4,
123
+ "test_layer": 3,
124
+ "docs_layer": 3,
125
+ "tooling_layer": 3,
126
+ "benchmark_layer": 3,
127
+ "unknown": 0,
128
+ }
129
+
130
+ _NOISE_ROLES = frozenset({"benchmark_layer", "test_layer", "docs_layer", "tooling_layer"})
131
+
132
+
133
+ class RuntimeClassifier:
134
+ """Classifies workspace packages by architectural role."""
135
+
136
+ def classify(self, root: Path, workspace_paths: list[str]) -> list[MonorepoPackageInfo]:
137
+ raw: list[tuple[str, dict[str, Any]]] = []
138
+ for ws_path in workspace_paths:
139
+ pkg_root = root / ws_path
140
+ if pkg_root.is_dir():
141
+ raw.append((ws_path, self._load_pkg_json(pkg_root)))
142
+
143
+ fan_in = self._compute_fan_in(raw)
144
+ results: list[MonorepoPackageInfo] = []
145
+
146
+ for ws_path, pkg_json in raw:
147
+ name = str(pkg_json.get("name", ws_path)) if pkg_json else ws_path
148
+ role, signals, conf = self._score_package(ws_path, name, pkg_json, fan_in.get(ws_path, 0))
149
+
150
+ # plugin_host detection (independent of runtime_core path)
151
+ if role not in _NOISE_ROLES and self._detect_plugin_host(pkg_json, ws_path, fan_in.get(ws_path, 0)):
152
+ role = "plugin_host"
153
+ signals.append("plugin_host:inferred")
154
+
155
+ criticality = self._criticality(role, fan_in.get(ws_path, 0))
156
+ results.append(MonorepoPackageInfo(
157
+ path=ws_path,
158
+ name=name,
159
+ architectural_role=role,
160
+ criticality=criticality,
161
+ confidence=conf,
162
+ role_signals=signals,
163
+ ))
164
+
165
+ return results
166
+
167
+ # ------------------------------------------------------------------
168
+ # Internal helpers
169
+ # ------------------------------------------------------------------
170
+
171
+ def _load_pkg_json(self, pkg_root: Path) -> dict[str, Any]:
172
+ try:
173
+ content = (pkg_root / "package.json").read_text(encoding="utf-8", errors="replace")
174
+ return json.loads(content) # type: ignore[no-any-return]
175
+ except Exception:
176
+ return {}
177
+
178
+ def _compute_fan_in(self, raw: list[tuple[str, dict[str, Any]]]) -> dict[str, int]:
179
+ name_to_path: dict[str, str] = {}
180
+ for ws_path, pkg_json in raw:
181
+ name = str(pkg_json.get("name", "")) if pkg_json else ""
182
+ if name:
183
+ name_to_path[name] = ws_path
184
+
185
+ fan_in: dict[str, int] = {}
186
+ for _, pkg_json in raw:
187
+ if not pkg_json:
188
+ continue
189
+ for field in ("dependencies", "devDependencies", "peerDependencies"):
190
+ for dep_name in pkg_json.get(field, {}):
191
+ target_path = name_to_path.get(str(dep_name))
192
+ if target_path:
193
+ fan_in[target_path] = fan_in.get(target_path, 0) + 1
194
+ return fan_in
195
+
196
+ def _has_runtime_evidence(self, pkg_json: dict[str, Any], ws_path: str) -> bool:
197
+ """True only when package has explicit evidence of being a running process."""
198
+ if not pkg_json:
199
+ return False
200
+ # bin field = CLI/server executable
201
+ if pkg_json.get("bin"):
202
+ return True
203
+ # start/serve/server script = runtime entry point
204
+ scripts = pkg_json.get("scripts") or {}
205
+ if isinstance(scripts, dict):
206
+ if _RUNTIME_SCRIPTS & set(scripts.keys()):
207
+ return True
208
+ return False
209
+
210
+ def _is_utility_package(self, ws_path: str, name: str) -> bool:
211
+ """True for packages that are libraries/utilities, NOT runtime processes."""
212
+ path_lower = ws_path.lower().replace("\\", "/")
213
+ name_lower = (name or "").lower()
214
+ return (
215
+ any(p.search(path_lower) for p in _NEGATIVE_RUNTIME_CORE)
216
+ or any(p.search(name_lower) for p in _NEGATIVE_NAME_RUNTIME_CORE)
217
+ )
218
+
219
+ def _score_package(
220
+ self, ws_path: str, name: str, pkg_json: dict[str, Any], fan_in: int
221
+ ) -> tuple[str, list[str], str]:
222
+ scores: dict[str, float] = {}
223
+ signals: list[str] = []
224
+
225
+ path_lower = ws_path.lower().replace("\\", "/")
226
+ name_lower = name.lower() if name else ""
227
+
228
+ for pattern, role, weight in _PATH_SIGNALS:
229
+ if pattern.search(path_lower):
230
+ scores[role] = scores.get(role, 0) + weight
231
+ signals.append(f"path:{role}")
232
+ break # one path signal per package
233
+
234
+ for pattern, role, weight in _NAME_SIGNALS:
235
+ if pattern.search(name_lower):
236
+ scores[role] = scores.get(role, 0) + weight
237
+ signals.append(f"name:{role}")
238
+ break
239
+
240
+ if pkg_json:
241
+ scripts = pkg_json.get("scripts", {}) or {}
242
+ if isinstance(scripts, dict):
243
+ for script_name, (role, weight) in _SCRIPT_SIGNALS.items():
244
+ if script_name in scripts:
245
+ scores[role] = scores.get(role, 0) + weight
246
+ signals.append(f"script:{script_name}")
247
+
248
+ # bin = CLI tool or server executable; strong runtime signal
249
+ if pkg_json.get("bin"):
250
+ scores["backend_runtime"] = scores.get("backend_runtime", 0) + 3
251
+ signals.append("bin:declared")
252
+
253
+ # exports/main: weak lib signal only — do NOT boost runtime_core
254
+ if pkg_json.get("exports") or pkg_json.get("main"):
255
+ signals.append("exports:declared")
256
+
257
+ prod_deps: set[str] = set()
258
+ for field in ("dependencies", "peerDependencies"):
259
+ dep_map = pkg_json.get(field) or {}
260
+ if isinstance(dep_map, dict):
261
+ prod_deps.update(str(k) for k in dep_map)
262
+
263
+ for dep_name in prod_deps:
264
+ for pattern, role, weight in _DEP_SIGNALS:
265
+ if pattern.search(dep_name.lower()):
266
+ scores[role] = scores.get(role, 0) + weight
267
+ break
268
+
269
+ # Fan-in: weak corroboration only (+1 per point, capped at 2)
270
+ # Never sole driver — it boosts the CURRENT leading role, not runtime_core
271
+ if fan_in >= 2:
272
+ signals.append(f"fan_in:{fan_in}")
273
+ fan_boost = min(fan_in, 2)
274
+ if scores:
275
+ # Boost whatever role is currently winning
276
+ current_best = max(scores, key=lambda r: (scores[r], _ROLE_PRIORITY.get(r, 0)))
277
+ scores[current_best] = scores.get(current_best, 0) + fan_boost
278
+ # No scores yet (purely utility pkg): high fan-in → infrastructure_layer
279
+ else:
280
+ scores["infrastructure_layer"] = scores.get("infrastructure_layer", 0) + fan_boost
281
+
282
+ if not scores:
283
+ return "unknown", signals, "low"
284
+
285
+ best_role = max(scores, key=lambda r: (scores[r], _ROLE_PRIORITY.get(r, 0)))
286
+ best_score = scores[best_role]
287
+
288
+ # Promote backend_runtime → runtime_core only with explicit runtime evidence
289
+ # AND not a utility/shared package
290
+ if best_role == "backend_runtime" and self._has_runtime_evidence(pkg_json, ws_path):
291
+ if not self._is_utility_package(ws_path, name):
292
+ best_role = "runtime_core"
293
+ signals.append("promoted:has_runtime_evidence")
294
+
295
+ # Hard guard: runtime_core NEVER assigned to utility packages
296
+ if best_role == "runtime_core" and self._is_utility_package(ws_path, name):
297
+ # Downgrade: utility with high fan-in → infrastructure_layer
298
+ best_role = "infrastructure_layer"
299
+ signals.append("demoted:utility_package")
300
+
301
+ conf = "high" if best_score >= 6 else "medium" if best_score >= 3 else "low"
302
+ return best_role, signals, conf
303
+
304
+ def _detect_plugin_host(self, pkg_json: dict[str, Any], ws_path: str, fan_in: int) -> bool:
305
+ if fan_in < 2:
306
+ return False
307
+ path_lower = ws_path.lower()
308
+ if "plugin" in path_lower or "benchmark" in path_lower:
309
+ return False
310
+ if not pkg_json:
311
+ return False
312
+ name = str(pkg_json.get("name", "")).lower()
313
+ if "plugin" in name:
314
+ return False
315
+ scripts = pkg_json.get("scripts", {}) or {}
316
+ if isinstance(scripts, dict):
317
+ if any("plugin" in str(k).lower() or "plugin" in str(v).lower()
318
+ for k, v in scripts.items()):
319
+ return True
320
+ exports = pkg_json.get("exports") or {}
321
+ if isinstance(exports, dict):
322
+ if any("plugin" in str(k).lower() for k in exports):
323
+ return True
324
+ return False
325
+
326
+ def _criticality(self, role: str, fan_in: int) -> str:
327
+ if role in {"runtime_core", "plugin_host"}:
328
+ return "high"
329
+ if role in {"backend_runtime", "frontend_runtime", "composition_layer"} or fan_in >= 3:
330
+ return "medium"
331
+ return "low"
sourcecode/schema.py CHANGED
@@ -72,6 +72,7 @@ class EntryPoint:
72
72
  confidence: Literal["high", "medium", "low"] = "high"
73
73
  reason: Optional[str] = None # console_script | entry_file_pattern | main_guard | typer_app | heuristic | convention
74
74
  evidence: Optional[str] = None # brief evidence string
75
+ entrypoint_type: Optional[Literal["production", "development", "benchmark", "example"]] = None
75
76
 
76
77
 
77
78
  @dataclass
@@ -307,6 +308,36 @@ class SemanticSummary:
307
308
  limitations: list[str] = field(default_factory=list)
308
309
 
309
310
 
311
+ # --- Runtime Architecture ---
312
+
313
+ ArchitecturalRole = Literal[
314
+ "runtime_core",
315
+ "plugin_host",
316
+ "plugin_package",
317
+ "frontend_runtime",
318
+ "backend_runtime",
319
+ "composition_layer",
320
+ "infrastructure_layer",
321
+ "tooling_layer",
322
+ "docs_layer",
323
+ "test_layer",
324
+ "benchmark_layer",
325
+ "unknown",
326
+ ]
327
+
328
+
329
+ @dataclass
330
+ class MonorepoPackageInfo:
331
+ """Classified workspace package with architectural role and criticality."""
332
+
333
+ path: str
334
+ name: str
335
+ architectural_role: str # ArchitecturalRole value
336
+ criticality: Literal["high", "medium", "low"] = "low"
337
+ confidence: Literal["high", "medium", "low"] = "low"
338
+ role_signals: list[str] = field(default_factory=list)
339
+
340
+
310
341
  # --- Phase 13 Plan 04: Architectural Inference ---
311
342
 
312
343
  @dataclass
@@ -547,8 +578,10 @@ class SourceMap:
547
578
  code_notes: list[CodeNote] = field(default_factory=list)
548
579
  code_adrs: list[AdrRecord] = field(default_factory=list)
549
580
  code_notes_summary: Optional[CodeNotesSummary] = None
550
- # Confidence & Explainability (v0.25.0)
581
+ # Confidence & Explainability (v0.26.0)
551
582
  confidence_summary: Optional[ConfidenceSummary] = None
552
583
  analysis_gaps: list[AnalysisGap] = field(default_factory=list)
553
- # AI context summary (v0.25.0)
584
+ # AI context summary (v0.26.0)
554
585
  context_summary: Optional[ContextSummary] = None
586
+ # Runtime architecture (v0.26.0)
587
+ monorepo_packages: list[MonorepoPackageInfo] = field(default_factory=list)
sourcecode/serializer.py CHANGED
@@ -410,18 +410,48 @@ def agent_view(sm: SourceMap) -> dict[str, Any]:
410
410
 
411
411
  result: dict[str, Any] = {"project": project}
412
412
 
413
- # ── 2. Entry points (with reason/evidence when available) ─────────────────
413
+ # ── 2. Entry points: production/runtime first, benchmark/example excluded ──
414
414
  if sm.entry_points:
415
415
  _ep_skip = {"workspace"}
416
- result["entry_points"] = [
416
+ _aux_parts = frozenset({
417
+ "benchmark", "benchmarks", "bench", "demo", "demos",
418
+ "example", "examples", "docs", "doc", "fixtures", "fixture",
419
+ })
420
+
421
+ def _ep_priority(ep_dict: dict[str, Any]) -> int:
422
+ ep_type = ep_dict.get("entrypoint_type")
423
+ if ep_type in ("benchmark", "example"):
424
+ return 10
425
+ path_parts = ep_dict.get("path", "").replace("\\", "/").lower().split("/")
426
+ if any(p in _aux_parts for p in path_parts):
427
+ return 5
428
+ if ep_type == "development":
429
+ return 3
430
+ return 0
431
+
432
+ all_ep = [
417
433
  {k: v for k, v in asdict(ep).items() if v is not None and v != "" and k not in _ep_skip}
418
434
  for ep in sm.entry_points
419
435
  ]
436
+ all_ep.sort(key=_ep_priority)
437
+ operational_ep = [ep for ep in all_ep if _ep_priority(ep) < 5]
438
+ result["entry_points"] = operational_ep if operational_ep else all_ep
420
439
 
421
440
  # ── 3. Architecture ───────────────────────────────────────────────────────
422
441
  if sm.architecture_summary:
423
442
  result["architecture"] = sm.architecture_summary
424
443
 
444
+ # ── 3b. Monorepo package roles (when available) ───────────────────────────
445
+ if sm.monorepo_packages:
446
+ _noise_roles = {"benchmark_layer", "tooling_layer", "docs_layer", "test_layer"}
447
+ operational_pkgs = [
448
+ {"path": p.path, "role": p.architectural_role, "criticality": p.criticality}
449
+ for p in sm.monorepo_packages
450
+ if p.architectural_role not in _noise_roles
451
+ ]
452
+ if operational_pkgs:
453
+ result["runtime_packages"] = operational_pkgs
454
+
425
455
  # ── 4. Key dependencies (role-sorted, already computed) ───────────────────
426
456
  if sm.dependency_summary and sm.dependency_summary.requested and sm.key_dependencies:
427
457
  _dep_skip = {"parent", "manifest_path", "workspace", "source", "ecosystem"}
sourcecode/summarizer.py CHANGED
@@ -6,9 +6,10 @@ Sin llamadas a API — solo templates aplicados sobre SourceMap.
6
6
  """
7
7
 
8
8
  from pathlib import Path
9
+ from typing import Any
9
10
 
10
11
  from sourcecode.detectors.parsers import load_json_file, load_toml_file
11
- from sourcecode.schema import SourceMap
12
+ from sourcecode.schema import MonorepoPackageInfo, SourceMap
12
13
 
13
14
  _TOOLING_PREFIXES = (".claude/", ".vscode/", "bin/")
14
15
  _SRC_TRANSPARENT = {"src", "lib", "app", "pkg"}
@@ -75,6 +76,11 @@ class ProjectSummarizer:
75
76
  return "Proyecto analizado."
76
77
 
77
78
  def _build_summary(self, sm: SourceMap) -> str:
79
+ # For monorepos with classified packages, structural inference beats README
80
+ if sm.project_type == "monorepo" and sm.monorepo_packages:
81
+ dep_part = self._build_dep_part(sm)
82
+ return self._build_monorepo_structural_summary(sm, dep_part)
83
+
78
84
  description = self._read_project_description()
79
85
  if description:
80
86
  return self._merge_description_with_structure(description, sm)
@@ -107,6 +113,9 @@ class ProjectSummarizer:
107
113
  dep_part = self._build_dep_part(sm)
108
114
 
109
115
  if project_type == "monorepo":
116
+ # Prefer structural inference over README for monorepos
117
+ if sm.monorepo_packages:
118
+ return self._build_monorepo_structural_summary(sm, dep_part)
110
119
  stacks_desc = ", ".join(sorted({s.stack.capitalize() for s in non_tooling_stacks}))
111
120
  n_ws = len({s.workspace for s in non_tooling_stacks if s.workspace})
112
121
  ws_part = f" con {n_ws} workspaces" if n_ws > 0 else ""
@@ -266,6 +275,71 @@ class ProjectSummarizer:
266
275
  sorted_domains = sorted(domain_counts.items(), key=lambda x: x[1], reverse=True)
267
276
  return [name for name, _ in sorted_domains[:5]]
268
277
 
278
+ def _build_monorepo_structural_summary(self, sm: SourceMap, dep_part: str) -> str:
279
+ pkgs = sm.monorepo_packages
280
+ total = len(pkgs)
281
+
282
+ # Group by role
283
+ by_role: dict[str, list[MonorepoPackageInfo]] = {}
284
+ for p in pkgs:
285
+ by_role.setdefault(p.architectural_role, []).append(p)
286
+
287
+ runtime_roles = {"runtime_core", "backend_runtime", "plugin_host"}
288
+ frontend_roles = {"frontend_runtime"}
289
+ plugin_roles = {"plugin_package"}
290
+ noise_roles = {"benchmark_layer", "tooling_layer", "docs_layer", "test_layer"}
291
+
292
+ runtime_pkgs = [p for r in runtime_roles for p in by_role.get(r, [])]
293
+ frontend_pkgs = [p for r in frontend_roles for p in by_role.get(r, [])]
294
+ plugin_pkgs = by_role.get("plugin_package", [])
295
+ composition_pkgs = by_role.get("composition_layer", [])
296
+ noise_count = sum(len(by_role.get(r, [])) for r in noise_roles)
297
+
298
+ # Detect plugin system
299
+ has_plugin_system = bool(plugin_pkgs) or bool(by_role.get("plugin_host"))
300
+
301
+ # Determine primary language
302
+ non_tooling = [s for s in sm.stacks if not self._is_tooling_path(s.root) and not self._is_tooling_path(s.workspace)]
303
+ lang_set = sorted({s.stack.capitalize() for s in non_tooling})
304
+ lang_desc = "/".join(lang_set[:2]) if lang_set else "TypeScript"
305
+
306
+ parts: list[str] = []
307
+
308
+ # Headline: type + language + key architecture signal
309
+ if has_plugin_system:
310
+ parts.append(f"Plugin-driven {lang_desc} monorepo with {total} packages.")
311
+ else:
312
+ ws_label = f"{total} packages" if total > 1 else "monorepo"
313
+ parts.append(f"{lang_desc} monorepo ({ws_label}).")
314
+
315
+ # Runtime core
316
+ if runtime_pkgs:
317
+ core_paths = ", ".join(p.path for p in runtime_pkgs[:4])
318
+ parts.append(f"Runtime core: {core_paths}.")
319
+
320
+ # Plugin packages (if plugin system detected)
321
+ if plugin_pkgs:
322
+ n_plugins = len(plugin_pkgs)
323
+ plugin_paths = ", ".join(p.path for p in plugin_pkgs[:3])
324
+ extra = f" (+ {n_plugins - 3} more)" if n_plugins > 3 else ""
325
+ parts.append(f"{n_plugins} plugin packages: {plugin_paths}{extra}.")
326
+
327
+ # Frontend
328
+ if frontend_pkgs:
329
+ fe_paths = ", ".join(p.path for p in frontend_pkgs[:3])
330
+ parts.append(f"Frontend runtime: {fe_paths}.")
331
+
332
+ # Composition/presets
333
+ if composition_pkgs:
334
+ comp_paths = ", ".join(p.path for p in composition_pkgs[:2])
335
+ parts.append(f"Composition layer: {comp_paths}.")
336
+
337
+ # Noise info (non-critical)
338
+ if noise_count > 0:
339
+ parts.append(f"{noise_count} auxiliary packages (tests/benchmarks/tooling — non-critical).")
340
+
341
+ return " ".join(parts) + dep_part
342
+
269
343
  def _build_dep_part(self, sm: SourceMap) -> str:
270
344
  if sm.dependency_summary and sm.dependency_summary.total_count > 0:
271
345
  ds = sm.dependency_summary
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: sourcecode
3
- Version: 0.25.0
3
+ Version: 0.26.0
4
4
  Summary: Genera un mapa de contexto estructurado de proyectos de software para agentes IA
5
5
  License: MIT
6
6
  Requires-Python: >=3.9
@@ -1,8 +1,8 @@
1
- sourcecode/__init__.py,sha256=VAs5D8vi_Ge5ynS03-USMO8DYf9wFvnsVKninR3VGz8,100
1
+ sourcecode/__init__.py,sha256=KWokO203w00OE-dSv_Mo5TSB7XXds2ISCqZJXXjyH1k,100
2
2
  sourcecode/architecture_analyzer.py,sha256=h81uQuSlxAorHve7M4jzXLT2PStOaGRMTlEPgW9zvxo,19741
3
- sourcecode/architecture_summary.py,sha256=_-M0TfPAacdFdakUOWQBZZ1HjQTXp7a5EVGkX9H9sbg,18483
3
+ sourcecode/architecture_summary.py,sha256=qolHmn6MWUIQHzY9WeHcfN41EJkQdnPQ5F_Z8pqQasA,20251
4
4
  sourcecode/classifier.py,sha256=Ft_RfYS-KOe0t7vjgUx04OoCJd1-DXK7k9-I0CFDSnU,6934
5
- sourcecode/cli.py,sha256=SyO5JHR0cKMoMEas-of1pnWneZMjr7JhTxURR7-Kv5w,30925
5
+ sourcecode/cli.py,sha256=-u4HmXhrJE5II5ohFbg_uVTlFXcMPhXf3RIl1YPLhIk,31278
6
6
  sourcecode/code_notes_analyzer.py,sha256=rRd8bFYV0krjlxxQV0wenwE9K7pVpUQSR7KvSvUQKw4,9226
7
7
  sourcecode/confidence_analyzer.py,sha256=gvQ92XCeGsACskSGU-GWTB7OqU1tT0eTgV-A7XA_lGs,8968
8
8
  sourcecode/context_summarizer.py,sha256=CiQrfBEzun949bWvmLabWoj2HhPn6Lw62ofqnsy0FlQ,6503
@@ -11,15 +11,17 @@ sourcecode/dependency_analyzer.py,sha256=Exq0BfInvfS5iAg9xAr6WI2uPNuotkIudTKcYJc
11
11
  sourcecode/doc_analyzer.py,sha256=Ec3orx6vBKsh5cNM3-F4y2Got2KuKx8w3dErwtdtM-A,19891
12
12
  sourcecode/env_analyzer.py,sha256=JZxBOuIxnM0xw0IaJFL6LiPJBErL848L3XoTOHGBqZI,13554
13
13
  sourcecode/git_analyzer.py,sha256=S9PGt8RDasBQYQUsZh9-H_KWVlPvzwR4jgM7TKN9ZCk,7643
14
- sourcecode/graph_analyzer.py,sha256=iJa9VAOG6R234otnsqPfUS8VGrb2GuoXnhyP-p8nCxo,61701
14
+ sourcecode/graph_analyzer.py,sha256=hMOsLLz9B0UnQ4xwbHdgr3bFvqpw0bQ8kN-xmEn3Krk,64156
15
15
  sourcecode/metrics_analyzer.py,sha256=4uh11v-Q0gdrN87BOxuFWUym3N3AOkOuy21K5N8peB8,20126
16
- sourcecode/prepare_context.py,sha256=ktVlTUa3VU6A0Q5r8EhZq0g97BKoFfndqwSPfeLzlqo,27701
16
+ sourcecode/prepare_context.py,sha256=--lD2dhNkBYI8kwb14d1DlFmEN8XF1Ygtf0Qk7-Y1Bs,30911
17
17
  sourcecode/redactor.py,sha256=xuGcadGEHaPw4qZXlMDvzMCsr4VOkdp3oBQptHyJk8c,2884
18
+ sourcecode/relevance_scorer.py,sha256=2yvxDFnz9YGrHEJubgx9soiVIDZHKv_pntOtTARtKow,5928
19
+ sourcecode/runtime_classifier.py,sha256=zWX3r3HCKHc-qtIobErOa8aKMmaoPYREtJKvPcBGPjQ,14792
18
20
  sourcecode/scanner.py,sha256=aM3h9-DCQ3xKpeHpHYdo2vX6T5P95HA_YwZbkAVNwmo,8288
19
- sourcecode/schema.py,sha256=fcglVk6IklJa0UIWakZgSxTSuqpbbBORKH89GsUmBZM,18262
21
+ sourcecode/schema.py,sha256=c2ADsc0MDtsCJ6TIFqEfjhkCsSeFhIY0XhXc8BqfrrQ,19208
20
22
  sourcecode/semantic_analyzer.py,sha256=asQfJf-EhzYaOTA-iMuZsrVXtbW7SV2WEKCxgsxa88Y,79413
21
- sourcecode/serializer.py,sha256=DgMHKQMF-lPtKw5ebV4OzGBAHBaoygEQwHwvZch7ePg,25385
22
- sourcecode/summarizer.py,sha256=YdIA5gGEc0XSctPgmTnFnuZI72bprrOYhkJNoDmvHwM,12114
23
+ sourcecode/serializer.py,sha256=k8ffuXNkjkBSU8RNad3lv_89FdRbJ7qdEmhD988X_Sw,26756
24
+ sourcecode/summarizer.py,sha256=YfBixsN1zWHHXdOEqaf793BylbJrsj75ST7FN6jcqRU,15424
23
25
  sourcecode/tree_utils.py,sha256=Fj9OIuUksBvgibNd3feog0sMDjVypJzPexp5lvMoYWI,1424
24
26
  sourcecode/workspace.py,sha256=fQlVoNx8S-fSHpKoJ0JBvEHCFkxszH0KZVJed1i3TRk,6845
25
27
  sourcecode/detectors/__init__.py,sha256=A0AACJFF6HWf_RgatNtWu3PUzstcKtIGM9f1PoFcJug,1987
@@ -33,7 +35,7 @@ sourcecode/detectors/heuristic.py,sha256=JWb_dXNI1YDxk9a3DFeXTbKYfAGOFqV6jVxxqou
33
35
  sourcecode/detectors/hybrid.py,sha256=IGFRUVsAZ1ooRlFdznCeJAV6vy1yVDx-VyghvLtddXc,9101
34
36
  sourcecode/detectors/java.py,sha256=cZvB13cqJ76zHDncEG-TOCuK8gJjJN2mZGS2DGEcZy8,7715
35
37
  sourcecode/detectors/jvm_ext.py,sha256=EgHJ5W8EE-ZTN9V607mVzohyKgZE8Mc2jCi-DF8RAZU,2616
36
- sourcecode/detectors/nodejs.py,sha256=IPcyKvOMjEFBYpBpSsgN1B0M-uEl9lcn6DJt6XzJ4Tc,6393
38
+ sourcecode/detectors/nodejs.py,sha256=BOGaghCC0nAZRvsib9Tptvr3-dJOzASnDPicXHez80c,10550
37
39
  sourcecode/detectors/parsers.py,sha256=ugPg8yNUf0Ai1gA7Fnn6wAkYGFjTxRodSP3IeViYJJ4,2290
38
40
  sourcecode/detectors/php.py,sha256=W_AQD0WMVDdWHa9h_ilX6W8XSpz0X4ctpMK2WXfXf1I,1887
39
41
  sourcecode/detectors/project.py,sha256=YCynNC8drJutPMiDGPutlYaL49IO8M-1Ms_RwVY09ss,6221
@@ -43,7 +45,7 @@ sourcecode/detectors/rust.py,sha256=Tij1vz8BFZ332GEvVkL6vyMli2OMHJfHyDAppWfe66c,
43
45
  sourcecode/detectors/systems.py,sha256=nYaKbGDFu0EOXFcd_1doWFT3tTUdkbxc2DjHUF5TcqQ,1627
44
46
  sourcecode/detectors/terraform.py,sha256=cxORPR_zVLOJpHlh4e9JnFpkQsn_UnqMMom5yG65hZ4,1693
45
47
  sourcecode/detectors/tooling.py,sha256=hIvop80No22pqyGVJ32NKliSdjkHRePQkIRroqG01bY,1875
46
- sourcecode-0.25.0.dist-info/METADATA,sha256=0oCmRp0cVAVI5vdasc0ONKAq45mBtUoLmtPSFs7P1RE,30326
47
- sourcecode-0.25.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
48
- sourcecode-0.25.0.dist-info/entry_points.txt,sha256=voDtdlGU2-jR3wCZpQoOls_0eUKaDVCKIfajGHFDATs,50
49
- sourcecode-0.25.0.dist-info/RECORD,,
48
+ sourcecode-0.26.0.dist-info/METADATA,sha256=5OCyy0oIIzulSBOou5ZDGiNrcggn_VT3FNxly1gu33s,30326
49
+ sourcecode-0.26.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
50
+ sourcecode-0.26.0.dist-info/entry_points.txt,sha256=voDtdlGU2-jR3wCZpQoOls_0eUKaDVCKIfajGHFDATs,50
51
+ sourcecode-0.26.0.dist-info/RECORD,,