@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.
- package/LICENSE +21 -0
- package/README.md +62 -2
- package/bin/aqaf.js +12 -0
- package/index.js +51 -0
- package/package.json +51 -4
- package/vendor/aqaf/__init__.py +2 -0
- package/vendor/aqaf/__main__.py +3 -0
- package/vendor/aqaf/adapters.py +583 -0
- package/vendor/aqaf/artifacts.py +28 -0
- package/vendor/aqaf/calibration.py +29 -0
- package/vendor/aqaf/catalogue.py +257 -0
- package/vendor/aqaf/cli.py +721 -0
- package/vendor/aqaf/cursor.py +56 -0
- package/vendor/aqaf/data/book_review_knowledge.json +1574 -0
- package/vendor/aqaf/data/catalog.json +562 -0
- package/vendor/aqaf/data/documentation_checks.json +52 -0
- package/vendor/aqaf/data/packs/automation.json +16 -0
- package/vendor/aqaf/data/packs/mobile.json +13 -0
- package/vendor/aqaf/data/packs/systems.json +17 -0
- package/vendor/aqaf/data/packs/web.json +16 -0
- package/vendor/aqaf/data/profiles/ai_pipeline.json +39 -0
- package/vendor/aqaf/data/profiles/android.json +34 -0
- package/vendor/aqaf/data/profiles/api_backend.json +44 -0
- package/vendor/aqaf/data/profiles/browser_extension.json +29 -0
- package/vendor/aqaf/data/profiles/cli_automation.json +34 -0
- package/vendor/aqaf/data/profiles/desktop_utility.json +39 -0
- package/vendor/aqaf/data/profiles/embedded_iot.json +34 -0
- package/vendor/aqaf/data/profiles/google_apps_script.json +34 -0
- package/vendor/aqaf/data/profiles/mcp_agent_server.json +34 -0
- package/vendor/aqaf/data/profiles/multirepo_system.json +29 -0
- package/vendor/aqaf/data/profiles/npm_library.json +39 -0
- package/vendor/aqaf/data/profiles/rust_app.json +34 -0
- package/vendor/aqaf/data/profiles/saas_or_web_app.json +44 -0
- package/vendor/aqaf/data/profiles/scraper_data_pipeline.json +39 -0
- package/vendor/aqaf/data/profiles/swift_app.json +34 -0
- package/vendor/aqaf/data/profiles/tauri_electron.json +34 -0
- package/vendor/aqaf/data/profiles/unknown_or_general_app.json +25 -0
- package/vendor/aqaf/data/profiles/windows_client_server.json +34 -0
- package/vendor/aqaf/data/profiles/wordpress_plugin.json +39 -0
- package/vendor/aqaf/data/review_checks.json +319 -0
- package/vendor/aqaf/data/source_registry.json +814 -0
- package/vendor/aqaf/discovery.py +580 -0
- package/vendor/aqaf/documentation.py +210 -0
- package/vendor/aqaf/evidence.py +132 -0
- package/vendor/aqaf/facets.py +63 -0
- package/vendor/aqaf/ignore.py +109 -0
- package/vendor/aqaf/knowledge.py +110 -0
- package/vendor/aqaf/markup.py +96 -0
- package/vendor/aqaf/matching.py +22 -0
- package/vendor/aqaf/portfolio.py +145 -0
- package/vendor/aqaf/project_commands.py +132 -0
- package/vendor/aqaf/projects.py +157 -0
- package/vendor/aqaf/registry.py +125 -0
- package/vendor/aqaf/review.py +446 -0
- package/vendor/aqaf/scope.py +401 -0
- 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)
|