diff --git a/config/project.example.toml b/config/project.example.toml index 1c9ba93..4ea972a 100644 --- a/config/project.example.toml +++ b/config/project.example.toml @@ -245,3 +245,16 @@ max_files = 8 max_blocks = 12 max_total_lines = 160 registration_prefixes = ["AddSC_"] + +# Context-aware source selection. Domain markers belong to this adapter. +protocol = 2 +max_seed_terms = 8 +max_candidate_matches = 50 +max_weak_hits = 8 +code_prefixes = ["src/"] +schema_prefixes = ["sql/base/"] +historical_prefixes = ["sql/old/", "sql/archive/"] +context_fact_names = ["zone_name", "area_name", "world_event_name"] +type_markers = {creature = ["NPC", "CREATURE"], quest = ["QUEST"], spell = ["SPELL"], item = ["ITEM"], gameobject = ["GO", "GAMEOBJECT"]} +entity_tables = [{table = "creature_template", entity_type = "creature", id_column = "entry", script_columns = ["ScriptName"]}] +focus_roles = ["diagnostic_focus", "explicit_quest"] diff --git a/docs/source-context-relevance-v2.md b/docs/source-context-relevance-v2.md new file mode 100644 index 0000000..d11e5ef --- /dev/null +++ b/docs/source-context-relevance-v2.md @@ -0,0 +1,118 @@ +# Kontextbezogene Source-Auswahl v2 + +Die Überarbeitung übernimmt Prinzipien aus der untersuchten wowsrc-0.7-Referenz: +Suchanker tragen Herkunft und Rolle; Wiederholungen einer Zahl erzeugen keine +zusätzliche Relevanz; eine leere primäre Auswahl ist zulässig. Es werden keine +wowsrc-Programme ausgeführt und keine vollständigen Dateien pauschal an eine LLM +übertragen. `docs/architecture.md` bleibt unverändert. + +## Anfrage und Herkunft + +```json +{"kind":"source.context","text":"23487","path_prefix":"","entity_refs":["N0005"],"scope":"code"} +``` + +`entity_refs` und `scope` sind optional. Die Referenzen müssen auf existierende +Entitäten im kanonischen Interpretationskontext zeigen; erfundene Entitäten oder +freie Modell-Ankerlisten sind unzulässig. Ohne Referenzen ordnet der Controller +eindeutige IDs oder Namen selbst zu. Bei mehreren Entitätstypen mit gleicher ID +wird kein Typ für eine reine Zahl geraten. Die im Interpretationskontext vorhandene +kontextuelle Namensauflösung bleibt eine beobachtungsbasierte Zuordnung, kein +Beweis für Ursache oder Rolle. + +Der Controller ergänzt begrenzt Namen, Schreibvarianten, konfigurierte Fokusrollen +und Kontextfakten. Gleicher Name und seine Schreibvarianten bleiben dieselbe +Ankergruppe. Scriptnamen werden nur aus zugeordneten, erfassten DB-Spalten gelesen; +die Fallzusammenfassung allein genügt dafür nicht. Im Vcore-Adapter ist dies zunächst +`creature_template.entry` → `ScriptName`. Die Konfiguration kann weitere tatsächlich +vorliegende Datensätze ergänzen; das Werkzeug führt dafür keine zusätzlichen +Datenbankabfragen aus. Ein bereits belegter Scriptname lässt sich auch direkt als +Suchbegriff verwenden. + +Die ausgewählten Anker werden mit Quelle, JSON-Pointer, Typ und Rollen im Capture +gespeichert. Case- und Interpretationskontext erhalten Referenz und Hash in der +Capture-Bindung. Die Werkzeugkonfiguration und die Anker gehen in den lokalen +Abfragecache ein. Gleiche Anfragen mit anderen belegten Ankern sind andere Captures. + +## Auswahlregeln + +- Scriptanker und ausdrücklich angefragte Symbole liefern starke Suchkandidaten. + Ein bekannter Anzeigename wird dadurch nicht zu einem Scriptnamen umgedeutet. +- Namen und Kontextanker liefern ergänzenden Kontext. Groß-/Kleinschreibung und + Unterstrichvarianten sind keine unabhängige Bestätigung. +- Eine Zahl allein bleibt eine schwache Fundstelle. Nur eine zur belegten + Entitätsart passende konfigurierte Namensraum-Bindung, beispielsweise + `NPC_… = 23487`, kann sie zum primären Kandidaten machen. Ein `SPELL_…`-Treffer + mit derselben Zahl ist kein NPC-Beleg. +- Solche Bindungen bleiben Textheuristiken, keine semantische C++-Analyse. Auch + ein passend benannter Zähler könnte falsch eingeordnet werden; die LLM muss + den tatsächlichen Code bewerten. +- Pro Datei und Ankergruppe zählt nur das stärkste Signal. Häufige Wiederholungen + und mehrere unzugeordnete Zahlen erzeugen keinen primären Kandidaten. +- Einfache, typisierte Konstantenbindungen können zu Symbol-/Registrierungssuchen + führen. Eine bloße Zahl führt nicht zur Erkundung sämtlicher Registrierungen + ihrer zufällig getroffenen Datei. +- Bekannte andere Ortszuordnungen werden über optionale Operatorregeln als + ergänzender Kontext eingeordnet. Sie werden nicht ausgeschlossen: gemeinsame + Infrastruktur und gebietsübergreifende Mechanismen können relevant sein. + +Die Auswahl enthält `primary_candidate`, `supporting_context`, +`supporting_other_location` oder `weak_numeric`. Schwache Zahlentreffer erhalten +nur kurze Fundstellenübersichten; es gibt keinen Ersatz-Primärtreffer, wenn kein +starkes Signal gefunden wurde. Die Klassifikation beschreibt Suchrelevanz, niemals +Confidence in eine Bugursache. + +## Suchräume und Grenzen + +`code` ist der Standard. Der Vcore-Adapter beschränkt ihn auf `src/` und schließt +SQL-Dateien aus. `schema` nutzt konfigurierte Schemaverzeichnisse, hier `sql/base/`. +`historical_sql` muss ausdrücklich angefordert werden und nutzt die konfigurierten +Archivverzeichnisse. Ein nicht zum Suchraum passender `path_prefix` wird kontrolliert +abgelehnt. Ein leerer Prefix bedeutet in v2 den konfigurierten Suchraum, nicht die +gesamte Repositorywurzel. + +Numerische Suchen berücksichtigen vollständige dezimale Tokens; Teile größerer +Zahlen, Bezeichner und Fließkommazahlen zählen nicht als ID-Treffer. Andere +Schreibweisen wie hexadezimale IDs sind damit nicht ausgeschlossen oder abgedeckt. + +Anzahl von Ankern, Kandidatentreffern, Suchen, schwachen Zusammenfassungen, Dateien, +Zeilen und Blöcken ist konfigurierbar begrenzt. Treffer werden vor der Auswahl von +Codeausschnitten gewichtet. Relevante Ausschnitte werden zusammengeführt; Code +wird nicht nochmals in jeder Trefferbeschreibung wiederholt. Fehlende Kontextzeilen +und gekürzte Zeilen werden gemeldet. Such-, Seed- und Ausgabelimits verhindern +weiterhin Vollständigkeitsbehauptungen außerhalb des geprüften Ausschnitts. + +`selection.primary_files` darf leer sein. Das heißt lediglich, dass unter den +untersuchten Treffern kein starker Kandidat vorliegt. Es schließt dynamische Pfade, +generierten Code, andere Suchräume, nicht untersuchte Anker oder andere +Darstellungsformen nicht aus. Die bisherigen Grundwerkzeuge bleiben verfügbar. + +## Aktivierung und Kompatibilität + +`[source_analysis] protocol = 2` aktiviert die neue Auswahl. Projektbezogene +Namensräume, DB-Spalten und Suchverzeichnisse stehen im Projektprofil, nicht als +Vcore-Wissen im Kernalgorithmus. `focus_roles`, `context_fact_names`, +`type_markers`, `entity_tables` und optionale `location_paths` sind Operatorregeln. +Neue v2-Einstellungen ohne expliziten Protokollwechsel werden abgelehnt. + +v1-Katalog, Argumentvertrag und Algorithmus bleiben für alte Konfigurationen +erhalten. Gespeicherte Kontexte werden nicht migriert; das Ausführen unter +abweichenden Einstellungen wird weiterhin kontrolliert abgelehnt. Bestehende +Prompts bleiben aus ihren gespeicherten Kontexten reproduzierbar. + +## Prüfung + +Lokale Tests behandeln gleiche Zahlen in unterschiedlichen Namensräumen, +massenhafte Wiederholung, Namensvarianten, reine Zahlen ohne primäre Kandidaten, +Quelltext trotz veränderter Arbeitskopie, unterschiedliche Orte bei erhaltenem +Querverweis, explizite SQL-Suchräume, ungültige Anker, Grenzen, Herkunft und +budgetierte Paket-Wiederaufnahme. + +Der lesende Probeaufruf am vorhandenen Bug-20-Paket wählt N0005 (Zielkreatur) und +N0004 (Questkontext) aus. Die im Codebereich geprüften Anker ergeben keinen starken +primären Kandidaten und keine historische SQL-Sammlung; das Suchbudget wird dabei +ausdrücklich als begrenzt gemeldet. Dies ist ein lokaler Werkzeugtest, keine neue +LLM-Bewertung des Bugs und kein Nachweis realer Token-/Kostenersparnis. + +Die zuvor bekannte fehlende Gateway-Schemafehlerdiagnostik für Bootstrap v21 ist +ein separater Punkt und wird durch diese Source-Überarbeitung nicht behoben. diff --git a/src/viano_bug_analysis/analysis/investigation_prompt.py b/src/viano_bug_analysis/analysis/investigation_prompt.py index af5c560..11b457f 100644 --- a/src/viano_bug_analysis/analysis/investigation_prompt.py +++ b/src/viano_bug_analysis/analysis/investigation_prompt.py @@ -20,7 +20,8 @@ def output_schema(context=None, *, strict=False): schema['properties']['next'] = extend_tool_schema(schema['properties']['next']) if context and context['tool_schema'].get('x-project-bindings', {}).get('source_analysis'): from viano_bug_analysis.tools.source_context import extend_tool_schema - schema['properties']['next'] = extend_tool_schema(schema['properties']['next']) + protocol=context['tool_schema']['x-project-bindings']['source_analysis']['version'] + schema['properties']['next'] = extend_tool_schema(schema['properties']['next'],protocol) if context and context.get('prompt_version') in {'investigation_project_v12','investigation_project_v13','investigation_project_v14','investigation_project_v15','investigation_project_v16','investigation_project_v17','investigation_project_v18'}: schema['properties']['update']['properties']['causal_review'] = json.loads( files('viano_bug_analysis.schemas').joinpath('investigation-causal-review-v1.schema.json').read_text()) diff --git a/src/viano_bug_analysis/analysis/investigator.py b/src/viano_bug_analysis/analysis/investigator.py index 82f6a86..faf68b4 100644 --- a/src/viano_bug_analysis/analysis/investigator.py +++ b/src/viano_bug_analysis/analysis/investigator.py @@ -260,7 +260,7 @@ class Investigator: from viano_bug_analysis.tools.source_context import extend_tool_schema context['tool_schema']['x-project-bindings']['source_analysis'] = service.source_context.catalog() items = context['tool_schema']['properties']['requests']['items'] - context['tool_schema']['properties']['requests']['items'] = extend_tool_schema(items) + context['tool_schema']['properties']['requests']['items'] = extend_tool_schema(items, service.source_context.config.protocol) if service.relations or service.source_context: context['tool_guidance_version'] = 1 else: diff --git a/src/viano_bug_analysis/analysis/tool_guidance.py b/src/viano_bug_analysis/analysis/tool_guidance.py index cbb52e2..b7277d6 100644 --- a/src/viano_bug_analysis/analysis/tool_guidance.py +++ b/src/viano_bug_analysis/analysis/tool_guidance.py @@ -32,8 +32,14 @@ def instructions(context): if bindings.get('source_analysis'): text += ('source.context arguments: {"kind":"source.context",' '"text":"SYMBOL_OR_IDENTIFIER_FROM_EVIDENCE","path_prefix":""}. ' - 'Replace text with the actual term; empty path_prefix means repository root. ' - 'No registration, offset, limit or command argument.\n') + 'Replace text with the actual term; empty path_prefix means ') + text += ('the configured scope prefixes. ' if bindings['source_analysis'].get('version')==2 else 'repository root. ') + text += 'No registration, offset, limit or command argument.\n' + if bindings['source_analysis'].get('version')==2: + text += ('source.context v2 optionally accepts entity_refs:["N0001"] from supplied case entities ' + 'and scope:"code"|"schema"|"historical_sql" (default code). Grounded names and scripts ' + 'are added by the controller; numeric coincidences are weak summaries, not identities. ' + 'Ranking is heuristic; supporting and other-location candidates may matter.\n') text += ('For primitive database.rows/database.count, filters MUST be a nonempty array of objects, ' 'for example [{"column":"observed_column","operator":"eq","value":123}]. ' 'Replace the illustrative column/value with schema-verified, case-grounded inputs. ' diff --git a/src/viano_bug_analysis/schemas/investigation-request-v1.schema.json b/src/viano_bug_analysis/schemas/investigation-request-v1.schema.json index cd6a23b..f24ab8b 100644 --- a/src/viano_bug_analysis/schemas/investigation-request-v1.schema.json +++ b/src/viano_bug_analysis/schemas/investigation-request-v1.schema.json @@ -730,6 +730,22 @@ "path_prefix": { "type": "string", "maxLength": 250 + }, + "scope": { + "enum": [ + "code", + "schema", + "historical_sql" + ] + }, + "entity_refs": { + "type": "array", + "maxItems": 4, + "uniqueItems": true, + "items": { + "type": "string", + "pattern": "^N[0-9]{4,}$" + } } } } diff --git a/src/viano_bug_analysis/schemas/investigation-state-v1.schema.json b/src/viano_bug_analysis/schemas/investigation-state-v1.schema.json index 8d40b0f..dc37161 100644 --- a/src/viano_bug_analysis/schemas/investigation-state-v1.schema.json +++ b/src/viano_bug_analysis/schemas/investigation-state-v1.schema.json @@ -1731,6 +1731,22 @@ "path_prefix": { "type": "string", "maxLength": 250 + }, + "scope": { + "enum": [ + "code", + "schema", + "historical_sql" + ] + }, + "entity_refs": { + "type": "array", + "maxItems": 4, + "uniqueItems": true, + "items": { + "type": "string", + "pattern": "^N[0-9]{4,}$" + } } } } diff --git a/src/viano_bug_analysis/schemas/project-capture-v1.schema.json b/src/viano_bug_analysis/schemas/project-capture-v1.schema.json index a242ead..5160a87 100644 --- a/src/viano_bug_analysis/schemas/project-capture-v1.schema.json +++ b/src/viano_bug_analysis/schemas/project-capture-v1.schema.json @@ -285,6 +285,22 @@ "path_prefix": { "type": "string", "maxLength": 250 + }, + "scope": { + "enum": [ + "code", + "schema", + "historical_sql" + ] + }, + "entity_refs": { + "type": "array", + "maxItems": 4, + "uniqueItems": true, + "items": { + "type": "string", + "pattern": "^N[0-9]{4,}$" + } } } } @@ -385,6 +401,9 @@ "items": { "type": "string" } + }, + "selection": { + "type": "object" } } } diff --git a/src/viano_bug_analysis/tools/project_query.py b/src/viano_bug_analysis/tools/project_query.py index 4207564..f5e7e91 100644 --- a/src/viano_bug_analysis/tools/project_query.py +++ b/src/viano_bug_analysis/tools/project_query.py @@ -97,12 +97,15 @@ class ProjectResearch: schema = extend_query_schema(self.schema()) if self.relations else self.schema() if self.source_context: from .source_context import extend_query_schema - schema = extend_query_schema(schema) + schema = extend_query_schema(schema, self.source_context.config.protocol) return schema def prepare(self, context, request): self.check_project(context.reader.manifest["project"]) self.validate(request["arguments"]) + if request['arguments']['kind']=='source.context' and self.source_context.config.protocol==2: + from .source_relevance import case_seeds + case_seeds(context,request['arguments'],self.source_context.config) def validate(self, arguments): from jsonschema import Draft202012Validator @@ -125,7 +128,7 @@ class ProjectResearch: return provider.validate(arguments) - def collect(self, arguments): + def collect(self, arguments, *, source_context=None): self.validate(arguments) provider = self.database if arguments["kind"].startswith("database.") else self.source binding = provider.binding() @@ -138,6 +141,8 @@ class ProjectResearch: settings = self.source_context.catalog() binding = {**binding, 'source_analysis': settings, 'source_analysis_sha256': hashlib.sha256(json_bytes(settings)).hexdigest()} + if self.source_context.config.protocol==2: + binding['anchor_context']=copy.deepcopy(source_context or {'anchors':[],'omitted_anchors':0}) cache_key = hashlib.sha256(json_bytes({"arguments": arguments, "binding": binding})).hexdigest() # A request is frozen for the controller's validation/persistence cycle. # Durable resume uses the captured reply in the package, never this cache. @@ -145,7 +150,7 @@ class ProjectResearch: if arguments['kind'] == 'database.relations': result = self.relations.query(copy.deepcopy(arguments)) elif arguments['kind'] == 'source.context': - result = self.source_context.query(copy.deepcopy(arguments)) + result = self.source_context.query(copy.deepcopy(arguments),seed_context=source_context) else: result = provider.query(copy.deepcopy(arguments)) capture = {"format": "viano-project-capture", "format_version": 1, @@ -163,7 +168,11 @@ class ProjectResearch: def execute(self, context, request): self.prepare(context, request) - data = self.collect(request["arguments"]) + seeds=None + if request['arguments']['kind']=='source.context' and self.source_context.config.protocol==2: + from .source_relevance import case_seeds + seeds=case_seeds(context,request['arguments'],self.source_context.config) + data = self.collect(request["arguments"],source_context=seeds) capture = json.loads(data) sha = hashlib.sha256(data).hexdigest() return {"matches": capture["result"]["items"], "coverage": capture["result"]["coverage"], diff --git a/src/viano_bug_analysis/tools/repository.py b/src/viano_bug_analysis/tools/repository.py index 3cf84f6..64ba30b 100644 --- a/src/viano_bug_analysis/tools/repository.py +++ b/src/viano_bug_analysis/tools/repository.py @@ -167,6 +167,10 @@ class GitSourceProvider: continue if '_extensions' in args and name.rsplit('.', 1)[-1] not in args['_extensions']: continue + if any(name.startswith(p) for p in args.get('_exclude_prefixes',[])): + continue + if args.get('_numeric_token') and not re.search(r'(?8:raise ToolError('PROJECT_SOURCE_CONTEXT_CONFIG') + for value in values:safe_path(value) + for values in [c.context_fact_names,c.focus_roles]: + if not isinstance(values,(tuple,list)) or len(values)>12: + raise ToolError('PROJECT_SOURCE_CONTEXT_CONFIG') + for value in values: + if not isinstance(value,str) or not re.fullmatch(r'[A-Za-z_][A-Za-z0-9_]{0,63}',value): + raise ToolError('PROJECT_SOURCE_CONTEXT_CONFIG') + if not isinstance(c.type_markers,dict) or len(c.type_markers)>20: + raise ToolError('PROJECT_SOURCE_CONTEXT_CONFIG') + for key,values in c.type_markers.items(): + if not isinstance(key,str) or not isinstance(values,list) or not 1 <= len(values)<=10: + raise ToolError('PROJECT_SOURCE_CONTEXT_CONFIG') + if any(not isinstance(v,str) or not re.fullmatch(r'[A-Za-z_][A-Za-z0-9_]{0,63}',v) for v in values): + raise ToolError('PROJECT_SOURCE_CONTEXT_CONFIG') + if not isinstance(c.entity_tables,(list,tuple)) or len(c.entity_tables)>20: + raise ToolError('PROJECT_SOURCE_CONTEXT_CONFIG') + for rule in c.entity_tables: + if not isinstance(rule,dict) or set(rule)!={'table','entity_type','id_column','script_columns'}: + raise ToolError('PROJECT_SOURCE_CONTEXT_CONFIG') + if not isinstance(rule['script_columns'],list) or len(rule['script_columns'])>5: + raise ToolError('PROJECT_SOURCE_CONTEXT_CONFIG') + for value in [rule['table'],rule['entity_type'],rule['id_column'],*rule['script_columns']]: + if not isinstance(value,str) or not re.fullmatch(r'[A-Za-z_][A-Za-z0-9_]{0,63}',value): + raise ToolError('PROJECT_SOURCE_CONTEXT_CONFIG') + if not isinstance(c.location_paths,dict) or len(c.location_paths)>300: + raise ToolError('PROJECT_SOURCE_CONTEXT_CONFIG') + for name,paths in c.location_paths.items(): + if not isinstance(name,str) or not 1<=len(name)<=100 or not isinstance(paths,list) or len(paths)>8: + raise ToolError('PROJECT_SOURCE_CONTEXT_CONFIG') + for path in paths:safe_path(path) + + +def case_seeds(context,args,config): + """Only canonical records and operator-typed DB columns become extra anchors.""" + from viano_bug_analysis.case.contracts import read_json + reader=context.reader + ref=reader.manifest['artifacts'].get('interpretation_context') + interpreted=read_json(reader,ref,'case-interpretation-context',4*1024*1024) if ref else None + sources=interpreted['sources'] if interpreted else {} + entities={sid:entry['data'] for sid,entry in sources.items() if entry.get('kind')=='entity'} + selected=args.get('entity_refs',[]) + if any(sid not in entities for sid in selected):raise ToolError('PROJECT_SOURCE_ENTITY_REF') + def names(e): + values=[e.get('name')]+[v.get('name') for v in e.get('name_bindings',[])] + return [v for v in values if isinstance(v,str) and v.strip()] + if not selected: + selected=[sid for sid,e in entities.items() if str(e.get('id'))==args['text'] + or args['text'].casefold() in [n.casefold() for n in names(e)]] + ambiguous=not args.get('entity_refs') and len({entities[sid].get('type') for sid in selected})>1 + # Related explicit focus is context, never a replacement for the affected entity. + selected=list(dict.fromkeys(selected+[sid for sid,e in entities.items() + if set(e.get('roles',[])) & set(config.focus_roles)])) + omitted_entities=max(0,len(selected)-8);selected=selected[:8] + anchors=[] + def add(term,kind,group,source_ref,pointer,**extra): + if type(term) in (str,int) and str(term).strip() and len(str(term))<=200: + anchors.append({'term':str(term),'kind':kind,'group':group,'source_ref':source_ref, + 'json_pointer':pointer,**extra}) + for sid in selected: + e=entities[sid];extra={'entity_type':None if ambiguous else e.get('type'), + 'entity_id':str(e.get('id')),'roles':e.get('roles',[])} + add(e.get('id'),'id',sid+':id',ref,'/sources/'+sid+'/data/id',**extra) + for name in names(e): + add(name,'name',sid+':name',ref,'/sources/'+sid+'/data',**extra) + snake=re.sub(r'[^A-Za-z0-9]+','_',name).strip('_') + if snake and snake!=name:add(snake,'name',sid+':name',ref,'/sources/'+sid+'/data',**extra) + facts=context.case.get('runtime_facts',{}).get('facts',[]) + for index,fact in enumerate(facts): + if fact.get('name') in config.context_fact_names: + add(fact.get('value'),'context','fact:'+fact['id'],context.case_ref, + '/runtime_facts/facts/'+str(index)+'/value') + value=fact.get('value',{}) + if fact.get('name')!='project_query' or not isinstance(value,dict):continue + capture_ref=value.get('capture_ref') + if capture_ref not in reader.files:continue + captured=read_json(reader,capture_ref,'project-capture',1024*1024) + if captured.get('query')!=value.get('query'):continue + for rule in config.entity_tables: + if value.get('query',{}).get('kind')!='database.rows' or value['query'].get('table')!=rule['table']:continue + for row_index,item in enumerate(captured.get('result',{}).get('items',[])): + row=item.get('row',{}) + if not any(e.get('type')==rule['entity_type'] and str(e.get('id'))==str(row.get(rule['id_column'])) for e in [entities[s] for s in selected]):continue + for column in rule['script_columns']: + script=row.get(column) + if isinstance(script,str) and re.fullmatch(r'[A-Za-z_][A-Za-z0-9_]{2,199}',script): + add(script,'script','script:'+script,capture_ref, + '/result/items/'+str(row_index)+'/row/'+column, + entity_type=rule['entity_type']) + priority={'script':0,'name':1,'context':2,'id':3} + anchors.sort(key=lambda a:(priority[a['kind']],a['group'],a['term'])) + return {'case_ref':context.case_ref,'case_sha256':hashlib.sha256(json_bytes(context.case)).hexdigest(), + 'interpretation_context_ref':ref, + 'interpretation_context_sha256':hashlib.sha256(json_bytes(interpreted)).hexdigest() if interpreted else None, + 'entity_refs':selected,'ambiguous_identity':ambiguous,'anchors':anchors[:config.max_seed_terms], + 'omitted_anchors':max(0,len(anchors)-config.max_seed_terms),'omitted_entities':omitted_entities} + + +def _prefixes(config,args): + scope=args.get('scope','code');requested=args['path_prefix'] + configured={'code':config.code_prefixes,'schema':config.schema_prefixes, + 'historical_sql':config.historical_prefixes}[scope] + if scope=='historical_sql' and not configured:raise ToolError('PROJECT_SOURCE_SCOPE_UNAVAILABLE') + effective=[] + for prefix in configured or ['']: + if not requested:effective.append(prefix) + elif requested.startswith(prefix):effective.append(requested) + elif prefix.startswith(requested):effective.append(prefix) + if not effective:raise ToolError('PROJECT_SOURCE_SCOPE_CONFLICT') + return list(dict.fromkeys(effective)) + + +def classify(seed,text,config): + """Scores order candidates, never express confidence in the bug cause.""" + if not seed['term'].isdigit(): + if seed['kind']=='script':return 100,'exact_script_anchor',True + if seed['kind']=='symbol':return 60,'requested_symbol',True + if seed['kind']=='registration':return 40,'registration_of_selected_candidate',True + if seed['kind']=='bound_symbol':return 50,'symbol_from_typed_binding_candidate',True + return (25 if seed['kind']=='name' else 15),seed['kind']+'_context',False + match=re.search(r'\b([A-Za-z_][A-Za-z0-9_]*)\s*=\s*'+re.escape(seed['term'])+r'(?:[uUlL]{0,3})(?![\w.])',text) + if match: + types={typ for typ,markers in config.type_markers.items() if any( + re.search(r'(?:^|_)'+re.escape(marker.upper())+r'(?:_|$)',match[1].upper()) for marker in markers)} + if seed.get('entity_type') and types=={seed['entity_type']}: + return 50,'typed_identifier_binding_candidate',True + if types:return 0,'different_or_ambiguous_identifier_namespace',False + return 0,'unbound_numeric_occurrence',False + + +def analyze(analysis,args,seed_context=None): + analysis.validate(args) + config,provider=analysis.config,analysis.provider + if args.get('entity_refs') and seed_context is None:raise ToolError('PROJECT_SOURCE_CONTEXT_REQUIRED') + seed_context=seed_context or {'anchors':[],'omitted_anchors':0} + seeds=copy.deepcopy(seed_context['anchors']) + root={'term':args['text'],'kind':'id' if args['text'].isdigit() else 'symbol', + 'group':'requested_term','source_ref':None,'json_pointer':None} + matches=[s for s in seeds if s['term'].casefold()==args['text'].casefold() and s['kind'] in {'script','name','context'}] + if matches and not args['text'].isdigit(): + match=min(matches,key=lambda s:{'script':0,'name':1,'context':2}[s['kind']]) + root={**match,'term':args['text']} + # The root numeric seed may inherit a unique verified namespace, not a guessed one. + types={s.get('entity_type') for s in seeds if s['term']==args['text'] and s.get('entity_type')} + if len(types)==1:root['entity_type']=next(iter(types)) + seeds=[root]+[s for s in seeds if not (s['term']==root['term'] and s['kind']=='id')] + unique={} + for seed in seeds:unique.setdefault((seed['term'].casefold(),seed['kind'],seed['group']),seed) + seeds=list(unique.values()) + def priority(s): + if s['kind']=='script':return 0 + if s['kind']=='symbol':return 1 + if s['kind']=='name' and not set(s.get('roles',[])) & set(config.focus_roles):return 1 + if s['group']=='requested_term':return 2 + return {'context':3,'name':4,'id':5}.get(s['kind'],6) + seeds.sort(key=priority) + if len(seeds)>config.max_seed_terms:seed_context={**seed_context,'omitted_anchors':seed_context.get('omitted_anchors',0)+len(seeds)-config.max_seed_terms} + seeds=seeds[:config.max_seed_terms] + if root not in seeds: + seeds=seeds[:config.max_seed_terms-1]+[root] + prefixes=_prefixes(config,args);scope=args.get('scope','code') + extensions=[e for e in config.extensions if e.lower()!='sql'] if scope=='code' else ['sql'] + deadline=time.monotonic()+provider.limits.timeout_seconds + probe=copy.copy(provider);searches=[];hits={};limits=set();pending=list(seeds);seen=set() + queued={s['term'].casefold() for s in seeds};content_cache={};input_bytes=0 + if seed_context.get('omitted_anchors') or seed_context.get('omitted_entities'):limits.add('seed_limit') + def tick(): + seconds=deadline-time.monotonic() + if seconds<=0:raise ToolError('PROJECT_SOURCE_TIMEOUT') + probe.limits=replace(provider.limits,timeout_seconds=max(1,math.ceil(seconds)), + max_search_bytes=max(1,provider.limits.max_search_bytes//config.max_searches)) + def content(path): + nonlocal input_bytes + if path not in content_cache: + if len(content_cache)>=config.max_files:return None + tick();text,origin=probe._read(path);input_bytes+=origin['size'] + if input_bytes>provider.limits.max_search_bytes:raise ToolError('PROJECT_SOURCE_INPUT_LIMIT') + content_cache[path]=(text,origin) + return content_cache[path] + def follow(term,kind,seed,path,line): + if term.casefold() in queued:return + if len(queued)>=config.max_seed_terms+config.max_searches*config.max_matches: + limits.add('candidate_limit');return + queued.add(term.casefold()) + derived={'term':term,'kind':kind,'group':seed['group'], + 'source_ref':path,'json_pointer':None,'derived_from_line':line} + if kind=='bound_symbol':pending.insert(0,derived) + else:pending.append(derived) + while pending and len(searches)=config.max_searches:limits.add('followup_search_limit');break + seen.add(key);tick() + query={'kind':'source.search','text':seed['term'],'path_prefix':prefix,'case_sensitive':False, + 'offset':0,'limit':min(config.max_candidate_matches,provider.limits.max_rows), + '_extensions':extensions,'_numeric_token':seed['term'].isdigit(), + '_exclude_prefixes':list(config.historical_prefixes) if scope!='historical_sql' else []} + result=probe.query(query);sid='S'+str(len(searches)+1) + searches.append({'id':sid,'seed':seed,'query':query,'coverage':result['coverage'], + 'truncated':result['truncated'],'returned_matches':len(result['items']), + 'skipped_files':result.get('skipped_files',[])}) + if result['truncated']:limits.add('match_limit') + if result.get('skipped_files'):limits.add('incomplete_search') + for hit in result['items']: + score,reason,strong=classify(seed,hit['text'],config) + key=(hit['path'],hit['line']);record=hits.setdefault(key,{**hit,'signals':[]}) + record['signals'].append({'search_id':sid,'group':seed['group'],'score':score,'reason':reason,'strong':strong}) + if strong and seed['term'].isdigit(): + alias=re.search(r'\b([A-Za-z_][A-Za-z0-9_]*)\s*=\s*'+re.escape(seed['term'])+r'\b',hit['text']) + if alias:follow(alias[1],'bound_symbol',seed,hit['path'],hit['line']) + if strong and seed['kind']!='registration' and not any(seed['term'].startswith(p) for p in config.registration_prefixes): + data=content(hit['path']) + if data: + found=0 + for number,line in enumerate(data[0].splitlines(),1): + if number%256==0:tick() + for marker in config.registration_prefixes: + for match in re.finditer(r'\b('+re.escape(marker)+r'[A-Za-z0-9_]*)\s*\(',line): + follow(match[1],'registration',seed,hit['path'],number);found+=1 + if found>=config.max_matches:limits.add('registration_limit');break + if any((s['term'].casefold(),p) not in seen for s in pending for p in prefixes):limits.add('followup_search_limit') + files={} + for hit in hits.values(): + row=files.setdefault(hit['path'],{'hits':[],'groups':{},'strong':False}) + row['hits'].append(hit) + for signal in hit['signals']: + row['groups'][signal['group']]=max(row['groups'].get(signal['group'],0),signal['score']) + row['strong']|=signal['strong'] + locations={s['term'].casefold() for s in seeds if s['kind']=='context' + and s['term'].casefold() in {name.casefold() for name in config.location_paths}} + for path,row in files.items(): + owners={name.casefold() for name,ps in config.location_paths.items() if any(path.startswith(p) for p in ps)} + row['other_location']=bool(locations and owners and not owners & locations) + row['score']=sum(row['groups'].values()) + row['tier']=('supporting_other_location' if row['other_location'] else 'primary_candidate') if row['strong'] else ('supporting_context' if row['score'] else 'weak_numeric') + ordered=sorted(files,key=lambda p:(files[p]['tier']=='weak_numeric',files[p]['other_location'],-files[p]['score'],p)) + items=[];used_files=used_lines=used_blocks=read_bytes=weak_count=0 + for path in ordered: + row=files[path];first=row['hits'][0] + meta={k:first[k] for k in ('path','git_blob','sha256','size')} + weak=row['tier']=='weak_numeric' + summaries=[] + for hit in sorted(row['hits'],key=lambda h:h['line']): + if weak and weak_count>=config.max_weak_hits:limits.add('weak_summary_limit');break + summaries.append({'line':hit['line'],'text':hit['text'][:240],'signals':hit['signals'], + 'text_truncated':hit.get('text_truncated',False) or len(hit['text'])>240}) + if weak:weak_count+=1 + if not weak: + for summary in summaries:summary.pop('text',None);summary.pop('text_truncated',None) + if weak: + if summaries:items.append({**meta,'tier':row['tier'],'ranking_score':0,'hits':summaries,'blocks':[], + 'notice':'Numeric occurrence has no verified matching identity binding; not expanded or followed.'}) + continue + if used_files>=config.max_files:limits.add('file_limit');continue + data=content(path) + if data is None:limits.add('file_limit');continue + text,origin=data;used_files+=1 + lines=text.splitlines();ranges=[] + for hit in sorted(row['hits'],key=lambda h:h['line']): + start,end=max(1,hit['line']-config.context_lines),min(len(lines),hit['line']+config.context_lines) + if ranges and start<=ranges[-1][1]+1:ranges[-1][1]=max(ranges[-1][1],end) + else:ranges.append([start,end]) + # Registrations are collected only inside a relevant implementation file. + # They do not cause every other registration in a global loader to be expanded. + registration=[] + if not any(s['kind']=='symbol' and any(s['term'].startswith(p) for p in config.registration_prefixes) for s in seeds): + for number,line in enumerate(lines,1): + if number%256==0:tick() + if len(registration)>=config.max_matches:limits.add('registration_limit');break + for prefix in config.registration_prefixes: + if re.search(r'\b'+re.escape(prefix)+r'[A-Za-z0-9_]*\s*\(',line):registration.append(number) + for number in registration:ranges.append([max(1,number-config.context_lines),min(len(lines),number+config.context_lines)]) + merged=[] + for start,end in sorted(ranges): + if merged and start<=merged[-1][1]+1:merged[-1][1]=max(merged[-1][1],end) + else:merged.append([start,end]) + blocks=[] + for start,end in merged: + count=min(end-start+1,config.max_total_lines-used_lines) + if used_blocks>=config.max_blocks:limits.add('block_limit');break + if count<=0:limits.add('line_limit');break + if count2048] + if truncated:limits.add('line_text_limit') + blocks.append({'start_line':start,'lines':[t[:2048] for t in segment],'truncated_lines':truncated}) + used_lines+=count;used_blocks+=1 + items.append({**origin,'tier':row['tier'],'ranking_score':row['score'],'hits':summaries,'blocks':blocks, + 'registration_candidate_lines':registration,'total_lines':len(lines), + 'omitted_context_lines':sum(end-start+1 for start,end in merged)-sum(len(b['lines']) for b in blocks)}) + tick() + result={'items':items,'searches':searches,'limits_reached':sorted(limits), + 'selection':{'scope':scope,'path_prefixes':prefixes,'excluded_prefixes':list(config.historical_prefixes) if scope!='historical_sql' else [], + 'primary_files':[p for p in ordered if files[p]['tier']=='primary_candidate'], + 'supporting_files':[p for p in ordered if files[p]['tier'].startswith('supporting')], + 'weak_numeric_files':sum(files[p]['tier']=='weak_numeric' for p in ordered), + 'notice':'No strong primary candidate in examined results is a valid outcome; it is not global absence.'}, + 'coverage':'partial_configured_source_context' if limits else 'complete_configured_source_context', + 'truncated':bool(limits),'interpretation': + 'Heuristic context candidates only. Numeric frequency and multiple unrelated IDs never establish identity. ' + 'Code/schema/history scopes and lexical token forms are limited; no complete call graph, ' + 'runtime conclusion or global absence proof. Empty primary selection is allowed.'} + if len(json_bytes(result))>provider.limits.max_result_bytes:raise ToolError('PROJECT_RESULT_LIMIT') + return result diff --git a/tests/test_source_relevance.py b/tests/test_source_relevance.py new file mode 100644 index 0000000..9065c14 --- /dev/null +++ b/tests/test_source_relevance.py @@ -0,0 +1,173 @@ +import copy +import json +from pathlib import Path +import subprocess +from types import SimpleNamespace + +import pytest + +from viano_bug_analysis.tools import ToolError +from viano_bug_analysis.tools.project_query import ProjectResearch +from viano_bug_analysis.tools.repository import GitSourceProvider +from viano_bug_analysis.tools.source_relevance import case_seeds +from test_case_builder import case_setup +from test_investigation_tools import tool_case + + +@pytest.fixture +def source(tmp_path): + root=tmp_path/'repo' + for d in ['src','sql/base','sql/old']: (root/d).mkdir(parents=True,exist_ok=True) + (root/'src/a_noise.cpp').write_text('\n'.join('SPELL_NOISE_'+str(i)+' = 77;' for i in range(20))+'\nvoid AddSC_noise() {}\n',encoding='utf-8') + (root/'src/target.cpp').write_text('enum { NPC_TARGET = 77 };\nvoid npc_target() { summon(NPC_TARGET); }\nvoid AddSC_target() { npc_target(); }\n',encoding='utf-8') + (root/'src/loader.cpp').write_text('void AddSC_target();\nvoid load() { AddSC_target(); }\nvoid AddSC_unrelated();\n',encoding='utf-8') + (root/'src/coordinates.cpp').write_text('float x = 77.123;\nint larger = 177;\nint random = 88;\n',encoding='utf-8') + (root/'src/other_area.cpp').write_text('void npc_cross_zone() {}\n',encoding='utf-8') + (root/'src/zone.cpp').write_text('void npc_local() {}\n',encoding='utf-8') + (root/'sql/base/world.sql').write_text('CREATE TABLE target (id int);\n',encoding='utf-8') + (root/'sql/old/noise.sql').write_text('INSERT INTO spell VALUES (77);\n',encoding='utf-8') + def git(*args):return subprocess.run(['git','-C',str(root),*args],check=True,capture_output=True).stdout.decode().strip() + git('init');git('add','.');git('-c','user.name=Test','-c','user.email=test@example.invalid','commit','-m','fixture') + return GitSourceProvider(root,git('rev-parse','HEAD'),'https://example.invalid/repo') + + +def service(source,**settings): + return ProjectResearch({},source=source,source_analysis={'protocol':2,'max_searches':8, + 'code_prefixes':['src/'],'schema_prefixes':['sql/base/'],'historical_prefixes':['sql/old/'], + 'registration_prefixes':['AddSC_'],'type_markers':{'actor':['NPC'],'effect':['SPELL']},**settings}) + + +def query(text='77',**extra):return {'kind':'source.context','text':text,'path_prefix':'',**extra} + + +def seeds(*anchors):return {'anchors':list(anchors),'omitted_anchors':0} + + +def anchor(term,kind='id',group='N0001:id',**extra): + return {'term':term,'kind':kind,'group':group,'source_ref':'case/case.json', + 'json_pointer':'/entities/0','entity_type':'actor',**extra} + + +def test_typed_collision_not_frequency_selects_implementation(source): + r=json.loads(service(source).collect(query(),source_context=seeds(anchor('77'))))['result'] + items={i['path']:i for i in r['items']} + assert items['src/target.cpp']['tier']=='primary_candidate' + assert items['src/a_noise.cpp']['tier']=='weak_numeric' and items['src/a_noise.cpp']['blocks']==[] + assert 'src/coordinates.cpp' not in items + assert all('different_or_ambiguous_identifier_namespace' in [s['reason'] for s in h['signals']] for h in items['src/a_noise.cpp']['hits']) + assert 'AddSC_noise' not in [s['query']['text'] for s in r['searches']] + assert {'NPC_TARGET','AddSC_target'} <= {s['query']['text'] for s in r['searches']} + + +def test_untyped_numbers_do_not_promote_or_expand_even_when_only_hits(source): + r=json.loads(service(source).collect(query()))['result'] + assert r['selection']['primary_files']==[] + assert all(i['tier']=='weak_numeric' and not i['blocks'] for i in r['items']) + assert len(r['searches'])==1 + assert len([h for i in r['items'] for h in i['hits']])<=8 + + +def test_names_variants_are_not_independent_corroboration(source): + r=json.loads(service(source).collect(query('77'),source_context=seeds( + anchor('npc_target','name','same_name'),anchor('NPC_TARGET','name','same_name'))))['result'] + item=next(i for i in r['items'] if i['path']=='src/target.cpp') + assert item['tier']=='supporting_context' and item['ranking_score']==25 + r=json.loads(service(source).collect(query('npc_target'),source_context=seeds( + anchor('npc_target','name','same_name'))))['result'] + assert r['selection']['primary_files']==[] + + +def test_known_other_area_remains_usable_context(source): + r=json.loads(service(source,location_paths={'Home':['src/zone.cpp'],'Elsewhere':['src/other_area.cpp']}).collect( + query('npc_local'),source_context=seeds(anchor('npc_cross_zone','script','script'), + anchor('Home','context','place'))))['result'] + items={i['path']:i for i in r['items']} + assert items['src/other_area.cpp']['tier']=='supporting_other_location' + assert items['src/other_area.cpp']['blocks'] + assert 'src/other_area.cpp' in r['selection']['supporting_files'] + + +def test_scopes_do_not_silently_mix_code_schema_and_history(source): + s=service(source) + assert not any(i['path'].endswith('.sql') for i in json.loads(s.collect(query()))['result']['items']) + r=json.loads(s.collect(query('CREATE TABLE',scope='schema')))['result'] + assert [i['path'] for i in r['items']]==['sql/base/world.sql'] + r=json.loads(s.collect(query(scope='historical_sql')))['result'] + assert [i['path'] for i in r['items']]==['sql/old/noise.sql'] + with pytest.raises(ToolError,match='SCOPE_CONFLICT'):s.collect(query(path_prefix='sql/old/')) + with pytest.raises(ToolError):s.collect(query(scope='all')) + + +def test_new_args_require_a_case_and_do_not_accept_model_fabricated_anchors(source): + s=service(source) + with pytest.raises(ToolError,match='CONTEXT_REQUIRED'):s.collect(query(entity_refs=['N0001'])) + with pytest.raises(ToolError):s.collect({**query(),'anchors':[anchor('npc_fake','script')]}) + assert ProjectResearch({},source=source,source_analysis={}).source_context.catalog()['version']==1 + with pytest.raises(ToolError):ProjectResearch({},source=source,source_analysis={'code_prefixes':['src/']}) + + +def test_selection_limits_are_explicit_and_source_worktree_is_ignored(source): + (source.path/'src/target.cpp').write_text('wrong mutable worktree\n',encoding='utf-8') + r=json.loads(service(source,max_total_lines=1).collect(query(),source_context=seeds(anchor('77'))))['result'] + assert r['truncated'] and 'line_limit' in r['limits_reached'] + assert any(i.get('omitted_context_lines',0)>0 for i in r['items']) + assert 'wrong mutable' not in json.dumps(r) + + +@pytest.mark.parametrize('settings',[{'type_markers':{'actor':['.*']}},{'protocol':True}, + {'code_prefixes':['../escape']},{'max_seed_terms':0},{'location_paths':{'Home':['C:/bad']}}]) +def test_invalid_adapter_rules_fail_controlled(source,settings): + with pytest.raises(ToolError):service(source,**settings) + + +def test_case_collector_names_roles_scripts_and_provenance(source,monkeypatch): + import viano_bug_analysis.case.contracts as contracts + sources={'N0001':{'kind':'entity','data':{'type':'actor','id':'77','name':'Target actor','roles':['affected_subject']}}, + 'N0002':{'kind':'entity','data':{'type':'effect','id':'88','name':'Related quest','roles':['selected_focus']}}} + captured={'query':{'kind':'database.rows','table':'actors'},'result':{'items':[{'row':{'entry':77,'ScriptName':'npc_target'}}]}} + fact={'id':'F0001','name':'project_query','value':{'capture_ref':'research/capture.json',**captured}} + case={'runtime_facts':{'facts':[fact,{'id':'F0002','name':'location_name','value':'Home'}]}} + ctx=SimpleNamespace(case=case,case_ref='case/case.json',reader=SimpleNamespace( + manifest={'artifacts':{'interpretation_context':'case/context.json'}},files=['research/capture.json'])) + monkeypatch.setattr(contracts,'read_json',lambda r,ref,*args: {'sources':sources} if ref=='case/context.json' else captured) + s=service(source,focus_roles=['selected_focus'],context_fact_names=['location_name'],entity_tables=[ + {'table':'actors','entity_type':'actor','id_column':'entry','script_columns':['ScriptName']}]) + c=case_seeds(ctx,query(),s.source_context.config) + assert c['entity_refs']==['N0001','N0002'] + assert any(a['term']=='npc_target' and a['source_ref']=='research/capture.json' for a in c['anchors']) + assert any(a['term']=='Home' for a in c['anchors']) + assert all(a['source_ref'] and a['json_pointer'] for a in c['anchors']) + with pytest.raises(ToolError,match='ENTITY_REF'):case_seeds(ctx,query(entity_refs=['N9999']),s.source_context.config) + + +def test_ambiguous_id_does_not_inherit_a_namespace(source,monkeypatch): + import viano_bug_analysis.case.contracts as contracts + sources={sid:{'kind':'entity','data':{'type':typ,'id':'77','roles':[]}} + for sid,typ in [('N0001','actor'),('N0002','effect')]} + monkeypatch.setattr(contracts,'read_json',lambda *args:{'sources':sources}) + ctx=SimpleNamespace(case={},case_ref='case/case.json',reader=SimpleNamespace( + manifest={'artifacts':{'interpretation_context':'case/context.json'}})) + s=service(source);c=case_seeds(ctx,query(),s.source_context.config) + assert c['ambiguous_identity'] and all(a.get('entity_type') is None for a in c['anchors']) + r=json.loads(s.collect(query(),source_context=c))['result'] + assert r['selection']['primary_files']==[] + + +def test_v2_fixture_loop_captures_case_seeds_and_resumes(source,tool_case,tmp_path): + from test_case_interpreter import gateway + from test_investigation_hybrid import HybridAdapter + from test_investigator import run + from viano_bug_analysis.analysis import Investigator,InvestigationConfig + from viano_bug_analysis.tools import InvestigationToolRouter + from viano_bug_analysis.package import PackageReader,PackageValidator + from viano_bug_analysis.lifecycle import Workspace,CheckpointStore + s=service(source) + with PackageReader(tool_case[1]) as reader:s.project=reader.manifest['project'] + adapter=HybridAdapter();adapter.bootstrap.change=lambda r,c:r.update(next={'kind':'tool','operation':'project.query','arguments':query('npc_target')}) + router=InvestigationToolRouter(project_research=s) + first=run(Investigator(tool_case[0],gateway(adapter),InvestigationConfig(lean_loop=True,lean_protocol=2,lean_checkpoint_steps=0),router=router),steps=3) + portable=CheckpointStore(Workspace(tmp_path/'portable'));portable.intake(first.package) + final=run(Investigator(portable,gateway(adapter),router=router)) + assert final.status=='COMPLETED' and PackageValidator().validate(final.package).valid + from viano_bug_analysis.llm.cache_measurement import measure_package + assert measure_package(final.package)['all_exactly_reconstructed']