@aqaf/cli 1.1.0 → 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/README.md +22 -3
- package/index.js +20 -1
- package/package.json +3 -2
- package/vendor/aqaf/__init__.py +1 -1
- 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 +171 -33
- package/vendor/aqaf/cursor.py +56 -0
- package/vendor/aqaf/data/book_review_knowledge.json +1574 -0
- package/vendor/aqaf/data/documentation_checks.json +52 -0
- package/vendor/aqaf/data/review_checks.json +319 -0
- package/vendor/aqaf/data/source_registry.json +513 -1
- 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 +4 -3
- package/vendor/aqaf/review.py +446 -0
- package/vendor/aqaf/scope.py +401 -0
- package/vendor/aqaf/swift_review.py +99 -0
package/vendor/aqaf/cli.py
CHANGED
|
@@ -11,6 +11,7 @@ from pathlib import Path
|
|
|
11
11
|
from collections import Counter, defaultdict
|
|
12
12
|
from . import __version__
|
|
13
13
|
from .registry import Registry, ProfileError, read_doc
|
|
14
|
+
from .evidence import parent_context, rust_test_count, has_warning_call
|
|
14
15
|
|
|
15
16
|
def _metadata():
|
|
16
17
|
"""Bundled source index; gracefully return empty if a developer copy is incomplete."""
|
|
@@ -61,16 +62,10 @@ class Finding:
|
|
|
61
62
|
self.evidence=evidence; self.risk=risk; self.recommendation=recommendation; self.confidence=confidence
|
|
62
63
|
def to_dict(self): return self.__dict__
|
|
63
64
|
|
|
64
|
-
def iter_files(root: Path):
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
p=Path(dirpath)/f
|
|
69
|
-
if p.is_symlink(): continue
|
|
70
|
-
try:
|
|
71
|
-
if p.stat().st_size > 1_500_000: continue
|
|
72
|
-
except OSError: continue
|
|
73
|
-
yield p
|
|
65
|
+
def iter_files(root: Path, respect_gitignore=True, policy='typed'):
|
|
66
|
+
from .scope import Scope
|
|
67
|
+
yield from Scope(root,respect_gitignore=respect_gitignore,policy=policy).build().files
|
|
68
|
+
|
|
74
69
|
|
|
75
70
|
def rel(p, root):
|
|
76
71
|
try: return str(p.relative_to(root))
|
|
@@ -80,8 +75,18 @@ def read_text(p):
|
|
|
80
75
|
try: return p.read_text(encoding='utf-8', errors='ignore')
|
|
81
76
|
except Exception: return ''
|
|
82
77
|
|
|
83
|
-
def scan(root: Path, profile: str = "auto", registry=None, pack: str = "auto", capabilities=()
|
|
84
|
-
|
|
78
|
+
def scan(root: Path, profile: str = "auto", registry=None, pack: str = "auto", capabilities=(), review_signals=False, respect_gitignore=True, scope_policy='typed', scope_config=None,
|
|
79
|
+
inventory_all=False, component=None, review_options=None, session=None, review_knowledge=False):
|
|
80
|
+
from .scope import Scope
|
|
81
|
+
scope=session or Scope(root,respect_gitignore,scope_policy,scope_config,inventory_all).build()
|
|
82
|
+
root=scope.root
|
|
83
|
+
files=scope.files
|
|
84
|
+
if component:
|
|
85
|
+
selected=next((n for n in scope.nodes if n['id']==component or n['root']==component),None)
|
|
86
|
+
if selected is None:raise ValueError('Unknown component/root: '+component)
|
|
87
|
+
root=scope.root if selected['root']=='.' else scope.root/selected['root']
|
|
88
|
+
files=[r['path'] for r in scope.records if r['owner']==selected['id']]
|
|
89
|
+
read=scope.text
|
|
85
90
|
code=[p for p in files if p.suffix.lower() in CODE_EXTS]
|
|
86
91
|
# Exclude AQAF's own scanner when it has been copied into a target repo; otherwise it can bias product detection.
|
|
87
92
|
app_code=[p for p in code if p.name.lower() != 'aqaf.py']
|
|
@@ -89,14 +94,14 @@ def scan(root: Path, profile: str = "auto", registry=None, pack: str = "auto", c
|
|
|
89
94
|
exts=Counter(p.suffix.lower() for p in files)
|
|
90
95
|
names={p.name.lower() for p in files}
|
|
91
96
|
path_text='\n'.join(rel(p,root).lower() for p in files)
|
|
92
|
-
sample_text='\n'.join(
|
|
97
|
+
sample_text='\n'.join(read(p)[:2000].lower() for p in detection_code[:250])
|
|
93
98
|
|
|
94
99
|
findings=[]
|
|
95
100
|
signals=[]
|
|
96
101
|
|
|
97
102
|
# Data-only registry makes product types additive without changing the scanner core.
|
|
98
103
|
reg = registry or Registry()
|
|
99
|
-
detected_product, evidence_signals, detection_confidence, candidates = reg.detect(files,root,detection_code,pack)
|
|
104
|
+
detected_product, evidence_signals, detection_confidence, candidates = reg.detect(files,root,detection_code,pack,reader=scope.text)
|
|
100
105
|
product = detected_product
|
|
101
106
|
if profile != 'auto':
|
|
102
107
|
chosen=reg.get_profile(profile)
|
|
@@ -117,8 +122,11 @@ def scan(root: Path, profile: str = "auto", registry=None, pack: str = "auto", c
|
|
|
117
122
|
if not re.fullmatch(r'[a-z][a-z0-9_]{1,63}',c):
|
|
118
123
|
raise ValueError(f'Invalid capability: {c}')
|
|
119
124
|
|
|
125
|
+
context = parent_context(root)
|
|
126
|
+
|
|
120
127
|
# docs/ADRs
|
|
121
128
|
has_arch_docs = any('adr' in rel(p,root).lower() or 'architecture' in rel(p,root).lower() for p in files if p.suffix.lower() in {'.md','.adoc','.rst'})
|
|
129
|
+
has_arch_docs = has_arch_docs or bool(context['architecture_files'])
|
|
122
130
|
if not has_arch_docs:
|
|
123
131
|
findings.append(Finding('AQ-DOC-001','medium','No architecture decision records or architecture docs found',
|
|
124
132
|
'No files with ADR/architecture naming were found.',
|
|
@@ -127,14 +135,18 @@ def scan(root: Path, profile: str = "auto", registry=None, pack: str = "auto", c
|
|
|
127
135
|
|
|
128
136
|
# tests
|
|
129
137
|
test_files=[p for p in files if re.search(r'(^|/|\\)(tests?|__tests__)(/|\\)', str(p).lower()) or re.search(r'(test_|_test\.|\.spec\.|\.test\.)', p.name.lower())]
|
|
130
|
-
if
|
|
138
|
+
inline_rust_tests = sum(rust_test_count(read(p)) for p in code if p.suffix.lower() == '.rs' and p not in test_files)
|
|
139
|
+
test_evidence = len(test_files) + inline_rust_tests
|
|
140
|
+
# One actual inline test is meaningful for a tiny crate; keep the size-based warning.
|
|
141
|
+
minimum_tests = max(1 if inline_rust_tests else 3, len(code)//50)
|
|
142
|
+
if test_evidence==0:
|
|
131
143
|
findings.append(Finding('AQ-TST-001','high','No tests detected',
|
|
132
144
|
'No conventional test files or test directories were found.',
|
|
133
145
|
'Architecture refactoring becomes risky because behavior is not protected.',
|
|
134
146
|
'Add characterization tests around critical workflows before structural refactoring.', 'high'))
|
|
135
|
-
elif
|
|
147
|
+
elif test_evidence < minimum_tests:
|
|
136
148
|
findings.append(Finding('AQ-TST-001','medium','Low visible test density',
|
|
137
|
-
f'{len(test_files)} test-like files for {len(code)} code files.',
|
|
149
|
+
f'{len(test_files)} test-like files' + (f' and {inline_rust_tests} inline Rust tests' if inline_rust_tests else '') + f' for {len(code)} code files.',
|
|
138
150
|
'Important behavior may be unprotected during AI-generated changes.',
|
|
139
151
|
'Add tests for the modules affected by upcoming architecture work.', 'medium'))
|
|
140
152
|
|
|
@@ -149,7 +161,7 @@ def scan(root: Path, profile: str = "auto", registry=None, pack: str = "auto", c
|
|
|
149
161
|
# secrets/config
|
|
150
162
|
secret_hits=[]
|
|
151
163
|
for p in code[:1000]:
|
|
152
|
-
txt=
|
|
164
|
+
txt=read(p)
|
|
153
165
|
for pat in SECRET_PATTERNS:
|
|
154
166
|
if pat.search(txt):
|
|
155
167
|
secret_hits.append(rel(p,root)); break
|
|
@@ -161,7 +173,7 @@ def scan(root: Path, profile: str = "auto", registry=None, pack: str = "auto", c
|
|
|
161
173
|
|
|
162
174
|
# module boundaries
|
|
163
175
|
module_dirs = [d for d in ['modules','features','domains','domain','application','infrastructure','adapters','services','src'] if (root/d).exists()]
|
|
164
|
-
if len(code)>30 and not any((root/d).exists() for d in ['modules','features','domain','domains','application']):
|
|
176
|
+
if len(code)>30 and not any((root/d).exists() for d in ['modules','features','domain','domains','application']) and (component or len(scope.nodes)==1):
|
|
165
177
|
findings.append(Finding('AQ-MOD-001','high','Growing codebase lacks obvious domain/feature module boundaries',
|
|
166
178
|
f'{len(code)} code files found, but no conventional modules/features/domain/application boundary directories.',
|
|
167
179
|
'Feature additions may spread logic across unrelated files, increasing change blast radius.',
|
|
@@ -172,7 +184,7 @@ def scan(root: Path, profile: str = "auto", registry=None, pack: str = "auto", c
|
|
|
172
184
|
for p in code:
|
|
173
185
|
rp=rel(p,root).lower()
|
|
174
186
|
if any(seg in ('/' + rp.replace('\\','/')) for seg in ['/domain/','/core/']):
|
|
175
|
-
txt=
|
|
187
|
+
txt=read(p).lower()
|
|
176
188
|
volatile=['sqlalchemy','django','flask','requests','boto3','firebase','androidx','wordpress','wp_','openai','ollama','stripe']
|
|
177
189
|
if any(v in txt for v in volatile):
|
|
178
190
|
dep_hits.append(rel(p,root))
|
|
@@ -186,9 +198,9 @@ def scan(root: Path, profile: str = "auto", registry=None, pack: str = "auto", c
|
|
|
186
198
|
operational_terms=['requests.','fetch(','axios','subprocess','open(','write_text','read_text','sqlite','psycopg','mysql','smtplib','shutil','ffmpeg','yt-dlp']
|
|
187
199
|
operational_files=[]; logging_files=0
|
|
188
200
|
for p in code:
|
|
189
|
-
txt=
|
|
201
|
+
txt=read(p).lower()
|
|
190
202
|
if any(t in txt for t in operational_terms): operational_files.append(p)
|
|
191
|
-
if any(t in txt for t in ['logging.','logger.','console.error','log.error','print(']): logging_files+=1
|
|
203
|
+
if any(t in txt for t in ['logging.','logger.','console.error','log.error','print(']) or has_warning_call(txt, python=p.suffix.lower()=='.py'): logging_files+=1
|
|
192
204
|
if operational_files and logging_files < max(1, len(operational_files)//5):
|
|
193
205
|
findings.append(Finding('AQ-OBS-001','medium','Operational code has little visible logging',
|
|
194
206
|
f'{len(operational_files)} files appear to touch IO/network/processes; logging appears in {logging_files} files.',
|
|
@@ -199,6 +211,7 @@ def scan(root: Path, profile: str = "auto", registry=None, pack: str = "auto", c
|
|
|
199
211
|
if product in {'saas_or_web_app','ai_pipeline','api_backend'}:
|
|
200
212
|
has_db=any(x in sample_text for x in ['sqlite','postgres','mysql','sqlalchemy','prisma','typeorm','sequelize','mongoose'])
|
|
201
213
|
has_migrations=any('migration' in rel(p,root).lower() or 'alembic' in rel(p,root).lower() or 'prisma/migrations' in rel(p,root).lower() for p in files)
|
|
214
|
+
has_migrations = has_migrations or bool(context['migration_files'])
|
|
202
215
|
if has_db and not has_migrations:
|
|
203
216
|
findings.append(Finding('AQ-DATA-001','high','Database usage detected without obvious migration strategy',
|
|
204
217
|
'Database-related terms were found, but no migration directory/tooling was detected.',
|
|
@@ -243,7 +256,7 @@ def scan(root: Path, profile: str = "auto", registry=None, pack: str = "auto", c
|
|
|
243
256
|
io_terms=['urlsession', 'filemanager', 'sqlite', 'coredata', 'userdefaults', 'keychain', 'openai', 'ollama', 'firebase', 'alamofire', 'stripe']
|
|
244
257
|
ui_io=[]
|
|
245
258
|
for p in swift_files:
|
|
246
|
-
txt=
|
|
259
|
+
txt=read(p).lower()
|
|
247
260
|
rp=rel(p,root).lower()
|
|
248
261
|
if any(u in txt for u in ui_terms) and any(i in txt for i in io_terms):
|
|
249
262
|
ui_io.append(rel(p,root))
|
|
@@ -266,7 +279,7 @@ def scan(root: Path, profile: str = "auto", registry=None, pack: str = "auto", c
|
|
|
266
279
|
main=root/'src'/'main.rs'
|
|
267
280
|
lib=root/'src'/'lib.rs'
|
|
268
281
|
if main.exists():
|
|
269
|
-
mt=
|
|
282
|
+
mt=read(main)
|
|
270
283
|
main_lines=len([ln for ln in mt.splitlines() if ln.strip() and not ln.strip().startswith('//')])
|
|
271
284
|
if main_lines>180 or (not lib.exists() and len(rust_files)>3):
|
|
272
285
|
findings.append(Finding('AQ-RUST-001','high','Rust binary entrypoint may contain too much core logic',
|
|
@@ -275,7 +288,7 @@ def scan(root: Path, profile: str = "auto", registry=None, pack: str = "auto", c
|
|
|
275
288
|
'Move core workflows into src/lib.rs/modules or workspace crates; keep main.rs thin.', 'medium'))
|
|
276
289
|
unsafe_hits=[]
|
|
277
290
|
for p in rust_files:
|
|
278
|
-
txt=
|
|
291
|
+
txt=read(p)
|
|
279
292
|
if 'unsafe ' in txt or 'unsafe{' in txt.replace(' ',''):
|
|
280
293
|
# crude safety comment check near unsafe
|
|
281
294
|
if 'SAFETY:' not in txt and 'Safety:' not in txt:
|
|
@@ -298,7 +311,7 @@ def scan(root: Path, profile: str = "auto", registry=None, pack: str = "auto", c
|
|
|
298
311
|
if product == 'browser_extension':
|
|
299
312
|
for manifest in (p for p in files if p.name.lower()=='manifest.json'):
|
|
300
313
|
try:
|
|
301
|
-
m=json.loads(
|
|
314
|
+
m=json.loads(read(manifest))
|
|
302
315
|
if not isinstance(m,dict) or 'manifest_version' not in m: continue
|
|
303
316
|
perms=m.get('host_permissions',[])+m.get('permissions',[])
|
|
304
317
|
if not isinstance(perms,list): continue
|
|
@@ -312,7 +325,7 @@ def scan(root: Path, profile: str = "auto", registry=None, pack: str = "auto", c
|
|
|
312
325
|
if product == 'google_apps_script':
|
|
313
326
|
for p in code:
|
|
314
327
|
if p.suffix.lower() not in {'.js','.gs'}: continue
|
|
315
|
-
content=
|
|
328
|
+
content=read(p)
|
|
316
329
|
# Only nearby text around a trigger signature is inspected; avoids pretending to parse the JS AST.
|
|
317
330
|
for match in re.finditer(r'function\s+(onEdit|onOpen|doGet|doPost)\s*\(',content):
|
|
318
331
|
neighborhood=content[match.start():match.start()+900]
|
|
@@ -326,7 +339,7 @@ def scan(root: Path, profile: str = "auto", registry=None, pack: str = "auto", c
|
|
|
326
339
|
for p in files:
|
|
327
340
|
if p.parent==root and p.name=='package.json':
|
|
328
341
|
try:
|
|
329
|
-
data=json.loads(
|
|
342
|
+
data=json.loads(read(p))
|
|
330
343
|
if not any(data.get(k) for k in ('exports','main','bin')):
|
|
331
344
|
findings.append(Finding('AQ-NPM-001','medium','No explicit library export or executable entrypoint detected',
|
|
332
345
|
'package.json has none of exports, main, bin.',
|
|
@@ -349,6 +362,7 @@ def scan(root: Path, profile: str = "auto", registry=None, pack: str = "auto", c
|
|
|
349
362
|
'detected_product_type': detected_product,
|
|
350
363
|
'limitations': ['Static structure and regex heuristics only', 'Does not execute target repository code', 'No measured runtime behavior, threat modeling, or performance tests', 'File absence is not proof a capability is absent', 'Index is not independently calibrated or certified'],
|
|
351
364
|
'root':str(root),
|
|
365
|
+
'context_evidence': context,
|
|
352
366
|
'product_type':product,
|
|
353
367
|
'selected_pack':pack,
|
|
354
368
|
'product_profile_status':chosen['status'],
|
|
@@ -375,6 +389,40 @@ def scan(root: Path, profile: str = "auto", registry=None, pack: str = "auto", c
|
|
|
375
389
|
{'id': sid, 'title': registry[sid]['title'], 'url': registry[sid]['url'], 'type': registry[sid]['type']}
|
|
376
390
|
for sid in sorted(source_ids) if sid in registry
|
|
377
391
|
]
|
|
392
|
+
# Child classifications and context are descriptive, not averaged scores.
|
|
393
|
+
for node in scope.nodes:
|
|
394
|
+
owned=[r['path'] for r in scope.records if r['owner']==node['id']]
|
|
395
|
+
child_root=scope.root if node['root']=='.' else scope.root/node['root']
|
|
396
|
+
child_code=[p for p in owned if p.suffix.lower() in CODE_EXTS]
|
|
397
|
+
child_profile,child_signals,confidence,_=reg.detect(owned,child_root,child_code,pack,reader=scope.text)
|
|
398
|
+
if node.get('profile_override'):
|
|
399
|
+
reg.get_profile(node['profile_override']);child_profile=node['profile_override'];confidence='user-selected'
|
|
400
|
+
node['classification']={'product_type':child_profile,'confidence':confidence,'signals':child_signals}
|
|
401
|
+
own_tests=[p for p in owned if re.search(r'(^|/)(tests?|__tests__)/|(?:test_|_test\.|\.test\.|\.spec\.)',p.relative_to(child_root).as_posix())]
|
|
402
|
+
own_docs=[p.relative_to(scope.root).as_posix() for p in owned if p.suffix.lower() in {'.md','.rst','.adoc'} and re.search(r'(?i)architecture|(?:^|/)adr(?:/|$)',p.relative_to(scope.root).as_posix())]
|
|
403
|
+
parent_ids=set();parent=node.get('parent')
|
|
404
|
+
while parent:
|
|
405
|
+
parent_ids.add(parent);parent=next((n.get('parent') for n in scope.nodes if n['id']==parent),None)
|
|
406
|
+
inherited_docs=[r['relative'] for r in scope.records if r['owner'] in parent_ids and r['path'].suffix.lower() in {'.md','.rst','.adoc'} and re.search(r'(?i)architecture|(?:^|/)adr(?:/|$)',r['relative'])]
|
|
407
|
+
inherited_ci=[r['relative'] for r in scope.records if r['owner'] in parent_ids and '.github/workflows/' in r['relative']]
|
|
408
|
+
node['assessment']={'code_files':len(child_code),'test_like_files':len(own_tests),'architecture_files':own_docs,'inherited_architecture':inherited_docs,'inherited_ci':inherited_ci,
|
|
409
|
+
'status':'selected_source_only' if child_code else 'no_collected_core_code',
|
|
410
|
+
'runtime_tests_executed':False,'score':None}
|
|
411
|
+
result['projects']=scope.graph
|
|
412
|
+
result['extensions_full']=dict(sorted(exts.items()))
|
|
413
|
+
scope_metadata=scope.report()
|
|
414
|
+
if review_signals:
|
|
415
|
+
from .review import review_files
|
|
416
|
+
result['review'] = review_files(files, root, scope=scope, **(review_options or {}))
|
|
417
|
+
result['scope']=scope_metadata
|
|
418
|
+
result['scope']['analysis_root']=str(root)
|
|
419
|
+
result['scope']['selected_component']=component
|
|
420
|
+
result['score_scope']='selected_container_source_not_an_average_of_children'
|
|
421
|
+
result['assessment_state']='source_collected' if code else 'no_collected_core_code'
|
|
422
|
+
result['limitations'].append('Scored absence hints describe the selected scope; unsupported or excluded source is not verified.')
|
|
423
|
+
if review_knowledge:
|
|
424
|
+
from .knowledge import review_agenda
|
|
425
|
+
result['review_knowledge']=review_agenda(scope,product,paths=files)
|
|
378
426
|
return result
|
|
379
427
|
|
|
380
428
|
def md_report(r):
|
|
@@ -391,9 +439,28 @@ def md_report(r):
|
|
|
391
439
|
lines.append(f"**Recommended target:** {r['recommended_target_architecture']}")
|
|
392
440
|
lines.append(f"**Files scanned:** {r['files_scanned']} total, {r['code_files']} code files")
|
|
393
441
|
lines.append('')
|
|
442
|
+
context = r.get('context_evidence', {})
|
|
443
|
+
if context.get('root'):
|
|
444
|
+
lines.append('## Parent context (metadata only)')
|
|
445
|
+
lines.append('Conventional source-slice parent: ' + context['root'])
|
|
446
|
+
for key in ('architecture_files', 'migration_files'):
|
|
447
|
+
for path in context.get(key, []): lines.append('- ' + path)
|
|
448
|
+
lines.append('Presence is not proof of adequacy; parent source code was not scanned.')
|
|
449
|
+
lines.append('')
|
|
450
|
+
if 'scope' in r:
|
|
451
|
+
scope=r['scope']
|
|
452
|
+
lines.extend(['## Scope and project coverage','',
|
|
453
|
+
'Policy: '+scope['policy']+'; respect .gitignore: '+str(scope['respect_gitignore']),
|
|
454
|
+
'Assessment state: '+r.get('assessment_state','unknown')+'; index applies only to selected source.',
|
|
455
|
+
'Discovered roots: '+str(len(r.get('projects',{}).get('nodes',[]))),
|
|
456
|
+
'Pruned subtrees are not a full physical census unless explicitly inventoried.',
|
|
457
|
+
'Scope outcomes: '+json.dumps(scope['counts'],sort_keys=True),''])
|
|
458
|
+
for node in r.get('projects',{}).get('nodes',[]):
|
|
459
|
+
lines.append('- '+node['id']+' / '+node['root']+' / '+node['relationship']+' / '+str(node['files_collected'])+' owned-candidate files')
|
|
460
|
+
lines.append('')
|
|
394
461
|
lines.append('## Extension summary')
|
|
395
462
|
lines.append('')
|
|
396
|
-
for ext,count in r['extensions'].items(): lines.append(f'- `{ext or "[no extension]"}`: {count}')
|
|
463
|
+
for ext,count in r.get('extensions_full',r['extensions']).items(): lines.append(f'- `{ext or "[no extension]"}`: {count}')
|
|
397
464
|
lines.append('')
|
|
398
465
|
lines.append('## Findings')
|
|
399
466
|
lines.append('')
|
|
@@ -422,6 +489,30 @@ def md_report(r):
|
|
|
422
489
|
for src in r['source_registry']:
|
|
423
490
|
lines.append(f"- {src['id']}: {src['title']} — {src['url']}")
|
|
424
491
|
lines.append('')
|
|
492
|
+
if 'review_knowledge' in r:
|
|
493
|
+
from .knowledge import markdown_agenda
|
|
494
|
+
lines.extend(markdown_agenda(r['review_knowledge']))
|
|
495
|
+
if 'review' in r:
|
|
496
|
+
review = r['review']
|
|
497
|
+
lines.append('## Experimental unscored review')
|
|
498
|
+
lines.append('Static observations and unreviewed candidates; no confirmed defects, score impact or baseline gates.')
|
|
499
|
+
for signal in review['signals']:
|
|
500
|
+
location = signal['locations'][0]
|
|
501
|
+
lines.append(f"- {signal['check_id']} / {signal['kind']} — {location['path']}:{location['line']}:{location['column']}: {signal['observed_fact']}")
|
|
502
|
+
for control in signal['observed_controls']: lines.append(' - Observed control: ' + control)
|
|
503
|
+
for unknown in signal['unknowns']: lines.append(' - Unknown: ' + unknown)
|
|
504
|
+
lines.append(' - Next characterization: ' + signal['next_test'])
|
|
505
|
+
coverage=review['scope']
|
|
506
|
+
lines.append('Review files: '+str(coverage.get('files_eligible','unknown'))+' eligible, '+str(coverage['files_attempted'])+' attempted, '+str(coverage.get('files_assessed','unknown'))+' assessed with partial language support.')
|
|
507
|
+
if coverage.get('zero_attempt_reason'):lines.append('Zero-attempt reason: '+coverage['zero_attempt_reason'])
|
|
508
|
+
lines.extend(['','| Component | Language | Eligible | Attempted | Assessed |','|---|---|---:|---:|---:|'])
|
|
509
|
+
for group in coverage.get('groups',[]):lines.append('| '+group['owner']+' | '+group['language']+' | '+str(group['eligible'])+' | '+str(group['attempted'])+' | '+str(group['assessed'])+' |')
|
|
510
|
+
lines.append('')
|
|
511
|
+
lines.append('Review scope outcomes: ' + json.dumps(review['scope']['outcomes'], sort_keys=True))
|
|
512
|
+
for limit in review['limitations']: lines.append('- ' + limit)
|
|
513
|
+
lines.append('Review sources contextualize concerns, not detector accuracy or endorsement:')
|
|
514
|
+
for source in review['source_registry']: lines.append(f"- {source['id']}: {source['title']} — {source['url']}")
|
|
515
|
+
lines.append('')
|
|
425
516
|
lines.append('## Limitations')
|
|
426
517
|
for limit in r['limitations']:
|
|
427
518
|
lines.append('- ' + limit)
|
|
@@ -497,13 +588,16 @@ def sarif_report(r):
|
|
|
497
588
|
'ruleId': f['rule_id'], 'level': {'high':'error','medium':'warning','low':'note'}.get(f['severity'],'note'),
|
|
498
589
|
'message': {'text': f['title'] + '. Evidence: ' + f['evidence'] + '. Recommendation: ' + f['recommendation']},
|
|
499
590
|
'partialFingerprints': {'primaryLocationLineHash': f['fingerprint']},
|
|
500
|
-
'properties': {'confidence': f['confidence'], 'baselineStatus': f
|
|
591
|
+
'properties': {'confidence': f['confidence'], 'baselineStatus': f.get('status','uncompared'), 'assessmentType': 'heuristic'},
|
|
501
592
|
})
|
|
502
593
|
return {'version':'2.1.0', '$schema':'https://json.schemastore.org/sarif-2.1.0.json', 'runs':[{
|
|
503
594
|
'tool':{'driver':{'name':'AQAF', 'version':__version__,
|
|
504
595
|
'rules':[{'id': k, 'name':k, 'shortDescription':{'text':v[1]}} for k,v in RULE_META.items()]}},
|
|
505
596
|
'results':results,
|
|
506
|
-
'properties':{'disclaimer':'Heuristic architecture hints, not security findings or a validated score.'
|
|
597
|
+
'properties':{'disclaimer':'Heuristic architecture hints, not security findings or a validated score.',
|
|
598
|
+
**({'review_knowledge':r['review_knowledge']} if 'review_knowledge' in r else {}),
|
|
599
|
+
**({'aqafReview':r['review']} if 'review' in r else {}),
|
|
600
|
+
**({'aqafScope':r['scope'],'aqafProjects':r['projects']} if 'scope' in r else {})}
|
|
507
601
|
}]}
|
|
508
602
|
|
|
509
603
|
|
|
@@ -539,17 +633,32 @@ def registry_command(argv):
|
|
|
539
633
|
|
|
540
634
|
def main(argv=None):
|
|
541
635
|
argv=list(sys.argv[1:] if argv is None else argv)
|
|
636
|
+
from . import project_commands
|
|
637
|
+
if project_commands.matches(argv):return project_commands.main(argv)
|
|
542
638
|
if argv and argv[0] in ('profiles','packs'): return registry_command(argv)
|
|
543
639
|
ap=argparse.ArgumentParser(description='AQAF local architecture scanner (heuristic, non-certifying)')
|
|
544
640
|
ap.add_argument('repo', nargs='?', default='.', help='Repository path')
|
|
545
641
|
ap.add_argument('--version', action='version', version='AQAF '+ __version__)
|
|
546
|
-
ap.add_argument('--format', choices=['md','json','sarif'], default='md')
|
|
642
|
+
ap.add_argument('--format', choices=['md','json','sarif','all'], default='md')
|
|
547
643
|
ap.add_argument('--output','-o', default='-')
|
|
548
644
|
ap.add_argument('--profile', default='auto', help='Registry profile ID; see `aqaf profiles list`')
|
|
549
645
|
ap.add_argument('--pack', default='auto', help='Limit auto-detection to a pack; see `aqaf packs list`')
|
|
550
646
|
ap.add_argument('--profile-dir', action='append', default=[], help='Additional data-only JSON profile directory')
|
|
551
647
|
ap.add_argument('--pack-dir', action='append', default=[], help='Additional data-only JSON pack directory')
|
|
552
648
|
ap.add_argument('--capability', action='append', default=[], help='Declared contextual capability (repeatable)')
|
|
649
|
+
ap.add_argument('--gitignore', action=argparse.BooleanOptionalAction, default=True, help='Respect root/nested .gitignore by default; --no-gitignore explicitly opts out')
|
|
650
|
+
ap.add_argument('--scope-policy', choices=['typed','legacy'], default='typed', help='Type-aware ownership/exclusions or historical directory rules')
|
|
651
|
+
ap.add_argument('--scope-config', help='Operator-selected bounded JSON scope configuration')
|
|
652
|
+
ap.add_argument('--inventory-all', action='store_true', help='Also count metadata in pruned subtrees; never analyze their bodies')
|
|
653
|
+
ap.add_argument('--component', help='Analyze only one discovered component ID or root-relative path')
|
|
654
|
+
ap.add_argument('--review-max-files', type=int, default=1000, help='Maximum experimental file attempts per scan')
|
|
655
|
+
ap.add_argument('--review-max-bytes', type=int, default=20000000, help='Maximum experimental source bytes per scan')
|
|
656
|
+
ap.add_argument('--review-file-bytes', type=int, default=256000, help='Maximum experimental bytes per file')
|
|
657
|
+
ap.add_argument('--review-cursor-in', help='Validate bounded full-content continuation state from an earlier page')
|
|
658
|
+
ap.add_argument('--review-cursor-out', help='Write new immutable continuation state outside the source')
|
|
659
|
+
ap.add_argument('--review-offset', type=int, default=0, help='Deterministic selection offset; separately reported partial coverage')
|
|
660
|
+
ap.add_argument('--review-knowledge',action='store_true',help='Optional book-informed human review agenda; unknown/unscored, no target execution')
|
|
661
|
+
ap.add_argument('--review-signals', action='store_true', help='Opt-in bounded multi-language review observations; experimental and unscored')
|
|
553
662
|
ap.add_argument('--baseline', help='Earlier AQAF JSON report for new/existing comparison')
|
|
554
663
|
ap.add_argument('--config', help='Path to local .aqaf.json suppression config; defaults to <repo>/.aqaf.json')
|
|
555
664
|
ap.add_argument('--fail-on', choices=['off','new-high','new-medium'], default='off', help='Optional CI gate for findings; requires a baseline')
|
|
@@ -562,11 +671,40 @@ def main(argv=None):
|
|
|
562
671
|
try:
|
|
563
672
|
registry=Registry(args.profile_dir,args.pack_dir)
|
|
564
673
|
if args.pack!='auto': registry.get_pack(args.pack)
|
|
565
|
-
|
|
674
|
+
from .scope import config_document
|
|
675
|
+
if args.review_max_files<1 or args.review_max_bytes<1 or args.review_file_bytes<1 or args.review_offset<0:raise ValueError('Review budgets must be positive; offset must be nonnegative')
|
|
676
|
+
if args.review_file_bytes>1500000 or args.review_max_files>100000 or args.review_max_bytes>256000000:raise ValueError('Review budget exceeds bounded maximum')
|
|
677
|
+
session=None
|
|
678
|
+
if args.review_cursor_in or args.review_cursor_out:
|
|
679
|
+
if not args.review_signals:raise ValueError('Continuation requires --review-signals')
|
|
680
|
+
if args.component:raise ValueError('Use a scoped root/configuration for continuation; --component continuation is not supported')
|
|
681
|
+
from .scope import Scope
|
|
682
|
+
from .cursor import identity,load
|
|
683
|
+
session=Scope(repo,args.gitignore,args.scope_policy,config_document(args.scope_config),args.inventory_all).build()
|
|
684
|
+
cursor_options={'max_files':args.review_max_files,'max_total_bytes':args.review_max_bytes,'max_file_bytes':args.review_file_bytes}
|
|
685
|
+
cursor_identity=identity(session,cursor_options)
|
|
686
|
+
if args.review_cursor_in:
|
|
687
|
+
if args.review_offset:raise ValueError('Choose a cursor or an explicit offset')
|
|
688
|
+
args.review_offset=load(args.review_cursor_in,cursor_identity)
|
|
689
|
+
r=add_fingerprints(scan(repo,args.profile,registry,args.pack,args.capability,review_signals=args.review_signals,
|
|
690
|
+
respect_gitignore=args.gitignore,scope_policy=args.scope_policy,scope_config=config_document(args.scope_config),
|
|
691
|
+
inventory_all=args.inventory_all,component=args.component,session=session,review_knowledge=args.review_knowledge,
|
|
692
|
+
review_options={'max_files':args.review_max_files,'max_total_bytes':args.review_max_bytes,'max_file_bytes':args.review_file_bytes,'offset':args.review_offset}))
|
|
693
|
+
if args.review_cursor_out:
|
|
694
|
+
from .cursor import write
|
|
695
|
+
# Include byte/size failures in the page; never silently merge older pages.
|
|
696
|
+
write(args.review_cursor_out,session,cursor_identity,min(r['review']['scope']['files_eligible'],args.review_offset+r['review']['scope']['files_attempted']))
|
|
566
697
|
r=apply_suppressions(r,Path(args.config).resolve() if args.config else repo/'.aqaf.json')
|
|
567
698
|
r=add_baseline_diff(r,Path(args.baseline) if args.baseline else None)
|
|
568
699
|
except (ValueError, ProfileError) as exc:
|
|
569
700
|
ap.error(str(exc))
|
|
701
|
+
if args.format=='all':
|
|
702
|
+
if args.output=='-':ap.error('--format all requires --output DIRECTORY')
|
|
703
|
+
from .artifacts import write_bundle
|
|
704
|
+
try:write_bundle(r,Path(args.output))
|
|
705
|
+
except (ValueError,OSError) as exc:ap.error(str(exc))
|
|
706
|
+
print('Wrote report.json, report.md and report.sarif to '+args.output)
|
|
707
|
+
return 2 if args.fail_on!='off' and any(f['status']=='new' and f['severity'] in ({'high'} if args.fail_on=='new-high' else {'high','medium'}) for f in r['findings']) else 0
|
|
570
708
|
out=json.dumps(r,indent=2) if args.format=='json' else json.dumps(sarif_report(r),indent=2) if args.format=='sarif' else md_report(r)
|
|
571
709
|
if args.output=='-': print(out)
|
|
572
710
|
else:
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""Bounded data-only continuation identity. Never merge different assessments."""
|
|
2
|
+
import hashlib,json,os,tempfile
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from . import __version__
|
|
5
|
+
|
|
6
|
+
MAX_STATE_BYTES=16384
|
|
7
|
+
MAX_IDENTITY_BYTES=256000000
|
|
8
|
+
MAX_IDENTITY_FILE=1500000
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def runtime_identity():
|
|
12
|
+
root=Path(__file__).parent;digest=hashlib.sha256()
|
|
13
|
+
for path in sorted(root.rglob('*')):
|
|
14
|
+
if path.is_file() and path.suffix in {'.py','.json'} and '__pycache__' not in path.parts:
|
|
15
|
+
digest.update(path.relative_to(root).as_posix().encode()+b'\0'+path.read_bytes())
|
|
16
|
+
return digest.hexdigest()
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def identity(scope,options):
|
|
20
|
+
"""Full selected-body hashes under explicit bounds; oversized identity fails closed."""
|
|
21
|
+
digest=hashlib.sha256();total=0
|
|
22
|
+
metadata={'root':str(scope.root),'runtime':runtime_identity(),'version':__version__,'policy':scope.policy,
|
|
23
|
+
'gitignore':scope.respect_gitignore,'ignore_sources':scope.ignore_sources,'config':scope.config,
|
|
24
|
+
'options':{k:v for k,v in options.items() if k!='offset'}}
|
|
25
|
+
digest.update(json.dumps(metadata,sort_keys=True).encode())
|
|
26
|
+
for record in sorted(scope.records,key=lambda r:r['relative']):
|
|
27
|
+
size=record['size'];total+=size
|
|
28
|
+
if size>MAX_IDENTITY_FILE or total>MAX_IDENTITY_BYTES:raise ValueError('Continuation identity exceeds safe full-content bounds; use a smaller component/scope or a fresh independent scan')
|
|
29
|
+
raw=scope.raw(record['path'],MAX_IDENTITY_FILE+1)
|
|
30
|
+
if len(raw)!=size:raise ValueError('Continuation identity input changed or is unreadable')
|
|
31
|
+
digest.update(record['relative'].encode()+b'\0'+hashlib.sha256(raw).digest())
|
|
32
|
+
return digest.hexdigest()
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def load(path,expected):
|
|
36
|
+
path=Path(path)
|
|
37
|
+
if path.is_symlink() or not path.is_file() or path.stat().st_size>MAX_STATE_BYTES:raise ValueError('Invalid bounded continuation state')
|
|
38
|
+
try:data=json.loads(path.read_text())
|
|
39
|
+
except (ValueError,OSError,RecursionError) as error:raise ValueError('Invalid continuation JSON') from error
|
|
40
|
+
if not isinstance(data,dict) or data.get('schema_version')!=1 or data.get('identity')!=expected:raise ValueError('Stale continuation: source, root, runtime, scope or budgets changed; start a new scan')
|
|
41
|
+
offset=data.get('next_offset')
|
|
42
|
+
if type(offset) is not int or not 0<=offset<=500000:raise ValueError('Invalid continuation offset')
|
|
43
|
+
return offset
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def write(path,scope,identity_value,next_offset):
|
|
47
|
+
path=Path(path).absolute()
|
|
48
|
+
resolved=path.resolve()
|
|
49
|
+
if resolved==scope.root or scope.root in resolved.parents:raise ValueError('Continuation state must be outside the assessed source')
|
|
50
|
+
if path.exists() or path.is_symlink():raise ValueError('Continuation state already exists; choose a new output file')
|
|
51
|
+
path.parent.mkdir(parents=True,exist_ok=True)
|
|
52
|
+
fd,temporary=tempfile.mkstemp(prefix='.aqaf-cursor-',dir=path.parent)
|
|
53
|
+
try:
|
|
54
|
+
with os.fdopen(fd,'w') as f:json.dump({'schema_version':1,'identity':identity_value,'next_offset':next_offset,'scope':'Independent partial page; previous evidence is not merged or marked complete.'},f,indent=2)
|
|
55
|
+
os.link(temporary,path) # Atomic create without replacing historical state.
|
|
56
|
+
finally:Path(temporary).unlink(missing_ok=True)
|