@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.
@@ -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
- for dirpath, dirnames, filenames in os.walk(root):
66
- dirnames[:] = sorted(d for d in dirnames if d not in SKIP_DIRS and not d.startswith('.cache'))
67
- for f in sorted(filenames):
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
- files=list(iter_files(root))
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(read_text(p)[:2000].lower() for p in detection_code[:250])
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 len(test_files)==0:
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 len(test_files) < max(3, len(code)//50):
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=read_text(p)
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=read_text(p).lower()
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=read_text(p).lower()
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=read_text(p).lower()
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=read_text(main)
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=read_text(p)
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(read_text(manifest))
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=read_text(p)
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(read_text(p))
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['status'], 'assessmentType': 'heuristic'},
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
- r=add_fingerprints(scan(repo,args.profile,registry,args.pack,args.capability))
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)