@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/CHANGELOG.md +86 -0
- package/README.md +27 -2
- package/affected_tests.py +301 -0
- package/client_hooks.py +2 -2
- package/code_graph.py +200 -14
- package/code_graph_java.mjs +4 -1
- package/code_graph_js.cjs +15 -1
- package/code_impact.py +132 -0
- package/config.py +22 -0
- package/doc_check.py +256 -0
- package/duplicates.py +188 -16
- package/edit_preview.py +92 -0
- package/hook_decision.py +100 -2
- package/index_profile.py +1 -1
- package/index_scope.py +16 -0
- package/indexer.py +10 -0
- package/package.json +1 -1
- package/router.py +19 -11
- package/setup_ui.py +53 -5
- package/smart_tool_daemon.py +110 -21
- package/version.py +1 -1
- package/web_adapters/browser/fetch_camoufox.py +16 -8
- package/web_adapters/browser/ranged_download.py +78 -0
- package/web_fetch.py +60 -3
- package/web_search_health.py +12 -4
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(
|
|
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
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
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
|
-
|
|
253
|
-
|
|
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
|
-
|
|
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
|
package/code_graph_java.mjs
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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):
|