sourcecode 1.35.32__py3-none-any.whl → 1.35.34__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 — Deterministic codebase context maps for AI coding agents."""
2
2
 
3
- __version__ = "1.35.32"
3
+ __version__ = "1.35.34"
@@ -237,7 +237,7 @@ class ArchitectureAnalyzer:
237
237
  return ArchitectureAnalysis(
238
238
  requested=True,
239
239
  pattern="unknown",
240
- limitations=["Arquitectura no inferida: proyecto sin archivos de codigo suficientes"],
240
+ limitations=["Architecture not inferred: insufficient source files in project"],
241
241
  evidence=[{"type": "none", "paths": [], "reason": "insufficient source files", "confidence": "high"}],
242
242
  tentative=False,
243
243
  )
sourcecode/classifier.py CHANGED
@@ -19,6 +19,10 @@ _API_FRAMEWORKS = {
19
19
  "Actix Web",
20
20
  "Rocket",
21
21
  "Spring Boot",
22
+ "Spring MVC",
23
+ "Spring WebFlux",
24
+ "Micronaut",
25
+ "Vert.x",
22
26
  "Quarkus",
23
27
  "Jakarta EE", # JAX-RS / Jakarta REST — pure JAX-RS projects must not fall to "unknown"
24
28
  "Laravel",
sourcecode/cli.py CHANGED
@@ -1227,159 +1227,164 @@ def main(
1227
1227
  except Exception:
1228
1228
  pass
1229
1229
 
1230
- if _git_sha and _git_root_str:
1231
- _excl_key = (
1232
- ",".join(sorted(e.strip() for e in exclude.split(",") if e.strip()))
1233
- if exclude else ""
1234
- )
1230
+ _excl_key = (
1231
+ ",".join(sorted(e.strip() for e in exclude.split(",") if e.strip()))
1232
+ if exclude else ""
1233
+ )
1235
1234
 
1236
- # ── Core (analysis) flags: affect which analyzers run + scan config ──
1237
- # Use effective_depth (not raw depth) so Java auto-adjustment is captured.
1238
- # acv = ANALYZER_CACHE_VERSION — bumped only when analysis logic/schema
1239
- # changes, NOT on every package release. Prevents patch-bump cache wipes.
1240
- _core_flags_str = (
1241
- f"acv={_cache_mod.ANALYZER_CACHE_VERSION},"
1242
- f"dep={dependencies},gm={graph_modules},"
1243
- f"docs={docs},fm={full_metrics},sem={semantics},"
1244
- f"arch={architecture},gc={git_context},em={env_map},"
1245
- f"cn={code_notes},"
1246
- f"ex={_excl_key},depth={effective_depth}"
1247
- )
1248
- _core_h = _hashlib.sha256(_core_flags_str.encode()).hexdigest()[:8]
1235
+ # ── Core (analysis) flags: affect which analyzers run + scan config ──
1236
+ # Use effective_depth (not raw depth) so Java auto-adjustment is captured.
1237
+ # acv = ANALYZER_CACHE_VERSION — bumped only when analysis logic/schema
1238
+ # changes, NOT on every package release. Prevents patch-bump cache wipes.
1239
+ _core_flags_str = (
1240
+ f"acv={_cache_mod.ANALYZER_CACHE_VERSION},"
1241
+ f"dep={dependencies},gm={graph_modules},"
1242
+ f"docs={docs},fm={full_metrics},sem={semantics},"
1243
+ f"arch={architecture},gc={git_context},em={env_map},"
1244
+ f"cn={code_notes},"
1245
+ f"ex={_excl_key},depth={effective_depth}"
1246
+ )
1247
+ _core_h = _hashlib.sha256(_core_flags_str.encode()).hexdigest()[:8]
1248
+ if _git_sha and _git_root_str:
1249
1249
  _core_key = f"{_git_sha}-{_core_h}"
1250
-
1251
- # ── View flags: output presentation only (no re-analysis needed) ──
1252
- _view_flags_str = (
1253
- f"c={compact},ag={agent},mode={mode},fmt={format},full={full},"
1254
- f"co={changed_only},tree={tree},nt={no_tree},"
1255
- f"rb={rank_by},sym={symbol},ep={entrypoints_only},"
1256
- f"nr={no_redact},gd={graph_detail},dd={docs_depth},"
1257
- f"mn={max_nodes},ge={graph_edges},mi={max_importers},"
1258
- f"eg={emit_graph}"
1259
- )
1260
- _view_h = _hashlib.sha256(_view_flags_str.encode()).hexdigest()[:8]
1261
-
1262
- # ── Lookup ──────────────────────────────────────────────────────
1263
- # Step 1: try L1 to obtain the core_hash needed for L2 key
1264
- _l1_result = _cache_mod.read_core(target, _core_key)
1265
-
1266
- # P1-A: --env-map misses L1 when base (em=False) exists.
1267
- # Try the base key so env analysis can be injected lazily (<1 s)
1268
- # instead of triggering a 17 s full rescan.
1269
- _l1_needs_env_inject = False
1270
- if _l1_result is None and env_map:
1271
- _base_flags = _core_flags_str.replace(",em=True,", ",em=False,")
1272
- _base_h8 = _hashlib.sha256(_base_flags.encode()).hexdigest()[:8]
1273
- _base_key = f"{_git_sha}-{_base_h8}"
1274
- _base_result = _cache_mod.read_core(target, _base_key)
1275
- if _base_result is not None:
1276
- _l1_result = _base_result
1277
- _l1_needs_env_inject = True
1278
-
1279
- if _l1_result is not None:
1280
- _core_dict_l1, _core_hash = _l1_result
1281
- _view_key = f"{_core_hash}-{_view_h}"
1282
-
1283
- # Step 2: try L2 (exact view match).
1284
- # Skip L2 for --changed-only: the stored view is a previous
1285
- # diff snapshot that is stale for the current diff.
1286
- if not changed_only:
1287
- _cache_hit_content = _cache_mod.read_view(target, _view_key)
1288
-
1289
- # Step 3: L1 hit but L2 miss → rebuild view from core dict
1290
- if _cache_hit_content is None:
1291
- try:
1292
- from sourcecode.serializer import build_view_from_core as _bvfc
1293
- _rebuilt = _bvfc(
1294
- _core_dict_l1,
1295
- compact=compact,
1296
- agent=agent,
1297
- full=full,
1298
- no_tree=no_tree,
1299
- tree=tree,
1300
- )
1301
- # P1-A: inject env analysis when base L1 (em=False) was used.
1302
- # EnvAnalyzer walks only env/config files — typically <1 s.
1303
- if _rebuilt is not None and _l1_needs_env_inject and compact:
1304
- try:
1305
- from sourcecode.env_analyzer import EnvAnalyzer as _EnvA_p1a
1306
- _env_r_p1a, _env_s_p1a = _EnvA_p1a().analyze(target, {})
1307
- if _env_s_p1a and (getattr(_env_s_p1a, "total", 0) or _env_r_p1a):
1308
- _es_p1a: dict = {
1309
- "total": getattr(_env_s_p1a, "total", 0),
1310
- "required": getattr(_env_s_p1a, "required_count", 0),
1311
- }
1312
- _cats = getattr(_env_s_p1a, "categories", None)
1313
- if _cats:
1314
- _es_p1a["categories"] = _cats
1315
- _rebuilt = dict(_rebuilt)
1316
- _rebuilt["env_summary"] = _es_p1a
1317
- if _env_r_p1a:
1318
- _sorted_er = sorted(
1319
- _env_r_p1a,
1320
- key=lambda e: (
1321
- not getattr(e, "required", False),
1322
- getattr(e, "key", ""),
1323
- ),
1324
- )
1325
- _rebuilt["env_map"] = [
1326
- {
1327
- "key": getattr(e, "key", ""),
1328
- **({"required": True} if getattr(e, "required", False) else {}),
1329
- **({"category": getattr(e, "category", None)} if getattr(e, "category", None) else {}),
1330
- }
1331
- for e in _sorted_er[:15]
1332
- ]
1333
- except Exception:
1334
- pass # env inject failed continue without env data
1335
- if _rebuilt is not None:
1336
- # Apply redaction
1337
- if not no_redact:
1338
- from sourcecode.redactor import redact_dict as _red_l1
1339
- _rebuilt = _red_l1(_rebuilt)
1340
- # Apply output budget
1341
- if agent:
1342
- from sourcecode.output_budget import (
1343
- trim_to_budget as _trim_l1,
1344
- BUDGET_AGENT,
1345
- )
1346
- _rebuilt = _trim_l1(_rebuilt, BUDGET_AGENT, label="agent")
1347
- elif compact:
1348
- from sourcecode.output_budget import (
1349
- trim_to_budget as _trim_l1c,
1350
- BUDGET_COMPACT,
1351
- )
1352
- _rebuilt = _trim_l1c(_rebuilt, BUDGET_COMPACT, label="compact")
1353
- # Serialize
1354
- if format == "yaml":
1355
- from io import StringIO as _SIO_L1
1356
- from ruamel.yaml import YAML as _YAML_L1
1357
- _yl1 = _YAML_L1()
1358
- _yl1.default_flow_style = False
1359
- _yl1.representer.add_representer(
1360
- type(None),
1361
- lambda d, v: d.represent_scalar(
1362
- "tag:yaml.org,2002:null", "null"
1363
- ),
1364
- )
1365
- _sl1 = _SIO_L1()
1366
- _yl1.dump(_rebuilt, _sl1)
1367
- _cache_hit_content = _sl1.getvalue()
1368
- else:
1369
- import json as _json_l1
1370
- _cache_hit_content = _json_l1.dumps(
1371
- _rebuilt, indent=2, ensure_ascii=False
1372
- )
1373
- # Cache rebuilt view in L2 (skip for --changed-only: stale diff)
1374
- if _cache_hit_content and not changed_only:
1375
- _cache_mod.write_view(
1376
- target,
1377
- _view_key,
1378
- _cache_hit_content,
1379
- fmt=format,
1380
- )
1381
- except Exception:
1382
- _cache_hit_content = None # rebuild failed → full analysis
1250
+ else:
1251
+ # No git history (untracked/no-commit repo) — stable synthetic key
1252
+ # scoped per repo path via cache_dir(); invalidated by --no-cache or cache clear.
1253
+ _core_key = f"nogit-{_core_h}"
1254
+
1255
+ # ── View flags: output presentation only (no re-analysis needed) ──
1256
+ _view_flags_str = (
1257
+ f"c={compact},ag={agent},mode={mode},fmt={format},full={full},"
1258
+ f"co={changed_only},tree={tree},nt={no_tree},"
1259
+ f"rb={rank_by},sym={symbol},ep={entrypoints_only},"
1260
+ f"nr={no_redact},gd={graph_detail},dd={docs_depth},"
1261
+ f"mn={max_nodes},ge={graph_edges},mi={max_importers},"
1262
+ f"eg={emit_graph}"
1263
+ )
1264
+ _view_h = _hashlib.sha256(_view_flags_str.encode()).hexdigest()[:8]
1265
+
1266
+ # ── Lookup ──────────────────────────────────────────────────────
1267
+ # Step 1: try L1 to obtain the core_hash needed for L2 key
1268
+ _l1_result = _cache_mod.read_core(target, _core_key)
1269
+
1270
+ # P1-A: --env-map misses L1 when base (em=False) exists.
1271
+ # Try the base key so env analysis can be injected lazily (<1 s)
1272
+ # instead of triggering a 17 s full rescan.
1273
+ _l1_needs_env_inject = False
1274
+ if _l1_result is None and env_map:
1275
+ _base_flags = _core_flags_str.replace(",em=True,", ",em=False,")
1276
+ _base_h8 = _hashlib.sha256(_base_flags.encode()).hexdigest()[:8]
1277
+ _sha_prefix = _git_sha if _git_sha else "nogit"
1278
+ _base_key = f"{_sha_prefix}-{_base_h8}"
1279
+ _base_result = _cache_mod.read_core(target, _base_key)
1280
+ if _base_result is not None:
1281
+ _l1_result = _base_result
1282
+ _l1_needs_env_inject = True
1283
+
1284
+ if _l1_result is not None:
1285
+ _core_dict_l1, _core_hash = _l1_result
1286
+ _view_key = f"{_core_hash}-{_view_h}"
1287
+
1288
+ # Step 2: try L2 (exact view match).
1289
+ # Skip L2 for --changed-only: the stored view is a previous
1290
+ # diff snapshot that is stale for the current diff.
1291
+ if not changed_only:
1292
+ _cache_hit_content = _cache_mod.read_view(target, _view_key)
1293
+
1294
+ # Step 3: L1 hit but L2 miss → rebuild view from core dict
1295
+ if _cache_hit_content is None:
1296
+ try:
1297
+ from sourcecode.serializer import build_view_from_core as _bvfc
1298
+ _rebuilt = _bvfc(
1299
+ _core_dict_l1,
1300
+ compact=compact,
1301
+ agent=agent,
1302
+ full=full,
1303
+ no_tree=no_tree,
1304
+ tree=tree,
1305
+ )
1306
+ # P1-A: inject env analysis when base L1 (em=False) was used.
1307
+ # EnvAnalyzer walks only env/config files typically <1 s.
1308
+ if _rebuilt is not None and _l1_needs_env_inject and compact:
1309
+ try:
1310
+ from sourcecode.env_analyzer import EnvAnalyzer as _EnvA_p1a
1311
+ _env_r_p1a, _env_s_p1a = _EnvA_p1a().analyze(target, {})
1312
+ if _env_s_p1a and (getattr(_env_s_p1a, "total", 0) or _env_r_p1a):
1313
+ _es_p1a: dict = {
1314
+ "total": getattr(_env_s_p1a, "total", 0),
1315
+ "required": getattr(_env_s_p1a, "required_count", 0),
1316
+ }
1317
+ _cats = getattr(_env_s_p1a, "categories", None)
1318
+ if _cats:
1319
+ _es_p1a["categories"] = _cats
1320
+ _rebuilt = dict(_rebuilt)
1321
+ _rebuilt["env_summary"] = _es_p1a
1322
+ if _env_r_p1a:
1323
+ _sorted_er = sorted(
1324
+ _env_r_p1a,
1325
+ key=lambda e: (
1326
+ not getattr(e, "required", False),
1327
+ getattr(e, "key", ""),
1328
+ ),
1329
+ )
1330
+ _rebuilt["env_map"] = [
1331
+ {
1332
+ "key": getattr(e, "key", ""),
1333
+ **({"required": True} if getattr(e, "required", False) else {}),
1334
+ **({"category": getattr(e, "category", None)} if getattr(e, "category", None) else {}),
1335
+ }
1336
+ for e in _sorted_er[:15]
1337
+ ]
1338
+ except Exception:
1339
+ pass # env inject failed — continue without env data
1340
+ if _rebuilt is not None:
1341
+ # Apply redaction
1342
+ if not no_redact:
1343
+ from sourcecode.redactor import redact_dict as _red_l1
1344
+ _rebuilt = _red_l1(_rebuilt)
1345
+ # Apply output budget
1346
+ if agent:
1347
+ from sourcecode.output_budget import (
1348
+ trim_to_budget as _trim_l1,
1349
+ BUDGET_AGENT,
1350
+ )
1351
+ _rebuilt = _trim_l1(_rebuilt, BUDGET_AGENT, label="agent")
1352
+ elif compact:
1353
+ from sourcecode.output_budget import (
1354
+ trim_to_budget as _trim_l1c,
1355
+ BUDGET_COMPACT,
1356
+ )
1357
+ _rebuilt = _trim_l1c(_rebuilt, BUDGET_COMPACT, label="compact")
1358
+ # Serialize
1359
+ if format == "yaml":
1360
+ from io import StringIO as _SIO_L1
1361
+ from ruamel.yaml import YAML as _YAML_L1
1362
+ _yl1 = _YAML_L1()
1363
+ _yl1.default_flow_style = False
1364
+ _yl1.representer.add_representer(
1365
+ type(None),
1366
+ lambda d, v: d.represent_scalar(
1367
+ "tag:yaml.org,2002:null", "null"
1368
+ ),
1369
+ )
1370
+ _sl1 = _SIO_L1()
1371
+ _yl1.dump(_rebuilt, _sl1)
1372
+ _cache_hit_content = _sl1.getvalue()
1373
+ else:
1374
+ import json as _json_l1
1375
+ _cache_hit_content = _json_l1.dumps(
1376
+ _rebuilt, indent=2, ensure_ascii=False
1377
+ )
1378
+ # Cache rebuilt view in L2 (skip for --changed-only: stale diff)
1379
+ if _cache_hit_content and not changed_only:
1380
+ _cache_mod.write_view(
1381
+ target,
1382
+ _view_key,
1383
+ _cache_hit_content,
1384
+ fmt=format,
1385
+ )
1386
+ except Exception:
1387
+ _cache_hit_content = None # rebuild failed → full analysis
1383
1388
 
1384
1389
  except Exception:
1385
1390
  _core_key = ""
@@ -2290,6 +2295,11 @@ def main(
2290
2295
  try:
2291
2296
  from sourcecode.ris import _has_uncommitted_changes as _huc_fresh
2292
2297
  _uncommitted_fresh = _huc_fresh(target)
2298
+ # _huc_fresh uses --untracked-files=no — it misses repos where all
2299
+ # files are untracked (no staged/unstaged changes to tracked files).
2300
+ # Promote to True when _allowed_changed_files contains untracked files.
2301
+ if not _uncommitted_fresh and _allowed_changed_files:
2302
+ _uncommitted_fresh = True
2293
2303
  except Exception:
2294
2304
  _uncommitted_fresh = False
2295
2305
  import datetime as _dt
@@ -2335,44 +2345,51 @@ def main(
2335
2345
  import atexit as _atexit
2336
2346
  import threading as _threading
2337
2347
 
2338
- # Capture all closure state before handing off to thread
2339
- _bg_sm = sm
2340
- _bg_target = target
2341
- _bg_core_key = _core_key
2342
- _bg_view_key = _view_key
2343
- _bg_view_flags_str = _view_flags_str
2344
- _bg_content = content
2345
- _bg_format = format
2346
- _bg_hashlib = _hashlib
2347
- _bg_cache_mod = _cache_mod
2348
-
2349
- def _write_cache_async() -> None:
2350
- try:
2351
- from sourcecode.serializer import core_view as _core_view_fn
2352
- _core_dict_write = _core_view_fn(_bg_sm)
2353
- _written_core_hash = _bg_cache_mod.write_core(
2354
- _bg_target, _bg_core_key, _core_dict_write
2355
- )
2356
- if _written_core_hash:
2357
- _vk = _bg_view_key
2358
- if not _vk:
2359
- _wvh = _bg_hashlib.sha256(_bg_view_flags_str.encode()).hexdigest()[:8]
2360
- _vk = f"{_written_core_hash}-{_wvh}"
2361
- _bg_cache_mod.write_view(
2362
- _bg_target,
2363
- _vk,
2364
- _bg_content,
2365
- fmt=_bg_format,
2366
- layers=_compute_analyzer_fingerprints(),
2348
+ # Pre-compute core dict in the main thread avoids the 5-second atexit
2349
+ # join race on large repos where core_view(sm) itself takes seconds.
2350
+ # Background thread only does gzip + disk I/O (fast, bounded latency).
2351
+ _bg_core_dict: dict | None = None
2352
+ try:
2353
+ from sourcecode.serializer import core_view as _core_view_fn
2354
+ _bg_core_dict = _core_view_fn(sm)
2355
+ except Exception:
2356
+ pass
2357
+
2358
+ if _bg_core_dict is not None:
2359
+ _bg_target = target
2360
+ _bg_core_key = _core_key
2361
+ _bg_view_key = _view_key
2362
+ _bg_view_flags_str = _view_flags_str
2363
+ _bg_content = content
2364
+ _bg_format = format
2365
+ _bg_hashlib = _hashlib
2366
+ _bg_cache_mod = _cache_mod
2367
+
2368
+ def _write_cache_async() -> None:
2369
+ try:
2370
+ _written_core_hash = _bg_cache_mod.write_core(
2371
+ _bg_target, _bg_core_key, _bg_core_dict
2367
2372
  )
2368
- from sourcecode.cache import cache_dir as _cdir, _gc as _run_gc
2369
- _run_gc(_cdir(_bg_target))
2370
- except Exception:
2371
- pass
2373
+ if _written_core_hash:
2374
+ _vk = _bg_view_key
2375
+ if not _vk:
2376
+ _wvh = _bg_hashlib.sha256(_bg_view_flags_str.encode()).hexdigest()[:8]
2377
+ _vk = f"{_written_core_hash}-{_wvh}"
2378
+ _bg_cache_mod.write_view(
2379
+ _bg_target,
2380
+ _vk,
2381
+ _bg_content,
2382
+ fmt=_bg_format,
2383
+ layers=_compute_analyzer_fingerprints(),
2384
+ )
2385
+ from sourcecode.cache import cache_dir as _cdir, _gc as _run_gc
2386
+ _run_gc(_cdir(_bg_target))
2387
+ except Exception:
2388
+ pass
2372
2389
 
2373
- _cache_write_thread = _threading.Thread(target=_write_cache_async, daemon=True)
2374
- _cache_write_thread.start()
2375
- _atexit.register(_cache_write_thread.join, 5.0)
2390
+ _cache_write_thread = _threading.Thread(target=_write_cache_async, daemon=True)
2391
+ _cache_write_thread.start()
2392
+ _atexit.register(_cache_write_thread.join, 30.0)
2376
2393
 
2377
2394
  # Update RIS with aggregated snapshot data (non-fatal side-effect).
2378
2395
  # Update RIS whenever git is available, even when L1/L2 cache is skipped
@@ -3449,21 +3466,34 @@ def repo_ir_cmd(
3449
3466
  _ir_tokens_est = _ir_size // 4
3450
3467
  # P1-C: abort when estimated tokens > 50K unless --force or --output is given.
3451
3468
  if _ir_tokens_est > 50_000 and not force:
3469
+ if summary_only:
3470
+ _hint = (
3471
+ "Use --max-nodes N --max-edges N to cap graph size, "
3472
+ "--output FILE to save to disk, or --force to bypass this guard."
3473
+ )
3474
+ else:
3475
+ _hint = (
3476
+ "Use --summary-only (~5K tokens), --max-nodes N --max-edges N, "
3477
+ "--output FILE to save to disk, or --force to bypass this guard."
3478
+ )
3452
3479
  _emit_error_json(
3453
3480
  "OUTPUT_TOO_LARGE",
3454
3481
  f"Estimated output is ~{_ir_tokens_est // 1000}K tokens — too large for most LLM context windows.",
3455
- hint=(
3456
- "Use --summary-only (~5K tokens), --max-nodes N --max-edges N, "
3457
- "--output FILE to save to disk, or --force to bypass this guard."
3458
- ),
3482
+ hint=_hint,
3459
3483
  expected="Output under 50K estimated tokens.",
3460
3484
  )
3461
3485
  raise typer.Exit(1)
3462
3486
  if _ir_tokens_est > 10_000:
3463
- sys.stderr.write(
3464
- f"[repo-ir] ~{_ir_tokens_est // 1000}K tokens — "
3465
- "use --summary-only or --output FILE for smaller output.\n"
3466
- )
3487
+ if summary_only:
3488
+ sys.stderr.write(
3489
+ f"[repo-ir] ~{_ir_tokens_est // 1000}K tokens "
3490
+ "use --max-nodes N --max-edges N or --output FILE for smaller output.\n"
3491
+ )
3492
+ else:
3493
+ sys.stderr.write(
3494
+ f"[repo-ir] ~{_ir_tokens_est // 1000}K tokens — "
3495
+ "use --summary-only or --output FILE for smaller output.\n"
3496
+ )
3467
3497
  sys.stderr.flush()
3468
3498
  try:
3469
3499
  sys.stdout.buffer.write(output.encode("utf-8"))
@@ -3573,6 +3603,15 @@ def impact_cmd(
3573
3603
  sys.stderr.flush()
3574
3604
  target, path = str(path), _target_as_path
3575
3605
 
3606
+ if not target.strip():
3607
+ _emit_error_json(
3608
+ INVALID_INPUT_CODE,
3609
+ "Class name must not be empty.",
3610
+ hint="Pass a class name or FQN. Example: sourcecode impact OrderService .",
3611
+ expected="A non-empty class name or FQN.",
3612
+ )
3613
+ raise typer.Exit(1)
3614
+
3576
3615
  root = path.resolve()
3577
3616
  if not root.is_dir():
3578
3617
  _emit_error_json(
@@ -3701,6 +3740,15 @@ def endpoints_cmd(
3701
3740
  sourcecode endpoints . --controller LiquidacionJornada
3702
3741
  sourcecode endpoints . --limit 10
3703
3742
  """
3743
+ if format not in ("json", "yaml"):
3744
+ _emit_error_json(
3745
+ INVALID_INPUT_CODE,
3746
+ f"Invalid format '{format}'.",
3747
+ hint="format must be: json or yaml.",
3748
+ expected="json | yaml",
3749
+ )
3750
+ raise typer.Exit(code=1)
3751
+
3704
3752
  target = path.resolve()
3705
3753
  if not target.exists() or not target.is_dir():
3706
3754
  _emit_error_json(
@@ -4241,6 +4289,15 @@ def impact_chain_cmd(
4241
4289
  from sourcecode.spring_impact import run_impact_chain
4242
4290
  from sourcecode.spring_findings import SpringAuditResult
4243
4291
 
4292
+ if not symbol.strip():
4293
+ _emit_error_json(
4294
+ INVALID_INPUT_CODE,
4295
+ "Symbol name must not be empty.",
4296
+ hint="Pass a class name or FQN. Example: sourcecode impact-chain OrderService .",
4297
+ expected="A non-empty class name or FQN.",
4298
+ )
4299
+ raise typer.Exit(code=1)
4300
+
4244
4301
  _VALID_TYPES = ("impact", "events")
4245
4302
  if query_type not in _VALID_TYPES:
4246
4303
  _emit_error_json(
@@ -4410,10 +4467,10 @@ def pr_impact_cmd(
4410
4467
  )
4411
4468
  raise typer.Exit(code=1)
4412
4469
 
4413
- if not files.exists():
4470
+ if not files.exists() or files.is_dir():
4414
4471
  _emit_error_json(
4415
4472
  INVALID_INPUT_CODE,
4416
- f"--files '{files}' does not exist. Expected a text file listing changed file paths (one per line), not a directory or class name.",
4473
+ f"--files '{files}' does not exist or is a directory. Expected a text file listing changed file paths (one per line).",
4417
4474
  path=str(files),
4418
4475
  hint=(
4419
4476
  "Create a file with one changed Java file path per line, then pass it with --files. "
@@ -4551,6 +4608,15 @@ def explain_cmd(
4551
4608
  from sourcecode.spring_model import SpringSemanticModel
4552
4609
  from sourcecode.explain import explain_class
4553
4610
 
4611
+ if not class_name.strip():
4612
+ _emit_error_json(
4613
+ INVALID_INPUT_CODE,
4614
+ "Class name must not be empty.",
4615
+ hint="Pass a class name. Example: sourcecode explain UserService .",
4616
+ expected="A non-empty class name.",
4617
+ )
4618
+ raise typer.Exit(code=1)
4619
+
4554
4620
  target = path.resolve()
4555
4621
  if not target.exists() or not target.is_dir():
4556
4622
  _emit_error_json(
@@ -5105,7 +5171,7 @@ def rename_class_cmd(
5105
5171
  help="Output format: json (default) or yaml.",
5106
5172
  ),
5107
5173
  ) -> None:
5108
- """Rename a Java class throughout the repository (BLOCKER-A fix).
5174
+ """Rename a Java class throughout the repository.
5109
5175
 
5110
5176
  \b
5111
5177
  Renames a Java class safely:
@@ -5116,7 +5182,7 @@ def rename_class_cmd(
5116
5182
  - Updates extends / implements
5117
5183
  - Updates generics, casts, Spring @Qualifier names
5118
5184
  - Renames the physical .java file
5119
- - Emits a structured change audit trail (BLOCKER-C)
5185
+ - Emits a structured change audit trail
5120
5186
 
5121
5187
  \b
5122
5188
  Examples:
@@ -5227,7 +5293,7 @@ def chunk_file_cmd(
5227
5293
  help="Copy output to clipboard after a successful run.",
5228
5294
  ),
5229
5295
  ) -> None:
5230
- """Split a large Java file into semantic chunks for AI agent consumption (BLOCKER-B fix).
5296
+ """Split a large Java file into semantic chunks for AI agent consumption.
5231
5297
 
5232
5298
  \b
5233
5299
  Splits a Java file at method/class boundaries so AI agents can read
@@ -5261,6 +5327,16 @@ def chunk_file_cmd(
5261
5327
  )
5262
5328
  raise typer.Exit(1)
5263
5329
 
5330
+ if abs_file.suffix != ".java":
5331
+ _emit_error_json(
5332
+ INVALID_INPUT_CODE,
5333
+ f"'{abs_file.name}' is not a Java file. chunk-file only supports .java files.",
5334
+ path=str(abs_file),
5335
+ hint="Pass a .java source file.",
5336
+ expected="A .java file path.",
5337
+ )
5338
+ raise typer.Exit(1)
5339
+
5264
5340
  result = chunk_java_file(abs_file, max_lines=max_lines, include_content=not metadata_only)
5265
5341
 
5266
5342
  if chunk_id is not None: