@aqaf/cli 1.1.0 → 1.1.2

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.
@@ -0,0 +1,446 @@
1
+ """Opt-in, bounded JS/TS source observations. No execution or credential decoding.
2
+
3
+ This deliberately limited lexer/structural reader is not a compiler, taint engine,
4
+ or proof of races, authentication failures, validation quality or data loss.
5
+ """
6
+ from __future__ import annotations
7
+ from collections import namedtuple
8
+ import hashlib
9
+ from importlib import resources
10
+ import json
11
+ from pathlib import Path
12
+ import re
13
+
14
+ Token = namedtuple('Token', 'kind value start end line column')
15
+ EXTENSIONS = {'.js', '.ts', '.mjs', '.mts', '.cjs', '.cts', '.gs'}
16
+ EXCLUDED = {'node_modules','vendor','target','build','dist','coverage','generated',
17
+ 'tests','test','__tests__','fixtures','__fixtures__','examples','docs',
18
+ 'references','reference','archive','archives','cache'}
19
+ MAX_FILE_BYTES = 256000
20
+ MAX_TOKENS = 20000
21
+ MAX_SCOPES = 128
22
+ MAX_FILES = 1000
23
+ MAX_TOTAL_BYTES = 20000000
24
+ _ATOM = re.compile(r'[A-Za-z_$][\w$]*|\d+(?:\.\d+)?|===|!==|=>|\+\+|--|\+=|-=|&&|\|\||\?\?|==|!=|<=|>=|\?\.')
25
+ CHECK_IDS = ('AQR-BROWSER-001','AQR-CRED-001','AQR-STATE-001','AQR-JOB-001','AQR-VALID-001','AQR-LIFE-001')
26
+
27
+
28
+ def catalog():
29
+ return json.loads(resources.files('aqaf').joinpath('data/review_checks.json').read_text(encoding='utf8'))
30
+
31
+
32
+ def excluded(path):
33
+ p=Path(path)
34
+ return (p.name.startswith('.') or any(x in EXCLUDED or x.startswith('.') for x in p.parts[:-1])
35
+ or bool(re.search(r'(?:\.test\.|\.spec\.|\.min\.|^test_|\.d\.ts$)',p.name)))
36
+
37
+
38
+ def lex(text):
39
+ tokens=[];i=0;line=1;last_newline=-1
40
+ while i<len(text):
41
+ start=i;kind=None;value=None;c=text[i]
42
+ if c.isspace():
43
+ i+=1
44
+ elif text.startswith('//',i):
45
+ end=text.find('\n',i);i=len(text) if end<0 else end
46
+ elif text.startswith('/*',i):
47
+ end=text.find('*/',i+2)
48
+ if end<0:raise ValueError('unsupported_syntax')
49
+ i=end+2
50
+ elif c in '\"\'`':
51
+ quote=c;i+=1
52
+ while i<len(text):
53
+ if text[i]=='\\':i+=2;continue
54
+ if text[i]==quote:break
55
+ i+=1
56
+ if i>=len(text):raise ValueError('unsupported_syntax')
57
+ i+=1;kind='opaque' if quote=='`' else 'string';value='' if quote=='`' else text[start+1:i-1]
58
+ elif c=='/' and (not tokens or tokens[-1].value in {'=', '(', '[', '{', ',', ':', 'return', '=>', '!', '?', '&&', '||', ';'}):
59
+ i+=1;in_class=False
60
+ while i<len(text):
61
+ if text[i]=='\\':i+=2;continue
62
+ if text[i]=='[':in_class=True
63
+ elif text[i]==']':in_class=False
64
+ elif text[i]=='/' and not in_class:break
65
+ elif text[i]=='\n':raise ValueError('unsupported_syntax')
66
+ i+=1
67
+ if i>=len(text):raise ValueError('unsupported_syntax')
68
+ i+=1
69
+ while i<len(text) and text[i].isalpha():i+=1
70
+ kind='opaque';value=''
71
+ else:
72
+ match=_ATOM.match(text,i)
73
+ if match:
74
+ value=match.group();i+=len(value);kind='name' if re.match('[A-Za-z_$]',value) else 'symbol'
75
+ else:kind='symbol';value=c;i+=1
76
+ if kind:
77
+ tokens.append(Token(kind,value,start,i,line,start-last_newline))
78
+ if len(tokens)>MAX_TOKENS:raise ValueError('unsupported_limit')
79
+ segment=text[start:i]
80
+ count=segment.count('\n')
81
+ if count:line+=count;last_newline=text.rfind('\n',start,i)
82
+ return tokens
83
+
84
+
85
+ class Structure:
86
+ def __init__(self,tokens):
87
+ self.t=tokens;self.pairs={};stack=[]
88
+ for i,t in enumerate(tokens):
89
+ if t.kind!='symbol':continue
90
+ if t.value in ('(', '[', '{'):stack.append(i)
91
+ elif t.value in (')',']','}'):
92
+ if not stack or {'(':')','[':']','{':'}'}[tokens[stack[-1]].value]!=t.value:raise ValueError('unsupported_syntax')
93
+ other=stack.pop();self.pairs[other]=i;self.pairs[i]=other
94
+ if stack:raise ValueError('unsupported_syntax')
95
+ self.scopes=[{'start':-1,'end':len(tokens),'params':[],'parent':None}]
96
+ for i,t in enumerate(tokens):
97
+ body=None;params=[]
98
+ if t.kind=='name' and t.value=='function':
99
+ j=i+1
100
+ if j<len(tokens) and tokens[j].value=='*':j+=1
101
+ if j<len(tokens) and tokens[j].kind=='name':j+=1
102
+ if j in self.pairs and tokens[j].value=='(':
103
+ close=self.pairs[j];params=list(range(j+1,close));k=close+1
104
+ while k<len(tokens) and k<close+32 and tokens[k].value not in {'{',';','=>'}:k+=1
105
+ if k in self.pairs and tokens[k].value=='{':body=k
106
+ elif t.kind=='symbol' and t.value=='=>' and i+1 in self.pairs and tokens[i+1].value=='{':
107
+ body=i+1
108
+ if i-1 in self.pairs and tokens[i-1].value==')':params=list(range(self.pairs[i-1]+1,i-1))
109
+ elif i:params=[i-1]
110
+ if body is not None:self.scopes.append({'start':body,'end':self.pairs[body],'params':params,'parent':None})
111
+ self.scopes[1:]=sorted(self.scopes[1:],key=lambda s:s['start'])
112
+ if len(self.scopes)>MAX_SCOPES:raise ValueError('unsupported_limit')
113
+ starts={s['start']:i for i,s in enumerate(self.scopes) if i};active=[0];self.owner=[]
114
+ for i in range(len(tokens)):
115
+ while len(active)>1 and i>self.scopes[active[-1]]['end']:active.pop()
116
+ if i in starts:
117
+ n=starts[i];self.scopes[n]['parent']=active[-1];active.append(n)
118
+ self.owner.append(active[-1])
119
+ self.by_owner={}
120
+ for i,owner in enumerate(self.owner):self.by_owner.setdefault(owner,[]).append(i)
121
+ self.defs={}
122
+ for owner,s in enumerate(self.scopes):
123
+ for chunk in self.split_indices(s['params']):
124
+ if chunk and tokens[chunk[0]].kind=='name':self.defs[(owner,tokens[chunk[0]].value)]=(chunk[0],None)
125
+ for i,t in enumerate(tokens[:-2]):
126
+ if t.kind=='name' and t.value in {'const','let','var'} and tokens[i+1].kind=='name' and tokens[i+2].value=='=':
127
+ end=self.expression_end(i+3)
128
+ self.defs[(self.owner[i],tokens[i+1].value)]=(i,list(range(i+3,end)))
129
+ # Straight-line identifier assignments supersede earlier local definitions.
130
+ for i,token in enumerate(tokens[:-2]):
131
+ if token.kind!='name' or tokens[i+1].value!='=' or (i and tokens[i-1].value in {'.','?.','const','let','var'}):continue
132
+ origin,_=self.definition(token.value,self.owner[i],i)
133
+ if origin is not None:self.defs[(origin,token.value)]=(i,list(range(i+2,self.expression_end(i+2))))
134
+ self.calls=[]
135
+ for i,t in enumerate(tokens[:-1]):
136
+ if t.kind!='name' or i+1 not in self.pairs or tokens[i+1].value!='(':continue
137
+ start=i;chain=[t.value]
138
+ while start>=2 and tokens[start-1].value in {'.','?.'} and tokens[start-2].kind=='name':
139
+ start-=2;chain.insert(0,tokens[start].value)
140
+ self.calls.append({'start':start,'end':self.pairs[i+1], 'name':chain,
141
+ 'args':self.split_range(i+2,self.pairs[i+1]),'owner':self.owner[start],
142
+ 'new':start>0 and tokens[start-1].value=='new'})
143
+
144
+ def split_indices(self,indices):
145
+ if not indices:return []
146
+ return self.split_range(indices[0],indices[-1]+1)
147
+
148
+ def split_range(self,start,end):
149
+ result=[];chunk=[];i=start
150
+ while i<end:
151
+ if self.t[i].kind=='symbol' and self.t[i].value==',':result.append(chunk);chunk=[];i+=1;continue
152
+ if i in self.pairs and self.pairs[i]>i:
153
+ close=self.pairs[i];chunk.extend(range(i,close+1));i=close+1
154
+ else:chunk.append(i);i+=1
155
+ if chunk:result.append(chunk)
156
+ return result
157
+
158
+ def expression_end(self,start):
159
+ i=start
160
+ while i<len(self.t):
161
+ if self.t[i].kind=='symbol' and self.t[i].value in {';', '}'}:return i
162
+ if i>start and self.t[i].line>self.t[i-1].line and self.t[i].value in {'const','let','var','function','return'}:return i
163
+ if i in self.pairs and self.pairs[i]>i:i=self.pairs[i]+1
164
+ else:i+=1
165
+ return i
166
+
167
+ def values(self,indices):return [self.t[i].value for i in indices]
168
+ def direct(self,owner):return self.by_owner.get(owner,[])
169
+ def definition(self,name,owner,before):
170
+ while owner is not None:
171
+ result=self.defs.get((owner,name))
172
+ if result and result[0]<before:return owner,result[1]
173
+ owner=self.scopes[owner]['parent']
174
+ return None,None
175
+
176
+ def properties(self,indices):
177
+ if not indices or indices[0] not in self.pairs or self.t[indices[0]].value!='{':return None
178
+ if self.pairs[indices[0]]!=indices[-1]:return None
179
+ out={}
180
+ for entry in self.split_range(indices[0]+1,indices[-1]):
181
+ if self.t[entry[0]].kind not in {'name','string'}:return None
182
+ if len(entry)>1 and self.t[entry[1]].value==':':out[self.t[entry[0]].value]=entry[2:]
183
+ elif len(entry)==1:out[self.t[entry[0]].value]=entry
184
+ else:return None
185
+ return out
186
+
187
+
188
+ def _review_source(text,path,apply_exclusions=True):
189
+ """Analyze a bounded source string; output contains no source snippets/literals."""
190
+ result={'status':'assessed','signals':[]}
191
+ if Path(path).is_absolute() or '..' in Path(path).parts or '\\' in str(path) or '\x00' in str(path):result['status']='unsupported_path';return result
192
+ if Path(path).suffix.lower() not in EXTENSIONS:result['status']='unsupported_language';return result
193
+ if apply_exclusions and excluded(path):result['status']='excluded_scope';return result
194
+ if len(text.encode('utf8'))>MAX_FILE_BYTES:result['status']='unsupported_limit';return result
195
+ try:s=Structure(lex(text))
196
+ except (ValueError,RecursionError) as error:
197
+ result['status']='unsupported_limit' if str(error)=='unsupported_limit' else 'unsupported_syntax';return result
198
+ t=s.t;checks={c['id']:c for c in catalog()['checks']};source_digest=hashlib.sha256(text.encode('utf8')).hexdigest()
199
+ seen=set(); budget=[5000]
200
+ def spend():
201
+ budget[0]-=1
202
+ if budget[0]<0:raise ValueError('unsupported_limit')
203
+ def emit(check,variant,index,fact,kind='review_candidate',controls=()):
204
+ key=(check,variant,index)
205
+ if key in seen:return
206
+ if len(result['signals'])>=100:
207
+ result['status']='assessed_limited';return
208
+ seen.add(key);token=t[index];entry=checks[check]
209
+ result['signals'].append({'check_id':check,'variant':variant,'kind':kind,'confirmed':False,'runtime_verified':False,
210
+ 'origin':'static_review_heuristic','review_state':'unreviewed','confidence':'medium' if kind=='observed_control' else 'low',
211
+ 'locations':[{'path':str(path),'line':token.line,'column':token.column}],
212
+ 'observed_fact':fact,'observed_controls':list(controls),
213
+ 'unknowns':entry['unknowns'],'next_test':entry['next_test'],'source_ids':entry['source_ids'],
214
+ 'source_digest':source_digest,'fingerprint':hashlib.sha256((check+'\0'+variant+'\0'+str(path)+'\0'+str(token.line)+'\0'+source_digest).encode()).hexdigest()})
215
+ # Worker/browser error transport is counterevidence, never a missing-logging claim.
216
+ for call in s.calls:
217
+ if call['name'][-1]=='postMessage' and call['args']:
218
+ props=s.properties(call['args'][0])
219
+ if props and any(t[i].kind=='string' and t[i].value=='error' for i in props.get('type',[])):
220
+ emit('AQR-BROWSER-001','error_message_channel',call['start'],
221
+ 'An explicit error-type message is sent through a browser/worker message channel.', 'observed_control',
222
+ ['Explicit error transport is visible; absence of console logging is not absence of error reporting.'])
223
+ # Encoded literal ancestry: allowlisted syntax only, no decoding or evaluation.
224
+ def embedded(indices,owner,before,depth=0,visited=None):
225
+ spend()
226
+ if depth>4 or len(indices)>4000:return False
227
+ visited=set() if visited is None else visited
228
+ for i in indices:
229
+ value=t[i].value
230
+ if t[i].kind=='string' and value and value not in {'base64','base64url','utf8','utf-8','hex'} and not re.search(r'(?i)todo|example|placeholder|fake|test.only|changeme',value):return True
231
+ if t[i].kind=='name' and value not in visited:
232
+ origin,expr=s.definition(value,owner,before)
233
+ if expr is not None and (origin,value) not in visited:
234
+ visited.add((origin,value))
235
+ if embedded(expr,origin,before,depth+1,visited):return True
236
+ return False
237
+ def decoded(indices,owner,before,depth=0):
238
+ spend()
239
+ if depth>4 or len(indices)>4000:return False
240
+ inside=[c for c in s.calls if c['start'] in indices and (c['name']==['Buffer','from'] or c['name'][-1]=='atob')]
241
+ for call in inside:
242
+ encoded=call['name'][-1]=='atob' or any(t[i].kind=='string' and t[i].value in {'base64','base64url'} for arg in call['args'][1:] for i in arg)
243
+ data=[i for i in indices if not (t[i].kind=='string' and t[i].value in {'base64','base64url','utf8','hex'})]
244
+ if encoded and embedded(data,owner,before):return True
245
+ for i in indices:
246
+ if t[i].kind=='name':
247
+ origin,expr=s.definition(t[i].value,owner,before)
248
+ if expr is not None and decoded(expr,origin,before,depth+1):return True
249
+ return False
250
+ for i,token in enumerate(t):
251
+ if token.value not in {'||','??'}:continue
252
+ end=s.expression_end(i+1);right=list(range(i+1,end));owner=s.owner[i]
253
+ # Only a fallback assignment to a header/auth-like binding; no general dataflow claim.
254
+ start=max(0,i-10);eq=next((j for j in range(i-1,start-1,-1) if t[j].value=='='),None)
255
+ if eq is None:continue
256
+ names=[t[j].value.lower() for j in range(start,eq) if t[j].kind in {'name','string'}]
257
+ if not any(re.search(r'header|authorization|credential|api_?key|token|password',name) for name in names):continue
258
+ if decoded(right,owner,i):emit('AQR-CRED-001','encoded_fallback_binding',i,
259
+ 'Supported lexical assignments link an encoded-literal construction to a fallback in a header/authentication-like binding. Branches, authentication use and validity are unverified; values are omitted.')
260
+ # Durable-state sequences: publication and recovery are reviewed independently.
261
+ for call in s.calls:
262
+ if call['name'][-1] in {'writeFile','writeFileSync'} and len(call['args'])>=2:
263
+ serial=any(c['name']==['JSON','stringify'] and c['start'] in call['args'][1] for c in s.calls)
264
+ if not serial:continue
265
+ path_arg=s.values(call['args'][0]);rename=next((c for c in s.calls if c['owner']==call['owner'] and c['start']>call['end'] and c['name'][-1] in {'rename','renameSync'} and len(c['args'])>=2 and s.values(c['args'][0])==path_arg and s.values(c['args'][1])!=path_arg),None)
266
+ if rename:emit('AQR-STATE-001','publication_sequence',call['start'],'A JSON write is followed by rename from the same temporary binding. Filesystem durability and all paths remain unverified.','observed_control')
267
+ else:emit('AQR-STATE-001','direct_json_write',call['start'],'A JSON serialization is written directly with no rename publication observed in the same function; wrapper behavior is unknown.')
268
+ for owner,scope in enumerate(s.scopes):
269
+ body=s.direct(owner);calls=[c for c in s.calls if c['owner']==owner]
270
+ parses=any(c['name']==['JSON','parse'] for c in calls)
271
+ for i in body:
272
+ if t[i].value!='catch' or not parses:continue
273
+ j=i+1
274
+ if j<len(t) and t[j].value=='(':j=s.pairs[j]+1
275
+ if j>=len(t) or t[j].value!='{':continue
276
+ part=list(range(j+1,s.pairs[j]));values=s.values(part)
277
+ empty=any(t[k].value=='return' and k+1<len(t) and t[k+1].value in {'{','['} and s.pairs.get(k+1)==k+2 for k in part)
278
+ if empty and 'throw' not in values and not any(c['start'] in part and c['name'][-1] in {'rename','renameSync','copyFile','copyFileSync'} for c in calls):
279
+ emit('AQR-STATE-001','empty_recovery_default',i,'A JSON-parse function catches failure and returns an empty container; preservation of corrupt state is not visible in this catch block.')
280
+ # Admission counter used before an await but incremented afterward.
281
+ for i in body:
282
+ if t[i].value!='while' or i+1>=len(t) or t[i+1].value!='(':continue
283
+ close=s.pairs[i+1];j=close+1
284
+ if j>=len(t) or t[j].value!='{':continue
285
+ loop=list(range(j+1,s.pairs[j]));condition={t[k].value for k in range(i+2,close) if t[k].kind=='name'}
286
+ claims=[c for c in calls if c['start'] in loop and c['name'][-1] in {'claim','claimJob','takeJob','dequeue','nextJob'} and c['start'] and t[c['start']-1].value=='await']
287
+ for claim in claims:
288
+ increments=[k for k in loop if t[k].value in condition and k+1<len(t) and t[k+1].value in {'++','+='}]
289
+ if increments and min(increments)>claim['end']:emit('AQR-JOB-001','admission_after_await',claim['start'],'A loop-condition counter is incremented after an awaited claim-like call. Overlapping admission and synchronization require characterization.')
290
+ # Unknown/any input asserted into an application type without a visible guard.
291
+ unknown={t[k].value for k in scope['params'] if t[k].kind=='name' and k+2<len(t) and t[k+1].value==':' and t[k+2].value in {'unknown','any'}}
292
+ for i in body:
293
+ if t[i].value!='return' or i+2>=len(t) or t[i+1].value not in unknown or t[i+2].value!='as':continue
294
+ name=t[i+1].value
295
+ guards=[]
296
+ for call in calls:
297
+ if call['start']>=i or not any(name in s.values(arg) for arg in call['args']):continue
298
+ for k in body:
299
+ if t[k].value!='if' or k+1>=len(t) or t[k+1].value!='(':continue
300
+ close=s.pairs[k+1];j=close+1
301
+ if k<call['start']<close and j<len(t) and t[j].value=='{' and s.pairs[j]<i and 'throw' in s.values(list(range(j+1,s.pairs[j]))):guards.append(call)
302
+ if guards:emit('AQR-VALID-001','validation_control',i,'A check of the same unknown input precedes a type assertion; validator semantics and control-flow coverage remain unverified.','observed_control')
303
+ else:emit('AQR-VALID-001','unknown_type_assertion',i,'A function returns an unknown/any input through a TypeScript assertion. Assertions alone do not perform runtime validation.')
304
+ for call in s.calls:
305
+ if call['name'][-1] not in {'update','updateMany'} or not call['args']:continue
306
+ props=s.properties(call['args'][0])
307
+ if not props:continue
308
+ where=s.properties(props.get('where',[]));data=s.properties(props.get('data',[]))
309
+ if where is None or not data or not any(t[i].kind=='string' and t[i].value in {'completed','failed','cancelled','canceled'} for i in data.get('status',[])):continue
310
+ if set(where)=={'id'}:emit('AQR-JOB-001','terminal_id_only',call['start'],'A terminal-state update has only an ID in its inline where clause; ownership, state and transaction fencing may exist elsewhere.')
311
+ elif 'id' in where:emit('AQR-JOB-001','terminal_guard_fields',call['start'],'A terminal update includes additional inline ownership/state filter fields; their semantics and database enforcement remain unverified.','observed_control')
312
+ for call in s.calls:
313
+ if call['name']!=['Worker'] or not call['new'] or call['owner']==0:continue
314
+ owner=call['owner'];start=call['start'];binding=t[start-2].value if start>=2 and t[start-1].value=='new' else ''
315
+ # Declaration is NAME = new Worker(...).
316
+ if start>=3 and t[start-1].value=='new' and t[start-2].value=='=':binding=t[start-3].value
317
+ terminate=False;error=False
318
+ for c in s.calls:
319
+ if c['name'][0]==binding:
320
+ origin,_=s.definition(binding,c['owner'],c['start'])
321
+ if origin==owner and c['name'][-1]=='terminate':terminate=True
322
+ if origin==owner and c['name'][-1]=='addEventListener' and c['args'] and any(t[k].value=='error' for k in c['args'][0]):error=True
323
+ for k in range(start+1,len(t)-3):
324
+ if [t[x].value for x in range(k,k+4)]==[binding,'.','onerror','=']:
325
+ origin,_=s.definition(binding,s.owner[k],k)
326
+ if origin==owner:error=True
327
+ if terminate and error:emit('AQR-LIFE-001','worker_controls',start,'A function-created worker has visible error and termination controls; lifecycle coverage and all exit paths remain unverified.','observed_control')
328
+ else:emit('AQR-LIFE-001','function_worker_ownership',start,'A worker is constructed inside a function; local error/termination coverage is incomplete or delegated.',controls=[label for yes,label in [(terminate,'Termination call observed.'),(error,'Error handler observed.')] if yes])
329
+ for call in s.calls:
330
+ if call['name'][-1]!='save' or not call['start'] or t[call['start']-1].value!='await':continue
331
+ owner=call['owner'];body=s.direct(owner)
332
+ for i in body:
333
+ if i<=call['end'] or t[i].value!='status' or i+2>=len(t) or t[i+1].value!='=' or t[i+2].kind!='string' or t[i+2].value.lower()!='saved':continue
334
+ guarded=False
335
+ for k in body:
336
+ if t[k].value!='if' or k+1>=len(t) or t[k+1].value!='(':continue
337
+ close=s.pairs[k+1];values=s.values(list(range(k+2,close)));j=close+1
338
+ if j<len(t) and t[j].value=='{' and j<i<s.pairs[j] and any(v in {'===','==','!==','!='} for v in values) and any(re.search(r'(?i)revision|version|generation',v) for v in values):guarded=True
339
+ if guarded:emit('AQR-LIFE-001','save_revision_control',i,'A revision/version comparison guards the saved-status assignment; revision ownership and acknowledgement semantics remain unverified.','observed_control')
340
+ else:emit('AQR-LIFE-001','saved_after_await',i,'A saved status is assigned after an awaited save without a visible revision guard on that assignment; concurrent edits require characterization.')
341
+ result['signals'].sort(key=lambda x:(x['locations'][0]['line'],x['locations'][0]['column'],x['check_id'],x['variant']))
342
+ return result
343
+
344
+
345
+ def review_source(text,path):
346
+ from .adapters import analyze_source
347
+ # Direct callers retain the conservative path-role exclusions.
348
+ if Path(path).is_absolute() or '..' in Path(path).parts or '\\' in str(path) or '\x00' in str(path):return {'status':'unsupported_path','signals':[]}
349
+ if excluded(path):return {'status':'excluded_scope','signals':[]}
350
+ return analyze_source(text,path)
351
+
352
+
353
+ def review_files(files,root,scope=None,max_files=MAX_FILES,max_total_bytes=MAX_TOTAL_BYTES,max_file_bytes=MAX_FILE_BYTES,offset=0):
354
+ """Round-robin components/languages; preserve evidence and explicit omissions."""
355
+ from collections import Counter,defaultdict,deque
356
+ import unicodedata
357
+ from .adapters import analyze_source,language,support_matrix,FAMILIES
358
+ metadata=catalog();signals=[];counts=Counter();buckets=defaultdict(list);groups={};outcomes=[];digests=defaultdict(list)
359
+ roles=(scope.config.get('review_roles',['production','source','unknown','tooling']) if scope else ['production','source','unknown','tooling'])
360
+ def group(record,lang):
361
+ key=(record.get('owner','root'),lang)
362
+ if key not in groups:groups[key]={'owner':key[0],'language':lang,'files_collected':0,'eligible':0,'attempted':0,'assessed':0,'outcomes':Counter()}
363
+ return groups[key]
364
+ feasibility=Counter();pool=0
365
+ for path in files:
366
+ relative=path.relative_to(root).as_posix();record=scope.record(path) if scope else {'relative':relative,'role':'unknown','priority':relative}
367
+ lang=language(relative) or (record.get('language') if record.get('language_basis')=='bounded_shebang' else None);g=group(record,lang or record.get('language','other'));g['files_collected']+=1
368
+ if not lang:
369
+ counts['unsupported_language']+=1;g['outcomes']['unsupported_language']+=1
370
+ if record.get('language') not in {'other',None}:feasibility[record['language']]+=1
371
+ continue
372
+ if record.get('role') not in roles or (scope is None and excluded(relative)):
373
+ counts['excluded_scope']+=1;g['outcomes']['excluded_scope']+=1;continue
374
+ pool+=1;g['eligible']+=1;buckets[(g['owner'],lang)].append((path,relative,record,g))
375
+ # Deterministic rotation prevents large PHP/JS trees from starving smaller components.
376
+ queue=deque(deque(sorted(rows,key=lambda r:r[2].get('priority',r[1]))) for _,rows in sorted(buckets.items()))
377
+ ordered=[]
378
+ while queue:
379
+ rows=queue.popleft();ordered.append(rows.popleft())
380
+ if rows:queue.append(rows)
381
+ attempted=0;total=0;assessed=0
382
+ for ordinal,(path,relative,record,g) in enumerate(ordered):
383
+ status=None;result=None;raw=None
384
+ if ordinal<offset:status='before_offset_unassessed'
385
+ elif attempted>=max_files:status='file_budget'
386
+ elif total>=max_total_bytes:status='byte_budget'
387
+ else:
388
+ attempted+=1;g['attempted']+=1
389
+ try:
390
+ if scope:size=record['size']
391
+ else:
392
+ if path.is_symlink():raise ValueError('excluded_symlink')
393
+ size=path.stat().st_size
394
+ if size>max_file_bytes:status='unsupported_limit'
395
+ elif total+size>max_total_bytes:status='byte_budget'
396
+ else:
397
+ raw=scope.raw(path,max_file_bytes+1) if scope else path.read_bytes()[:max_file_bytes+1]
398
+ total+=len(raw)
399
+ if len(raw)!=size:status='changed_or_unreadable'
400
+ elif len(raw)>max_file_bytes:status='unsupported_limit'
401
+ else:
402
+ text=raw.decode('utf8');context={'role':record.get('role','unknown'),'owner':record.get('owner','root'),'sql_dialect':scope.config.get('sql_dialect','unknown') if scope else 'unknown'}
403
+ result=analyze_source(text,relative,context);status=result['status']
404
+ digest=hashlib.sha256(raw).hexdigest();digests[digest].append(relative)
405
+ if status.startswith('assessed'):assessed+=1;g['assessed']+=1
406
+ available=max(0,5000-len(signals));new=result['signals'][:available]
407
+ for signal in new:
408
+ # Evidence identity is content-sensitive; concern identity is separate.
409
+ loc=signal['locations'][0];logical='\0'.join([signal['check_id'],signal['variant'],record.get('owner','root'),relative,str(loc['line']),str(loc['column'])])
410
+ signal['logical_concern_id']=hashlib.sha256(logical.encode()).hexdigest()
411
+ signal['display_path']=unicodedata.normalize('NFC',relative);signal['source_digest']=digest
412
+ signals.extend(new)
413
+ if len(result['signals'])>available:counts['signal_budget']+=1
414
+ except UnicodeError:status='unsupported_encoding'
415
+ except OSError:status='unreadable'
416
+ except ValueError as error:status=str(error) if str(error)=='excluded_symlink' else 'unsupported_syntax'
417
+ counts[status]+=1;g['outcomes'][status]+=1
418
+ if len(outcomes)<10000:outcomes.append({'path':relative,'owner':g['owner'],'language':g['language'],'status':status,'ordinal':ordinal,**({'diagnostics':result['diagnostics']} if result and result.get('diagnostics') else {})})
419
+ sources=json.loads(resources.files('aqaf').joinpath('data/source_registry.json').read_text(encoding='utf8'))
420
+ ids={sid for check in metadata['checks'] for sid in check['source_ids']}|{sid for signal in signals for sid in signal['source_ids']}
421
+ for g in groups.values():g['outcomes']=dict(sorted(g['outcomes'].items()))
422
+ secondary=[]
423
+ for lang,n in sorted(feasibility.items()):
424
+ examples=[{'path':record['relative'],'owner':record['owner'],'role':record['role']} for record in (scope.records if scope else []) if record.get('language')==lang][:8]
425
+ secondary.append({'language':lang,'collected_candidate_files':n,'examples':examples,'ownership':'filtered_owned_candidates_not_certified_authorship',
426
+ 'decision':'deferred','candidate_slice':'Passive publication/error/resource ownership facts, with language-specific sources and positive/control/adversarial fixtures before enabling.',
427
+ 'validation_effort':'Separate bounded adapter proposal plus independent applicability review; no automatic parser/compiler installation.',
428
+ 'reason':'No calibrated bounded adapter; authored intent and useful characterization require independent review. Presence is not support.'})
429
+ selected_checks=sorted({c for g in groups.values() if g['assessed'] for c in FAMILIES.get(g['language'],[])})
430
+ return {'schema_version':2,'status':'experimental','score_impact':0,'affects_baseline_gates':False,'checks':metadata['checks'],'signals':signals,
431
+ 'support_matrix':support_matrix(),'secondary_language_feasibility':secondary,
432
+ 'coverage_groups':[{'owner':g['owner'],'language':g['language'],'check_id':check,'files_eligible':g['eligible'],'files_assessed_partial_syntax':g['assessed'],'status':'partial_source_assessed' if g['assessed'] else 'unassessed'} for g in groups.values() for check in FAMILIES.get(g['language'],[])],
433
+ 'coverage':[{'check_id':check['id'],'status':'observed' if any(s['check_id']==check['id'] for s in signals) else 'no_observation_in_assessed_subset' if check['id'] in selected_checks else 'unassessed'} for check in metadata['checks']],
434
+ 'scope':{'languages':sorted(__import__('aqaf.adapters',fromlist=['LANGUAGE_EXTENSIONS']).LANGUAGE_EXTENSIONS),'review_roles':roles,
435
+ 'max_file_bytes':max_file_bytes,'max_files':max_files,'max_total_bytes':max_total_bytes,'files_eligible':pool,'files_attempted':attempted,'files_assessed':assessed,'bytes_read':total,'offset':offset,
436
+ 'scheduler':'round_robin_owner_language_then_source_priority','coverage_complete':counts['assessed']==pool and not counts.get('signal_budget'),
437
+ 'zero_attempt_reason':None if attempted else 'no_supported_owned_files_in_selected_roles' if not pool else 'offset_beyond_pool',
438
+ 'outcomes':dict(sorted(counts.items())),'groups':[groups[k] for k in sorted(groups)],'file_outcomes':outcomes,'file_outcomes_truncated':len(ordered)>len(outcomes),
439
+ 'resume':'Offset is an independent partial selection, not a validated merged run. No prior results or human labels are silently reused.'},
440
+ 'exact_source_copies':[{'sha256':sha,'paths':paths,'assessment':'Every owned path retains ownership and evidence; copies are not silently deduplicated.'} for sha,paths in sorted(digests.items()) if len(paths)>1][:2000],
441
+ 'limitations':['Partial experimental adapters recognize declared bounded syntax, not complete languages or frameworks.',
442
+ 'Observations are unscored, unconfirmed and not runtime verification. No observation does not establish correctness.',
443
+ 'No target execution, imports, build hooks, macros, credential decoding, provider calls or source excerpts.',
444
+ 'Per-language support is partial; dynamic calls, aliases, caller controls, complex markup and unrecognized syntax remain unresolved.',
445
+ 'Independent defect precision and human calibration remain pending; fixture conformance is not defect accuracy.'],
446
+ 'source_registry':[s for s in sources if s['id'] in ids]}