@allansantos-dev/smart-tool 0.0.0-stage → 0.9.0
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 +23 -0
- package/LICENSE +202 -0
- package/NOTICE +32 -0
- package/README.md +116 -3
- package/atomic_io.py +94 -0
- package/autostart.py +199 -0
- package/bin/smart-tool.mjs +82 -0
- package/browser_service_client.py +232 -0
- package/capabilities.py +63 -0
- package/client_hooks.py +192 -0
- package/code_graph.py +403 -0
- package/code_graph_java.mjs +49 -0
- package/code_graph_js.cjs +135 -0
- package/config.py +241 -0
- package/daemon_launcher.py +165 -0
- package/directory_watch.py +131 -0
- package/document_text.py +69 -0
- package/duplicates.py +313 -0
- package/embedding_cache.py +99 -0
- package/endpoint_sync.py +179 -0
- package/folder_picker.py +73 -0
- package/freshness.py +278 -0
- package/gateway.py +220 -0
- package/hook_decision.py +96 -0
- package/hooks/pretool_router.py +45 -0
- package/index_inventory.py +75 -0
- package/index_profile.py +167 -0
- package/index_scope.py +458 -0
- package/index_views.py +121 -0
- package/indexer.py +878 -0
- package/install.py +251 -0
- package/install_runtime.py +249 -0
- package/integration_metrics.py +82 -0
- package/local_embedder.py +172 -0
- package/model_adapters/__init__.py +0 -0
- package/model_adapters/openai_compatible.py +57 -0
- package/model_catalog.py +74 -0
- package/model_client.py +38 -0
- package/model_defaults.py +70 -0
- package/package.json +35 -6
- package/paths.py +6 -0
- package/project_identity.py +42 -0
- package/project_insights_ui.py +38 -0
- package/project_maps_ui.py +96 -0
- package/project_monitor.py +204 -0
- package/project_scheduler.py +61 -0
- package/project_storage.py +165 -0
- package/project_store.py +220 -0
- package/projects_ui.py +146 -0
- package/requirements.txt +8 -0
- package/research_cache.py +171 -0
- package/research_metrics.py +110 -0
- package/router.py +582 -0
- package/secret_store.py +121 -0
- package/setup_ui.py +986 -0
- package/site_memory.py +53 -0
- package/smart_tool_daemon.py +3604 -0
- package/tray.py +245 -0
- package/update_check.py +22 -0
- package/usage_meter.py +124 -0
- package/version.py +18 -0
- package/view_compare.py +68 -0
- package/web_adapters/browser/browser_service.py +302 -0
- package/web_adapters/browser/crawl4ai_search.py +236 -0
- package/web_adapters/browser/requirements.txt +2 -0
- package/web_adapters/node/adapter-common.mjs +40 -0
- package/web_adapters/node/ddg-search.mjs +89 -0
- package/web_adapters/node/open-websearch-adapter.mjs +77 -0
- package/web_adapters/node/package-lock.json +2720 -0
- package/web_adapters/node/package.json +12 -0
- package/web_adapters/node/wikipedia-search.mjs +44 -0
- package/web_document_graph.py +71 -0
- package/web_fetch.py +242 -0
- package/web_search_adapters.py +193 -0
- package/web_search_ddgs.py +42 -0
- package/web_search_external.py +288 -0
- package/web_search_health.py +88 -0
package/code_graph.py
ADDED
|
@@ -0,0 +1,403 @@
|
|
|
1
|
+
"""Grafos estáticos do conteúdo indexado: cobertura, imports e chamadas resolvidas."""
|
|
2
|
+
import ast
|
|
3
|
+
import collections
|
|
4
|
+
import copy
|
|
5
|
+
import contextvars
|
|
6
|
+
import hashlib
|
|
7
|
+
import json
|
|
8
|
+
import os
|
|
9
|
+
import posixpath
|
|
10
|
+
import subprocess
|
|
11
|
+
import threading
|
|
12
|
+
import time
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
import indexer
|
|
16
|
+
import index_inventory
|
|
17
|
+
import project_identity
|
|
18
|
+
import web_search_adapters
|
|
19
|
+
import web_document_graph
|
|
20
|
+
import document_text
|
|
21
|
+
|
|
22
|
+
VERSION = 2
|
|
23
|
+
MAX_FILES = 3000
|
|
24
|
+
MAX_TEXT = 24 * 1024 * 1024
|
|
25
|
+
MAX_SYMBOLS = 8000
|
|
26
|
+
MAX_EDGES = 20000
|
|
27
|
+
_CACHE = collections.OrderedDict()
|
|
28
|
+
_LOCK = threading.Lock()
|
|
29
|
+
_ANALYSIS_SLOTS = threading.BoundedSemaphore(2)
|
|
30
|
+
_WARM_SLOT = threading.BoundedSemaphore(1)
|
|
31
|
+
_WARMING = set()
|
|
32
|
+
_WARM_STATE = {}
|
|
33
|
+
WARM_RETRY_S = 600
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def reconstruct(rows):
|
|
37
|
+
"""Remove sobreposição de chunks e recusa buracos/conflitos; mantém linhas reais."""
|
|
38
|
+
lines = {}
|
|
39
|
+
previous = None
|
|
40
|
+
for start, end, text in rows:
|
|
41
|
+
parts = text.splitlines(keepends=True)
|
|
42
|
+
if len(parts) != end-start+1:
|
|
43
|
+
return None, 'Chunks do not reconstruct the full lines of the file.'
|
|
44
|
+
for offset, part in enumerate(parts):
|
|
45
|
+
line = start+offset
|
|
46
|
+
if line in lines and start == end == line and previous and previous[0] == previous[1] == line and not previous[2].endswith(('\n','\r')):
|
|
47
|
+
lines[line] += part
|
|
48
|
+
elif line in lines and lines[line] != part:
|
|
49
|
+
return None, 'Overlapping chunks contain conflicting versions.'
|
|
50
|
+
else:
|
|
51
|
+
lines[line] = part
|
|
52
|
+
previous = (start,end,text)
|
|
53
|
+
if not lines:
|
|
54
|
+
return '', None
|
|
55
|
+
if min(lines) != 1 or len(lines) != max(lines):
|
|
56
|
+
return None, 'The snapshot has gaps; relations were not inferred for this file.'
|
|
57
|
+
return ''.join(lines[i] for i in range(1,max(lines)+1)), None
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _snapshot(path):
|
|
61
|
+
conn = indexer._readonly(path)
|
|
62
|
+
try:
|
|
63
|
+
conn.execute('BEGIN')
|
|
64
|
+
columns = {row[1] for row in conn.execute('PRAGMA table_info(manifest)')}
|
|
65
|
+
size = 'm.size' if 'size' in columns else 'NULL'
|
|
66
|
+
mtime = 'm.mtime_ns' if 'mtime_ns' in columns else 'NULL'
|
|
67
|
+
records = conn.execute(f'SELECT m.path,m.hash,{size},{mtime},count(c.id),max(c.end_line) FROM manifest m LEFT JOIN chunks c ON c.path=m.path GROUP BY m.path ORDER BY m.path LIMIT ?', (MAX_FILES+1,)).fetchall()
|
|
68
|
+
files, sources, diagnostics, used = [], {}, [], 0
|
|
69
|
+
for original, digest, byte_size, stamp, chunks, line_count in records[:MAX_FILES]:
|
|
70
|
+
file_path = original.replace('\\','/')
|
|
71
|
+
record = {'id':file_path,'path':file_path,'hash':digest,'size':byte_size,'mtime_ns':stamp,
|
|
72
|
+
'chunks':chunks,'lines':line_count or 0,'group':posixpath.dirname(file_path) or '(root)',
|
|
73
|
+
'location_kind':document_text.location_kind(file_path),'format':posixpath.splitext(file_path)[1].lower().lstrip('.') or 'text'}
|
|
74
|
+
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')):
|
|
76
|
+
continue
|
|
77
|
+
rows = conn.execute('SELECT start_line,end_line,text FROM chunks WHERE path=? ORDER BY start_line,id', (original,)).fetchall()
|
|
78
|
+
text, error = reconstruct(rows)
|
|
79
|
+
if error:
|
|
80
|
+
diagnostics.append({'path':file_path,'reason':error})
|
|
81
|
+
elif used+len(text) > MAX_TEXT:
|
|
82
|
+
diagnostics.append({'path':file_path,'reason':'Content limit for this analysis reached.'})
|
|
83
|
+
else:
|
|
84
|
+
used += len(text)
|
|
85
|
+
sources[file_path] = text
|
|
86
|
+
return files, sources, diagnostics, len(records)>MAX_FILES
|
|
87
|
+
finally:
|
|
88
|
+
conn.close()
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _python_graph(sources):
|
|
92
|
+
trees, symbols, dependencies, calls, unresolved, diagnostics = {}, [], [], [], [], []
|
|
93
|
+
decls, aliases, scopes, classes, owners, shadow_scopes, node_symbols = {}, {}, {}, {}, {}, {}, {}
|
|
94
|
+
def bind_import(path,scope,name,binding):
|
|
95
|
+
table=aliases[path].setdefault(scope,{})
|
|
96
|
+
table[name]=binding if name not in table or table[name]==binding else None
|
|
97
|
+
def resolve_file(spec):
|
|
98
|
+
spec=posixpath.normpath(spec)
|
|
99
|
+
return next((p for p in (spec+'.py',spec+'/__init__.py','src/'+spec+'.py','src/'+spec+'/__init__.py') if p in sources), None)
|
|
100
|
+
for path, text in sources.items():
|
|
101
|
+
if not path.endswith('.py'):
|
|
102
|
+
continue
|
|
103
|
+
try:
|
|
104
|
+
tree=ast.parse(text,filename=path)
|
|
105
|
+
except (SyntaxError,ValueError,RecursionError) as exc:
|
|
106
|
+
diagnostics.append({'path':path,'reason':'Incomplete or invalid Python syntax in the snapshot.','line':getattr(exc,'lineno',None)})
|
|
107
|
+
continue
|
|
108
|
+
trees[path]=tree;aliases[path]={};decls[path]={};classes[path]={}
|
|
109
|
+
def collect(node,qual=(),owner=None):
|
|
110
|
+
if isinstance(node,(ast.FunctionDef,ast.AsyncFunctionDef,ast.ClassDef)):
|
|
111
|
+
qual=qual+(node.name,)
|
|
112
|
+
sym={'id':path+'::'+'.'.join(qual)+'@'+str(node.lineno),'path':path,'name':'.'.join(qual),
|
|
113
|
+
'start_line':node.lineno,'end_line':node.end_lineno,'kind':'class' if isinstance(node,ast.ClassDef) else 'function', 'language':'python'}
|
|
114
|
+
decls[path][qual]=sym if qual not in decls[path] else None
|
|
115
|
+
scopes[id(node)]=qual;node_symbols[id(node)]=sym
|
|
116
|
+
if isinstance(node,ast.ClassDef):classes[path][qual]=node
|
|
117
|
+
if len(symbols)<MAX_SYMBOLS:symbols.append(sym)
|
|
118
|
+
owner=sym
|
|
119
|
+
owners[id(node)]=qual
|
|
120
|
+
for child in ast.iter_child_nodes(node):collect(child,qual,owner)
|
|
121
|
+
collect(tree)
|
|
122
|
+
for node in ast.walk(tree):
|
|
123
|
+
if isinstance(node,ast.Import):
|
|
124
|
+
for item in node.names:
|
|
125
|
+
spec=item.name.replace('.','/');target=resolve_file(spec)
|
|
126
|
+
bind_import(path,owners[id(node)],item.asname or item.name,(target,None))
|
|
127
|
+
dependencies.append({'source':path,'target':target,'specifier':item.name,'line':node.lineno,'kind':'import','resolution':'resolved' if target else 'external'})
|
|
128
|
+
elif isinstance(node,ast.ImportFrom):
|
|
129
|
+
prefix=posixpath.dirname(path)
|
|
130
|
+
if node.level:
|
|
131
|
+
for _ in range(node.level-1):prefix=posixpath.dirname(prefix)
|
|
132
|
+
spec=posixpath.join(prefix,(node.module or '').replace('.','/'))
|
|
133
|
+
else:spec=(node.module or '').replace('.','/')
|
|
134
|
+
base=resolve_file(spec)
|
|
135
|
+
for item in node.names:
|
|
136
|
+
target=resolve_file(spec+'/'+item.name) if item.name!='*' else None
|
|
137
|
+
target=target or base
|
|
138
|
+
bind_import(path,owners[id(node)],item.asname or item.name,(target,None if target and target!=base else item.name))
|
|
139
|
+
dependencies.append({'source':path,'target':target,'specifier':('.'*node.level)+(node.module or '')+'.'+item.name,'line':node.lineno,'kind':'import','resolution':'resolved' if target else 'unresolved' if node.level else 'external'})
|
|
140
|
+
def chain(expr):
|
|
141
|
+
if isinstance(expr,ast.Name):return expr.id
|
|
142
|
+
if isinstance(expr,ast.Attribute):
|
|
143
|
+
base=chain(expr.value)
|
|
144
|
+
return base+'.'+expr.attr if base else None
|
|
145
|
+
return None
|
|
146
|
+
def resolve(path,qual,name,shadow,instances):
|
|
147
|
+
if not name:return None
|
|
148
|
+
parts=name.split('.')
|
|
149
|
+
if parts[0] in instances:
|
|
150
|
+
typ=instances[parts[0]]
|
|
151
|
+
return decls.get(typ['path'],{}).get(tuple(typ['name'].split('.'))+tuple(parts[1:]))
|
|
152
|
+
if parts[0] in ('self','cls') and len(parts)>1:
|
|
153
|
+
for i in range(len(qual),0,-1):
|
|
154
|
+
if qual[:i] in classes[path]:return decls[path].get(qual[:i]+tuple(parts[1:]))
|
|
155
|
+
if parts[0] in shadow:return None
|
|
156
|
+
for i in range(len(qual),-1,-1):
|
|
157
|
+
if i<len(qual) and qual[:i] in classes[path]:
|
|
158
|
+
continue # O namespace da classe não é escopo lexical de seus métodos.
|
|
159
|
+
if parts[0] in shadow_scopes.get((path,qual[:i]),set()):
|
|
160
|
+
return None
|
|
161
|
+
if qual[:i]+tuple(parts) in decls[path]:
|
|
162
|
+
return decls[path][qual[:i]+tuple(parts)]
|
|
163
|
+
for length in range(len(parts),0,-1):
|
|
164
|
+
table=aliases[path].get(qual[:i],{})
|
|
165
|
+
binding=table.get('.'.join(parts[:length]))
|
|
166
|
+
if '.'.join(parts[:length]) in table:
|
|
167
|
+
if binding is None:return None
|
|
168
|
+
target_path,imported=binding
|
|
169
|
+
rest=(([imported] if imported and imported!='*' else [])+parts[length:])
|
|
170
|
+
return decls.get(target_path,{}).get(tuple(rest))
|
|
171
|
+
return None
|
|
172
|
+
for path,tree in trees.items():
|
|
173
|
+
module={'id':path+'::<module>','path':path,'name':'<module>','start_line':1,'end_line':1,'kind':'module','language':'python'}
|
|
174
|
+
used_module=False
|
|
175
|
+
def visit_scope(node,qual=()):
|
|
176
|
+
nonlocal used_module
|
|
177
|
+
owner=node_symbols.get(id(node),module)
|
|
178
|
+
shadow=set();instances={}
|
|
179
|
+
args=getattr(node,'args',None)
|
|
180
|
+
if args:
|
|
181
|
+
params=[*args.posonlyargs,*args.args,*args.kwonlyargs]+([args.vararg] if args.vararg else [])+([args.kwarg] if args.kwarg else [])
|
|
182
|
+
shadow.update(arg.arg for arg in params)
|
|
183
|
+
for arg in params:
|
|
184
|
+
target=resolve(path,qual,chain(arg.annotation),set(),{}) if arg.annotation else None
|
|
185
|
+
if target and target['kind']=='class':instances[arg.arg]=target
|
|
186
|
+
# Levanta ligações locais sem atravessar escopos aninhados.
|
|
187
|
+
def local_nodes(n):
|
|
188
|
+
for child in ast.iter_child_nodes(n):
|
|
189
|
+
if isinstance(child,(ast.FunctionDef,ast.AsyncFunctionDef,ast.ClassDef,ast.Lambda)):
|
|
190
|
+
continue
|
|
191
|
+
yield child
|
|
192
|
+
yield from local_nodes(child)
|
|
193
|
+
local=list(local_nodes(node))
|
|
194
|
+
assigned={}
|
|
195
|
+
for n in local:
|
|
196
|
+
if isinstance(n,(ast.Assign,ast.AnnAssign)):
|
|
197
|
+
targets=n.targets if isinstance(n,ast.Assign) else [n.target]
|
|
198
|
+
for target in targets:
|
|
199
|
+
if isinstance(target,ast.Name):
|
|
200
|
+
shadow.add(target.id);assigned.setdefault(target.id,[]).append(n.value)
|
|
201
|
+
for name,values in assigned.items():
|
|
202
|
+
if len(values)==1 and isinstance(values[0],ast.Call):
|
|
203
|
+
typ=resolve(path,qual,chain(values[0].func),shadow-set([name]),instances)
|
|
204
|
+
if typ and typ['kind']=='class':instances[name]=typ
|
|
205
|
+
shadow_scopes[(path,qual)]=shadow
|
|
206
|
+
for n in local:
|
|
207
|
+
if not isinstance(n,ast.Call):continue
|
|
208
|
+
expr=chain(n.func)
|
|
209
|
+
target=resolve(path,qual,expr,shadow,instances)
|
|
210
|
+
if target:
|
|
211
|
+
if owner is module:used_module=True
|
|
212
|
+
if len(calls)<MAX_EDGES:calls.append({'source':owner['id'],'target':target['id'],'line':n.lineno,'kind':'construct' if target['kind']=='class' else 'call','resolution':'static'})
|
|
213
|
+
elif len(unresolved)<1000:
|
|
214
|
+
unresolved.append({'path':path,'line':n.lineno,'expression':expr or '<dynamic expression>','reason':'External, dynamic or unresolved target in this snapshot.'})
|
|
215
|
+
for child in ast.iter_child_nodes(node):
|
|
216
|
+
if isinstance(child,(ast.FunctionDef,ast.AsyncFunctionDef,ast.ClassDef)):
|
|
217
|
+
visit_scope(child,scopes[id(child)])
|
|
218
|
+
else:
|
|
219
|
+
# Definições em blocos if/try também podem declarar escopos.
|
|
220
|
+
def nested(n):
|
|
221
|
+
for c in ast.iter_child_nodes(n):
|
|
222
|
+
if isinstance(c,(ast.FunctionDef,ast.AsyncFunctionDef,ast.ClassDef)):visit_scope(c,scopes[id(c)])
|
|
223
|
+
else:nested(c)
|
|
224
|
+
nested(child)
|
|
225
|
+
visit_scope(tree)
|
|
226
|
+
if used_module:symbols.append(module)
|
|
227
|
+
return {'symbols':symbols,'dependencies':dependencies[:MAX_EDGES],'calls':calls,'unresolved':unresolved,
|
|
228
|
+
'diagnostics':diagnostics,'parsed':list(trees),'truncated':len(symbols)>=MAX_SYMBOLS or len(dependencies)>MAX_EDGES}
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def _javascript_graph(sources, all_paths=None):
|
|
232
|
+
extensions=('.js','.jsx','.ts','.tsx','.mjs','.cjs','.mts','.cts')
|
|
233
|
+
relevant={path:text for path,text in sources.items() if path.endswith(extensions+('.json','.html','.htm'))}
|
|
234
|
+
if not any(path.endswith(extensions) for path in relevant):
|
|
235
|
+
return {}
|
|
236
|
+
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)
|
|
244
|
+
except (OSError,subprocess.TimeoutExpired,ValueError,RuntimeError) as exc:
|
|
245
|
+
return {'diagnostics':[{'path':'JavaScript/TypeScript','reason':str(exc)[:250]}],'parsed':[],'retryable':True}
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def _java_graph(sources):
|
|
249
|
+
files=[{'path':path,'text':text} for path,text in sources.items() if path.lower().endswith('.java')]
|
|
250
|
+
if not files:return {}
|
|
251
|
+
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)
|
|
257
|
+
except (OSError,ValueError,RuntimeError,subprocess.TimeoutExpired) as exc:
|
|
258
|
+
return {'diagnostics':[{'path':'Java','reason':str(exc)[:250]}],'parsed':[],'retryable':True}
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def _analyze(path):
|
|
262
|
+
files,sources,diagnostics,truncated=_snapshot(path)
|
|
263
|
+
merged={'symbols':[],'dependencies':[],'calls':[],'unresolved':[],'diagnostics':diagnostics,'parsed':[]}
|
|
264
|
+
retryable=False
|
|
265
|
+
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])):
|
|
266
|
+
for key in merged:merged[key].extend(output.get(key,[]))
|
|
267
|
+
truncated |= output.get('truncated',False)
|
|
268
|
+
retryable |= output.get('retryable',False)
|
|
269
|
+
by_id={sym['id']:sym for sym in merged['symbols'][:MAX_SYMBOLS]}
|
|
270
|
+
# Relações repetidas preservam contagem e locais de chamada sem poluir o desenho.
|
|
271
|
+
def collapse(rows,fields):
|
|
272
|
+
result={}
|
|
273
|
+
for row in rows:
|
|
274
|
+
key=tuple(row.get(field) for field in fields)
|
|
275
|
+
if key not in result:result[key]={**row,'count':0,'lines':[]}
|
|
276
|
+
result[key]['count']+=1
|
|
277
|
+
if len(result[key]['lines'])<20:result[key]['lines'].append(row.get('line'))
|
|
278
|
+
return list(result.values())
|
|
279
|
+
deps=collapse(merged['dependencies'],('source','target','specifier','kind'))
|
|
280
|
+
calls=collapse([r for r in merged['calls'] if r['source'] in by_id and r['target'] in by_id],('source','target','kind'))
|
|
281
|
+
supported=set(merged['parsed'])
|
|
282
|
+
for file in files:file['analysis']='parsed' if file['path'] in supported else 'not_analyzed'
|
|
283
|
+
return {'files':files,'symbols':merged['symbols'][:MAX_SYMBOLS],
|
|
284
|
+
'dependencies':deps[:MAX_EDGES],'calls':calls[:MAX_EDGES],
|
|
285
|
+
'unresolved':merged['unresolved'][:1000],'diagnostics':merged['diagnostics'],
|
|
286
|
+
'truncated':truncated or len(deps)>MAX_EDGES or len(calls)>MAX_EDGES,'retryable':retryable,
|
|
287
|
+
'counts':{'files':len(files),'parsed_files':len(supported),'symbols':len(merged['symbols']),
|
|
288
|
+
'resolved_imports':sum(bool(r['target']) for r in deps),
|
|
289
|
+
'external_imports':sum(r['resolution']=='external' for r in deps),
|
|
290
|
+
'unresolved_calls':len(merged['unresolved']),'calls':len(calls)}}
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def _focus(data,file_path):
|
|
294
|
+
if not isinstance(file_path,str):
|
|
295
|
+
raise ValueError('file_path must be a string.')
|
|
296
|
+
target=file_path.replace('\\','/').removeprefix('./')
|
|
297
|
+
files=[f for f in data['files'] if f['path']==target]
|
|
298
|
+
if not files:
|
|
299
|
+
raise ValueError(f'File is outside the indexed view: {file_path}')
|
|
300
|
+
by_id={s['id']:s for s in data['symbols']}
|
|
301
|
+
own={s['id'] for s in data['symbols'] if s.get('path')==target}
|
|
302
|
+
calls=[c for c in data['calls'] if c['source'] in own or c['target'] in own]
|
|
303
|
+
linked=own|{c['source'] for c in calls}|{c['target'] for c in calls}
|
|
304
|
+
deps=[d for d in data['dependencies'] if target in (d['source'],d['target'])]
|
|
305
|
+
paths={f['path'] for f in data['files']}
|
|
306
|
+
data.update(files=files,symbols=[by_id[i] for i in linked if i in by_id],dependencies=deps,calls=calls,
|
|
307
|
+
unresolved=[u for u in data['unresolved'] if u.get('path')==target],
|
|
308
|
+
diagnostics=[d for d in data['diagnostics'] if d.get('path')==target or d.get('path') not in paths],
|
|
309
|
+
focus={'file_path':target,'symbols':len(own),'imported_by':sum(d['target']==target for d in deps),
|
|
310
|
+
'imports':sum(d['source']==target for d in deps),
|
|
311
|
+
'incoming_calls':sum(c['target'] in own and c['source'] not in own for c in calls),
|
|
312
|
+
'outgoing_calls':sum(c['source'] in own and c['target'] not in own for c in calls)})
|
|
313
|
+
return data
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
def _by_file(data):
|
|
317
|
+
by_file={}
|
|
318
|
+
for symbol in data['symbols']:
|
|
319
|
+
by_file.setdefault(symbol['path'],[]).append(symbol)
|
|
320
|
+
parsed={f['path'] for f in data['files'] if f.get('analysis')=='parsed'}
|
|
321
|
+
note='Graph truncated by analysis limits; some files have no functions.' if data.get('truncated') else None
|
|
322
|
+
return {'symbols':by_file,'parsed':parsed,'note':note}
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
def cached_symbols(root):
|
|
326
|
+
path=indexer.existing_db_path(root)
|
|
327
|
+
if not path:
|
|
328
|
+
return None,None
|
|
329
|
+
stat=os.stat(path);key=(path,stat.st_mtime_ns,stat.st_size,VERSION)
|
|
330
|
+
with _LOCK:
|
|
331
|
+
data=_CACHE.get(key)
|
|
332
|
+
if data is not None:
|
|
333
|
+
_CACHE.move_to_end(key)
|
|
334
|
+
state=_WARM_STATE.get(root)
|
|
335
|
+
if data is None and state and state['key']==key and (state['result'] or time.time()-state['at']<WARM_RETRY_S):
|
|
336
|
+
return state['result'],state['note']
|
|
337
|
+
start=data is None and root not in _WARMING
|
|
338
|
+
if start:_WARMING.add(root)
|
|
339
|
+
if data is not None:
|
|
340
|
+
return _by_file(data),None
|
|
341
|
+
if start:
|
|
342
|
+
context=contextvars.copy_context()
|
|
343
|
+
threading.Thread(target=context.run,args=(_warm,root,key),daemon=True).start()
|
|
344
|
+
return None,None
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
def _warm(root,key):
|
|
348
|
+
result,note=None,None
|
|
349
|
+
try:
|
|
350
|
+
data=build(root,background=True)
|
|
351
|
+
if data.get('retryable'):
|
|
352
|
+
result=_by_file(data)
|
|
353
|
+
reasons='; '.join(d.get('reason','') for d in data.get('diagnostics',[])[:2])
|
|
354
|
+
note=f'Partial analysis: {reasons}'[:300]
|
|
355
|
+
except Exception as exc:
|
|
356
|
+
note=f'The last analysis failed: {type(exc).__name__}: {exc}'[:300]
|
|
357
|
+
finally:
|
|
358
|
+
with _LOCK:
|
|
359
|
+
if note:_WARM_STATE[root]={'key':key,'at':time.time(),'result':result,'note':note}
|
|
360
|
+
else:_WARM_STATE.pop(root,None)
|
|
361
|
+
_WARMING.discard(root)
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
def build(root,view_id=None,storage_id=None,file_path=None,background=False):
|
|
365
|
+
inventory=index_inventory.inspect(root,view_id,storage_id=storage_id)
|
|
366
|
+
if not inventory['selected']:
|
|
367
|
+
return {**inventory,'symbols':[],'dependencies':[],'calls':[],'diagnostics':[],'counts':{}}
|
|
368
|
+
path=inventory['selected']['path'];stat=os.stat(path)
|
|
369
|
+
key=(path,stat.st_mtime_ns,stat.st_size,VERSION)
|
|
370
|
+
with _LOCK:
|
|
371
|
+
cached=_CACHE.get(key)
|
|
372
|
+
if cached:_CACHE.move_to_end(key)
|
|
373
|
+
was_cached=cached is not None
|
|
374
|
+
if cached is None:
|
|
375
|
+
slots=_WARM_SLOT if background else _ANALYSIS_SLOTS
|
|
376
|
+
if not slots.acquire(timeout=None if background else 3):
|
|
377
|
+
raise RuntimeError('Two map analyses are in progress. Wait for them to finish and reload.')
|
|
378
|
+
try:
|
|
379
|
+
cached=_analyze(path)
|
|
380
|
+
finally:
|
|
381
|
+
slots.release()
|
|
382
|
+
after=os.stat(path)
|
|
383
|
+
if not cached['retryable'] and (after.st_mtime_ns,after.st_size)==(stat.st_mtime_ns,stat.st_size):
|
|
384
|
+
with _LOCK:
|
|
385
|
+
_CACHE[key]=cached
|
|
386
|
+
while len(_CACHE)>3:_CACHE.popitem(last=False)
|
|
387
|
+
data=copy.deepcopy(cached)
|
|
388
|
+
# Só compara metadados do diretório de trabalho quando a visão é a atual.
|
|
389
|
+
for file in data['files']:
|
|
390
|
+
file['status']='stored'
|
|
391
|
+
if inventory['selected']['current'] and file.get('mtime_ns'):
|
|
392
|
+
candidate=os.path.join(root,file['path'])
|
|
393
|
+
if project_identity.within_root(root,candidate):
|
|
394
|
+
try:
|
|
395
|
+
current=os.stat(candidate)
|
|
396
|
+
file['status']='indexed' if (current.st_mtime_ns,current.st_size)==(file['mtime_ns'],file['size']) else 'pending'
|
|
397
|
+
except OSError:file['status']='missing'
|
|
398
|
+
file.pop('mtime_ns',None)
|
|
399
|
+
data.update(views=inventory['views'],selected=inventory['selected'],current_view=inventory['current_view'],
|
|
400
|
+
message=inventory['message'],read_only=True,source='indexed_snapshot',generated_at=time.time(),
|
|
401
|
+
limits={'files':MAX_FILES,'symbols':MAX_SYMBOLS,'edges':MAX_EDGES,'display_nodes_default':120},
|
|
402
|
+
languages=['Java','Angular','Python','JavaScript','TypeScript','JSX','TSX','HTML','CSS','Markdown'],cache_hit=was_cached)
|
|
403
|
+
return _focus(data,file_path) if file_path else data
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
// Análise estática conservadora de Java a partir de snapshots; nenhum código é executado.
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import {parse} from './web_adapters/node/node_modules/java-parser/src/index.js';
|
|
4
|
+
const input=JSON.parse(fs.readFileSync(0,'utf8'));
|
|
5
|
+
const symbols=[],dependencies=[],calls=[],unresolved=[],diagnostics=[],units=[],classes=new Map(),methodNodes=new Map();
|
|
6
|
+
const MAX_SYMBOLS=8000,MAX_EDGES=20000;
|
|
7
|
+
function children(n){return n?.children?Object.values(n.children).flat().filter(x=>x.children):[]}
|
|
8
|
+
function all(n,name,stop=false){const result=[];function visit(node){if(node?.name===name){result.push(node);if(stop)return}for(const child of children(node))visit(child)}visit(n);return result}
|
|
9
|
+
function first(n,name){return all(n,name,true)[0]}
|
|
10
|
+
function identifiers(n){const out=[];function visit(node){if(node?.children)for(const [key,items] of Object.entries(node.children))for(const child of items){if(child.children)visit(child);else if(key==='Identifier')out.push(child)}}visit(n);return out.sort((a,b)=>a.startOffset-b.startOffset)}
|
|
11
|
+
function text(u,n){return n?.location?u.text.slice(n.location.startOffset,n.location.endOffset+1):''}
|
|
12
|
+
function addSymbol(u,n,name,kind){if(symbols.length>=MAX_SYMBOLS)return null;const s={id:u.path+'::'+name+'@'+n.location.startLine+':'+n.location.startOffset,path:u.path,name,kind,language:'java',start_line:n.location.startLine,end_line:n.location.endLine};symbols.push(s);return s}
|
|
13
|
+
const classKinds=new Set(['normalClassDeclaration','normalInterfaceDeclaration','enumDeclaration','recordDeclaration']);
|
|
14
|
+
for(const file of input.files){try{const tree=parse(file.text),pkg=first(tree,'packageDeclaration'),unit={...file,tree,package:pkg?identifiers(pkg).map(t=>t.image).join('.'): '',imports:[],classes:[]};
|
|
15
|
+
for(const n of all(tree,'importDeclaration')){const raw=text(unit,n).replace(/^import\s+/,'').replace(/;\s*$/,'').trim(),stat=raw.startsWith('static '),spec=(stat?raw.slice(7):raw).replace(/\s+/g,'');unit.imports.push({spec,static:stat,line:n.location.startLine})}
|
|
16
|
+
function scan(n,parent=null){let current=parent;if(classKinds.has(n.name)){const id=first(n,'typeIdentifier')||first(n,'typeName'),short=identifiers(id||n)[0]?.image;if(!short)return;const local=(parent?parent.local+'.':'')+short,fqn=(unit.package?unit.package+'.':'')+local;const symbol=addSymbol(unit,n,local,n.name==='normalInterfaceDeclaration'?'interface':'class');if(!symbol)return;current={unit,fqn,local,short,symbol,node:n,body:n.children.classBody?.[0]||n.children.interfaceBody?.[0]||n.children.recordBody?.[0]||n.children.enumBody?.[0],methods:[],fields:new Map(),extends:text(unit,n.children.classExtends?.[0]).replace(/^extends\s+/, '').trim()};classes.set(fqn,classes.has(fqn)?null:current);unit.classes.push(current)}
|
|
17
|
+
if(current&&n.name==='classBody'&&n!==current.body)return;
|
|
18
|
+
if(current&&['methodDeclaration','interfaceMethodDeclaration','constructorDeclaration','compactConstructorDeclaration'].includes(n.name)){const declarator=first(n,'methodDeclarator')||first(n,'constructorDeclarator'),ctor=n.name.includes('Constructor')||n.name==='constructorDeclaration',name=ctor?current.short:(declarator?.children.Identifier?.[0]?.image);if(name){const params=all(declarator||n,'formalParameter'),types=params.map(p=>text(unit,first(p,'unannType'))||'?');const signature=name+'('+types.join(',')+')';const sym=addSymbol(unit,n,current.local+'.'+signature,ctor?'constructor':'method');if(sym){sym.signature=signature;const method={symbol:sym,name,arity:params.length,types,node:n,cls:current,params,concrete:!!first(n,'block')||ctor};current.methods.push(method);methodNodes.set(n,method)}}}
|
|
19
|
+
for(const child of children(n))scan(child,current)}scan(tree);units.push(unit)
|
|
20
|
+
}catch{diagnostics.push({path:file.path,reason:'Invalid or fragmented Java, or syntax not supported by the parser.'})}}
|
|
21
|
+
function resolveType(unit,raw,context){let name=raw.replace(/@[\w.]+(?:\([^)]*\))?/g,'').replace(/<.*>/g,'').replace(/\[\]|\.\.\./g,'').trim();if(!name||['void','boolean','byte','short','int','long','float','double','char','var'].includes(name))return null;if(classes.has(name))return classes.get(name);const candidates=new Set();const local=[context?context.fqn+'.'+name:'',unit.package?unit.package+'.'+name:name];for(const fq of local)if(classes.has(fq))candidates.add(fq);for(const item of unit.imports.filter(i=>!i.static)){if(item.spec.endsWith('.'+name)&&classes.has(item.spec))candidates.add(item.spec);if(item.spec.endsWith('.*')){const fq=item.spec.slice(0,-1)+name;if(classes.has(fq))candidates.add(fq)}}return candidates.size===1?classes.get([...candidates][0]):null}
|
|
22
|
+
function lookupMethod(cls,name,arity,visited=new Set()){if(!cls||visited.has(cls.fqn))return null;visited.add(cls.fqn);const methods=cls.methods.filter(m=>m.name===name&&m.arity===arity&&m.concrete);if(methods.length===1)return methods[0].symbol;if(methods.length>1)return null;return cls.extends?lookupMethod(resolveType(cls.unit,cls.extends,cls),name,arity,visited):null}
|
|
23
|
+
function putBinding(map,name,type){if(name)map.set(name,map.has(name)&&map.get(name)!==type?null:type)}
|
|
24
|
+
function scoped(node,name){const out=[];function scan(n){if(n!==node&&(classKinds.has(n.name)||n.name==='lambdaExpression'||n.name==='classBody'))return;if(n.name===name)out.push(n);for(const c of children(n))scan(c)}scan(node);return out}
|
|
25
|
+
function collectVariables(unit,node,map){for(const parentName of ['fieldDeclaration','localVariableDeclaration','variableParaRegularParameter','variableArityParameter'])for(const declaration of scoped(node,parentName)){const type=text(unit,first(declaration,'unannType'));for(const id of all(declaration,'variableDeclaratorId'))putBinding(map,identifiers(id)[0]?.image,type)}}
|
|
26
|
+
for(const unit of units){for(const cl of unit.classes){const fieldNodes=scoped(cl.body,'fieldDeclaration');for(const node of fieldNodes){const type=text(unit,first(node,'unannType'));for(const id of all(node,'variableDeclaratorId'))putBinding(cl.fields,identifiers(id)[0]?.image,type)}}}
|
|
27
|
+
for(const unit of units){
|
|
28
|
+
for(const item of unit.imports){const spec=item.spec;let target=classes.get(item.static?spec.split('.').slice(0,-1).join('.'):spec);dependencies.push({source:unit.path,target:target?.unit.path||null,specifier:spec,line:item.line,kind:item.static?'java_static_import':'import',resolution:target?'resolved':'external'})}
|
|
29
|
+
function walk(n,cls=null,method=null,vars=new Map(),owner=null){if(classKinds.has(n.name))cls=unit.classes.find(c=>c.node===n)||cls;if(cls&&n.name==='classBody'&&n!==cls.body)return;
|
|
30
|
+
if(methodNodes.has(n)){method=methodNodes.get(n);vars=new Map(cls.fields);collectVariables(unit,n,vars);owner=method.symbol}
|
|
31
|
+
if(n.name==='lambdaExpression'){vars=new Map(vars);for(const param of identifiers(n.children.lambdaParameters?.[0]))vars.set(param.image,null);owner=addSymbol(unit,n,(cls?.local||'')+'.lambda@'+n.location.startLine,'function')||owner}
|
|
32
|
+
if(cls&&['unannType','classType','interfaceType'].includes(n.name)){const type=resolveType(unit,text(unit,n),cls);if(type&&type.unit.path!==unit.path&&dependencies.length<MAX_EDGES)dependencies.push({source:unit.path,target:type.unit.path,specifier:type.fqn,line:n.location.startLine,kind:'java_type',resolution:'resolved'})}
|
|
33
|
+
if(cls&&n.name==='primary'){
|
|
34
|
+
const prefix=n.children.primaryPrefix?.[0],prefixText=text(unit,prefix).trim(),suffixes=n.children.primarySuffix||[];let chain=prefixText,fluent=false;
|
|
35
|
+
const newMatch=/^new\s+([\w.$]+)/.exec(prefixText);let constructed=newMatch?resolveType(unit,newMatch[1],cls):null;
|
|
36
|
+
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'});
|
|
37
|
+
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]}}
|
|
39
|
+
if(constructed&&!fluent){const methodName=/\.([\w$]+)$/.exec(chain)?.[1];if(methodName)target=lookupMethod(constructed,methodName,arity)}
|
|
40
|
+
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
|
+
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.'});
|
|
42
|
+
fluent=true;chain+='<result>';
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
for(const child of children(n))walk(child,cls,method,vars,owner)
|
|
46
|
+
}walk(unit.tree)
|
|
47
|
+
}
|
|
48
|
+
const ids=new Set(symbols.map(s=>s.id));
|
|
49
|
+
process.stdout.write(JSON.stringify({symbols,dependencies:dependencies.slice(0,MAX_EDGES),calls:calls.filter(c=>ids.has(c.source)&&ids.has(c.target)),unresolved,diagnostics,parsed:units.map(u=>u.path),truncated:symbols.length>=MAX_SYMBOLS||dependencies.length>MAX_EDGES||calls.length>=MAX_EDGES}));
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
/* Análise estática de snapshots JS/TS. Não executa código nem lê arquivos do projeto. */
|
|
2
|
+
const fs = require('node:fs');
|
|
3
|
+
const path = require('node:path').posix;
|
|
4
|
+
const ts = require('./web_adapters/node/node_modules/typescript');
|
|
5
|
+
const input = JSON.parse(fs.readFileSync(0, 'utf8'));
|
|
6
|
+
const clean = name => path.normalize(name.replaceAll('\\', '/')).replace(/^\.\//, '');
|
|
7
|
+
const docs = new Map(input.files.map(f => ['/snapshot/' + clean(f.path), f]));
|
|
8
|
+
const knownFiles = new Set([...docs.keys(),...(input.paths||[]).map(p=>'/snapshot/'+clean(p))]);
|
|
9
|
+
const names = [...docs.keys()].filter(n => /\.(?:[cm]?[jt]s|[jt]sx)$/.test(n));
|
|
10
|
+
const options = {allowJs:true, checkJs:true, jsx:ts.JsxEmit.Preserve, target:ts.ScriptTarget.Latest,
|
|
11
|
+
module:ts.ModuleKind.ESNext, moduleResolution:ts.ModuleResolutionKind.Bundler,
|
|
12
|
+
noLib:true, noEmit:true, skipLibCheck:true, allowImportingTsExtensions:true};
|
|
13
|
+
let configPath = docs.has('/snapshot/tsconfig.json') ? '/snapshot/tsconfig.json' : '/snapshot/jsconfig.json';
|
|
14
|
+
const configSeen = new Set();
|
|
15
|
+
function readConfig(name, depth=0) {
|
|
16
|
+
if (depth>5 || configSeen.has(name) || !docs.has(name)) return {};
|
|
17
|
+
configSeen.add(name);
|
|
18
|
+
const raw = ts.parseConfigFileTextToJson(name, docs.get(name).text).config || {};
|
|
19
|
+
const base = typeof raw.extends==='string' && raw.extends.startsWith('.') ? readConfig(clean(path.join(path.dirname(name),raw.extends.endsWith('.json')?raw.extends:raw.extends+'.json')),depth+1) : {};
|
|
20
|
+
return {...base,...raw.compilerOptions};
|
|
21
|
+
}
|
|
22
|
+
const config=readConfig(configPath);
|
|
23
|
+
const aliases=config.paths||{},baseUrl=clean(path.join(path.dirname(configPath),config.baseUrl||'.'));
|
|
24
|
+
function candidate(base) {
|
|
25
|
+
const variants=[base,base.replace(/\.[cm]?jsx?$/,'.ts'),base.replace(/\.jsx?$/,'.tsx'),
|
|
26
|
+
...['.ts','.tsx','.js','.jsx','.mts','.cts','.mjs','.cjs'].map(e=>base+e),
|
|
27
|
+
...['.ts','.tsx','.js','.jsx'].map(e=>base+'/index'+e)];
|
|
28
|
+
return variants.find(name=>knownFiles.has(clean(name)));
|
|
29
|
+
}
|
|
30
|
+
function resolve(spec, from) {
|
|
31
|
+
if (spec.startsWith('.')) return candidate(clean(path.join(path.dirname(from),spec)));
|
|
32
|
+
for (const [pattern, targets] of Object.entries(aliases)) {
|
|
33
|
+
const [prefix,suffix='']=pattern.split('*');
|
|
34
|
+
if (pattern.includes('*') ? spec.startsWith(prefix)&&spec.endsWith(suffix) : spec===pattern) {
|
|
35
|
+
const middle=pattern.includes('*')?spec.slice(prefix.length,suffix? -suffix.length:undefined):'';
|
|
36
|
+
for (const target of Array.isArray(targets)?targets:[]) {
|
|
37
|
+
const found=candidate(clean(path.join(baseUrl,target.replace('*',middle))));
|
|
38
|
+
if(found)return found;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
if(config.baseUrl)return candidate(clean(path.join(baseUrl,spec)));
|
|
43
|
+
return undefined;
|
|
44
|
+
}
|
|
45
|
+
const host={
|
|
46
|
+
getSourceFile:(name,version)=>docs.has(clean(name))?ts.createSourceFile(clean(name),docs.get(clean(name)).text,version,true):undefined,
|
|
47
|
+
getDefaultLibFileName:()=>'/lib.d.ts',writeFile:()=>{},getCurrentDirectory:()=>'/snapshot',
|
|
48
|
+
getDirectories:()=>[],fileExists:name=>docs.has(clean(name)),readFile:name=>docs.get(clean(name))?.text,
|
|
49
|
+
getCanonicalFileName:name=>clean(name),useCaseSensitiveFileNames:()=>true,getNewLine:()=> '\n',
|
|
50
|
+
resolveModuleNames:(modules,containing)=>modules.map(spec=>{const resolved=resolve(spec,containing);return resolved&&docs.has(resolved)&&/\.(?:[cm]?[jt]s|[jt]sx|json)$/.test(resolved)?{resolvedFileName:resolved,extension:ts.extensionFromPath(resolved),isExternalLibraryImport:false}:undefined;})
|
|
51
|
+
};
|
|
52
|
+
const program=ts.createProgram(names,options,host),checker=program.getTypeChecker();
|
|
53
|
+
const symbols=[],dependencies=[],calls=[],unresolved=[],diagnostics=[],declarations=new Map(),moduleNodes=new Map();
|
|
54
|
+
const maxSymbols=8000,maxEdges=20000;
|
|
55
|
+
function filePath(source){return source.fileName.replace('/snapshot/','');}
|
|
56
|
+
function line(source,pos){return source.getLineAndCharacterOfPosition(pos).line+1;}
|
|
57
|
+
function displayName(node) {
|
|
58
|
+
if(node.name)return node.name.getText().replace(/^['"]|['"]$/g,'');
|
|
59
|
+
if(ts.isConstructorDeclaration(node))return 'constructor';
|
|
60
|
+
if(node.parent && ts.isVariableDeclaration(node.parent))return node.parent.name.getText();
|
|
61
|
+
if(node.parent && ts.isPropertyAssignment(node.parent))return node.parent.name.getText();
|
|
62
|
+
if(node.parent && ts.isPropertyDeclaration(node.parent))return node.parent.name.getText();
|
|
63
|
+
if(node.parent && ts.isExportAssignment(node.parent))return 'default';
|
|
64
|
+
return 'callback';
|
|
65
|
+
}
|
|
66
|
+
function isFunction(node){return !!node.body && (ts.isFunctionDeclaration(node)||ts.isMethodDeclaration(node)||ts.isFunctionExpression(node)||ts.isArrowFunction(node)||ts.isConstructorDeclaration(node)||ts.isGetAccessor(node)||ts.isSetAccessor(node));}
|
|
67
|
+
function addSymbol(node,source,owner){
|
|
68
|
+
if(symbols.length>=maxSymbols)return null;
|
|
69
|
+
let name=displayName(node),parent=node.parent;
|
|
70
|
+
const classParent=parent&&ts.isPropertyDeclaration(parent)?parent.parent:parent;
|
|
71
|
+
const cls=classParent && (ts.isClassDeclaration(classParent)||ts.isClassExpression(classParent)) ? classParent.name?.getText() : null;
|
|
72
|
+
if(cls)name=cls+'.'+name;
|
|
73
|
+
const start=line(source,node.getStart(source)),id=filePath(source)+'::'+name+'@'+start+':'+node.pos;
|
|
74
|
+
const symbol={id,path:filePath(source),name,start_line:start,end_line:line(source,node.end),kind:cls?'method':'function',language:'typescript',parent:owner?.id||null};
|
|
75
|
+
symbols.push(symbol);declarations.set(node,symbol);return symbol;
|
|
76
|
+
}
|
|
77
|
+
const parsed=[];
|
|
78
|
+
for(const source of program.getSourceFiles()){
|
|
79
|
+
if(!docs.has(source.fileName))continue;
|
|
80
|
+
if(source.parseDiagnostics.length){diagnostics.push({path:filePath(source),reason:'Incomplete or invalid syntax in the snapshot.',line:line(source,source.parseDiagnostics[0].start||0)});continue;}
|
|
81
|
+
parsed.push(source);
|
|
82
|
+
function collect(node,owner){const current=isFunction(node)?addSymbol(node,source,owner):owner;ts.forEachChild(node,child=>collect(child,current));}
|
|
83
|
+
collect(source,null);
|
|
84
|
+
}
|
|
85
|
+
function moduleSymbol(source){if(!moduleNodes.has(source.fileName)){const s={id:filePath(source)+'::<module>',path:filePath(source),name:'<module>',start_line:1,end_line:1,kind:'module',language:'typescript'};moduleNodes.set(source.fileName,s);symbols.push(s);}return moduleNodes.get(source.fileName);}
|
|
86
|
+
function targetFor(node){
|
|
87
|
+
const signature=checker.getResolvedSignature(node);let target=declarations.get(signature?.declaration);
|
|
88
|
+
if(target)return target;
|
|
89
|
+
let symbol=checker.getSymbolAtLocation(node.expression);
|
|
90
|
+
if(symbol?.flags & ts.SymbolFlags.Alias)symbol=checker.getAliasedSymbol(symbol);
|
|
91
|
+
for(const declaration of symbol?.declarations||[]){
|
|
92
|
+
if(declarations.has(declaration))return declarations.get(declaration);
|
|
93
|
+
if(ts.isVariableDeclaration(declaration)&&declarations.has(declaration.initializer))return declarations.get(declaration.initializer);
|
|
94
|
+
}
|
|
95
|
+
return null;
|
|
96
|
+
}
|
|
97
|
+
for(const source of parsed){
|
|
98
|
+
function visit(node,owner){
|
|
99
|
+
if(isFunction(node) && !declarations.has(node))return;
|
|
100
|
+
const active=declarations.get(node)||owner;
|
|
101
|
+
if((ts.isImportDeclaration(node)||ts.isExportDeclaration(node)) && node.moduleSpecifier && ts.isStringLiteral(node.moduleSpecifier)) {
|
|
102
|
+
const spec=node.moduleSpecifier.text,target=resolve(spec,source.fileName);
|
|
103
|
+
if(dependencies.length<maxEdges)dependencies.push({source:filePath(source),target:target?.replace('/snapshot/','')||null,specifier:spec,line:line(source,node.getStart(source)),kind:node.importClause?.isTypeOnly||node.isTypeOnly?'type_import':'import',resolution:target?'resolved':spec.startsWith('.')?'unresolved':'external'});
|
|
104
|
+
}
|
|
105
|
+
if(ts.isCallExpression(node)||ts.isNewExpression(node)){
|
|
106
|
+
const expression=node.expression,at=line(source,node.getStart(source));
|
|
107
|
+
if(ts.isCallExpression(node) && (expression.kind===ts.SyntaxKind.ImportKeyword || ts.isIdentifier(expression)&&expression.text==='require') && node.arguments?.length && ts.isStringLiteral(node.arguments[0])){
|
|
108
|
+
const spec=node.arguments[0].text,target=resolve(spec,source.fileName);
|
|
109
|
+
if(dependencies.length<maxEdges)dependencies.push({source:filePath(source),target:target?.replace('/snapshot/','')||null,specifier:spec,line:at,kind:'dynamic_import',resolution:target?'resolved':spec.startsWith('.')?'unresolved':'external'});
|
|
110
|
+
}else{
|
|
111
|
+
const target=targetFor(node);
|
|
112
|
+
if(target && calls.length<maxEdges)calls.push({source:(active||moduleSymbol(source)).id,target:target.id,line:at,kind:ts.isNewExpression(node)?'construct':'call',resolution:'static'});
|
|
113
|
+
else if(unresolved.length<1000)unresolved.push({path:filePath(source),line:at,expression:expression.getText(source).slice(0,120),reason:'External or dynamic target, or no resolved implementation in the snapshot.'});
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
ts.forEachChild(node,child=>visit(child,active));
|
|
117
|
+
}
|
|
118
|
+
visit(source,null);
|
|
119
|
+
}
|
|
120
|
+
// Metadados Angular são lidos da AST; templates são analisados pelo compilador,
|
|
121
|
+
// sem inicializar a aplicação, resolver DI em runtime ou executar seus decorators.
|
|
122
|
+
const components=[],pipes=[],templatePaths=new Set();
|
|
123
|
+
for(const source of parsed){
|
|
124
|
+
const angularAliases=new Map(),angularNamespaces=new Set();
|
|
125
|
+
for(const statement of source.statements){if(ts.isImportDeclaration(statement)&&statement.moduleSpecifier.text==='@angular/core'){const bindings=statement.importClause?.namedBindings;if(bindings&&ts.isNamedImports(bindings))for(const spec of bindings.elements)angularAliases.set(spec.name.text,(spec.propertyName||spec.name).text);else if(bindings&&ts.isNamespaceImport(bindings))angularNamespaces.add(bindings.name.text)}}
|
|
126
|
+
function visitClasses(node){if(ts.isClassDeclaration(node))for(const decorator of ts.canHaveDecorators(node)?ts.getDecorators(node)||[]:[]){const call=decorator.expression;if(!ts.isCallExpression(call)||!call.arguments.length||!ts.isObjectLiteralExpression(call.arguments[0]))continue;let type=null;if(ts.isIdentifier(call.expression)){const symbol=checker.getSymbolAtLocation(call.expression);if(symbol?.declarations?.some(ts.isImportSpecifier))type=angularAliases.get(call.expression.text)}else if(ts.isPropertyAccessExpression(call.expression)&&angularNamespaces.has(call.expression.expression.getText(source)))type=call.expression.name.text;if(!['Component','Pipe'].includes(type))continue;const metadata=new Map(call.arguments[0].properties.filter(ts.isPropertyAssignment).map(p=>[p.name.getText(source).replace(/^['"]|['"]$/g,''),p.initializer]));const methods=new Map();for(const member of node.members){const symbol=declarations.get(member)||declarations.get(member.initializer);if(symbol&&member.name)methods.set(member.name.getText(source).replace(/^['"]|['"]$/g,''),symbol)}const literal=n=>n&&ts.isStringLiteralLike(n)?n.text:null;const entry={source,node,name:node.name?.text||'<component>',methods,selector:literal(metadata.get('selector'))};if(type==='Pipe'){entry.pipeName=literal(metadata.get('name'));pipes.push(entry);continue}const template=metadata.get('template'),url=literal(metadata.get('templateUrl'));entry.template=literal(template);entry.templatePath=source.fileName;entry.inlineLine=template?line(source,template.getStart(source)):1;if(url){const target=resolve(url.startsWith('.')?url:'./'+url,source.fileName);entry.templatePath=target;entry.template=target?docs.get(target)?.text:null;entry.inlineLine=1;dependencies.push({source:filePath(source),target:target?.replace('/snapshot/','')||null,specifier:url,line:line(source,node.getStart(source)),kind:'angular_template',resolution:target?'resolved':'unresolved'})}for(const key of ['styleUrl','styleUrls']){const value=metadata.get(key),values=value&&ts.isArrayLiteralExpression(value)?value.elements:[value];for(const item of values){const style=literal(item);if(!style)continue;const target=resolve(style.startsWith('.')?style:'./'+style,source.fileName);dependencies.push({source:filePath(source),target:target?.replace('/snapshot/','')||null,specifier:style,line:line(source,item.getStart(source)),kind:'angular_style',resolution:target?'resolved':'unresolved'})}}components.push(entry)}ts.forEachChild(node,visitClasses)}visitClasses(source)
|
|
127
|
+
}
|
|
128
|
+
if(components.some(c=>c.template!=null)){
|
|
129
|
+
try{
|
|
130
|
+
const {parseTemplate}=require('./web_adapters/node/node_modules/@angular/compiler/fesm2022/compiler.mjs');
|
|
131
|
+
for(const component of components){if(component.template==null||!component.templatePath)continue;const pathName=component.templatePath.replace('/snapshot/',''),tree=parseTemplate(component.template,pathName);if(tree.errors?.length){diagnostics.push({path:pathName,reason:'Invalid Angular template or syntax not supported in this version.'});continue}templatePaths.add(pathName);const owner={id:pathName+'::'+filePath(component.source)+'::'+component.name+'.template@'+component.inlineLine,path:pathName,name:component.name+'.template',kind:'template',language:'angular',start_line:component.inlineLine,end_line:component.inlineLine+component.template.split('\n').length-1,location_kind:component.templatePath===component.source.fileName?'inline_template':'source_line'};if(symbols.length>=maxSymbols)break;symbols.push(owner);const shadow=new Set(),seen=new WeakSet();function walk(n,fn){if(!n||typeof n!=='object'||seen.has(n))return;seen.add(n);fn(n);for(const [key,value] of Object.entries(n)){if(['sourceSpan','span','startSourceSpan','endSourceSpan','keySpan','valueSpan','parent'].includes(key))continue;if(Array.isArray(value))value.forEach(x=>walk(x,fn));else if(value&&typeof value==='object')walk(value,fn)}}walk(tree.nodes,n=>{if(['Variable','Reference','LetDeclaration'].includes(n.constructor.name)&&n.name)shadow.add(n.name);if(n.constructor.name==='ForLoopBlock'&&n.item?.name)shadow.add(n.item.name)});const seen2=new WeakSet();function inspect(n){if(!n||typeof n!=='object'||seen2.has(n))return;seen2.add(n);const start=n.sourceSpan?.start,at=(typeof start==='number'?component.template.slice(0,start).split('\n').length-1:(start?.line??0))+component.inlineLine;if(n.constructor.name==='Call'||n.constructor.name==='SafeCall'){const receiver=n.receiver;if(receiver?.constructor.name==='PropertyRead'&&['ImplicitReceiver','ThisReceiver'].includes(receiver.receiver?.constructor.name)){const target=shadow.has(receiver.name)?null:component.methods.get(receiver.name);if(target&&calls.length<maxEdges)calls.push({source:owner.id,target:target.id,line:at,kind:'template_call',resolution:'static'});else if(unresolved.length<1000)unresolved.push({path:pathName,line:at,expression:receiver.name+'()',reason:'Unresolved template method, or a local variable that shadows the component.'})}}if(n.constructor.name==='Element'){const matches=components.filter(c=>c.selector===n.name);if(matches.length===1&&filePath(matches[0].source)!==pathName)dependencies.push({source:pathName,target:filePath(matches[0].source),specifier:n.name,line:at,kind:'angular_component',resolution:'resolved'})}if(n.constructor.name==='BindingPipe'){const matches=pipes.filter(p=>p.pipeName===n.name&&p.methods.has('transform'));if(matches.length===1){const pipe=matches[0];dependencies.push({source:pathName,target:filePath(pipe.source),specifier:n.name,line:at,kind:'angular_pipe',resolution:'resolved'});calls.push({source:owner.id,target:pipe.methods.get('transform').id,line:at,kind:'template_pipe',resolution:'static'})}}for(const [key,value] of Object.entries(n)){if(['sourceSpan','span','startSourceSpan','endSourceSpan','keySpan','valueSpan','parent'].includes(key))continue;if(Array.isArray(value))value.forEach(inspect);else if(value&&typeof value==='object')inspect(value)}}inspect(tree.nodes)
|
|
132
|
+
}
|
|
133
|
+
}catch{diagnostics.push({path:'Angular',reason:'The Angular template analyzer did not finish. Check the local runtime.'})}
|
|
134
|
+
}
|
|
135
|
+
process.stdout.write(JSON.stringify({symbols,dependencies:dependencies.slice(0,maxEdges),calls:calls.slice(0,maxEdges),unresolved,diagnostics,parsed:[...parsed.map(filePath),...templatePaths],truncated:symbols.length>=maxSymbols||calls.length>=maxEdges||dependencies.length>=maxEdges}));
|