@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.
@@ -0,0 +1,210 @@
1
+ """Separate unscored documentation observations; no target execution or certification."""
2
+ from __future__ import annotations
3
+ import ast
4
+ import hashlib
5
+ import re
6
+ import unicodedata
7
+ from pathlib import Path,PurePosixPath
8
+ from urllib.parse import unquote,urlsplit
9
+ from .scope import Scope
10
+
11
+
12
+ def markdown_body(text):
13
+ """Remove fenced/indented samples, comments and inline code before link parsing."""
14
+ lines=[];fence=None
15
+ for line in text.splitlines():
16
+ m=re.match(r'^ {0,3}(`{3,}|~{3,})',line)
17
+ if m:
18
+ token=m.group(1)
19
+ if fence is None:fence=token
20
+ elif token[0]==fence[0] and len(token)>=len(fence):fence=None
21
+ lines.append('');continue
22
+ lines.append('' if fence or line.startswith((' ','\t')) else line)
23
+ return re.sub(r'<!--[\s\S]*?-->|(`+).*?\1','', '\n'.join(lines))
24
+
25
+
26
+ def links(text):
27
+ body=markdown_body(text);refs={}
28
+ for m in re.finditer(r'(?m)^ {0,3}\[([^]\n]+)\]:\s*<?([^\s>]+)>?',body):refs[m.group(1).casefold()]=m.group(2)
29
+ body=re.sub(r'(?m)^ {0,3}\[[^]\n]+\]:[^\n]*','',body)
30
+ found=[]
31
+ # Bounded balanced parentheses allow ordinary paths containing parentheses.
32
+ for m in re.finditer(r'!?\[([^]\n]*)\]\(\s*(<[^>\n]+>|(?:[^\s()\\]|\\.|\([^()\n]*\)){1,2048})(?:\s+["\'][^\n]*?["\'])?\s*\)',body):
33
+ found.append((m.group(2).strip('<>'),body.count('\n',0,m.start())+1))
34
+ for m in re.finditer(r'!?\[([^]\n]+)\](?:\[([^]\n]*)\])?(?![(:])',body):
35
+ key=(m.group(2) or m.group(1)).casefold()
36
+ if key in refs:found.append((refs[key],body.count('\n',0,m.start())+1))
37
+ return sorted(set(found),key=lambda x:(x[1],x[0]))
38
+
39
+
40
+ def anchors(text):
41
+ result=set();counts={}
42
+ for line in markdown_body(text).splitlines():
43
+ m=re.match(r'^ {0,3}#{1,6}\s+(.+?)\s*#*$',line)
44
+ if not m:continue
45
+ label=re.sub(r'[^\w\- ]','',m.group(1).lower()).replace(' ','-')
46
+ n=counts.get(label,0);counts[label]=n+1;result.add(label+('-'+str(n) if n else ''))
47
+ result.update(re.findall(r'\bid=["\']([^"\']+)',text))
48
+ return result
49
+
50
+
51
+ def documented_default(node,flag):
52
+ """Only JSON scalars; never return credential-shaped literal metadata."""
53
+ import math
54
+ if not isinstance(node,ast.Constant):return None,'unknown'
55
+ value=node.value
56
+ if type(value) not in {str,int,float,bool,type(None)} or (type(value) is float and not math.isfinite(value)):
57
+ return None,'unknown'
58
+ sensitive_name=re.search(r'password|passphrase|secret|token|key|credential|auth',flag,re.I)
59
+ sensitive_value=isinstance(value,str) and re.search(r'://[^/\s]+@|(?:api[_-]?key|token|password|secret)=[^&\s]+|^Bearer\s+\S+|^(?:sk-|gh[pousr]_|xox[baprs]-|AKIA)',value,re.I)
60
+ if value is not None and (sensitive_name or sensitive_value):return '[redacted]','redacted'
61
+ return value,'observed'
62
+
63
+
64
+ def interfaces(scope):
65
+ facts=[];unknown=[];read_bytes=0;attempted=0
66
+ for r in scope.selected({'production','source','unknown','tooling'}):
67
+ if r['language']=='other' and r['path'].suffix not in {'.json','.service','.plist'}:continue
68
+ if attempted>=1000 or read_bytes>=16000000:
69
+ unknown.append({'reason':'interface_budget','remaining_coverage':'unknown'});break
70
+ attempted+=1
71
+ raw=scope.raw(r['path'],min(131072,16000000-read_bytes));read_bytes+=len(raw);text=raw.decode('utf8','replace');sha=hashlib.sha256(raw).hexdigest()
72
+ base={'path':r['relative'],'sha256':sha,'runtime_verified':False}
73
+ if r['language']=='python':
74
+ try:tree=ast.parse(text)
75
+ except (SyntaxError,ValueError,RecursionError):unknown.append({**base,'reason':'python_syntax_or_truncated'});continue
76
+ for node in ast.walk(tree):
77
+ if not isinstance(node,ast.Call):continue
78
+ name=node.func.attr if isinstance(node.func,ast.Attribute) else ''
79
+ if name=='add_argument':
80
+ flags=[a.value for a in node.args if isinstance(a,ast.Constant) and isinstance(a.value,str) and a.value.startswith('--')]
81
+ default_node=next((k.value for k in node.keywords if k.arg=='default'),None)
82
+ for flag in flags:
83
+ default,default_state=documented_default(default_node,flag)
84
+ facts.append({**base,'kind':'cli_flag','name':flag,'default':default,'default_state':default_state,'line':node.lineno,'state':'observed'})
85
+ if not flags:unknown.append({**base,'reason':'dynamic_cli_registration','line':node.lineno})
86
+ if name in {'get','post','put','patch','delete','route'} and node.args and isinstance(node.args[0],ast.Constant) and isinstance(node.args[0].value,str) and node.args[0].value.startswith('/'):
87
+ facts.append({**base,'kind':'route_candidate','name':node.args[0].value,'method':name.upper(),'line':node.lineno,'state':'inferred'})
88
+ elif r['language'] in {'javascript','typescript','shell','powershell','cmd'}:
89
+ from .adapters import mask_source
90
+ try:view=mask_source(text,r['language'])[0] if r['language']!='cmd' else text
91
+ except (ValueError,RecursionError):
92
+ unknown.append({**base,'reason':'unsupported_or_truncated_syntax'});continue
93
+ if r['language'] in {'shell','powershell'}:
94
+ for i,line in enumerate(text.splitlines(),1):
95
+ visible=view.splitlines()[i-1] if i<=len(view.splitlines()) else ''
96
+ if not re.match(r'^\s*(?:exec\s+)?(?:python[23]?|node|pwsh)\s+',visible):continue
97
+ m=re.match(r'^\s*(?:exec\s+)?(python[23]?|node|pwsh)\s+([\w./\\-]+)',line)
98
+ if m:facts.append({**base,'kind':'launcher','name':m.group(1),'target':m.group(2),'line':i,'state':'observed','syntax_scope':'literal_launcher_only'})
99
+ if r['language']=='cmd':
100
+ for i,line in enumerate(text.splitlines(),1):
101
+ if re.match(r'(?i)^\s*@?(?:rem\b|::)',line):continue
102
+ m=re.match(r'(?i)^\s*@?(?:call\s+)?(python(?:3|\.exe)?|node(?:\.exe)?|powershell(?:\.exe)?|pwsh|java(?:\.exe)?)\s+([\w./\\-]+)',line)
103
+ if m:facts.append({**base,'kind':'launcher','name':m.group(1),'target':m.group(2),'line':i,'state':'observed','syntax_scope':'literal_cmd_launcher_only'})
104
+ # String-bearing registration requires a call spelling outside strings/comments.
105
+ for m in re.finditer(r'\.(option|command|get|post|put|delete)\s*\(',view):
106
+ tail=text[m.end():m.end()+512];literal=re.match(r'\s*["\']([^"\'\n]{1,256})["\']',tail)
107
+ if literal:
108
+ value=literal.group(1);kind='cli_flag' if m.group(1)=='option' else 'route_candidate'
109
+ if kind=='cli_flag' and not value.startswith('-'):continue
110
+ facts.append({**base,'kind':kind,'name':value.split()[0],'default':None,'state':'inferred','line':text.count('\n',0,m.start())+1})
111
+ else:unknown.append({**base,'reason':'dynamic_registration'})
112
+ if r['path'].suffix in {'.service','.plist'}:facts.append({**base,'kind':'service_definition','name':r['path'].name,'state':'observed'})
113
+ if re.search(r'\b(?:getenv|environ|process\.env)\b',text):unknown.append({**base,'reason':'environment_precedence_requires_review'})
114
+ return facts,unknown
115
+
116
+ EXPECTATIONS={
117
+ 'desktop_ui':('end_user','install|installation','state|storage','reset|troubleshoot'),
118
+ 'desktop_widget':('end_user','install|installation','state|storage|reset'),
119
+ 'windows_service':('administrator','privilege|administrator|account','pair|trust|permission'),
120
+ 'browser_python':('learner','worker|stop|cancel','offline|asset|download'),
121
+ 'proxy':('operator','auth|token|access','stream','retention|logging'),
122
+ 'backup':('operator','restore|recovery','dry.run|preview|verify'),
123
+ 'ocr':('reviewer','evidence|confidence|manual.review','provenance|original|source'),
124
+ 'pdf_mapping':('content_author','page|mapping','provenance|source|chapter'),
125
+ 'apps_script':('administrator','permission|scope|oauth','deploy|installation'),
126
+ 'ide_extension':('developer','install|plugin','model|endpoint|privacy')}
127
+
128
+
129
+ def check(root,scope=None,generated_routes=(),audience=None):
130
+ owned=scope is None;scope=scope or Scope(root).build();root=scope.root
131
+ try:
132
+ all_docs=[r for r in scope.records if r['path'].suffix.lower() in {'.md','.markdown'}]
133
+ docs=all_docs[:2048];budget=16000000;doc_partial=len(docs)<len(all_docs)
134
+ texts={};hashes={};seen={};duplicates=0;evidence=[];findings=[];finding_keys={}
135
+ for r in docs:
136
+ if budget<=0:doc_partial=True;break
137
+ raw=scope.raw(r['path'],min(262144,budget));budget-=len(raw)
138
+ if len(raw)!=r['size']:doc_partial=True
139
+ sha=hashlib.sha256(raw).hexdigest();texts[r['relative']]=raw.decode('utf8','replace')
140
+ hashes[r['relative']]={'sha256':sha,'bytes':len(raw),'complete_file':len(raw)==r['size']}
141
+ evidence.append({'path':r['relative'],**hashes[r['relative']],'kind':'documentation','audience':audience or 'unknown'})
142
+ if sha in seen:duplicates+=1
143
+ seen[sha]=r['relative']
144
+ def issue(rule,state,r,message,action,**extra):
145
+ h=hashes.get(r['relative'])
146
+ if h is None:
147
+ raw=scope.raw(r['path'],131072);h={'sha256':hashlib.sha256(raw).hexdigest(),'bytes':len(raw),'complete_file':len(raw)==r['size']}
148
+ key=(rule,state,h['sha256'],extra.get('target',extra.get('name',action)))
149
+ if key in finding_keys:
150
+ finding_keys[key].setdefault('copied_occurrences',[]).append(r['relative']);return
151
+ item={'rule_id':rule,'state':state,'path':r['relative'],**h,
152
+ 'audience':audience or 'maintainer','consequence':message,'action':action,'priority':'medium' if state=='observed' else 'low',
153
+ 'confirmed':False,'runtime_verified':False,**extra}
154
+ findings.append(item);finding_keys[key]=item
155
+ selected={x['relative'] for x in scope.records}
156
+ for r in docs:
157
+ if r['relative'] not in texts:continue
158
+ text=texts[r['relative']]
159
+ for target,line in links(text):
160
+ if re.match(r'^[a-z][a-z0-9+.-]*:',target,re.I) or target.startswith('//'):continue
161
+ parsed=urlsplit(target);link=unquote(parsed.path);fragment=unquote(parsed.fragment)
162
+ if link.startswith('/'):
163
+ if link not in generated_routes:issue('AQD-LINK-003','unknown',r,'Root-relative route has no configured resolution.','Configure the generated route or verify it separately.',target=target,line=line)
164
+ continue
165
+ candidate=r['path'].parent/link if link else r['path']
166
+ candidate=candidate.resolve()
167
+ try:rel=candidate.relative_to(root).as_posix()
168
+ except ValueError:
169
+ issue('AQD-LINK-003','unknown',r,'Link leaves the selected root.','Verify the external local dependency separately.',target=target,line=line);continue
170
+ if rel not in selected and not candidate.is_dir():
171
+ portable=[n for n in selected if unicodedata.normalize('NFC',n).casefold()==unicodedata.normalize('NFC',rel).casefold()]
172
+ if portable:issue('AQD-LINK-002','observed',r,'Link differs in case or Unicode normalization.','Use the exact portable path spelling.',target=target,line=line)
173
+ else:issue('AQD-LINK-001','observed' if not candidate.exists() else 'unknown',r,'Local link target is missing or outside selected scope.','Repair the link or declare its generated/excluded target.',target=target,line=line)
174
+ elif fragment and candidate in scope.by_path:
175
+ if rel not in texts:
176
+ issue('AQD-LINK-003','unknown',r,'Anchor target is outside the documentation read budget or supported Markdown scope.','Verify this fragment separately.',target=target,line=line)
177
+ elif fragment not in anchors(texts[rel]):issue('AQD-LINK-004','inferred',r,'Heading anchor does not match the supported Markdown convention.','Verify renderer anchor rules and correct the fragment.',target=target,line=line)
178
+ interface,unknown=interfaces(scope)
179
+ canonical='\n'.join(t for p,t in texts.items() if not re.search(r'(?i)changelog|release.notes|history',p))
180
+ documented=set(re.findall(r'--[a-zA-Z][a-zA-Z0-9_-]*',canonical));known={x['name'] for x in interface if x['kind']=='cli_flag'}
181
+ for fact in interface:
182
+ if fact['kind']=='route_candidate' and fact['name'] not in canonical:
183
+ issue('AQD-API-001','inferred',scope.record(root/fact['path']),'A static route-shaped registration has no selected documentation mention.','Check whether this is an exposed route and document its method/authentication contract.',name=fact['name'])
184
+ if fact['kind']=='cli_flag' and fact['name'] not in documented:
185
+ r=scope.record(root/fact['path']);issue('AQD-CLI-001','inferred',r,'A static CLI flag is absent from selected current documentation.','Document this flag if it is public.',name=fact['name'])
186
+ if fact['kind']=='cli_flag' and fact.get('default') is not None and fact['default']!='[redacted]':
187
+ for line in canonical.splitlines():
188
+ if fact['name'] in line:
189
+ m=re.search(r'default\s*[:=]\s*[`"\']?([\w./-]+)',line,re.I)
190
+ if m and m.group(1)!=str(fact['default']):
191
+ issue('AQD-CLI-002','inferred',scope.record(root/fact['path']),'Documented default differs from a static literal.','Check precedence and update the public default.',name=fact['name']);break
192
+ for flag in sorted(documented-known):
193
+ if docs:issue('AQD-CLI-003','unknown',docs[0],'Documented flag was not found in supported static registrations.','Check rename, external command or dynamic registration.',name=flag)
194
+ from .facets import facets
195
+ capabilities=facets(scope)
196
+ present={x['facet'] for x in capabilities['facts']}
197
+ for facet,(reader,*patterns) in EXPECTATIONS.items():
198
+ if facet not in present:continue
199
+ for pattern in patterns:
200
+ if audience and audience!=reader:continue
201
+ if not re.search(pattern,canonical,re.I) and scope.records:
202
+ issue('AQD-PRODUCT-001','inferred',scope.records[0],'Product evidence suggests an undocumented reader need: '+facet+'.','Review and document '+pattern+' for '+reader+'.',product_facet=facet,reader=reader)
203
+ for index,item in enumerate(findings):item['id']='doc-'+hashlib.sha256((item['rule_id']+item['path']+str(item.get('target',item.get('name',item['action'])))).encode()).hexdigest()[:20]
204
+ return {'schema_version':1,'kind':'aqaf_documentation_review','root':str(root),'status':'partial' if doc_partial or not scope.selection_complete() or any(x.get('reason') in {'interface_budget','unsupported_or_truncated_syntax','python_syntax_or_truncated'} for x in unknown) else 'complete_within_static_scope',
205
+ 'score_effect':'none','certification':False,'coverage':{'documents_selected':len(all_docs),'documents_read':len(texts),'max_documents':2048,'max_document_bytes':16000000,'max_document_file_bytes':262144},'documents':evidence,'duplicate_documents':duplicates,'interfaces':interface,'interface_unknowns':unknown,
206
+ 'capabilities':capabilities,'findings':findings,'backlog':[{'id':f['id'],'action':f['action'],'evidence':{'path':f['path'],'sha256':f['sha256']},'state':f['state'],'priority':f['priority']} for f in findings],
207
+ 'applicability':[{'facet':facet,'reader':reader,'state':'not_applicable' if audience and audience!=reader else 'inferred' if facet in present else 'unknown'} for facet,(reader,*_) in EXPECTATIONS.items()],
208
+ 'scope':scope.report(),'limitations':['Static bounded registrations only; dynamic interfaces and runtime claims require verification.','Document presence and term matches do not establish correctness or readability.','Renderer-specific anchors and complex Markdown remain reviewable unknowns.']}
209
+ finally:
210
+ if owned:scope.cache.close()
@@ -0,0 +1,132 @@
1
+ """Bounded static evidence helpers. Never import or execute assessed project code."""
2
+ from __future__ import annotations
3
+ import os
4
+ import ast
5
+ from pathlib import Path
6
+ import re
7
+
8
+ _BLOCK_MARKER = re.compile(r'/\*|\*/')
9
+
10
+ PACKAGE_MARKERS = ('.git', 'package.json', 'pyproject.toml', 'Cargo.toml', 'Package.swift',
11
+ 'pom.xml', 'settings.gradle', 'settings.gradle.kts', 'go.mod', 'composer.json')
12
+
13
+
14
+ def _metadata_files(root, relative, suffixes):
15
+ """Known metadata locations only; no symlink traversal or unbounded ancestor walk."""
16
+ base = root
17
+ for part in Path(relative).parts:
18
+ base = base / part
19
+ if base.is_symlink():
20
+ return []
21
+ if base.is_file():
22
+ return [base] if base.suffix.lower() in suffixes else []
23
+ if not base.is_dir():
24
+ return []
25
+ found = []
26
+ visited = 0
27
+ for directory, dirs, names in os.walk(base, followlinks=False):
28
+ visited += 1
29
+ if visited > 256:
30
+ break
31
+ dirs[:] = sorted(d for d in dirs if not d.startswith('.') and not (Path(directory)/d).is_symlink())
32
+ for name in sorted(names):
33
+ path = Path(directory) / name
34
+ if not path.is_symlink() and path.is_file() and path.suffix.lower() in suffixes:
35
+ found.append(path)
36
+ if len(found) == 1000:
37
+ return found
38
+ return found
39
+
40
+
41
+ def _unique_paths(paths):
42
+ # Case-insensitive filesystems can resolve uppercase/lowercase candidates to
43
+ # one file. Preserve the first valid spelling; keep distinct Linux files.
44
+ seen = set(); result = []
45
+ for path in paths:
46
+ try:
47
+ info = path.stat(); identity = (info.st_dev, info.st_ino)
48
+ except OSError:
49
+ continue
50
+ if identity not in seen:
51
+ seen.add(identity); result.append(path)
52
+ return result
53
+
54
+
55
+ def parent_context(root: Path):
56
+ """Consider only the immediate owner of a conventional source slice.
57
+
58
+ An independently marked package/repository never inherits its parent's docs.
59
+ Evidence is descriptive file presence, not verified architectural compliance.
60
+ """
61
+ root = root.resolve()
62
+ result = {'root': None, 'architecture_files': [], 'migration_files': []}
63
+ if root.name.lower() not in {'src', 'app', 'lib'} or any((root/name).exists() or (root/name).is_symlink() for name in PACKAGE_MARKERS):
64
+ return result
65
+ parent = root.parent
66
+ documents = []
67
+ for relative in ('architecture.md', 'ARCHITECTURE.md', 'architecture.rst', 'ARCHITECTURE.rst',
68
+ 'docs/architecture.md', 'docs/ARCHITECTURE.md', 'docs/architecture', 'docs/adr'):
69
+ documents.extend(_metadata_files(parent, relative, {'.md', '.rst', '.adoc'}))
70
+ migrations = []
71
+ for relative in ('migrations', 'prisma/migrations', 'alembic/versions', 'db/migrate', 'db/migrations'):
72
+ migrations.extend(_metadata_files(parent, relative, {'.sql', '.py', '.js', '.ts', '.rb', '.php', '.xml', '.json', '.yml', '.yaml'}))
73
+ documents = _unique_paths(documents)
74
+ migrations = _unique_paths(migrations)
75
+ if documents or migrations:
76
+ result = {'root': str(parent),
77
+ 'architecture_files': sorted({'../'+p.relative_to(parent).as_posix() for p in documents}),
78
+ 'migration_files': sorted({'../'+p.relative_to(parent).as_posix() for p in migrations})}
79
+ return result
80
+
81
+
82
+ def code_tokens(text, rust=False):
83
+ """Mask comments and literals for narrow call/attribute heuristics, not parsing.
84
+
85
+ Rust block comments nest; Rust raw-string delimiters are preserved while
86
+ finding the closing quote. Template strings are masked as a whole.
87
+ """
88
+ single = r"'(?:\\.|[^'\\])*'" if not rust else r"'(?:\\[^\n]|[^'\\\n])'"
89
+ raw = r'(?P<raw>(?:br|r)(?P<hashes>#{0,255})")|' if rust else ''
90
+ pattern = re.compile(r'//[^\n]*|(?P<block>/\*)|' + raw + r'"(?:\\[\s\S]|[^"\\])*"|' + single + r'|`(?:\\[\s\S]|[^`\\])*`')
91
+ parts = []; offset = 0
92
+ while True:
93
+ match = pattern.search(text, offset)
94
+ if not match:
95
+ parts.append(text[offset:]); break
96
+ parts.append(text[offset:match.start()]); end = match.end()
97
+ if match.group('block'):
98
+ depth = 1
99
+ while depth:
100
+ marker = _BLOCK_MARKER.search(text, end)
101
+ if not marker:
102
+ end = len(text); break
103
+ depth += 1 if marker.group() == '/*' else -1
104
+ end = marker.end()
105
+ elif rust and match.group('raw'):
106
+ closing = '"' + match.group('hashes')
107
+ position = text.find(closing, end)
108
+ end = len(text) if position < 0 else position + len(closing)
109
+ parts.append(''.join('\n' if c == '\n' else ' ' for c in text[match.start():end]))
110
+ offset = end
111
+ return ''.join(parts)
112
+
113
+
114
+ def rust_test_count(text):
115
+ if not re.search(r'#\s*\[\s*(?:(?:tokio|async_std)\s*::\s*)?test(?:\s*\([^]]*\))?\s*\]', text):
116
+ return 0
117
+ tokens = code_tokens(text, rust=True)
118
+ return len(re.findall(r'#\s*\[\s*(?:(?:tokio|async_std)\s*::\s*)?test(?:\s*\([^]]*\))?\s*\]\s*(?:#\[[^\]]*\]\s*)*(?:pub(?:\([^)]*\))?\s+)?(?:async\s+)?fn\s+[A-Za-z_]\w*\s*\(', tokens))
119
+
120
+
121
+ def has_warning_call(text, python=False):
122
+ pattern = r'\b(?:console|logger|log)\s*\.\s*warn(?:ing)?\s*\('
123
+ if not re.search(pattern, text): return False
124
+ if python:
125
+ try:
126
+ tree = ast.parse(text)
127
+ except (SyntaxError, RecursionError):
128
+ return False
129
+ return any(isinstance(n, ast.Call) and isinstance(n.func, ast.Attribute)
130
+ and n.func.attr in {'warn', 'warning'} and isinstance(n.func.value, ast.Name)
131
+ and n.func.value.id in {'console', 'logger', 'log'} for n in ast.walk(tree))
132
+ return bool(re.search(pattern, code_tokens(text)))
@@ -0,0 +1,63 @@
1
+ """Unscored product facets from bounded static evidence, separate from profiles."""
2
+ import re
3
+ import hashlib
4
+
5
+ # Spelling evidence is an inferred capability, never proof of complete behavior.
6
+ PATTERNS={
7
+ 'browser_python':r'\b(?:loadPyodide|runPythonAsync|pyodide)\b',
8
+ 'pseudocode':r'\b(?:parsePseudocode|PseudocodeParser|executePseudocode)\b',
9
+ 'ocr':r'\b(?:pytesseract|Tesseract|recognizeText|VNRecognizeTextRequest|OCRResult|OcrResult|ocr|ocr_[A-Za-z0-9_]+)\b',
10
+ 'grading':r'\b(?:grade_submission|evaluate_submission|EvaluationResult|criterion_scores)\b',
11
+ 'desktop_ui':r'\b(?:QApplication|NSApplication|Tk|WindowGroup|ApplicationWindow|QMainWindow)\b',
12
+ 'desktop_widget':r'\b(?:WidgetKit|WidgetConfiguration|AppWidgetProvider)\b',
13
+ 'windows_service':r'\b(?:ServiceBase|AddWindowsService|UseWindowsService|ServiceFramework)\b',
14
+ 'proxy':r'\b(?:StreamingResponse|ReverseProxy|ProxyPass|proxy_pass|http_proxy)\b',
15
+ 'authentication':r'\b(?:OAuth2PasswordBearer|AuthenticationMiddleware|AddAuthentication|login_required|verify_oauth2_token|verify_id_token|OAuth2AuthorizationCodeBearer)\b',
16
+ 'backup':r'\b(?:CreateBackup|create_backup|backup_source|BackupFlow|PortBackupSource)\b',
17
+ 'pdf_mapping':r'\b(?:page_map|page_mapping|chapter_manifest|build_book_packs)\b',
18
+ 'browser_worker':r'\b(?:Worker|postMessage|terminate)\b',
19
+ 'apps_script':r'\b(?:SpreadsheetApp|ScriptApp|DocumentApp)\b',
20
+ 'ide_extension':r'\b(?:ToolWindowFactory|AnAction|IntelliJPlatform|ProjectActivity)\b',
21
+ 'infrastructure':r'\b(?:subprocess|ProcessBuilder|StartProcess|Popen)\b',
22
+ }
23
+
24
+ def facets(scope,max_files=128,max_bytes=2000000,owner=None):
25
+ from .discovery import code_view,load_engine
26
+ engine=load_engine();facts=[];diagnostics=[];used=0;attempted=0
27
+ records=[r for r in scope.selected({'production','source','unknown','tooling'}) if owner is None or r['owner']==owner]
28
+ for record in records:
29
+ if record['language']=='other':continue
30
+ if attempted>=max_files or used>=max_bytes:break
31
+ raw=scope.raw(record['path'],min(32768,max_bytes-used));used+=len(raw);attempted+=1
32
+ text=raw.decode('utf8','replace')
33
+ try:view=code_view(text,record['language'],engine)
34
+ except (ValueError,RecursionError,SyntaxError):
35
+ diagnostics.append({'path':record['relative'],'reason':'unsupported_or_truncated_syntax'});continue
36
+ for facet,pattern in PATTERNS.items():
37
+ match=re.search(pattern,view)
38
+ if match:facts.append({'facet':facet,'state':'inferred','path':record['relative'],
39
+ 'sha256':hashlib.sha256(raw).hexdigest(),'line':view.count('\n',0,match.start())+1,
40
+ 'read_bytes':len(raw),'complete_file':len(raw)==record['size'],
41
+ 'confirmed':False,'runtime_verified':False})
42
+ present={f['facet'] for f in facts}
43
+ delivery=sorted({kind for facet,kind in [('desktop_ui','desktop'),('browser_python','browser'),('browser_worker','browser'),('desktop_widget','desktop'),('windows_service','service'),('ide_extension','ide_plugin'),('apps_script','cloud_script')] if facet in present})
44
+ return {'delivery':delivery,'runtimes':sorted({r['language'] for r in records if r['language']!='other'}),'source_evidence_state':'bounded_source_sample' if attempted else 'metadata_only_or_unread','platform_constraints':['windows'] if 'windows_service' in present else ['apple'] if 'desktop_widget' in present and any(r['language']=='swift' for r in records) else ['unknown'],
45
+ 'diagnostics':diagnostics[:200],'schema_version':1,'facts':facts,'attempted':attempted,'bytes':used,
46
+ 'partial':bool(diagnostics) or attempted<sum(r['language']!='other' for r in records) or any(not f['complete_file'] for f in facts),
47
+ 'interpretation':'Static capability spellings; absence from bounded evidence means unknown.'}
48
+
49
+
50
+ def classification(scope):
51
+ """Existing profile IDs, shared selected roots, masked source-only text cues."""
52
+ from .discovery import code_view,load_engine
53
+ from .registry import Registry
54
+ engine=load_engine();records=[r for r in scope.records if r['owner']=='root']
55
+ code=[r['path'] for r in records if r['language']!='other']
56
+ def reader(path,limit=65536):
57
+ text=scope.text(path,limit);lang=scope.record(path).get('language')
58
+ if lang and lang!='other':
59
+ try:return code_view(text,lang,engine)
60
+ except (ValueError,RecursionError):return ''
61
+ return text
62
+ ident,evidence,confidence,alternatives=Registry().detect([r['path'] for r in records],scope.root,code,reader=reader)
63
+ return {'profile':ident,'evidence':evidence,'confidence':confidence,'alternatives':alternatives,'kind':'static_heuristic','confirmed':False}
@@ -0,0 +1,109 @@
1
+ """Bounded .gitignore matching without Git, shell execution, or index access.
2
+
3
+ Patterns apply to scanned files regardless of tracked status. Only ignore files
4
+ inside the authorized root are loaded; global Git config is never consulted.
5
+ """
6
+ from __future__ import annotations
7
+ from dataclasses import dataclass
8
+ import fnmatch
9
+ import hashlib
10
+ from pathlib import PurePosixPath
11
+
12
+ MAX_IGNORE_BYTES = 131072
13
+ MAX_RULES = 4096
14
+ MAX_PATTERN = 512
15
+
16
+
17
+ def _unescape(pattern):
18
+ out=[];i=0
19
+ while i<len(pattern):
20
+ if pattern[i]=='\\' and i+1<len(pattern):
21
+ i+=1;c=pattern[i];out.append({'*':'[*]','?':'[?]','[':'[[]'}.get(c,c))
22
+ elif pattern.startswith('[^',i):
23
+ # Git accepts both ! and ^ to negate a character class.
24
+ out.append('[!');i+=2;continue
25
+ else:out.append(pattern[i])
26
+ i+=1
27
+ return ''.join(out)
28
+
29
+
30
+ def _components_match(pattern, parts):
31
+ # DP rather than a recursive/backtracking ** regex over an entire path.
32
+ states={0}
33
+ for index,component in enumerate(pattern):
34
+ next_states=set()
35
+ for pos in states:
36
+ if component=='**':
37
+ # A trailing '/**' matches descendants, never the parent itself.
38
+ minimum=1 if index==len(pattern)-1 and len(pattern)>1 else 0
39
+ next_states.update(range(pos+minimum,len(parts)+1))
40
+ elif pos<len(parts) and fnmatch.fnmatchcase(parts[pos],component):next_states.add(pos+1)
41
+ states=next_states
42
+ if not states:return False
43
+ return len(parts) in states
44
+
45
+
46
+ @dataclass(frozen=True)
47
+ class IgnoreRule:
48
+ base: tuple
49
+ pattern: tuple
50
+ basename: bool
51
+ directory_only: bool
52
+ negate: bool
53
+ source: str
54
+ line: int
55
+
56
+ def matches(self, relative, is_dir):
57
+ parts=PurePosixPath(relative).parts
58
+ if parts[:len(self.base)]!=self.base:return False
59
+ parts=parts[len(self.base):]
60
+ if not parts:return False
61
+ if self.basename:
62
+ return any(fnmatch.fnmatchcase(part,self.pattern[0]) and (not self.directory_only or i<len(parts)-1 or is_dir) for i,part in enumerate(parts))
63
+ # A matched directory also excludes descendants. The walker prunes
64
+ # excluded parents, so a later child negation cannot rescue them.
65
+ for end in range(1,len(parts)+1):
66
+ if self.directory_only and end==len(parts) and not is_dir:continue
67
+ if _components_match(self.pattern,parts[:end]):return True
68
+ return False
69
+
70
+
71
+ def parse_ignore(raw, source='.gitignore'):
72
+ """Return rules plus an explicit diagnostic for unsupported/oversized input."""
73
+ if len(raw)>MAX_IGNORE_BYTES:return [],'oversized_gitignore'
74
+ try:text=raw.decode('utf-8-sig')
75
+ except UnicodeError:return [],'invalid_gitignore_encoding'
76
+ base=PurePosixPath(source).parent.parts
77
+ rules=[];diagnostic=None
78
+ for line_no,line in enumerate(text.splitlines(),1):
79
+ while line.endswith(' '):
80
+ preceding=len(line[:-1])-len(line[:-1].rstrip('\\'))
81
+ if preceding%2:break
82
+ line=line[:-1]
83
+ if not line or line.startswith('#'):continue
84
+ negate=line.startswith('!')
85
+ if negate:line=line[1:]
86
+ if not line:continue
87
+ directory=line.endswith('/')
88
+ if directory:line=line[:-1]
89
+ anchored=line.startswith('/')
90
+ if anchored:line=line[1:]
91
+ if not line:continue
92
+ if len(line)>MAX_PATTERN or len(line.split('/'))>64 or line.count('*')+line.count('?')>32 or '\x00' in line or '[[:' in line:
93
+ diagnostic='unsupported_gitignore_pattern';continue
94
+ basename='/' not in line and not anchored
95
+ pattern=tuple(_unescape(x) for x in line.split('/'))
96
+ rules.append(IgnoreRule(base,pattern,basename,directory,negate,source,line_no))
97
+ if len(rules)>=MAX_RULES:diagnostic='gitignore_rule_limit';break
98
+ return rules,diagnostic
99
+
100
+
101
+ def ignore_decision(relative,is_dir,rules):
102
+ ignored=False;last=None
103
+ for rule in rules:
104
+ if rule.matches(relative,is_dir):ignored=not rule.negate;last=rule
105
+ return ignored,({'path':last.source,'line':last.line} if ignored and last else None)
106
+
107
+
108
+ def ignore_evidence(raw, relative):
109
+ return {'path':relative,'sha256':hashlib.sha256(raw).hexdigest()}
@@ -0,0 +1,110 @@
1
+ """Optional, data-only book-informed review questions. Never scored detectors."""
2
+ from __future__ import annotations
3
+ import hashlib
4
+ from importlib import resources
5
+ import json
6
+ import re
7
+ from urllib.parse import urlsplit
8
+ from pathlib import Path
9
+
10
+
11
+ def load_knowledge():
12
+ data=json.loads(resources.files('aqaf').joinpath('data/book_review_knowledge.json').read_text(encoding='utf8'))
13
+ return validate_knowledge(data)
14
+
15
+
16
+ def validate_knowledge(data):
17
+ if data.get('schema_version')!=1:raise ValueError('Unsupported review knowledge version')
18
+ books={b['id']:b for b in data['books']}
19
+ if len(books)!=len(data['books']):raise ValueError('Duplicate knowledge book')
20
+ routes=set()
21
+ for b in books.values():
22
+ if not b.get('edition') or not b.get('review_summary') or not b.get('review_contribution') or not b.get('reading_references'):
23
+ raise ValueError('Missing book reading metadata')
24
+ expected='/books/'+b['id']+'/'
25
+ if b.get('website_path')!=expected or not re.fullmatch(r'/books/[a-z0-9-]+/',expected):raise ValueError('Invalid book page')
26
+ for ref in b['reading_references']:
27
+ if ref.get('kind') not in {'chapter','topic','pattern'} or not ref.get('title') or ref.get('edition')!=b['edition']:
28
+ raise ValueError('Invalid reading reference')
29
+ if ref.get('number') is not None and not re.fullmatch(r'[1-9][0-9]*',str(ref['number'])):raise ValueError('Invalid reference number')
30
+ url=ref.get('source_url','');parsed=urlsplit(url)
31
+ if parsed.scheme!='https' or not parsed.hostname or parsed.username is not None or parsed.password is not None or any(c.isspace() or c=='\\' for c in url):raise ValueError('Invalid reference URL')
32
+ if ref.get('verification_status')!='verified_primary' or not re.fullmatch(r'\d{4}-\d{2}-\d{2}',ref.get('verified_on','')) or not ref.get('verification_basis'):
33
+ raise ValueError('Unverified reading reference')
34
+ ids=set()
35
+ for card in data['principles']:
36
+ if card['id'] in ids or not card['book_ids'] or not set(card['book_ids'])<=set(books):raise ValueError('Invalid knowledge references')
37
+ route=card.get('website_path','')
38
+ if not re.fullmatch(r'/learn/[a-z0-9-]+/',route) or route in routes:raise ValueError('Invalid guide page')
39
+ routes.add(route)
40
+ if not all(card.get(k) for k in ('explanation','example','counterexample','editorial_basis','example_basis')):raise ValueError('Incomplete guide')
41
+ ids.add(card['id'])
42
+ if set(card['applies_when'])!={'profiles','facets','languages'}:raise ValueError('Invalid knowledge applicability')
43
+ return data
44
+
45
+
46
+ def review_agenda(scope,profile,paths=None):
47
+ """Ask context-sensitive questions, without interpreting absence as failure."""
48
+ from .facets import facets
49
+ allowed=set(Path(p) for p in paths) if paths is not None else set(scope.files)
50
+ records=[r for r in scope.records if r['path'] in allowed]
51
+ owners={r['owner'] for r in records}
52
+ context=facets(scope,owner=next(iter(owners)) if len(owners)==1 else None) if records else {'facts':[],'partial':False}
53
+ relative={r['relative'] for r in records}
54
+ facts=[f for f in context['facts'] if f['path'] in relative]
55
+ present={f['facet'] for f in facts};languages={r['language'] for r in records if r['language']!='other'}
56
+ # Evidence is deliberately bounded, contains no source bodies or default values.
57
+ samples=[]
58
+ for role in ({'production','source','unknown','tooling'},{'docs'},{'tests'}):
59
+ candidates=[r for r in records if r['role'] in role]
60
+ for r in candidates[:2]:
61
+ raw=scope.raw(r['path'],8192)
62
+ samples.append({'path':r['relative'],'role':r['role'],'sha256':hashlib.sha256(raw).hexdigest(),
63
+ 'bytes':len(raw),'complete_file':len(raw)==r['size'],'state':'observed_inventory_only'})
64
+ data=load_knowledge();books={b['id']:b for b in data['books']};questions=[]
65
+ for card in data['principles']:
66
+ when=card['applies_when'];basis=[]
67
+ if not any(when.values()):basis.append({'kind':'general_review','claim':'Applicability and adequacy require a reviewer; not a missing-feature finding.'})
68
+ if profile in when['profiles']:basis.append({'kind':'profile_hint','profile':profile,'confirmed':False})
69
+ for lang in sorted(languages & set(when['languages'])):basis.append({'kind':'language_presence','language':lang})
70
+ for f in facts:
71
+ if f['facet'] in when['facets']:basis.append({'kind':'inferred_static_facet',**f})
72
+ if not basis:continue
73
+ questions.append({k:card[k] for k in ('id','title','question','required_evidence','verification','limitations','explanation')} | {
74
+ 'kind':'manual_verification','state':'unknown','confirmed':False,'runtime_verified':False,'mandatory':False,
75
+ 'guide_url':'https://aqaf.focsd.com'+card['website_path'],
76
+ 'applicability_basis':basis,'sources':[{'source_id':books[b]['source_id'],'book_id':b,'title':books[b]['title'],
77
+ 'url':books[b]['primary_url'],'book_url':'https://aqaf.focsd.com'+books[b]['website_path'],
78
+ 'edition':books[b]['edition'],'contribution':books[b]['review_contribution'],
79
+ 'reading_references':books[b]['reading_references'],'basis':'secondary_application_notes','notes_path':books[b]['notes_path'],
80
+ 'notes_sha256':books[b]['notes_sha256'],'full_book_reviewed':False} for b in card['book_ids']],
81
+ 'completion_record':{'reviewer':None,'date':None,'evidence':[],'result':'unknown','not_applicable_reason':None}})
82
+ return {'schema_version':1,'knowledge_version':data['knowledge_version'],'kind':'book_informed_review_agenda','score_effect':'none',
83
+ 'profile_hint':profile,'languages':sorted(languages),'questions':questions,'inventory_evidence':samples,
84
+ 'context_sampling_partial':context.get('partial',False),'target_code_executed':False,
85
+ 'limitations':data['limitations']+['Profile/facet hints select questions; they do not establish a product contract or a defect.',
86
+ 'No repository commands, Git history, tests, network checks or hardware actions are executed.',
87
+ 'Receiver validation, authentication, runtime behavior and architecture conformance need independent evidence.']}
88
+
89
+
90
+ def markdown_agenda(agenda):
91
+ """Render current and earlier additive agenda records without online lookups."""
92
+ lines=['## Book-informed review agenda','','Optional human verification questions; unknown until reviewed. No score effect. Application guidance uses secondary notes; selected reading-reference facts were independently checked. These references do not validate detectors.','']
93
+ for q in agenda['questions']:
94
+ lines.extend(['### '+q['id']+' — '+q['title'],q['question']])
95
+ if q.get('explanation'):lines.extend(['',q['explanation']])
96
+ if q.get('guide_url'):lines.extend(['','[Why this matters and how to review it]('+q['guide_url']+')'])
97
+ lines.extend(['','- Evidence to request: '+q['required_evidence'],
98
+ '- Suggested verification (not executed): '+q['verification'],'- Limit: '+q['limitations'],
99
+ '- State: unknown; assign a reviewer and date or explain why not applicable.','', 'Further reading:'])
100
+ for source in q['sources']:
101
+ edition=' — '+source['edition'] if source.get('edition') else ''
102
+ contribution='. '+source['contribution'] if source.get('contribution') else ''
103
+ lines.extend(['- ['+source['title']+']('+source.get('book_url',source['url'])+')'+edition+contribution,
104
+ ' Primary resource: ['+source['title']+']('+source['url']+').'])
105
+ for ref in source.get('reading_references',[]):
106
+ label=ref['kind'].capitalize()+(' '+ref['number'] if ref['number'] is not None else '')+': '+ref['title']
107
+ lines.append(' - ['+label+']('+ref['source_url']+') — '+ref['edition']+'; verified '+ref['verified_on']+' (title/number/edition only).')
108
+ lines.append(' Source ID: '+source['source_id']+'. Full book not reviewed.')
109
+ lines.append('')
110
+ return lines