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 +1 -1
- sourcecode/architecture_summary.py +40 -2
- sourcecode/cli.py +120 -4
- sourcecode/detectors/nodejs.py +131 -41
- sourcecode/graph_analyzer.py +50 -1
- sourcecode/prepare_context.py +73 -4
- sourcecode/relevance_scorer.py +182 -0
- sourcecode/runtime_classifier.py +331 -0
- sourcecode/schema.py +35 -2
- sourcecode/serializer.py +32 -2
- sourcecode/summarizer.py +75 -1
- sourcecode/telemetry/__init__.py +111 -0
- sourcecode/telemetry/config.py +67 -0
- sourcecode/telemetry/consent.py +63 -0
- sourcecode/telemetry/events.py +78 -0
- sourcecode/telemetry/filters.py +140 -0
- sourcecode/telemetry/transport.py +52 -0
- sourcecode-0.27.0.dist-info/METADATA +565 -0
- {sourcecode-0.25.0.dist-info → sourcecode-0.27.0.dist-info}/RECORD +22 -13
- sourcecode-0.27.0.dist-info/licenses/LICENSE +186 -0
- sourcecode-0.25.0.dist-info/METADATA +0 -724
- {sourcecode-0.25.0.dist-info → sourcecode-0.27.0.dist-info}/WHEEL +0 -0
- {sourcecode-0.25.0.dist-info → sourcecode-0.27.0.dist-info}/entry_points.txt +0 -0
sourcecode/__init__.py
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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="
|
|
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
|
-
"""
|
|
171
|
-
#
|
|
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.
|
|
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")
|
sourcecode/detectors/nodejs.py
CHANGED
|
@@ -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
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
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
|
-
|
|
165
|
+
bin_paths.append(bin_field.strip())
|
|
144
166
|
elif isinstance(bin_field, dict):
|
|
145
|
-
|
|
146
|
-
str(
|
|
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
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
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
|
-
|
|
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
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
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"
|
sourcecode/graph_analyzer.py
CHANGED
|
@@ -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
|
-
|
|
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
|
sourcecode/prepare_context.py
CHANGED
|
@@ -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(
|
|
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
|
-
|
|
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
|
|
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(
|