sourcecode 2.2.0__py3-none-any.whl → 2.4.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.
@@ -31,6 +31,18 @@ _CORE_DETECTION_MODULES = {"scanner", "detectors", "classifier", "workspace"}
31
31
  # headline to a qualified, consistent phrasing instead of overclaiming.
32
32
  _MIN_REST_ENDPOINTS_FOR_LABEL = 5
33
33
 
34
+ # BUG (neo4j field test): endpoint COUNT alone cannot separate "an API server"
35
+ # from "a large engine/platform that also exposes a few HTTP endpoints". A graph
36
+ # database with ~14 JAX-RS endpoints across 5,600+ source files is not a "REST
37
+ # API" — yet it clears the count threshold above and gets mis-headlined, the exact
38
+ # thing a reader sees in the first line. Add a proportionality guard: on a large
39
+ # JVM codebase, only keep the "REST API" headline when the HTTP surface is
40
+ # architecturally significant relative to code size (at least one endpoint per
41
+ # _API_DOMINANCE_FILES_PER_ENDPOINT source files). Below that density the web
42
+ # layer is a minor component, not the architecture.
43
+ _LARGE_JVM_SOURCE_FILES = 400
44
+ _API_DOMINANCE_FILES_PER_ENDPOINT = 100
45
+
34
46
  _OPTIONAL_LABEL_MAP: dict[str, str] = {
35
47
  "DependencyAnalyzer": "dependencias",
36
48
  "GraphAnalyzer": "grafo de módulos",
@@ -207,6 +219,21 @@ class ArchitectureSummarizer:
207
219
  f"(HTTP framework present; only {total} endpoint{plural} "
208
220
  f"detected — see `endpoints`)."
209
221
  )
222
+ # Proportionality guard: a large JVM codebase whose HTTP surface is
223
+ # a rounding error is an engine/platform exposing an API, not an
224
+ # "API" project. Endpoint count already cleared the threshold above,
225
+ # so lead with what the system IS instead of overclaiming "REST API".
226
+ n_src = self._jvm_source_file_count(sm)
227
+ if (
228
+ n_src >= _LARGE_JVM_SOURCE_FILES
229
+ and total * _API_DOMINANCE_FILES_PER_ENDPOINT < n_src
230
+ ):
231
+ plural = "s" if total != 1 else ""
232
+ return (
233
+ f"{stack_label} multi-module codebase{fw_str} — large "
234
+ f"system ({n_src} source files) with a minor HTTP surface "
235
+ f"({total} endpoint{plural}); not API-first. See `endpoints`."
236
+ )
210
237
  return f"{stack_label} {runtime.lower()}{fw_str}."
211
238
  return f"{stack_label} project{fw_str}."
212
239
 
@@ -228,6 +255,20 @@ class ArchitectureSummarizer:
228
255
  self._endpoint_support_cache = (total, high)
229
256
  return self._endpoint_support_cache
230
257
 
258
+ def _jvm_source_file_count(self, sm: SourceMap) -> int:
259
+ """Count JVM source files in the scanned tree (excludes tooling paths).
260
+ Used by the proportionality guard to tell a large engine/platform apart
261
+ from an API-first project. A truncated tree only undercounts, which makes
262
+ the guard more conservative (less likely to degrade a real API)."""
263
+ exts = (".java", ".kt", ".kts", ".scala")
264
+ try:
265
+ return sum(
266
+ 1 for p in flatten_file_tree(sm.file_tree)
267
+ if p.endswith(exts) and not self._is_tooling_path(p)
268
+ )
269
+ except Exception:
270
+ return 0
271
+
231
272
  def _qualify_web_pattern(self, arch: Any, arch_line: str, sm: SourceMap) -> str:
232
273
  """BUG #1 (Jenkins field test): the "mvc" pattern is a directory-name
233
274
  heuristic (dirs matching controller/model/view keywords). On a Java/Kotlin
sourcecode/classifier.py CHANGED
@@ -34,6 +34,30 @@ _API_FRAMEWORKS = {
34
34
  _WEB_FRAMEWORKS = {"Next.js", "React", "Vue", "Svelte", "Vite", "Flutter", "Phoenix", "Angular"}
35
35
  _CLI_FRAMEWORKS = {"Typer", "Cobra", "Clap"}
36
36
  _API_STACKS = {"python", "go", "java", "php", "ruby", "dotnet", "kotlin", "scala"}
37
+
38
+ # Center-of-gravity gate for the "api" decision (neo4j field test).
39
+ # The historical rule was pure PRESENCE: any API framework on the classpath →
40
+ # project_type="api". That mislabels a large engine/platform that merely exposes a
41
+ # small HTTP surface. Two evidence paths defeat the existing adapter-localization
42
+ # guard because they are repo-wide (no locatable minority module):
43
+ # * a jakarta.ws.rs / javax.ws.rs-api coordinate pinned in a BOM/parent pom's
44
+ # <dependencyManagement> (a version pin, not usage), and
45
+ # * a source-annotation "Jakarta EE" marker synthesized from @Path resources
46
+ # (detected_via carries no file path).
47
+ # So on a repo large enough to HAVE a distinct center of gravity, require the API
48
+ # surface to occupy a real share of the code (or the dominant module) before we
49
+ # headline the whole system as an API. Small/single-module repos keep presence-based
50
+ # behavior (a real API framework there defines the project — e.g. the Eureka field
51
+ # test). JVM-only: this is where the false positive lives; other stacks are untouched.
52
+ _JVM_STACKS = {"java", "kotlin", "scala", "groovy"}
53
+ _JVM_CODE_EXTS = (".java", ".kt", ".kts", ".scala", ".groovy")
54
+ _API_DOMINANCE_MIN_FILES = 300
55
+ _API_PRIMARY_SHARE = 0.15
56
+ _API_SURFACE_ENTRY_KINDS = frozenset({
57
+ "jax_rs_controller", "jax_rs_provider",
58
+ "rest_controller", "mvc_controller", "controller", "web", "server",
59
+ })
60
+
37
61
  ConfidenceLevel = Literal["high", "medium", "low"]
38
62
 
39
63
 
@@ -143,7 +167,18 @@ class TypeClassifier:
143
167
  return "web_mvc"
144
168
 
145
169
  if framework_names & _API_FRAMEWORKS:
146
- return "api"
170
+ if not (stack_names & _JVM_STACKS) or self._api_is_center_of_gravity(
171
+ file_tree, stacks, entry_points
172
+ ):
173
+ return "api"
174
+ # A JVM API framework is present but is NOT the repo's center of gravity.
175
+ # It survived adapter-localization only because its evidence is repo-wide
176
+ # (a BOM/parent-pom version pin, or a source-annotation marker with no
177
+ # locatable minority module). Do not headline the whole system as an API —
178
+ # the framework stays in stacks[].frameworks as a secondary capability and
179
+ # the primary type falls back to the structural center of gravity.
180
+ if self._is_multi_module(file_tree):
181
+ return "library"
147
182
 
148
183
  # All app-defining frameworks were localized to optional adapter submodules
149
184
  # (multi-module library with per-framework integrations) — report library,
@@ -263,6 +298,64 @@ class TypeClassifier:
263
298
  localized.add(fw_name)
264
299
  return localized
265
300
 
301
+ def _api_is_center_of_gravity(
302
+ self,
303
+ file_tree: dict[str, Any],
304
+ stacks: Sequence[StackDetection],
305
+ entry_points: Sequence[EntryPoint],
306
+ ) -> bool:
307
+ """True when an API/web framework reflects the repo's center of gravity, not
308
+ merely its presence. Small or single-module JVM repos: a present API framework
309
+ defines the project (keeps the historical behavior). Large multi-module repos:
310
+ the HTTP surface must live in the dominant module or occupy at least
311
+ ``_API_PRIMARY_SHARE`` of the JVM source — otherwise it is a minor capability
312
+ of an engine/platform/library, not the primary type."""
313
+ counts: dict[str, int] = {}
314
+ for p in flatten_file_tree(file_tree):
315
+ if p.endswith(_JVM_CODE_EXTS):
316
+ mod = self._module_of(p)
317
+ counts[mod] = counts.get(mod, 0) + 1
318
+ counts.pop("", None)
319
+ total = sum(counts.values())
320
+ # Too small, or a single module, to reason about a center of gravity.
321
+ if total < _API_DOMINANCE_MIN_FILES or len(counts) < 2:
322
+ return True
323
+ dominant = max(counts, key=lambda m: counts[m])
324
+ api_modules = self._api_surface_modules(stacks, entry_points)
325
+ if not api_modules:
326
+ # API framework known only repo-wide (BOM version pin / annotation marker)
327
+ # with no source-located HTTP surface in a large multi-module system.
328
+ return False
329
+ if dominant in api_modules:
330
+ return True
331
+ api_files = sum(counts.get(m, 0) for m in api_modules)
332
+ return (api_files / total) >= _API_PRIMARY_SHARE
333
+
334
+ def _api_surface_modules(
335
+ self,
336
+ stacks: Sequence[StackDetection],
337
+ entry_points: Sequence[EntryPoint],
338
+ ) -> set[str]:
339
+ """Modules that actually host an HTTP/REST surface: derived from API entry
340
+ points (controllers / JAX-RS resources) and from API-framework file evidence.
341
+ Repo-wide manifest evidence (no path) contributes no module, by design."""
342
+ modules: set[str] = set()
343
+ for ep in entry_points:
344
+ if getattr(ep, "kind", None) in _API_SURFACE_ENTRY_KINDS and ep.path:
345
+ modules.add(self._module_of(ep.path))
346
+ for stack in stacks:
347
+ for fw in stack.frameworks:
348
+ if fw.name not in _API_FRAMEWORKS:
349
+ continue
350
+ for ev in fw.detected_via:
351
+ if ev.startswith("manifest:"):
352
+ continue
353
+ m = self._EVIDENCE_PATH_RE.search(ev)
354
+ if m:
355
+ modules.add(self._module_of(m.group(1).strip()))
356
+ modules.discard("")
357
+ return modules
358
+
266
359
  def _is_fullstack(self, stacks: Sequence[StackDetection]) -> bool:
267
360
  has_web = False
268
361
  has_api = False
sourcecode/cli.py CHANGED
@@ -246,6 +246,8 @@ _SUBCOMMANDS: frozenset[str] = frozenset(
246
246
  "rename-class",
247
247
  # Large file semantic chunking (BLOCKER-B)
248
248
  "chunk-file",
249
+ # Experimental evidence-based archetype (parallel to legacy)
250
+ "archetype",
249
251
  }
250
252
  )
251
253
 
@@ -5339,6 +5341,7 @@ def explain_cmd(
5339
5341
  from sourcecode.context_graph import ContextGraph
5340
5342
  from sourcecode.spring_model import SpringSemanticModel
5341
5343
  from sourcecode.explain import explain_class
5344
+ from sourcecode import context_cache as _ctxcache
5342
5345
 
5343
5346
  if not class_name.strip():
5344
5347
  _emit_error_json(
@@ -5381,14 +5384,27 @@ def explain_cmd(
5381
5384
  )
5382
5385
  raise typer.Exit(code=1)
5383
5386
 
5387
+ # AI Context Cache — operates at the *knowledge* level: it caches the
5388
+ # reusable Canonical IR (the expensive Java parse), keyed only by knowledge
5389
+ # state, not by command. explain then derives its answer cheaply from that
5390
+ # shared CIR. The same cached CIR is reused for free by any other command.
5391
+ # Provider-agnostic and best-effort: any fault degrades to a fresh build.
5384
5392
  _prog = Progress()
5385
5393
  _prog.start(f"explaining {class_name} ({len(file_list)} files)")
5394
+ _cc_look = None
5386
5395
  try:
5387
- cir = ContextGraph.build(file_list, target).cir
5396
+ try:
5397
+ cir, _cc_look = _ctxcache.get_or_build_cir(
5398
+ _resolve_repo_root(target), target, file_list
5399
+ )
5400
+ except Exception:
5401
+ cir = ContextGraph.build(file_list, target).cir # fallback: never break explain
5388
5402
  model = SpringSemanticModel.build(cir)
5389
5403
  explanation = explain_class(class_name, cir, model)
5390
5404
  finally:
5391
5405
  _prog.finish()
5406
+ if _cc_look is not None:
5407
+ typer.echo(_cc_look.render(), err=True)
5392
5408
 
5393
5409
  if format == "json":
5394
5410
  output = _json.dumps(explanation.to_dict(), indent=2, ensure_ascii=False)
@@ -6422,6 +6438,77 @@ def config_cmd() -> None:
6422
6438
 
6423
6439
  # ── cold-start (RIS bootstrap for external MCP and agents) ───────────────────
6424
6440
 
6441
+ @app.command("archetype")
6442
+ def archetype_cmd(
6443
+ path: Path = typer.Argument(Path("."), help="Repository path (default: current directory)"),
6444
+ output_path: Optional[Path] = typer.Option(
6445
+ None, "--output", "-o", help="Write output to a file instead of stdout."
6446
+ ),
6447
+ ) -> None:
6448
+ """[EXPERIMENTAL] Evidence-based architectural archetype (parallel to legacy).
6449
+
6450
+ Emits a 4-dimension archetype analysis — software_archetype, architectural_style,
6451
+ deployment_shape, primary_interface — each with scored candidates, the evidence
6452
+ (weighted by code mass), a confidence level, and a human explanation.
6453
+
6454
+ This runs ALONGSIDE the legacy architecture pattern and does NOT change any
6455
+ report wording. It exists to validate the new model against the old one across
6456
+ many repositories before any cutover.
6457
+ """
6458
+ import json as _json
6459
+ from sourcecode.adaptive_scanner import AdaptiveScanner
6460
+ from sourcecode.repo_classifier import RepoClassifier
6461
+ from sourcecode.detectors import ProjectDetector, build_default_detectors
6462
+ from sourcecode.schema import SourceMap
6463
+ from sourcecode.tree_utils import flatten_file_tree
6464
+ from sourcecode.archetype import ArchetypeClassifier
6465
+
6466
+ target = Path(path).resolve()
6467
+ topology = RepoClassifier().classify(target)
6468
+ scanner = AdaptiveScanner(target, topology=topology, base_depth=12)
6469
+ file_tree = scanner.scan_tree()
6470
+ manifests = scanner.find_manifests()
6471
+
6472
+ detector = ProjectDetector(build_default_detectors())
6473
+ stacks, entry_points, _ = detector.detect(target, file_tree, manifests)
6474
+ stacks, project_type = detector.classify_results(file_tree, stacks, entry_points)
6475
+
6476
+ sm = SourceMap(
6477
+ file_tree=file_tree, stacks=stacks,
6478
+ project_type=project_type, entry_points=entry_points,
6479
+ )
6480
+ sm.file_paths = [p.replace("\\", "/") for p in flatten_file_tree(file_tree)]
6481
+ _java = next((s for s in stacks if s.stack == "java"), None)
6482
+ if _java is not None:
6483
+ sm.packaging = getattr(_java, "packaging", None)
6484
+
6485
+ # Module graph as a first-class Evidence Provider (ADR-0003 step 2). Built
6486
+ # here so archetype can consume topological evidence. Bounded + best-effort:
6487
+ # on very large trees or any failure it degrades to None (name-mass-only),
6488
+ # which is exactly the pre-provider behaviour — no regression.
6489
+ module_graph = None
6490
+ try:
6491
+ from sourcecode.graph_analyzer import GraphAnalyzer
6492
+ module_graph = GraphAnalyzer(max_nodes=1200, max_edges=4000).analyze(
6493
+ target, file_tree, detail="full", entry_points=entry_points,
6494
+ )
6495
+ if module_graph is not None:
6496
+ sm.module_graph = module_graph
6497
+ sm.module_graph_summary = module_graph.summary
6498
+ except Exception:
6499
+ module_graph = None
6500
+
6501
+ analysis = ArchetypeClassifier().analyze(sm, root=target, module_graph=module_graph)
6502
+ result = analysis.to_dict()
6503
+ result["legacy_project_type"] = project_type # side-by-side with legacy for validation
6504
+ out = _json.dumps(result, indent=2, ensure_ascii=False)
6505
+ if output_path:
6506
+ _safe_write_file(output_path, out)
6507
+ typer.echo(f"Archetype analysis written to {output_path}")
6508
+ else:
6509
+ typer.echo(out)
6510
+
6511
+
6425
6512
  @app.command("cold-start")
6426
6513
  def cold_start_cmd(
6427
6514
  path: Path = typer.Argument(Path("."), help="Repository path (default: current directory)"),
@@ -7126,6 +7213,53 @@ def cache_freshness_cmd(
7126
7213
  typer.echo(f"RIS updated: {result.get('ris_last_updated_at') or 'never'}")
7127
7214
 
7128
7215
 
7216
+ @cache_app.command("context-stats")
7217
+ def cache_context_stats_cmd(
7218
+ path: Path = typer.Argument(Path("."), help="Repository path (default: current directory)"),
7219
+ json_output: bool = typer.Option(False, "--json", help="Output as JSON."),
7220
+ ) -> None:
7221
+ """Show AI Context Cache statistics (structured-knowledge reuse metrics).
7222
+
7223
+ This is the provider-agnostic context cache — distinct from the snapshot
7224
+ cache reported by `ask cache status`. It measures reuse of ASK's structured
7225
+ context (entities/evidence/observations/summaries), never prompts or LLM output.
7226
+ """
7227
+ from sourcecode import context_cache as _ctxcache
7228
+ target = _resolve_repo_root(Path(path))
7229
+ stats = _ctxcache.stats_for_repo(target)
7230
+ if json_output:
7231
+ import json as _j
7232
+ typer.echo(_j.dumps(stats, indent=2, ensure_ascii=False))
7233
+ else:
7234
+ typer.echo(f"Cache dir: {stats['cache_dir']}")
7235
+ typer.echo(f"Enabled: {stats['enabled']}")
7236
+ typer.echo(f"Contexts: {stats['contexts']}")
7237
+ typer.echo(f"Hits: {stats['hits']}")
7238
+ typer.echo(f"Misses: {stats['misses']}")
7239
+ typer.echo(f"Invalidations: {stats['invalidations']}")
7240
+ typer.echo(f"Stores: {stats['stores']}")
7241
+ typer.echo(f"Hit ratio: {stats['hit_ratio']}")
7242
+ typer.echo(f"Avg lookup: {stats['avg_lookup_ms']} ms")
7243
+ typer.echo(f"Avg build: {stats['avg_build_ms']} ms")
7244
+ typer.echo(f"Bytes stored: {stats['bytes_stored']}")
7245
+ typer.echo(f"KL version: {stats['kl_schema_version']} producer={stats['producer_version']}")
7246
+
7247
+
7248
+ @cache_app.command("context-clear")
7249
+ def cache_context_clear_cmd(
7250
+ path: Path = typer.Argument(Path("."), help="Repository path (default: current directory)"),
7251
+ yes: bool = typer.Option(False, "--yes", "-y", help="Skip confirmation prompt."),
7252
+ ) -> None:
7253
+ """Delete the AI Context Cache for a repository (structured context + stats)."""
7254
+ from sourcecode import context_cache as _ctxcache
7255
+ target = _resolve_repo_root(Path(path))
7256
+ if not yes and sys.stdin.isatty():
7257
+ import click as _click
7258
+ _click.confirm(f"Delete AI context cache for {target}?", abort=True, err=True)
7259
+ removed = _ctxcache.clear_for_repo(target)
7260
+ typer.echo(f"Removed {removed} context file(s).", err=True)
7261
+
7262
+
7129
7263
  # ── Entry point ───────────────────────────────────────────────────────────────
7130
7264
 
7131
7265
  def _stderr_is_interactive() -> bool: