@allansantos-dev/smart-tool 0.9.2 → 0.9.4

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.
package/code_graph.py CHANGED
@@ -2,6 +2,7 @@
2
2
  import ast
3
3
  import collections
4
4
  import copy
5
+ import gzip
5
6
  import contextvars
6
7
  import hashlib
7
8
  import json
@@ -14,12 +15,15 @@ from pathlib import Path
14
15
 
15
16
  import indexer
16
17
  import index_inventory
18
+ import index_scope
17
19
  import project_identity
18
20
  import web_search_adapters
19
21
  import web_document_graph
20
22
  import document_text
21
23
 
22
24
  VERSION = 2
25
+ SOURCE_EXTENSIONS = ('.py','.java','.js','.jsx','.ts','.tsx','.mjs','.cjs','.mts','.cts','.json','.html','.htm','.css','.scss','.sass','.less','.md','.markdown')
26
+ MAX_OVERLAY_BYTES = 1024 * 1024
23
27
  MAX_FILES = 3000
24
28
  MAX_TEXT = 24 * 1024 * 1024
25
29
  MAX_SYMBOLS = 8000
@@ -72,7 +76,7 @@ def _snapshot(path):
72
76
  'chunks':chunks,'lines':line_count or 0,'group':posixpath.dirname(file_path) or '(root)',
73
77
  'location_kind':document_text.location_kind(file_path),'format':posixpath.splitext(file_path)[1].lower().lstrip('.') or 'text'}
74
78
  files.append(record)
75
- if not file_path.lower().endswith(('.py','.java','.js','.jsx','.ts','.tsx','.mjs','.cjs','.mts','.cts','.json','.html','.htm','.css','.scss','.sass','.less','.md','.markdown')):
79
+ if not file_path.lower().endswith(SOURCE_EXTENSIONS):
76
80
  continue
77
81
  rows = conn.execute('SELECT start_line,end_line,text FROM chunks WHERE path=? ORDER BY start_line,id', (original,)).fetchall()
78
82
  text, error = reconstruct(rows)
@@ -228,19 +232,34 @@ def _python_graph(sources):
228
232
  'diagnostics':diagnostics,'parsed':list(trees),'truncated':len(symbols)>=MAX_SYMBOLS or len(dependencies)>MAX_EDGES}
229
233
 
230
234
 
235
+ NODE_HEAP_MB = (1024, 4096)
236
+
237
+
238
+ def _run_analyzer(script, payload, failure):
239
+ """Runs a Node analyzer, retrying once with a larger heap when it runs out of memory (634 TypeScript files, 2.9 MB,
240
+ needed more than the former fixed 512 MB)."""
241
+ for heap in NODE_HEAP_MB:
242
+ process=subprocess.run([web_search_adapters.NODE_EXECUTABLE,f'--max-old-space-size={heap}',str(Path(__file__).with_name(script))],
243
+ input=payload,capture_output=True,text=True,encoding='utf-8',timeout=120,
244
+ creationflags=getattr(subprocess,'CREATE_NO_WINDOW',0))
245
+ if process.returncode==0:
246
+ return json.loads(process.stdout)
247
+ if 'heap out of memory' not in (process.stderr or '') or heap==NODE_HEAP_MB[-1]:
248
+ break
249
+ detail=' (out of memory)' if 'heap out of memory' in (process.stderr or '') else ''
250
+ raise RuntimeError(failure+detail)
251
+
252
+
231
253
  def _javascript_graph(sources, all_paths=None):
232
254
  extensions=('.js','.jsx','.ts','.tsx','.mjs','.cjs','.mts','.cts')
233
255
  relevant={path:text for path,text in sources.items() if path.endswith(extensions+('.json','.html','.htm'))}
234
256
  if not any(path.endswith(extensions) for path in relevant):
235
257
  return {}
236
258
  try:
237
- process=subprocess.run([web_search_adapters.NODE_EXECUTABLE,'--max-old-space-size=512',str(Path(__file__).with_name('code_graph_js.cjs'))],
238
- input=json.dumps({'files':[{'path':path,'text':text} for path,text in relevant.items()],'paths':list(all_paths or sources)},ensure_ascii=False),
239
- capture_output=True,text=True,encoding='utf-8',timeout=40,
240
- creationflags=getattr(subprocess,'CREATE_NO_WINDOW',0))
241
- if process.returncode:
242
- raise RuntimeError('The TypeScript analyzer did not finish. Check the local code-analysis runtime.')
243
- return json.loads(process.stdout)
259
+ return _run_analyzer('code_graph_js.cjs',
260
+ json.dumps({'files':[{'path':path,'text':text} for path,text in relevant.items()],
261
+ 'paths':list(all_paths or sources)},ensure_ascii=False),
262
+ 'The TypeScript analyzer did not finish. Check the local code-analysis runtime.')
244
263
  except (OSError,subprocess.TimeoutExpired,ValueError,RuntimeError) as exc:
245
264
  return {'diagnostics':[{'path':'JavaScript/TypeScript','reason':str(exc)[:250]}],'parsed':[],'retryable':True}
246
265
 
@@ -249,17 +268,17 @@ def _java_graph(sources):
249
268
  files=[{'path':path,'text':text} for path,text in sources.items() if path.lower().endswith('.java')]
250
269
  if not files:return {}
251
270
  try:
252
- result=subprocess.run([web_search_adapters.NODE_EXECUTABLE,'--max-old-space-size=512',str(Path(__file__).with_name('code_graph_java.mjs'))],
253
- input=json.dumps({'files':files},ensure_ascii=False),capture_output=True,text=True,encoding='utf-8',timeout=40,
254
- creationflags=getattr(subprocess,'CREATE_NO_WINDOW',0))
255
- if result.returncode:raise RuntimeError('The Java analyzer did not finish. Check the local runtime.')
256
- return json.loads(result.stdout)
271
+ return _run_analyzer('code_graph_java.mjs',json.dumps({'files':files},ensure_ascii=False),
272
+ 'The Java analyzer did not finish. Check the local runtime.')
257
273
  except (OSError,ValueError,RuntimeError,subprocess.TimeoutExpired) as exc:
258
274
  return {'diagnostics':[{'path':'Java','reason':str(exc)[:250]}],'parsed':[],'retryable':True}
259
275
 
260
276
 
261
277
  def _analyze(path):
262
- files,sources,diagnostics,truncated=_snapshot(path)
278
+ return _analyze_sources(*_snapshot(path))
279
+
280
+
281
+ def _analyze_sources(files,sources,diagnostics,truncated):
263
282
  merged={'symbols':[],'dependencies':[],'calls':[],'unresolved':[],'diagnostics':diagnostics,'parsed':[]}
264
283
  retryable=False
265
284
  for output in (_python_graph(sources),_javascript_graph(sources,[f['path'] for f in files]),_java_graph(sources),web_document_graph.analyze(sources,[f['path'] for f in files])):
@@ -322,6 +341,41 @@ def _by_file(data):
322
341
  return {'symbols':by_file,'parsed':parsed,'note':note}
323
342
 
324
343
 
344
+ def _disk_path(view_path):
345
+ path=indexer.graph_cache_path(view_path)
346
+ return os.path.dirname(path),path
347
+
348
+
349
+ def _disk_load(key):
350
+ """Analysis saved for exactly this index state and analyzer version, or None. A damaged file is a miss: the
351
+ caller analyzes again and overwrites it."""
352
+ _folder,path=_disk_path(key[0])
353
+ try:
354
+ with gzip.open(path,'rt',encoding='utf-8') as stream:
355
+ saved=json.load(stream)
356
+ except (OSError,EOFError,ValueError):
357
+ return None
358
+ return saved.get('graph') if saved.get('key')==[key[1],key[2],key[3]] else None
359
+
360
+
361
+ def _disk_save(key,data):
362
+ """Writes the analysis next to the index (atomic replace) and removes caches of views that no longer exist.
363
+ Returns the error text when it could not be written, so the caller can report it."""
364
+ folder,path=_disk_path(key[0])
365
+ try:
366
+ os.makedirs(folder,exist_ok=True)
367
+ temporary=f'{path}.{os.getpid()}.{threading.get_ident()}.tmp'
368
+ with gzip.open(temporary,'wt',encoding='utf-8',compresslevel=5) as stream:
369
+ json.dump({'key':[key[1],key[2],key[3]],'graph':data},stream,ensure_ascii=False)
370
+ os.replace(temporary,path)
371
+ for name in os.listdir(folder):
372
+ if name.endswith('.json.gz') and not os.path.isfile(os.path.join(os.path.dirname(folder),name[:-8]+'.sqlite3')):
373
+ os.remove(os.path.join(folder,name))
374
+ except OSError as exc:
375
+ return f'Graph cache not saved ({type(exc).__name__}: {exc}); the next restart analyzes again.'[:300]
376
+ return None
377
+
378
+
325
379
  def cached_symbols(root):
326
380
  path=indexer.existing_db_path(root)
327
381
  if not path:
@@ -331,6 +385,13 @@ def cached_symbols(root):
331
385
  data=_CACHE.get(key)
332
386
  if data is not None:
333
387
  _CACHE.move_to_end(key)
388
+ if data is None:
389
+ data=_disk_load(key)
390
+ if data is not None:
391
+ with _LOCK:
392
+ _CACHE[key]=data
393
+ while len(_CACHE)>3:_CACHE.popitem(last=False)
394
+ with _LOCK:
334
395
  state=_WARM_STATE.get(root)
335
396
  if data is None and state and state['key']==key and (state['result'] or time.time()-state['at']<WARM_RETRY_S):
336
397
  return state['result'],state['note']
@@ -344,6 +405,12 @@ def cached_symbols(root):
344
405
  return None,None
345
406
 
346
407
 
408
+ def warm(root):
409
+ """Starts the analysis of the project's current index in the background unless memory or disk already has it, so
410
+ the next edit hook or search finds the graph ready (called when an indexing job ends)."""
411
+ cached_symbols(root)
412
+
413
+
347
414
  def _warm(root,key):
348
415
  result,note=None,None
349
416
  try:
@@ -371,6 +438,13 @@ def build(root,view_id=None,storage_id=None,file_path=None,background=False):
371
438
  cached=_CACHE.get(key)
372
439
  if cached:_CACHE.move_to_end(key)
373
440
  was_cached=cached is not None
441
+ save_note=None
442
+ if cached is None:
443
+ cached=_disk_load(key)
444
+ if cached is not None:
445
+ with _LOCK:
446
+ _CACHE[key]=cached
447
+ while len(_CACHE)>3:_CACHE.popitem(last=False)
374
448
  if cached is None:
375
449
  slots=_WARM_SLOT if background else _ANALYSIS_SLOTS
376
450
  if not slots.acquire(timeout=None if background else 3):
@@ -384,7 +458,9 @@ def build(root,view_id=None,storage_id=None,file_path=None,background=False):
384
458
  with _LOCK:
385
459
  _CACHE[key]=cached
386
460
  while len(_CACHE)>3:_CACHE.popitem(last=False)
461
+ save_note=_disk_save(key,cached)
387
462
  data=copy.deepcopy(cached)
463
+ if save_note:data['diagnostics']=[*data.get('diagnostics',[]),{'path':'','reason':save_note}]
388
464
  # Só compara metadados do diretório de trabalho quando a visão é a atual.
389
465
  for file in data['files']:
390
466
  file['status']='stored'
@@ -401,3 +477,113 @@ def build(root,view_id=None,storage_id=None,file_path=None,background=False):
401
477
  limits={'files':MAX_FILES,'symbols':MAX_SYMBOLS,'edges':MAX_EDGES,'display_nodes_default':120},
402
478
  languages=['Java','Angular','Python','JavaScript','TypeScript','JSX','TSX','HTML','CSS','Markdown'],cache_hit=was_cached)
403
479
  return _focus(data,file_path) if file_path else data
480
+
481
+
482
+ def working_tree_changes(root):
483
+ """Project-relative paths changed in the git working tree (staged, unstaged, untracked) -> 'deleted' or
484
+ 'changed'; empty when the folder is not in a git repository."""
485
+ def git(*args):
486
+ return subprocess.run(['git',*args],cwd=root,capture_output=True,timeout=30,
487
+ creationflags=getattr(subprocess,'CREATE_NO_WINDOW',0))
488
+ prefix=git('rev-parse','--show-prefix')
489
+ if prefix.returncode:
490
+ return {}
491
+ prefix=prefix.stdout.decode('utf-8','replace').strip()
492
+ status=git('status','--porcelain=v1','-z','--untracked-files=all','--','.')
493
+ if status.returncode:
494
+ raise RuntimeError('git status failed: '+status.stderr.decode('utf-8','replace').strip()[:200])
495
+ entries=status.stdout.decode('utf-8','replace').split('\0');changes={};i=0
496
+ while i<len(entries):
497
+ entry=entries[i];i+=1
498
+ if len(entry)<4:continue
499
+ code,path=entry[:2],entry[3:]
500
+ if 'R' in code or 'C' in code:i+=1
501
+ if path.startswith(prefix):changes[path[len(prefix):]]='deleted' if 'D' in code else 'changed'
502
+ return changes
503
+
504
+
505
+ def pending_changes(root,view_path):
506
+ """Files the indexed view does not reflect yet: working-tree changes, indexed files whose size or modification time
507
+ differs on disk, and files tracked by git inside the index scope that were never indexed (commits made after the
508
+ last indexing). Path -> 'deleted' or 'changed'."""
509
+ changes=working_tree_changes(root)
510
+ conn=indexer._readonly(view_path)
511
+ try:
512
+ columns={row[1] for row in conn.execute('PRAGMA table_info(manifest)')}
513
+ if not {'size','mtime_ns'} <= columns:
514
+ raise RuntimeError('Index without file sizes and times; reindex the project.')
515
+ rows=conn.execute('SELECT path,size,mtime_ns FROM manifest').fetchall()
516
+ finally:
517
+ conn.close()
518
+ indexed=set()
519
+ for original,size,stamp in rows:
520
+ rel=original.replace('\\','/');indexed.add(rel)
521
+ if rel in changes:continue
522
+ try:
523
+ stat=os.stat(os.path.join(root,rel))
524
+ except OSError:
525
+ changes[rel]='deleted';continue
526
+ if (stat.st_size,stat.st_mtime_ns)!=(size,stamp):changes[rel]='changed'
527
+ scope=index_scope.load_scope(root)
528
+ listed=subprocess.run(['git','ls-files','-z'],cwd=root,capture_output=True,timeout=30,
529
+ creationflags=getattr(subprocess,'CREATE_NO_WINDOW',0))
530
+ if scope and listed.returncode==0:
531
+ for rel in listed.stdout.decode('utf-8','replace').split('\0'):
532
+ if rel and rel not in indexed and rel not in changes and rel.lower().endswith(SOURCE_EXTENSIONS) \
533
+ and index_scope.in_scope(scope,rel) and os.path.isfile(os.path.join(root,rel)):
534
+ changes[rel]='changed'
535
+ return changes
536
+
537
+
538
+ def build_current(root,view_id=None):
539
+ """The graph an agent should see while editing: the indexed view plus every file it does not reflect yet (see
540
+ pending_changes and build_overlay) when that view is the current one; a pinned or older view is returned as
541
+ indexed."""
542
+ inventory=index_inventory.inspect(root,view_id)
543
+ selected=inventory.get('selected') or {}
544
+ changes=pending_changes(root,selected['path']) if selected.get('current') else {}
545
+ return build_overlay(root,changes,view_id) if changes else build(root,view_id)
546
+
547
+
548
+ def build_overlay(root,changes,view_id=None):
549
+ """Graph of the indexed view with the given changed files read from the working tree instead of the snapshot
550
+ (changes: project-relative path -> 'deleted' or any other status), so a caller right after an edit sees new files,
551
+ new imports and current line numbers without reindexing or embeddings. A deleted file keeps its indexed copy so
552
+ the files that imported it stay linked to it. Cached per snapshot and file contents."""
553
+ inventory=index_inventory.inspect(root,view_id)
554
+ if not inventory['selected']:
555
+ return {**inventory,'symbols':[],'dependencies':[],'calls':[],'diagnostics':[],'counts':{},'files':[]}
556
+ path=inventory['selected']['path'];stat=os.stat(path)
557
+ files,sources,diagnostics,truncated=_snapshot(path)
558
+ by_path={f['path']:f for f in files};overlaid=[]
559
+ for rel,status in sorted(changes.items()):
560
+ full=os.path.join(root,rel)
561
+ if status=='deleted' or not project_identity.within_root(root,full):
562
+ continue
563
+ try:
564
+ if os.path.getsize(full)>MAX_OVERLAY_BYTES:continue
565
+ with open(full,encoding='utf-8') as stream:text=stream.read()
566
+ except (OSError,UnicodeDecodeError):continue
567
+ by_path.setdefault(rel,{'id':rel,'path':rel,'hash':None,'size':len(text),'mtime_ns':None,'chunks':0,
568
+ 'lines':text.count('\n')+1,'group':posixpath.dirname(rel) or '(root)',
569
+ 'location_kind':document_text.location_kind(rel),
570
+ 'format':posixpath.splitext(rel)[1].lower().lstrip('.') or 'text'})
571
+ if rel.lower().endswith(SOURCE_EXTENSIONS):
572
+ sources[rel]=text;overlaid.append((rel,hashlib.sha1(text.encode('utf-8')).hexdigest()))
573
+ key=(path,stat.st_mtime_ns,stat.st_size,VERSION,'overlay',tuple(overlaid))
574
+ with _LOCK:
575
+ cached=_CACHE.get(key)
576
+ if cached is None:
577
+ if not _ANALYSIS_SLOTS.acquire(timeout=3):
578
+ raise RuntimeError('Two map analyses are in progress. Wait for them to finish and retry.')
579
+ try:
580
+ cached=_analyze_sources(list(by_path.values()),sources,diagnostics,truncated)
581
+ finally:
582
+ _ANALYSIS_SLOTS.release()
583
+ if not cached['retryable']:
584
+ with _LOCK:
585
+ _CACHE[key]=cached
586
+ while len(_CACHE)>3:_CACHE.popitem(last=False)
587
+ data=copy.deepcopy(cached)
588
+ data.update(selected=inventory['selected'],source='indexed_snapshot_with_working_tree',overlaid=[r for r,_h in overlaid])
589
+ return data
@@ -33,9 +33,12 @@ for(const unit of units){
33
33
  if(cls&&n.name==='primary'){
34
34
  const prefix=n.children.primaryPrefix?.[0],prefixText=text(unit,prefix).trim(),suffixes=n.children.primarySuffix||[];let chain=prefixText,fluent=false;
35
35
  const newMatch=/^new\s+([\w.$]+)/.exec(prefixText);let constructed=newMatch?resolveType(unit,newMatch[1],cls):null;
36
+ const link=(type,line)=>{if(type&&type.unit.path!==unit.path&&dependencies.length<MAX_EDGES)dependencies.push({source:unit.path,target:type.unit.path,specifier:type.fqn,line,kind:'java_type',resolution:'resolved'})};
36
37
  if(constructed&&calls.length<MAX_EDGES)calls.push({source:(owner||cls.symbol).id,target:constructed.symbol.id,line:n.location.startLine,kind:'construct',resolution:'static'});
38
+ link(constructed,n.location.startLine);
39
+ const staticHead=/^([A-Z][\w$]*)\./.exec(prefixText);if(staticHead&&!vars.has(staticHead[1]))link(resolveType(unit,staticHead[1],cls),n.location.startLine);
37
40
  for(const suffix of suffixes){const invoke=suffix.children.methodInvocationSuffix?.[0];if(!invoke){chain+=text(unit,suffix);continue}const args=invoke.children.argumentList?.[0],arity=args?(args.children.expression||[]).length:0;const match=/^(?:([\w.$]+)\.)?([\w$]+)$/.exec(chain);let target=null;
38
- if(match&&!fluent){const receiver=match[1],name=match[2];let type=null;if(!receiver||receiver==='this')type=cls;else if(receiver==='super')type=resolveType(unit,cls.extends,cls);else if(receiver.startsWith('this.'))type=resolveType(unit,cls.fields.get(receiver.slice(5))||'',cls);else if(vars.has(receiver))type=resolveType(unit,vars.get(receiver)||'',cls);else type=resolveType(unit,receiver,cls);target=lookupMethod(type,name,arity);if(!target&&!receiver){const candidates=unit.imports.filter(i=>i.static&&(i.spec.endsWith('.'+name)||i.spec.endsWith('.*'))).map(i=>lookupMethod(classes.get(i.spec.split('.').slice(0,-1).join('.')),name,arity)).filter(Boolean);if(candidates.length===1)target=candidates[0]}}
41
+ if(match&&!fluent){const receiver=match[1],name=match[2];let type=null;if(!receiver||receiver==='this')type=cls;else if(receiver==='super')type=resolveType(unit,cls.extends,cls);else if(receiver.startsWith('this.'))type=resolveType(unit,cls.fields.get(receiver.slice(5))||'',cls);else if(vars.has(receiver))type=resolveType(unit,vars.get(receiver)||'',cls);else{type=resolveType(unit,receiver,cls);link(type,invoke.location.startLine)}target=lookupMethod(type,name,arity);if(!target&&!receiver){const candidates=unit.imports.filter(i=>i.static&&(i.spec.endsWith('.'+name)||i.spec.endsWith('.*'))).map(i=>lookupMethod(classes.get(i.spec.split('.').slice(0,-1).join('.')),name,arity)).filter(Boolean);if(candidates.length===1)target=candidates[0]}}
39
42
  if(constructed&&!fluent){const methodName=/\.([\w$]+)$/.exec(chain)?.[1];if(methodName)target=lookupMethod(constructed,methodName,arity)}
40
43
  if(target&&calls.length<MAX_EDGES)calls.push({source:(owner||cls.symbol).id,target:target.id,line:invoke.location.startLine,kind:'call',resolution:'static'});
41
44
  else if(unresolved.length<1000)unresolved.push({path:unit.path,line:invoke.location.startLine,expression:chain.slice(0,140),reason:'External/dynamic type, interface without implementation, or ambiguous overload in the Java snapshot.'});
package/code_graph_js.cjs CHANGED
@@ -25,7 +25,19 @@ function candidate(base) {
25
25
  const variants=[base,base.replace(/\.[cm]?jsx?$/,'.ts'),base.replace(/\.jsx?$/,'.tsx'),
26
26
  ...['.ts','.tsx','.js','.jsx','.mts','.cts','.mjs','.cjs'].map(e=>base+e),
27
27
  ...['.ts','.tsx','.js','.jsx'].map(e=>base+'/index'+e)];
28
- return variants.find(name=>knownFiles.has(clean(name)));
28
+ const found=variants.find(name=>knownFiles.has(clean(name)));
29
+ return found&&clean(found);
30
+ }
31
+ const workspaces=new Map();
32
+ for(const [name,file] of docs)if(name.endsWith('/package.json')&&!name.includes('/node_modules/')){try{const pkg=JSON.parse(file.text);if(typeof pkg.name==='string'&&pkg.name)workspaces.set(pkg.name,{dir:path.dirname(name),entries:[pkg.source,pkg.main,pkg.module].filter(e=>typeof e==='string')})}catch{}}
33
+ function workspace(spec){
34
+ for(const [name,pkg] of workspaces){
35
+ if(spec!==name&&!spec.startsWith(name+'/'))continue;
36
+ const sub=spec.slice(name.length+1);
37
+ if(sub)return candidate(path.join(pkg.dir,'src',sub))||candidate(path.join(pkg.dir,sub));
38
+ return candidate(path.join(pkg.dir,'src/index'))||candidate(path.join(pkg.dir,'index'))||pkg.entries.map(e=>candidate(path.join(pkg.dir,e))).find(Boolean);
39
+ }
40
+ return undefined;
29
41
  }
30
42
  function resolve(spec, from) {
31
43
  if (spec.startsWith('.')) return candidate(clean(path.join(path.dirname(from),spec)));
@@ -39,6 +51,8 @@ function resolve(spec, from) {
39
51
  }
40
52
  }
41
53
  }
54
+ const local=workspace(spec);
55
+ if(local)return local;
42
56
  if(config.baseUrl)return candidate(clean(path.join(baseUrl,spec)));
43
57
  return undefined;
44
58
  }
package/code_impact.py ADDED
@@ -0,0 +1,132 @@
1
+ """What changing a function touches, from the indexed code graph: where it is defined, who calls it, what it calls,
2
+ which tests reach it through static calls and which files import its module, each with the first line of its
3
+ docstring. Answers the question an agent asks before an edit without a chain of searches and file reads."""
4
+ import os
5
+ from collections import defaultdict
6
+
7
+ import code_graph
8
+ import doc_check
9
+ import index_profile
10
+ import index_scope
11
+
12
+ MAX_DEPTH = 4
13
+ NOTE = ("Static analysis of the indexed snapshot plus the files changed in the working tree: calls made through callbacks, dynamic dispatch or names built at "
14
+ "runtime are not seen, so treat an empty list as 'none found', not as 'none exist'.")
15
+
16
+
17
+ def _matches(symbols, name):
18
+ path, _sep, wanted = name.strip().rpartition("::")
19
+ path = path.replace("\\", "/").removeprefix("./")
20
+ pool = [s for s in symbols if s["path"] == path] if path else symbols
21
+ exact = [s for s in pool if s["id"] == wanted or s["name"] == wanted]
22
+ return exact or [s for s in pool if s["name"].split(".")[-1] == wanted]
23
+
24
+
25
+ def _site(by_id, symbol_id, line, of=None):
26
+ symbol = by_id.get(symbol_id) or {}
27
+ site = {"id": symbol_id, "function": symbol.get("name", symbol_id), "path": symbol.get("path"), "line": line}
28
+ if of:
29
+ site["of"] = of
30
+ return site
31
+
32
+
33
+ def summaries(root, symbols, wanted_ids):
34
+ """First docstring line of the wanted symbols, read from the working tree once per file."""
35
+ by_path = defaultdict(list)
36
+ for symbol in symbols:
37
+ by_path[symbol["path"]].append(symbol)
38
+ docs = {}
39
+ for path in {s["path"] for s in symbols if s["id"] in wanted_ids}:
40
+ try:
41
+ with open(os.path.join(root, path), encoding="utf-8") as stream:
42
+ source = stream.read()
43
+ except (OSError, UnicodeDecodeError):
44
+ continue
45
+ for function in doc_check.functions(path, source, by_path[path]):
46
+ if function["doc"]:
47
+ docs[(path, function["start"])] = function["doc"]
48
+ return {s["id"]: docs[(s["path"], s["start_line"])] for s in symbols
49
+ if s["id"] in wanted_ids and (s["path"], s["start_line"]) in docs}
50
+
51
+
52
+ def impact(root, symbol, view_id=None, depth=3, limit=30):
53
+ if not isinstance(symbol, str) or not symbol.strip():
54
+ raise ValueError("Pass symbol: a function, method (Class.method) or class name.")
55
+ if type(depth) is not int or not 1 <= depth <= MAX_DEPTH:
56
+ raise ValueError(f"depth must be an integer from 1 to {MAX_DEPTH}.")
57
+ data = code_graph.build_current(root, view_id)
58
+ found = _matches(data.get("symbols") or [], symbol)
59
+ diagnostics = [d.get("reason") for d in data.get("diagnostics") or [] if d.get("reason")]
60
+ if not found:
61
+ cause = f" Analysis problems: {'; '.join(diagnostics)}" if diagnostics else ""
62
+ raise ValueError(f"No function or class named {symbol!r} in the indexed view; check the name or reindex.{cause}")
63
+ profile = index_profile.current((index_scope.load_scope(root) or {}).get("profile"))
64
+ by_id = {s["id"]: s for s in data["symbols"]}
65
+ callers, callees = defaultdict(list), defaultdict(list)
66
+ for call in data["calls"]:
67
+ callers[call["target"]].append(call)
68
+ callees[call["source"]].append(call)
69
+ targets = {s["id"] for s in found}
70
+ label = {s["id"]: f"{s['path']}::{s['name']}" for s in found} if len(found) > 1 else {}
71
+ tests = {}
72
+ for origin in targets:
73
+ seen, frontier = set(targets), {origin}
74
+ for hops in range(1, depth + 1):
75
+ reached = set()
76
+ for target in frontier:
77
+ for call in callers[target]:
78
+ source = call["source"]
79
+ if source in seen:
80
+ continue
81
+ seen.add(source)
82
+ reached.add(source)
83
+ caller = by_id.get(source)
84
+ if caller and index_profile.kind(caller["path"], profile) == "test" and source not in tests:
85
+ tests[source] = {**_site(by_id, source, caller["start_line"], label.get(origin)), "hops": hops}
86
+ frontier = reached
87
+ direct = [_site(by_id, c["source"], c["line"], label.get(t)) for t in targets for c in callers[t]
88
+ if c["source"] not in targets]
89
+ outgoing = [_site(by_id, c["target"], c["line"], label.get(t)) for t in targets for c in callees[t]
90
+ if c["target"] not in targets]
91
+ files = {s["path"] for s in found}
92
+ importers = sorted({d["source"] for d in data["dependencies"] if d.get("target") in files and d["source"] not in files})
93
+ ordered_tests = sorted(tests.values(), key=lambda t: (t["hops"], t["path"], t["line"]))
94
+ shown = direct[:limit] + outgoing[:limit] + ordered_tests[:limit]
95
+ docs = summaries(root, data["symbols"], targets | {site["id"] for site in shown})
96
+ for site in shown:
97
+ doc = docs.get(site.pop("id"))
98
+ if doc:
99
+ site["doc"] = doc
100
+ definitions = []
101
+ for s in found:
102
+ entry = {"name": s["name"], "kind": s.get("kind"), "path": s["path"], "lines": f"{s['start_line']}-{s['end_line']}"}
103
+ if docs.get(s["id"]):
104
+ entry["doc"] = docs[s["id"]]
105
+ definitions.append(entry)
106
+ return {
107
+ "symbol": symbol.strip(),
108
+ "definitions": definitions,
109
+ "callers": direct[:limit],
110
+ "calls": outgoing[:limit],
111
+ "tests": ordered_tests[:limit],
112
+ "imported_by": importers[:limit],
113
+ "counts": {"callers": len(direct), "calls": len(outgoing), "tests": len(ordered_tests),
114
+ "imported_by": len(importers)},
115
+ "test_depth": depth,
116
+ "diagnostics": diagnostics,
117
+ "view": (data.get("selected") or {}).get("label"),
118
+ "note": NOTE + (f" {len(found)} definitions share this name; each entry says which one ('of'); pass "
119
+ "path::name to keep one." if label else ""),
120
+ }
121
+
122
+
123
+ def for_agent(data, limit):
124
+ """The file-focused graph without what an agent never reads (view list, display limits) and with lists capped."""
125
+ for key in ("symbols", "dependencies", "calls", "unresolved", "diagnostics"):
126
+ items = data.get(key) or []
127
+ if len(items) > limit:
128
+ data[key] = items[:limit]
129
+ data.setdefault("omitted", {})[key] = len(items) - limit
130
+ for key in ("views", "limits", "languages", "current_view", "generated_at", "cache_hit", "read_only", "source"):
131
+ data.pop(key, None)
132
+ return data
package/config.py CHANGED
@@ -31,6 +31,8 @@ DEFAULT_CONFIG = {
31
31
  "site_memory_similarity_threshold": 0.35,
32
32
  "local_models": "fallback",
33
33
  "hook_mode": "redirect",
34
+ "doc_mode": "remind",
35
+ "duplicate_mode": "warn",
34
36
  "contact": "",
35
37
  "model_adapter": "",
36
38
  "model_adapter_options": {},
@@ -42,6 +44,26 @@ LOCAL_MODEL_MODES = ("fallback", "off", "prefer")
42
44
  # PreToolUse hook: redirect = deny the native tool and point to Smart Tool; advise = let it run and add the tip to the
43
45
  # agent's context; off = no routing at all.
44
46
  HOOK_MODES = ("redirect", "advise", "off")
47
+ # Edit hook on functions without a docstring: require = deny the edit until it adds one; remind = let it run and tell
48
+ # the agent; off = say nothing. Independent of hook_mode, which only routes searches and reads.
49
+ DOC_MODES = ("require", "remind", "off")
50
+ # Edit hook on functions that copy one already indexed (exact or near-identical body): warn = let the edit run and
51
+ # point to the existing function; off = say nothing. Never blocks: near matches are leads, not defects.
52
+ DUPLICATE_MODES = ("warn", "off")
53
+
54
+
55
+ def doc_mode(cfg=None):
56
+ value = (cfg if cfg is not None else load_config()).get("doc_mode") or DEFAULT_CONFIG["doc_mode"]
57
+ if value not in DOC_MODES:
58
+ raise ValueError(f"Invalid doc_mode ({value!r}) in {CONFIG_PATH}: use require, remind or off.")
59
+ return value
60
+
61
+
62
+ def duplicate_mode(cfg=None):
63
+ value = (cfg if cfg is not None else load_config()).get("duplicate_mode") or DEFAULT_CONFIG["duplicate_mode"]
64
+ if value not in DUPLICATE_MODES:
65
+ raise ValueError(f"Invalid duplicate_mode ({value!r}) in {CONFIG_PATH}: use warn or off.")
66
+ return value
45
67
 
46
68
 
47
69
  def hook_mode(cfg=None):