@aqaf/cli 0.0.0-stage → 1.1.1

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.
Files changed (56) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +62 -2
  3. package/bin/aqaf.js +12 -0
  4. package/index.js +51 -0
  5. package/package.json +51 -4
  6. package/vendor/aqaf/__init__.py +2 -0
  7. package/vendor/aqaf/__main__.py +3 -0
  8. package/vendor/aqaf/adapters.py +583 -0
  9. package/vendor/aqaf/artifacts.py +28 -0
  10. package/vendor/aqaf/calibration.py +29 -0
  11. package/vendor/aqaf/catalogue.py +257 -0
  12. package/vendor/aqaf/cli.py +721 -0
  13. package/vendor/aqaf/cursor.py +56 -0
  14. package/vendor/aqaf/data/book_review_knowledge.json +1574 -0
  15. package/vendor/aqaf/data/catalog.json +562 -0
  16. package/vendor/aqaf/data/documentation_checks.json +52 -0
  17. package/vendor/aqaf/data/packs/automation.json +16 -0
  18. package/vendor/aqaf/data/packs/mobile.json +13 -0
  19. package/vendor/aqaf/data/packs/systems.json +17 -0
  20. package/vendor/aqaf/data/packs/web.json +16 -0
  21. package/vendor/aqaf/data/profiles/ai_pipeline.json +39 -0
  22. package/vendor/aqaf/data/profiles/android.json +34 -0
  23. package/vendor/aqaf/data/profiles/api_backend.json +44 -0
  24. package/vendor/aqaf/data/profiles/browser_extension.json +29 -0
  25. package/vendor/aqaf/data/profiles/cli_automation.json +34 -0
  26. package/vendor/aqaf/data/profiles/desktop_utility.json +39 -0
  27. package/vendor/aqaf/data/profiles/embedded_iot.json +34 -0
  28. package/vendor/aqaf/data/profiles/google_apps_script.json +34 -0
  29. package/vendor/aqaf/data/profiles/mcp_agent_server.json +34 -0
  30. package/vendor/aqaf/data/profiles/multirepo_system.json +29 -0
  31. package/vendor/aqaf/data/profiles/npm_library.json +39 -0
  32. package/vendor/aqaf/data/profiles/rust_app.json +34 -0
  33. package/vendor/aqaf/data/profiles/saas_or_web_app.json +44 -0
  34. package/vendor/aqaf/data/profiles/scraper_data_pipeline.json +39 -0
  35. package/vendor/aqaf/data/profiles/swift_app.json +34 -0
  36. package/vendor/aqaf/data/profiles/tauri_electron.json +34 -0
  37. package/vendor/aqaf/data/profiles/unknown_or_general_app.json +25 -0
  38. package/vendor/aqaf/data/profiles/windows_client_server.json +34 -0
  39. package/vendor/aqaf/data/profiles/wordpress_plugin.json +39 -0
  40. package/vendor/aqaf/data/review_checks.json +319 -0
  41. package/vendor/aqaf/data/source_registry.json +814 -0
  42. package/vendor/aqaf/discovery.py +580 -0
  43. package/vendor/aqaf/documentation.py +210 -0
  44. package/vendor/aqaf/evidence.py +132 -0
  45. package/vendor/aqaf/facets.py +63 -0
  46. package/vendor/aqaf/ignore.py +109 -0
  47. package/vendor/aqaf/knowledge.py +110 -0
  48. package/vendor/aqaf/markup.py +96 -0
  49. package/vendor/aqaf/matching.py +22 -0
  50. package/vendor/aqaf/portfolio.py +145 -0
  51. package/vendor/aqaf/project_commands.py +132 -0
  52. package/vendor/aqaf/projects.py +157 -0
  53. package/vendor/aqaf/registry.py +125 -0
  54. package/vendor/aqaf/review.py +446 -0
  55. package/vendor/aqaf/scope.py +401 -0
  56. package/vendor/aqaf/swift_review.py +99 -0
@@ -0,0 +1,583 @@
1
+ """Bounded passive review adapters. Facts/controls are not runtime defect proofs.
2
+
3
+ Python uses its AST without compilation/import. Other new adapters recognize a
4
+ small declared subset of masked, qualified call syntax; no full language parser,
5
+ macro expansion, type/taint resolution or arbitrary control-flow proof is claimed.
6
+ """
7
+ from __future__ import annotations
8
+ import ast
9
+ from collections import Counter
10
+ import hashlib
11
+ import json
12
+ from pathlib import Path
13
+ import re
14
+
15
+ LANGUAGE_EXTENSIONS={'.js':'javascript','.mjs':'javascript','.cjs':'javascript','.gs':'javascript','.ts':'typescript','.mts':'typescript','.cts':'typescript',
16
+ '.jsx':'jsx','.tsx':'tsx','.html':'html','.htm':'html','.py':'python','.kt':'kotlin','.kts':'kotlin','.java':'java','.cs':'csharp',
17
+ '.php':'php','.rs':'rust','.ps1':'powershell','.psm1':'powershell','.command':'shell','.sh':'shell','.bash':'shell','.zsh':'shell','.sql':'sql','.swift':'swift'}
18
+ SOURCES={'swift':['S37','S38','S39','S40','S42'],'python':['S22','S30','S31'],'kotlin':['S23'],'java':['S32'],'csharp':['S24','S35'], 'php':['S25','S36'],'rust':['S26'],'powershell':['S27','S41'],'shell':['S33'],'sql':['S28'],'jsx':['S29'],'tsx':['S29'],'html':['S34']}
19
+ FAMILIES={'swift':['AQR-STATE-001','AQR-VALID-001','AQR-LIFE-001'],'python':['AQR-STATE-001','AQR-CRED-001','AQR-JOB-001','AQR-VALID-001','AQR-LIFE-001','AQR-ERROR-001'],
20
+ 'kotlin':['AQR-STATE-001','AQR-CRED-001','AQR-VALID-001','AQR-LIFE-001','AQR-ERROR-001'],
21
+ 'java':['AQR-STATE-001','AQR-CRED-001','AQR-VALID-001','AQR-ERROR-001'],
22
+ 'csharp':['AQR-STATE-001','AQR-CRED-001','AQR-JOB-001','AQR-VALID-001','AQR-LIFE-001','AQR-ERROR-001'],
23
+ 'php':['AQR-STATE-001','AQR-CRED-001','AQR-VALID-001','AQR-ERROR-001'],
24
+ 'rust':['AQR-STATE-001','AQR-CRED-001','AQR-LIFE-001','AQR-VALID-001','AQR-ERROR-001'],
25
+ 'powershell':['AQR-STATE-001','AQR-CRED-001','AQR-LIFE-001','AQR-ERROR-001'],
26
+ 'shell':['AQR-STATE-001','AQR-LIFE-001','AQR-ERROR-001'],'sql':['AQR-SQL-001']}
27
+ ALL_JS=['AQR-BROWSER-001','AQR-CRED-001','AQR-STATE-001','AQR-JOB-001','AQR-VALID-001','AQR-LIFE-001']
28
+ for _lang in ['javascript','typescript','jsx','tsx','html']:FAMILIES[_lang]=ALL_JS
29
+ MAX_SOURCE_BYTES=1500000
30
+ MAX_AST_NODES=20000
31
+ MAX_LOCAL_CALLS=2000
32
+ MAX_SIGNALS=100
33
+
34
+
35
+ def shebang_language(text):
36
+ line=text.split('\n',1)[0][:256]
37
+ if not line.startswith('#!'):return None
38
+ import shlex
39
+ try:parts=shlex.split(line[2:].strip())
40
+ except ValueError:return None
41
+ if not parts:return None
42
+ command=Path(parts[0]).name
43
+ if command=='env':
44
+ parts=parts[1:]
45
+ if parts and parts[0]=='-S':parts=parts[1:]
46
+ if not parts:return None
47
+ command=Path(parts[0]).name
48
+ if re.fullmatch(r'python(?:[23](?:\.\d+)?)?',command):return 'python'
49
+ return {'sh':'shell','bash':'shell','zsh':'shell','node':'javascript','php':'php','pwsh':'powershell'}.get(command)
50
+
51
+
52
+ def language(path,text=None):
53
+ return LANGUAGE_EXTENSIONS.get(Path(path).suffix.lower()) or (shebang_language(text) if text is not None and not Path(path).suffix else None)
54
+
55
+
56
+ def support_matrix():
57
+ return [{'language':name,'extensions':sorted(k for k,v in LANGUAGE_EXTENSIONS.items() if v==name),
58
+ 'status':'experimental_partial','checks':checks,'analysis':'passive_python_ast' if name=='python' else 'bounded_source_patterns',
59
+ **({'dialects':['unknown_common_syntax','sqlite_common_syntax','postgresql_common_syntax','mysql_common_syntax','sqlserver_common_syntax']} if name=='sql' else {}),
60
+ 'limitations':['No whole-program/type/taint analysis or executed behavior.',
61
+ 'Qualified direct calls/local bindings only; imported wrappers and dynamic syntax remain unresolved.',
62
+ 'JS primitive retains its 256000-byte/20000-token parser limit even when the outer file budget is raised.' if name in {'javascript','typescript','jsx','tsx','html'} else 'Complex interpolation, macros, dialect constructs and unsupported templates can remain unassessed.']}
63
+ for name,checks in sorted(FAMILIES.items())]
64
+
65
+
66
+ class Emitter:
67
+ def __init__(self,text,path,lang,context):
68
+ self.text=text;self.path=path;self.lang=lang;self.context=context or {};self.signals=[];self.seen=set()
69
+ self.digest=hashlib.sha256(text.encode('utf8')).hexdigest()
70
+
71
+ def emit(self,check,variant,index,fact,kind='review_candidate',controls=()):
72
+ if len(self.signals)>=MAX_SIGNALS:return
73
+ index=max(0,min(index,len(self.text)));line=self.text.count('\n',0,index)+1;last=self.text.rfind('\n',0,index);column=index-last
74
+ key=(check,variant,line,column)
75
+ if key in self.seen:return
76
+ self.seen.add(key)
77
+ if check=='AQR-STATE-001' and self.context.get('role') in {'tooling','tests','docs','fixture'} and kind=='review_candidate':
78
+ kind='source_observation';fact+=' The selected role is tooling/test/documentation; a persistent-state requirement has not been established.'
79
+ self.signals.append({'check_id':check,'variant':variant,'language':self.lang,'kind':kind,'confirmed':False,'runtime_verified':False,
80
+ 'origin':'static_review_heuristic','review_state':'unreviewed','confidence':'medium' if kind=='observed_control' else 'low',
81
+ 'locations':[{'path':self.path,'line':line,'column':column}],'observed_fact':fact,'observed_controls':list(controls),
82
+ 'unknowns':['Actual call binding, supported local control flow, imported/caller controls and operational intent require review.',
83
+ 'Persistence, validation, authorization, lifetime and transaction behavior are not proven by this observation.'],
84
+ 'next_test':'Verify the source/role and relevant controls, then characterize the declared behavior with bounded disposable fixtures before changing application code.',
85
+ 'source_ids':SOURCES.get(self.lang,[])+(['S20'] if check=='AQR-CRED-001' else []),
86
+ 'source_digest':self.digest,'fingerprint':hashlib.sha256((check+'\0'+variant+'\0'+self.path+'\0'+str(line)+'\0'+self.digest).encode()).hexdigest(),
87
+ 'applicability':{'role':self.context.get('role','unknown'),'owner':self.context.get('owner','root'),'intent_verified':False,**({key:self.context[key] for key in ('sql_role','sql_dialect') if key in self.context})}})
88
+
89
+
90
+ def _index(text,node):
91
+ # Python AST columns are UTF-8 byte offsets, reports use Unicode codepoints.
92
+ lines=text.splitlines(keepends=True);line=max(1,getattr(node,'lineno',1));prefix=''.join(lines[:line-1]);raw=lines[line-1].encode('utf8') if line<=len(lines) else b''
93
+ return len(prefix)+len(raw[:getattr(node,'col_offset',0)].decode('utf8','ignore'))
94
+
95
+
96
+ def _name(node):
97
+ if isinstance(node,ast.Name):return node.id
98
+ if isinstance(node,ast.Attribute):
99
+ before=_name(node.value);return before+'.'+node.attr if before else ''
100
+ return ''
101
+
102
+
103
+ def _scope_nodes(node):
104
+ stack=[node]
105
+ while stack:
106
+ current=stack.pop();yield current
107
+ children=list(ast.iter_child_nodes(current))
108
+ for child in reversed(children):
109
+ if child is not node and isinstance(child,(ast.FunctionDef,ast.AsyncFunctionDef,ast.ClassDef,ast.Lambda)):continue
110
+ stack.append(child)
111
+
112
+
113
+ def python_review(text,path,context):
114
+ emitter=Emitter(text,path,'python',context)
115
+ try:tree=ast.parse(text)
116
+ except (SyntaxError,ValueError,RecursionError):return {'status':'unsupported_syntax','signals':[]}
117
+ all_nodes=list(ast.walk(tree))
118
+ if len(all_nodes)>MAX_AST_NODES:return {'status':'unsupported_limit','signals':[]}
119
+ aliases={};shadow=set()
120
+ for n in tree.body:
121
+ if isinstance(n,ast.Import):
122
+ for alias in n.names:aliases[alias.asname or alias.name.split('.')[0]]=alias.name
123
+ elif isinstance(n,ast.ImportFrom):
124
+ for alias in n.names:
125
+ if n.level:shadow.add(alias.asname or alias.name)
126
+ else:aliases[alias.asname or alias.name]=(n.module or '')+'.'+alias.name
127
+ shadow.update(n.name for n in tree.body if isinstance(n,(ast.FunctionDef,ast.AsyncFunctionDef,ast.ClassDef)) and n.name in aliases)
128
+ scopes=[tree]+[n for n in all_nodes if isinstance(n,(ast.FunctionDef,ast.AsyncFunctionDef))]
129
+ for scope in scopes:
130
+ nodes=list(_scope_nodes(scope));local_shadow=set(shadow);definitions={};handles={}
131
+ if isinstance(scope,(ast.FunctionDef,ast.AsyncFunctionDef)):
132
+ local_shadow.update(a.arg for a in scope.args.args+scope.args.kwonlyargs)
133
+ if scope.args.vararg:local_shadow.add(scope.args.vararg.arg)
134
+ if scope.args.kwarg:local_shadow.add(scope.args.kwarg.arg)
135
+ for n in nodes:
136
+ if isinstance(n,(ast.Assign,ast.AnnAssign)):
137
+ targets=n.targets if isinstance(n,ast.Assign) else [n.target]
138
+ for target in targets:
139
+ if isinstance(target,ast.Name):definitions.setdefault(target.id,[]).append((n.lineno,n.value));local_shadow.add(target.id)
140
+ if isinstance(n,(ast.With,ast.AsyncWith)):
141
+ for item in n.items:
142
+ if isinstance(item.optional_vars,ast.Name) and isinstance(item.context_expr,ast.Call) and _name(item.context_expr.func)=='open':
143
+ args=item.context_expr.args;mode=args[1].value if len(args)>1 and isinstance(args[1],ast.Constant) else None
144
+ handles[item.optional_vars.id]=(item.context_expr,mode)
145
+ def name(n):
146
+ value=_name(n);root=value.split('.')[0]
147
+ if root in aliases and root not in local_shadow:value=aliases[root]+value[len(root):]
148
+ elif root in local_shadow and root in {'json','base64','typing','asyncio','logging'}:return ''
149
+ return value
150
+ def calls_in(n):return [x for x in ast.walk(n) if isinstance(x,ast.Call)]
151
+ calls=[n for n in nodes if isinstance(n,ast.Call)]
152
+ def empty(n):return isinstance(n,(ast.Dict,ast.List,ast.Set,ast.Tuple)) and not (getattr(n,'keys',None) or getattr(n,'elts',None))
153
+ def json_dump(n):return isinstance(n,ast.Call) and name(n.func) in {'json.dump','json.dumps'}
154
+ def guarded(data_name,before):
155
+ for n in nodes:
156
+ if not isinstance(n,ast.If) or n.lineno>=before:continue
157
+ if not any(isinstance(x,ast.Name) and x.id==data_name for x in ast.walk(n.test)):continue
158
+ # Only an explicit rejection branch is counterevidence, not a call named validate.
159
+ if isinstance(n.test,ast.UnaryOp) and isinstance(n.test.op,ast.Not) and any(isinstance(x,(ast.Raise,ast.Return)) for x in n.body):return True
160
+ return False
161
+ def decoded(n,before,depth=0,seen=None):
162
+ if depth>4:return False
163
+ if isinstance(n,ast.Call) and name(n.func) in {'base64.b64decode','base64.urlsafe_b64decode','bytes.fromhex'}:
164
+ return bool(n.args and isinstance(n.args[0],ast.Constant) and isinstance(n.args[0].value,(str,bytes)))
165
+ if isinstance(n,ast.Name):
166
+ seen=set() if seen is None else seen
167
+ if n.id in seen:return False
168
+ prior=[x for x in definitions.get(n.id,[]) if x[0]<before]
169
+ return bool(prior and decoded(prior[-1][1],prior[-1][0],depth+1,seen|{n.id}))
170
+ return False
171
+ for call in calls:
172
+ cname=name(call.func);index=_index(text,call)
173
+ if cname=='json.dump' and len(call.args)>1:
174
+ handle=call.args[1];known=isinstance(handle,ast.Name) and handle.id in handles and handles[handle.id][1] in {'w','wb','a','ab','x','xb'}
175
+ controls=[c for c in calls if name(c.func) in {'os.replace','os.rename'} and c.lineno>call.lineno]
176
+ # Match the source path bound to the file handle, not an unrelated rename.
177
+ linked=False
178
+ if known:
179
+ opener=handles[handle.id][0]
180
+ linked=any(c.args and opener.args and ast.dump(c.args[0])==ast.dump(opener.args[0]) for c in controls)
181
+ emitter.emit('AQR-STATE-001','json_publication_control' if linked else 'json_file_publication',index,
182
+ 'JSON serialization writes to a file-like sink; publication/recovery requirements and caller controls remain unresolved.',
183
+ 'observed_control' if linked else 'review_candidate' if known else 'source_observation', ['A later rename/replace uses the same locally opened path.'] if linked else [])
184
+ if isinstance(call.func,ast.Attribute) and call.func.attr in {'write_text','write_bytes','write'} and call.args and any(json_dump(c) for c in calls_in(call.args[0])):
185
+ receiver=call.func.value;known=False;written_path=None
186
+ if isinstance(receiver,ast.Call) and name(receiver.func) in {'pathlib.Path','open'}:
187
+ known=True;written_path=receiver.args[0] if receiver.args else None
188
+ if isinstance(receiver,ast.Name):
189
+ if receiver.id in handles:known=True;opener=handles[receiver.id][0];written_path=opener.args[0] if opener.args else None
190
+ else:
191
+ prior=[x for x in definitions.get(receiver.id,[]) if x[0]<call.lineno]
192
+ if prior and isinstance(prior[-1][1],ast.Call) and name(prior[-1][1].func) in {'pathlib.Path'}:
193
+ known=True;written_path=prior[-1][1].args[0] if prior[-1][1].args else None
194
+ linked=bool(written_path and any(name(c.func) in {'os.replace','os.rename'} and c.lineno>call.lineno and c.args and ast.dump(c.args[0])==ast.dump(written_path) for c in calls))
195
+ emitter.emit('AQR-STATE-001','json_publication_control' if linked else 'direct_json_write',index,
196
+ 'A recognized write-method call receives JSON serialization; sink identity and publication intent require characterization.',
197
+ 'observed_control' if linked else 'review_candidate' if known else 'source_observation', ['Same-path rename/replace is visible.'] if linked else [])
198
+ if cname in {'typing.cast'} and len(call.args)>1 and isinstance(call.args[1],ast.Name):
199
+ controlled=guarded(call.args[1].id,call.lineno)
200
+ emitter.emit('AQR-VALID-001','validation_control' if controlled else 'python_type_cast',index,'A type-cast syntax uses external/dynamic data; a cast does not establish domain validation.', 'observed_control' if controlled else 'review_candidate')
201
+ if cname.endswith(('.model_validate','.parse_obj','.parse_raw')):
202
+ emitter.emit('AQR-VALID-001','validator_api_observed',index,'A validation-shaped API is called; schema completeness, result use and actual binding remain unverified.','observed_control')
203
+ if cname in {'asyncio.create_task','asyncio.ensure_future'}:
204
+ owner=next((target for target,values in definitions.items() if any(value is call for _,value in values)),None)
205
+ awaited=bool(owner and any(isinstance(n,ast.Await) and isinstance(n.value,ast.Name) and n.value.id==owner for n in nodes))
206
+ cancelled=bool(owner and any(isinstance(c.func,ast.Attribute) and _name(c.func.value)==owner and c.func.attr=='cancel' for c in calls))
207
+ emitter.emit('AQR-LIFE-001','task_owner_control' if awaited or cancelled else 'python_task_ownership',index,'A task is created in the selected scope; cleanup, cancellation and all exit paths require review.', 'observed_control' if awaited or cancelled else 'review_candidate', [x for yes,x in [(awaited,'Await of the same task binding is visible.'),(cancelled,'Cancellation on the same task binding is visible.')] if yes])
208
+ if cname.startswith(('logging.','logger.')) and cname.split('.')[-1] in {'warning','error','exception','critical','warn'}:
209
+ emitter.emit('AQR-ERROR-001','diagnostic_call',index,'A recognized diagnostic/error call is present; adequacy and sensitive-data handling remain unverified.','observed_control')
210
+ for node in nodes:
211
+ if isinstance(node,ast.Try) and any(name(c.func) in {'json.load','json.loads'} for n in node.body for c in calls_in(n)):
212
+ readers=[c for statement in node.body for c in calls_in(statement)]
213
+ file_context=any(name(c.func)=='open' or (isinstance(c.func,ast.Attribute) and c.func.attr in {'read_text','read_bytes'}) or (name(c.func)=='json.load' and c.args and isinstance(c.args[0],ast.Name) and c.args[0].id in handles) for c in readers)
214
+ for handler in node.handlers:
215
+ for statement in handler.body:
216
+ if isinstance(statement,ast.Return) and statement.value is not None and empty(statement.value):emitter.emit('AQR-STATE-001','empty_json_recovery',_index(text,statement),'An exception handler around JSON-reading syntax returns an empty container; file-backed state and recovery intent require characterization.', 'review_candidate' if file_context else 'source_observation')
217
+ if isinstance(node,ast.While):
218
+ counters={x.id for x in ast.walk(node.test) if isinstance(x,ast.Name)}
219
+ claims=[x for x in ast.walk(node) if isinstance(x,ast.Await) and isinstance(x.value,ast.Call) and re.fullmatch(r'(?:claim|claim_job|take_job|dequeue)',name(x.value.func).split('.')[-1])]
220
+ for claim in claims:
221
+ late=[x for x in ast.walk(node) if isinstance(x,ast.AugAssign) and isinstance(x.target,ast.Name) and x.target.id in counters and isinstance(x.op,ast.Add) and x.lineno>claim.lineno]
222
+ if late:
223
+ locked=False
224
+ for control in nodes:
225
+ if not isinstance(control,ast.AsyncWith):continue
226
+ if not any(x is claim for x in ast.walk(control)):continue
227
+ for item in control.items:
228
+ expr=item.context_expr
229
+ if isinstance(expr,ast.Name):
230
+ prior=[value for line,value in definitions.get(expr.id,[]) if line<control.lineno]
231
+ expr=prior[-1] if prior else expr
232
+ if isinstance(expr,ast.Call) and name(expr.func) in {'asyncio.Lock','asyncio.Semaphore','asyncio.BoundedSemaphore'}:locked=True
233
+ emitter.emit('AQR-JOB-001','admission_lock_context' if locked else 'admission_after_await',_index(text,claim),
234
+ 'A loop admission counter is incremented after an awaited claim-shaped call; overlap and serialization require characterization.',
235
+ 'source_observation' if locked else 'review_candidate', ['An enclosing async-with uses a locally constructed asyncio lock/semaphore; capacity and complete admission coverage remain unresolved.'] if locked else [])
236
+ if isinstance(node,(ast.Assign,ast.AnnAssign)):
237
+ targets=node.targets if isinstance(node,ast.Assign) else [node.target]
238
+ if isinstance(node.value,ast.BoolOp) and isinstance(node.value.op,ast.Or) and any(re.search(r'(?i)header|auth|api_?key|token|secret',_name(t)) for t in targets) and any(decoded(x,node.lineno) for x in node.value.values):
239
+ emitter.emit('AQR-CRED-001','encoded_fallback',_index(text,node),'An authentication/header-shaped fallback has bounded lexical ancestry to an encoded literal; request use, credential meaning and validity are unverified.')
240
+ return {'status':'assessed_limited' if len(emitter.signals)>=MAX_SIGNALS else 'assessed','signals':emitter.signals}
241
+
242
+
243
+ def mask_source(text,lang):
244
+ """Position-preserving mask for comments/literals and shell here-documents."""
245
+ chars=list(text);literals=[];i=0;n=len(text)
246
+ def erase(start,end):
247
+ for k in range(start,end):
248
+ if chars[k] not in '\r\n':chars[k]=' '
249
+ while i<n:
250
+ start=i
251
+ if lang=='powershell' and text.startswith(('@\"',"@'"),i):
252
+ quote=text[i+1];end=re.search(r'(?m)^'+re.escape(quote+'@')+r'(?=$|\s|[)\]};|+.,])',text[i+2:])
253
+ if not end:raise ValueError('unsupported_syntax')
254
+ i=i+2+end.end();erase(start,i);continue
255
+ if lang=='shell' and text.startswith('<<',i):
256
+ match=re.match(r"<<-?\s*['\"]?([A-Za-z_][A-Za-z0-9_]*)['\"]?[^\n]*\n",text[i:])
257
+ if match:
258
+ after=i+match.end();end=re.search(r'(?m)^\t*'+re.escape(match.group(1))+r'\s*$',text[after:])
259
+ if not end:raise ValueError('unsupported_syntax')
260
+ i=after+end.end();erase(start,i);continue
261
+ block=None
262
+ if text.startswith('/*',i) and lang not in {'powershell','shell'}:block='*/'
263
+ if lang=='powershell' and text.startswith('<#',i):block='#>'
264
+ if block:
265
+ end=text.find(block,i+2)
266
+ if end<0:raise ValueError('unsupported_syntax')
267
+ if lang=='sql' and '/*' in text[i+2:end]:raise ValueError('unsupported_syntax')
268
+ i=end+2;erase(start,i);continue
269
+ comment=(text.startswith('//',i) and lang not in {'powershell','shell','sql'}) or (text[i]=='#' and (lang in {'powershell','shell'} or (lang=='php' and not text.startswith('#[',i)))) or (text.startswith('--',i) and lang=='sql')
270
+ if comment:
271
+ end=text.find('\n',i);i=n if end<0 else end;erase(start,i);continue
272
+ if lang=='rust' and re.match(r'(?:br|r)#{0,32}"',text[i:]):
273
+ m=re.match(r'(?:br|r)(#{0,32})"',text[i:]);end=text.find('"'+m.group(1),i+len(m.group()))
274
+ if end<0:raise ValueError('unsupported_syntax')
275
+ i=end+1+len(m.group(1));literals.append((start,i));erase(start,i);continue
276
+ if lang=='powershell' and text[i]=='`':erase(i,min(i+2,n));i+=2;continue
277
+ if lang=='csharp' and text.startswith('"""',i) and not (i>0 and text[i-1]=='@'):
278
+ count=len(re.match(r'"{3,}',text[i:]).group());end=text.find('"'*count,i+count)
279
+ if end<0:raise ValueError('unsupported_syntax')
280
+ i=end+count;literals.append((start,i));erase(start,i);continue
281
+ if text[i] in '\"\'`':
282
+ quote=text[i];verbatim=lang=='csharp' and i>0 and text[i-1]=='@'
283
+ if lang=='rust' and quote=="'" and re.match(r"'[A-Za-z_]\w*\b(?!')",text[i:]):i+=1;continue
284
+ i+=1
285
+ while i<n:
286
+ escaped=(text[i]=='\\' and lang!='powershell' and not verbatim and not (lang=='shell' and quote=="'")) or (lang=='powershell' and text[i]=='`' and quote=='"')
287
+ if escaped:i+=2;continue
288
+ if text[i]==quote:
289
+ if lang in {'sql','powershell','csharp'} and i+1<n and text[i+1]==quote:i+=2;continue
290
+ break
291
+ i+=1
292
+ if i>=n:raise ValueError('unsupported_syntax')
293
+ i+=1;literals.append((start,i));erase(start,i);continue
294
+ i+=1
295
+ return ''.join(chars),literals
296
+
297
+
298
+ def _pairs(mask):
299
+ pairs={};stack=[]
300
+ for i,c in enumerate(mask):
301
+ if c in '([{':stack.append(i)
302
+ elif c in ')]}':
303
+ if not stack or {'(':')','[':']','{':'}'}[mask[stack[-1]]]!=c:raise ValueError('unsupported_syntax')
304
+ other=stack.pop();pairs[other]=i;pairs[i]=other
305
+ if stack:raise ValueError('unsupported_syntax')
306
+ return pairs
307
+
308
+
309
+ def _calls(mask,pairs):
310
+ calls=[]
311
+ pattern=r'\b([A-Za-z_$][\w$]*(?:(?:::|\?\.|\.|->)[A-Za-z_$][\w$]*)*)\s*(?:<[^;{}\n()]{1,100}>\s*)?\('
312
+ for match in re.finditer(pattern,mask):
313
+ opening=match.end()-1
314
+ if opening not in pairs:continue
315
+ closing=pairs[opening];args=[];start=opening+1;i=start
316
+ while i<closing:
317
+ if mask[i]==',':args.append((start,i));start=i+1;i+=1
318
+ elif i in pairs and pairs[i]>i:i=pairs[i]+1
319
+ else:i+=1
320
+ if start<closing:args.append((start,closing))
321
+ calls.append({'name':match.group(1),'start':match.start(),'end':closing,'args':args})
322
+ if len(calls)>MAX_LOCAL_CALLS:raise ValueError('unsupported_limit')
323
+ return calls
324
+
325
+
326
+ def php_regions(text):
327
+ """Mask HTML outside PHP tags while preserving original coordinates."""
328
+ if not re.search(r'<\?(?:php\b|=)',text,re.I):raise ValueError('unsupported_embedded_content')
329
+ chars=[c if c in '\r\n' else ' ' for c in text];position=0
330
+ while position<len(text):
331
+ match=re.search(r'<\?(?:php\b|=)',text[position:],re.I)
332
+ if not match:break
333
+ start=position+match.end();i=start
334
+ while i<len(text):
335
+ if text.startswith('?>',i):break
336
+ if text.startswith('<<<',i):raise ValueError('unsupported_syntax')
337
+ if text.startswith('/*',i):
338
+ end=text.find('*/',i+2)
339
+ if end<0:raise ValueError('unsupported_syntax')
340
+ i=end+2;continue
341
+ if text.startswith('//',i) or (text[i]=='#' and not text.startswith('#[',i)):
342
+ newline=text.find('\n',i);close=text.find('?>',i)
343
+ if close>=0 and (newline<0 or close<newline):i=close;break
344
+ i=len(text) if newline<0 else newline;continue
345
+ if text[i] in '\"\'`':
346
+ quote=text[i];i+=1
347
+ while i<len(text):
348
+ if text[i]=='\\':i+=2;continue
349
+ if text[i]==quote:break
350
+ i+=1
351
+ if i>=len(text):raise ValueError('unsupported_syntax')
352
+ i+=1
353
+ chars[start:i]=text[start:i];position=i+2
354
+ return ''.join(chars)
355
+
356
+
357
+ def structured_review(text,path,lang,context):
358
+ emitter=Emitter(text,path,lang,context)
359
+ try:
360
+ scoped=php_regions(text) if lang=='php' else text
361
+ mask,literals=mask_source(scoped,lang)
362
+ if len(re.findall(r'\w+|[^\s\w]',mask))>20000:return {'status':'unsupported_limit','signals':[]}
363
+ pairs={} if lang=='shell' else _pairs(mask);calls=[] if lang=='shell' else _calls(mask,pairs)
364
+ except (ValueError,RecursionError) as error:return {'status':str(error) if str(error) in {'unsupported_limit','unsupported_syntax','unsupported_embedded_content'} else 'unsupported_syntax','signals':[]}
365
+ def block(index):
366
+ containers=[(a,b) for a,b in pairs.items() if a<index<b and mask[a]=='{']
367
+ return max(containers,key=lambda x:x[0]) if containers else (0,len(text))
368
+ def expression(span):return text[span[0]:span[1]].strip()
369
+ def same_block(a,b):return block(a)==block(b)
370
+ writes={'csharp':{'File.WriteAllText','File.WriteAllTextAsync','System.IO.File.WriteAllText','System.IO.File.WriteAllTextAsync'},
371
+ 'java':{'Files.writeString','Files.write','java.nio.file.Files.writeString'},
372
+ 'kotlin':{'Files.writeString','file.writeText'},'php':{'file_put_contents'},'rust':{'std::fs::write','fs::write'}}
373
+ serializers={'csharp':r'\b(?:JsonSerializer\.Serialize|JsonConvert\.SerializeObject)\s*\(',
374
+ 'java':r'\b(?:gson\.toJson|mapper\.writeValueAsString|new\s+Gson)\s*\(',
375
+ 'kotlin':r'\b(?:Json\.encodeToString|json\.encodeToString|gson\.toJson)\s*(?:<[^>]*>)?\(',
376
+ 'php':r'\bjson_encode\s*\(','rust':r'\bserde_json::to_(?:string|vec)(?:_pretty)?\s*\('}
377
+ renames={'File.Move','File.Replace','System.IO.File.Move','System.IO.File.Replace','Files.move','java.nio.file.Files.move','rename','std::fs::rename','fs::rename'}
378
+ for call in calls:
379
+ cname=call['name'];start=call['start'];a,b=block(start);local=mask[a:b]
380
+ is_write=cname in writes.get(lang,set())
381
+ if lang=='rust' and cname=='fs::write' and not re.search(r'\buse\s+std::fs\b',mask):is_write=False
382
+ if lang=='kotlin' and cname=='writeText':
383
+ prefix=mask[max(a,start-300):start]
384
+ is_write=bool(re.search(r'\bFile\s*\([^()]*\)\s*\.\s*$',prefix))
385
+ if is_write and call['args'] and lang in serializers and re.search(serializers[lang],mask[start:call['end']+1]):
386
+ target=expression(call['args'][0]);controls=[]
387
+ for other in calls:
388
+ if other['name'] in renames and other['start']>call['end'] and same_block(start,other['start']) and other['args'] and expression(other['args'][0])==target:
389
+ if not cname.endswith('Async') or re.search(r'\bawait\s*$',mask[max(a,start-20):start]):controls.append('A later rename/move uses the same written path expression.')
390
+ emitter.emit('AQR-STATE-001','json_publication_control' if controls else 'direct_json_write',start,
391
+ 'A qualified write-call spelling receives JSON serialization. Publication intent, sink binding and crash/recovery behavior require characterization.',
392
+ 'observed_control' if controls else 'review_candidate',controls)
393
+ if lang in {'csharp','kotlin','java','php','rust'} and re.search(r'(?:Deserialize|decodeFromString|fromJson|readValue|json_decode|serde_json::from_(?:str|slice))$',cname):
394
+ line_start=max(mask.rfind(';',a,start),mask.rfind('\n',a,start),a);prefix=mask[line_start:start]
395
+ installing=bool(re.search(r'\breturn\s*$|\b(?:state|project|document|config)\s*=\s*$',prefix,re.I))
396
+ if installing and call['args']:
397
+ arg=mask[call['args'][0][0]:call['args'][0][1]].strip();prior=mask[a:start]
398
+ guarded=bool(arg and re.search(r'\bif\s*\(\s*!\s*(?:isValid\w*|[Vv]alidate\w*)\s*\(\s*'+re.escape(arg)+r'\s*\)[^{}]*\)\s*\{?\s*(?:throw|return)',prior))
399
+ emitter.emit('AQR-VALID-001','validation_control' if guarded else 'typed_deserialization_boundary',start,
400
+ 'Deserialization-shaped syntax feeds a returned or installed value; domain validation and framework/caller controls remain unresolved.',
401
+ 'observed_control' if guarded else 'source_observation')
402
+ if lang=='rust' and cname in {'std::fs::write','fs::write','serde_json::to_string','serde_json::to_vec','serde_json::from_str','serde_json::from_slice'}:
403
+ tail=mask[call['end']+1:call['end']+100]
404
+ if re.match(r'\s*\?',tail):
405
+ emitter.emit('AQR-ERROR-001','rust_result_propagation',start,
406
+ 'A recognized filesystem/serialization call is followed by the question-mark propagation operator; enclosing Result type, caller handling and complete failure coverage remain unresolved.','observed_control')
407
+ if re.search(r'(?:^|[.:])(?:LogWarning|LogError|warning|error|exception|warn|error_log)$',cname):
408
+ emitter.emit('AQR-ERROR-001','diagnostic_call',start,'A diagnostic/error-call spelling is visible; call binding, privacy and end-to-end failure coverage remain unverified.','observed_control')
409
+ if re.search(r'(?:FromBase64String|base64_decode|decodeBase64|decode)$',cname):
410
+ literal=any(start<x<call['end'] for x,y in literals)
411
+ statement_start=max(mask.rfind(';',a,start),mask.rfind('\n',a,start),a);statement=mask[statement_start:call['end']+1]
412
+ if literal and re.search(r'(?i)\b(?:header|auth\w*|api_?key|token|secret)\b',statement) and re.search(r'\?\?|\?:|\|\||\s-or\s',statement):
413
+ emitter.emit('AQR-CRED-001','encoded_fallback',start,'An auth/header-shaped fallback directly contains encoded-literal decoding syntax; no value is decoded, reconstructed or tested.')
414
+ if re.search(r'(?:^|[.:])(?:claim|claimJob|ClaimJob|takeJob|dequeue|Dequeue)(?:Async)?$',cname) and re.search(r'\bawait\s*$',mask[max(a,start-30):start]):
415
+ # Only a visible enclosing loop and counter name link warrant a candidate.
416
+ for m in re.finditer(r'\bwhile\s*\(([^{};]*)\)\s*\{',mask[:start]):
417
+ opening=m.end()-1
418
+ if pairs.get(opening,0)<start:continue
419
+ names=set(re.findall(r'\b[A-Za-z_]\w*\b',m.group(1)));tail=mask[call['end']+1:pairs[opening]]
420
+ increments=[name for name in names if re.search(r'\b'+re.escape(name)+r'\s*(?:\+\+|\+=)',tail)]
421
+ if increments:
422
+ emitter.emit('AQR-JOB-001','admission_after_await',start,'A loop counter is incremented after an awaited claim-shaped call; locks, serialization and overlap require characterization.')
423
+ break
424
+ # Startup syntax is recorded as a fact where framework ownership cannot be resolved.
425
+ startup={'kotlin':r'\b(?:GlobalScope|viewModelScope|lifecycleScope|scope)\.(?:launch|async)\s*(?:\(|\{)',
426
+ 'csharp':r'\b(?:Task\.Run|Task\.Factory\.StartNew)\s*\(',
427
+ 'rust':r'\b(?:tokio::spawn|std::thread::spawn|thread::spawn)\s*\('}
428
+ if lang in startup:
429
+ for match in re.finditer(startup[lang],mask):
430
+ emitter.emit('AQR-LIFE-001','async_startup_context',match.start(),
431
+ 'A recognized task/coroutine/thread startup spelling is visible. Framework scopes, returned owners, cancellation and cleanup remain unresolved.','source_observation')
432
+ if lang=='php':
433
+ for match in re.finditer(r'\b(?:check_ajax_referer|check_admin_referer|wp_verify_nonce|current_user_can|sanitize_text_field|sanitize_email)\s*\(',mask):
434
+ emitter.emit('AQR-VALID-001','wordpress_boundary_api',match.start(),
435
+ 'A WordPress nonce, authorization or sanitization API spelling is present; these have different roles and do not establish complete domain validation.','source_observation')
436
+ if lang=='rust':
437
+ for match in re.finditer(r'\.\s*(?:unwrap|expect)\s*\(',mask):
438
+ emitter.emit('AQR-ERROR-001','rust_error_boundary_context',match.start(),
439
+ 'An unwrap/expect call spelling is visible; invariant/test intent, concrete result type and reachable panic behavior require review.','source_observation')
440
+ # Bind recovery to the adjacent try body, not arbitrary preceding methods.
441
+ for match in re.finditer(r'\bcatch\b[^{}]*\{',mask):
442
+ opening=match.end()-1;closing=pairs.get(opening)
443
+ previous=match.start()-1
444
+ while previous>=0 and mask[previous].isspace():previous-=1
445
+ try_start=pairs.get(previous)
446
+ if closing is None or try_start is None or not re.search(r'\btry\s*$',mask[max(0,try_start-24):try_start]):continue
447
+ body=mask[opening+1:closing];before=mask[try_start+1:previous]
448
+ if re.search(r'\b(?:Deserialize|decodeFromString|json_decode|fromJson|readValue|JSON\.parse|serde_json::from_str)\b',before) and re.search(r'\breturn\s*(?:\{\s*\}|\[\s*\]|new\s+\w+(?:<[^>]*>)?\s*\(\s*\))',body):
449
+ file_context=bool(re.search(r'\b(?:File\.ReadAll(?:Text|Bytes)|Files\.read\w*|file_get_contents|std::fs::read\w*|fs::read\w*)\s*\(',before))
450
+ emitter.emit('AQR-STATE-001','empty_json_recovery',match.start(),
451
+ 'An empty/default return is visible in the catch attached to JSON-reading syntax; storage binding and recovery intent require review.',
452
+ 'review_candidate' if file_context else 'source_observation')
453
+ # Bounded local save-revision acknowledgement, without claiming a race.
454
+ if lang in {'csharp','kotlin','java'}:
455
+ for match in re.finditer(r'\bawait\s+[\w.]*[Ss]ave\w*\s*\(',mask):
456
+ a,b=block(match.start());tail=mask[match.end():b]
457
+ saved=next(((x,y) for x,y in literals if match.end()<x<b and re.search(r'(?i)saved',text[x:y]) and re.search(r'(?i)(?:status|state|acknowledg\w*)\s*=\s*$',mask[max(a,x-80):x])),None)
458
+ if saved:
459
+ controlled=bool(re.search(r'\bif\s*\([^)]*(?:revision|version|generation)[^)]*(?:==|!=)[^)]*\)',tail,re.I))
460
+ emitter.emit('AQR-LIFE-001','save_revision_control' if controlled else 'saved_after_await',saved[0],
461
+ 'A saved-status literal follows awaited save syntax; revision ownership, acknowledgement and concurrent edits require characterization.',
462
+ 'observed_control' if controlled else 'review_candidate')
463
+ if lang=='powershell':
464
+ for match in re.finditer(r'\bConvertTo-Json\b[^\n;]*\|\s*(?:Set-Content|Out-File)\b',mask,re.I):
465
+ statement=mask[match.start():mask.find('\n',match.start()) if '\n' in mask[match.start():] else len(mask)]
466
+ emitter.emit('AQR-STATE-001','powershell_json_publication',match.start(),'A JSON conversion pipeline reaches a file-output cmdlet spelling; path binding, encoding and publication/recovery intent require review.', 'source_observation' if any(part in {'deploy','samples'} for part in Path(path).parts[:-1]) else 'review_candidate')
467
+ for match in re.finditer(r'\b(Start-Job|Start-ThreadJob|Start-Process)\b',mask,re.I):
468
+ line_end=mask.find('\n',match.end());line_end=len(mask) if line_end<0 else line_end
469
+ statement=mask[match.start():line_end];prefix=mask[max(0,mask.rfind('\n',0,match.start())):match.start()]
470
+ process=match.group(1).lower()=='start-process';controls=[]
471
+ binding=re.search(r'(\$(?:(?:script|global|local):)?[A-Za-z_]\w*)\s*=\s*$',prefix,re.I)
472
+ if process and re.search(r'(?:^|\s)-Wait(?:\s|$)',statement,re.I):controls.append('The same Start-Process command includes -Wait.')
473
+ if process and binding:
474
+ a,b=block(match.start());tail=mask[match.end():b];name=re.escape(binding.group(1))
475
+ rebound=re.search(name+r'\s*=',tail,re.I)
476
+ if rebound:tail=tail[:rebound.start()]
477
+ linked=re.search(name+r'\s*\.\s*(?:WaitForExit|Kill|CloseMainWindow)\s*\(',tail,re.I)
478
+ if linked:controls.append('A wait/termination-shaped method is visible on the same local process binding; exit coverage remains unresolved.')
479
+ other=bool(re.search(r'\b(?:Wait-Job|Stop-Job|Remove-Job)\b',mask[match.end():],re.I)) if not process else False
480
+ emitter.emit('AQR-LIFE-001','powershell_process_controls' if controls else 'powershell_process_context' if process else 'powershell_job_controls' if other else 'powershell_job_ownership',match.start(),
481
+ 'Process/job startup syntax is present; independent launch intent, retained handles and complete lifetime coverage require review.',
482
+ 'observed_control' if controls else 'source_observation' if process or other else 'review_candidate',controls)
483
+ for match in re.finditer(r'\b(?:Write-Error|Write-Warning|throw)\b',mask,re.I):emitter.emit('AQR-ERROR-001','powershell_error_path',match.start(),'An explicit diagnostic/error propagation spelling is visible; error-mode configuration and actual handling remain unverified.','observed_control')
484
+ if lang=='shell':
485
+ for match in re.finditer(r'(?m)^\s*trap\b',mask):emitter.emit('AQR-LIFE-001','shell_cleanup_trap',match.start(),'A shell trap declaration is present; its body, signals, resource binding and exit coverage remain unresolved.','source_observation')
486
+ for match in re.finditer(r'(?m)^\s*(?:jq|python[23]?)\b[^\n]*>[^\n]*',mask):
487
+ emitter.emit('AQR-STATE-001','shell_generated_file_output',match.start(),'A supported tool-call spelling redirects output; whether this is JSON or persistent state remains unresolved.','source_observation')
488
+ if re.search(r'(?m)^\s*set\s+-[A-Za-z]*e',mask):emitter.emit('AQR-ERROR-001','shell_error_mode',re.search(r'(?m)^\s*set\s+-[A-Za-z]*e',mask).start(),'An errexit-shaped option is declared; shell exception contexts and actual command outcomes are not proven.','source_observation')
489
+ interpolation=lang=='csharp' and bool(re.search(r'\$(?:@)?"|@\$"',text))
490
+ limited=lang=='shell' or interpolation or len(emitter.signals)>=MAX_SIGNALS
491
+ return {'status':'assessed_limited' if limited else 'assessed','signals':emitter.signals,**({'diagnostics':['shell_case_substitution_and_full_grammar_unassessed']} if lang=='shell' else {'diagnostics':['csharp_interpolation_code_unassessed']} if interpolation else {})}
492
+
493
+
494
+ def sql_review(text,path,context):
495
+ context=dict(context or {});name=path.lower()
496
+ sql_role=next((role for pattern,role in [(r'dump|backup','dump_or_backup'),(r'reset|teach|practice|example|fixture|test','teaching_or_reset'),(r'seed','seed'),(r'migrat','migration'),(r'schema','schema'),(r'quer','application_query')] if re.search(pattern,name)),'unknown')
497
+ context['sql_role']=sql_role;context.setdefault('sql_dialect','unknown')
498
+ emitter=Emitter(text,path,'sql',context)
499
+ try:mask,_=mask_source(text,'sql')
500
+ except ValueError:return {'status':'unsupported_syntax','signals':[]}
501
+ dialect=(context or {}).get('sql_dialect','unknown');role=(context or {}).get('role','unknown')
502
+ for m in re.finditer(r'(?im)(?:^|;)\s*(BEGIN(?:\s+TRANSACTION)?|COMMIT|ROLLBACK|SAVEPOINT|RELEASE)\b',mask):
503
+ emitter.emit('AQR-SQL-001','transaction_syntax',m.start(1),'Transaction-control syntax is present; dialect, implicit transactions, binding and runtime atomicity remain unverified.','observed_control')
504
+ for m in re.finditer(r'(?im)(?:^|;)\s*(DROP\s+(?:TABLE|DATABASE|SCHEMA)|TRUNCATE(?:\s+TABLE)?)\b',mask):
505
+ reset=bool(re.search(r'(?i)(?:reset|seed|demo|fixture|example|test)',path)) or role in {'tests','fixture','tooling'}
506
+ emitter.emit('AQR-SQL-001','destructive_statement_context',m.start(1),
507
+ 'Explicit schema/reset syntax is present. Teaching, disposable/reset and production-migration intent must be distinguished; no unsafe execution or data loss is established.',
508
+ 'review_candidate' if not reset and dialect!='unknown' and sql_role=='migration' and role in {'production','source'} else 'source_observation')
509
+ return {'status':'assessed_limited' if dialect=='unknown' or len(emitter.signals)>=MAX_SIGNALS else 'assessed','signals':emitter.signals,
510
+ 'diagnostics':['unknown_sql_dialect_common_syntax_only'] if dialect=='unknown' else []}
511
+
512
+
513
+ def markup_review(text,path,lang,context):
514
+ """Analyze bounded script regions with original Unicode positions/digests."""
515
+ from .review import _review_source
516
+ emitter=Emitter(text,path,lang,context);segments=[];diagnostics=[]
517
+ if lang=='html':
518
+ from html.parser import HTMLParser
519
+ class Scripts(HTMLParser):
520
+ def __init__(self):super().__init__(convert_charrefs=False);self.active=None
521
+ def source_offset(self):
522
+ line,col=self.getpos();return sum(len(x) for x in text.splitlines(keepends=True)[:line-1])+col
523
+ def handle_starttag(self,tag,attrs):
524
+ if tag!='script':return
525
+ attrs=dict(attrs);kind=attrs.get('type','').lower()
526
+ if 'src' in attrs:diagnostics.append('external_script_not_followed');return
527
+ if kind not in {'','module','text/javascript','application/javascript','text/typescript'}:diagnostics.append('non_javascript_script_excluded');return
528
+ self.active=self.source_offset()+len(self.get_starttag_text())
529
+ def handle_endtag(self,tag):
530
+ if tag=='script' and self.active is not None:segments.append((self.active,self.source_offset()));self.active=None
531
+ parser=Scripts()
532
+ try:parser.feed(text);parser.close()
533
+ except (ValueError,RecursionError):return {'status':'unsupported_syntax','signals':[]}
534
+ if parser.active is not None:return {'status':'unsupported_syntax','signals':[]}
535
+ if len(segments)>64:return {'status':'unsupported_limit','signals':[]}
536
+ if not segments:return {'status':'unsupported_embedded_content','signals':[],'diagnostics':diagnostics+['no_supported_inline_scripts']}
537
+ diagnostics.append('html_handlers_templates_and_external_scripts_unassessed')
538
+ else:
539
+ from .markup import prepare_jsx
540
+ try:prepared,jsx_diagnostics=prepare_jsx(text)
541
+ except ValueError as error:return {'status':str(error),'signals':[]}
542
+ segments=[(0,len(text))];diagnostics.extend(jsx_diagnostics)
543
+ statuses=[]
544
+ for start,end in segments:
545
+ # Whitespace prefix preserves original line/column coordinates.
546
+ prefix=''.join(c if c in '\r\n' else ' ' for c in text[:start])
547
+ fragment=(prepared if lang!='html' else prefix+text[start:end])
548
+ result=_review_source(fragment,'embedded.ts',apply_exclusions=False);statuses.append(result['status'])
549
+ for signal in result['signals']:
550
+ loc=signal['locations'][0];lines=text.splitlines(keepends=True)
551
+ index=sum(len(x) for x in lines[:loc['line']-1])+loc['column']-1
552
+ count_before=len(emitter.signals)
553
+ emitter.emit(signal['check_id'],signal['variant'],index,signal['observed_fact'],signal['kind'],signal['observed_controls'])
554
+ if len(emitter.signals)==count_before:continue
555
+ emitter.signals[-1]['source_ids']=sorted(set(signal['source_ids']+SOURCES[lang]))
556
+ emitter.signals[-1]['segment_digest']=hashlib.sha256(text[start:end].encode()).hexdigest()
557
+ status='assessed_limited' if len(emitter.signals)>=MAX_SIGNALS or any(s!='assessed' for s in statuses) or lang=='html' or 'template_interpolation_unassessed' in diagnostics else 'assessed'
558
+ if not any(s.startswith('assessed') for s in statuses):status=statuses[0]
559
+ return {'status':status,'signals':emitter.signals,'diagnostics':diagnostics}
560
+
561
+
562
+ def analyze_source(text,path,context=None):
563
+ if Path(path).is_absolute() or '..' in Path(path).parts or '\\' in str(path) or '\x00' in str(path):return {'status':'unsupported_path','signals':[]}
564
+ lang=language(path,text)
565
+ if not lang:return {'status':'unsupported_language','signals':[]}
566
+ if len(text.encode('utf8'))>MAX_SOURCE_BYTES:return {'status':'unsupported_limit','signals':[]}
567
+ try:
568
+ if lang in {'javascript','typescript'}:
569
+ from .review import _review_source
570
+ result=_review_source(text,path,apply_exclusions=False)
571
+ for signal in result['signals']:
572
+ signal['language']=lang;signal['applicability']={'role':(context or {}).get('role','unknown'),'owner':(context or {}).get('owner','root'),'intent_verified':False}
573
+ if signal['check_id']=='AQR-STATE-001' and signal['kind']=='review_candidate' and (context or {}).get('role') in {'tooling','tests','docs','fixture'}:signal['kind']='source_observation'
574
+ return result
575
+ if lang in {'jsx','tsx','html'}:return markup_review(text,path,lang,context)
576
+ if lang=='python':return python_review(text,path,context)
577
+ if lang=='sql':return sql_review(text,path,context)
578
+ if lang=='swift':
579
+ from .swift_review import swift_review
580
+ return swift_review(text,path,context)
581
+ return structured_review(text,path,lang,context)
582
+ except (IndexError,KeyError,SyntaxError):return {'status':'unsupported_syntax','signals':[]}
583
+ except (ValueError,RecursionError,OverflowError):return {'status':'unsupported_limit','signals':[]}
@@ -0,0 +1,28 @@
1
+ """Publish complete format bundles atomically, never replace historical results."""
2
+ from pathlib import Path
3
+ import json,os,tempfile
4
+
5
+
6
+ def write_bundle(report,destination):
7
+ from .cli import md_report,sarif_report
8
+ destination=Path(destination)
9
+ if destination.is_symlink():raise ValueError('Bundle destination cannot be a symlink')
10
+ if destination.exists() and any(destination.iterdir()):raise ValueError('Bundle destination is not empty; choose a new attempt directory')
11
+ destination.mkdir(parents=True,exist_ok=True)
12
+ values={'report.json':json.dumps(report,indent=2),'report.md':md_report(report),'report.sarif':json.dumps(sarif_report(report),indent=2)}
13
+ # Each file is complete before rename; the manifest is the commit marker.
14
+ import hashlib
15
+ for name,text in values.items():
16
+ target=destination/name
17
+ if target.is_symlink():raise ValueError('Bundle artifact cannot be a symlink')
18
+ fd,temporary=tempfile.mkstemp(prefix='.aqaf-',dir=destination)
19
+ try:
20
+ with os.fdopen(fd,'w',encoding='utf8') as stream:stream.write(text+'\n')
21
+ os.replace(temporary,target)
22
+ finally:Path(temporary).unlink(missing_ok=True)
23
+ manifest={'schema_version':1,'status':'complete','files':{k:hashlib.sha256((v+'\n').encode()).hexdigest() for k,v in values.items()}}
24
+ fd,temporary=tempfile.mkstemp(prefix='.aqaf-',dir=destination)
25
+ try:
26
+ with os.fdopen(fd,'w',encoding='utf8') as stream:json.dump(manifest,stream,indent=2)
27
+ os.replace(temporary,destination/'bundle.json')
28
+ finally:Path(temporary).unlink(missing_ok=True)