Rank source context using grounded typed anchors and scoped searches
This commit is contained in:
1 parent
de3a37bfd0
commit
af2ee6cb91
13 files changed
+756
-19
No files matched your search
@@ -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"]
|
||||
@@ -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.
|
||||
@@ -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())
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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. '
|
||||
|
||||
@@ -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,}$"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,}$"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -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'(?<![\w.])'+re.escape(args['text'])+r'(?:[uUlL]{0,3})(?![\w.])',text):
|
||||
continue
|
||||
if name not in sources:
|
||||
try:
|
||||
_, sources[name] = self._read(name)
|
||||
|
||||
@@ -3,32 +3,37 @@ import copy
|
||||
import math
|
||||
import re
|
||||
import time
|
||||
from dataclasses import asdict, dataclass, replace
|
||||
from dataclasses import asdict, dataclass, replace, field
|
||||
|
||||
from viano_bug_analysis.lifecycle.storage import json_bytes
|
||||
from .config import ToolError
|
||||
from .repository import safe_path
|
||||
|
||||
|
||||
def request_schema():
|
||||
return {'type': 'object', 'additionalProperties': False,
|
||||
def request_schema(protocol=1):
|
||||
schema = {'type': 'object', 'additionalProperties': False,
|
||||
'required': ['kind', 'text', 'path_prefix'], 'properties': {
|
||||
'kind': {'const': 'source.context'},
|
||||
'text': {'type': 'string', 'minLength': 1, 'maxLength': 200},
|
||||
'path_prefix': {'type': 'string', 'maxLength': 250}}}
|
||||
|
||||
|
||||
def extend_query_schema(schema):
|
||||
schema = copy.deepcopy(schema)
|
||||
schema['oneOf'].append(request_schema())
|
||||
if protocol == 2:
|
||||
schema['properties'].update(scope={'enum': ['code', 'schema', 'historical_sql']},
|
||||
entity_refs={'type':'array','maxItems':4,'uniqueItems':True,
|
||||
'items':{'type':'string','pattern':'^N[0-9]{4,}$'}})
|
||||
return schema
|
||||
|
||||
|
||||
def extend_tool_schema(schema):
|
||||
def extend_query_schema(schema, protocol=1):
|
||||
schema = copy.deepcopy(schema)
|
||||
schema['oneOf'].append(request_schema(protocol))
|
||||
return schema
|
||||
|
||||
|
||||
def extend_tool_schema(schema, protocol=1):
|
||||
schema = copy.deepcopy(schema)
|
||||
for branch in schema.get('oneOf', []):
|
||||
if branch.get('properties', {}).get('operation', {}).get('const') == 'project.query':
|
||||
branch['properties']['arguments'] = extend_query_schema(branch['properties']['arguments'])
|
||||
branch['properties']['arguments'] = extend_query_schema(branch['properties']['arguments'], protocol)
|
||||
return schema
|
||||
|
||||
|
||||
@@ -44,6 +49,18 @@ class SourceContextConfig:
|
||||
registration_prefixes: tuple = ()
|
||||
extensions: tuple = ('cpp', 'h', 'hpp', 'c', 'sql', 'py', 'js', 'ts', 'tsx',
|
||||
'cs', 'go', 'rs', 'lua', 'java', 'cmake', 'txt')
|
||||
protocol: int = 1
|
||||
max_seed_terms: int = 8
|
||||
max_candidate_matches: int = 50
|
||||
max_weak_hits: int = 8
|
||||
code_prefixes: tuple = ()
|
||||
schema_prefixes: tuple = ()
|
||||
historical_prefixes: tuple = ()
|
||||
context_fact_names: tuple = ()
|
||||
focus_roles: tuple = ('diagnostic_focus',)
|
||||
type_markers: dict = field(default_factory=dict)
|
||||
entity_tables: tuple = ()
|
||||
location_paths: dict = field(default_factory=dict)
|
||||
|
||||
def __post_init__(self):
|
||||
bounds = {'context_lines': 100, 'max_searches': 12, 'max_matches': 100,
|
||||
@@ -61,6 +78,8 @@ class SourceContextConfig:
|
||||
raise ToolError('PROJECT_SOURCE_CONTEXT_CONFIG')
|
||||
if not self.extensions:
|
||||
raise ToolError('PROJECT_SOURCE_CONTEXT_CONFIG')
|
||||
from .source_relevance import validate_config
|
||||
validate_config(self)
|
||||
|
||||
|
||||
class SourceContextAnalysis:
|
||||
@@ -70,11 +89,23 @@ class SourceContextAnalysis:
|
||||
self.config = SourceContextConfig(**settings)
|
||||
except (TypeError, ValueError):
|
||||
raise ToolError('PROJECT_SOURCE_CONTEXT_CONFIG') from None
|
||||
if self.config.protocol==1 and set(settings) & {'max_seed_terms','max_candidate_matches','max_weak_hits',
|
||||
'code_prefixes','schema_prefixes','historical_prefixes','context_fact_names','focus_roles',
|
||||
'type_markers','entity_tables','location_paths'}:
|
||||
raise ToolError('PROJECT_SOURCE_CONTEXT_CONFIG')
|
||||
if self.config.enabled and (not callable(getattr(provider, '_read', None)) or not callable(getattr(provider, 'query', None))):
|
||||
raise ToolError('PROJECT_SOURCE_CONTEXT_CONFIG')
|
||||
|
||||
def catalog(self):
|
||||
settings = {k: list(v) if isinstance(v, tuple) else v for k, v in asdict(self.config).items()}
|
||||
if self.config.protocol == 2:
|
||||
return {'version':2,'settings':settings,
|
||||
'preference':'Use source.context with case-grounded entity_refs when available. Default scope=code; '
|
||||
'schema and historical_sql are explicit scopes. Numeric coincidences remain weak summaries; '
|
||||
'ranking is heuristic, not identity, causation or a complete call graph.'}
|
||||
for key in ('protocol','max_seed_terms','max_candidate_matches','max_weak_hits','code_prefixes',
|
||||
'schema_prefixes','historical_prefixes','context_fact_names','focus_roles','type_markers','entity_tables','location_paths'):
|
||||
settings.pop(key)
|
||||
return {'version': 1, 'settings': settings,
|
||||
'preference': 'Prefer source.context for a symbol or entity identifier before separate '
|
||||
'search/read/registration calls. Returned links are lexical candidates, '
|
||||
@@ -85,7 +116,13 @@ class SourceContextAnalysis:
|
||||
if not args['text'].strip() or any(ord(c) < 32 or ord(c) == 127 for c in args['text']):
|
||||
raise ToolError('PROJECT_SOURCE_TEXT')
|
||||
|
||||
def query(self, args):
|
||||
def query(self, args, *, seed_context=None):
|
||||
if self.config.protocol == 2:
|
||||
from .source_relevance import analyze
|
||||
return analyze(self, args, seed_context)
|
||||
return self._query_v1(args)
|
||||
|
||||
def _query_v1(self, args):
|
||||
self.validate(args)
|
||||
config, provider = self.config, self.provider
|
||||
deadline = time.monotonic() + provider.limits.timeout_seconds
|
||||
|
||||
@@ -0,0 +1,325 @@
|
||||
"""v2 source selection: grounded seeds, namespace candidates, no numeric promotion."""
|
||||
import copy
|
||||
import hashlib
|
||||
import math
|
||||
import re
|
||||
import time
|
||||
from dataclasses import replace
|
||||
|
||||
from viano_bug_analysis.lifecycle.storage import json_bytes
|
||||
from .config import ToolError
|
||||
from .repository import safe_path
|
||||
|
||||
|
||||
def validate_config(c):
|
||||
if type(c.protocol) is not int or c.protocol not in {1,2}:
|
||||
raise ToolError('PROJECT_SOURCE_CONTEXT_CONFIG')
|
||||
for key, maximum in [('max_seed_terms',24),('max_candidate_matches',500),('max_weak_hits',50)]:
|
||||
if type(getattr(c,key)) is not int or not 1 <= getattr(c,key) <= maximum:
|
||||
raise ToolError('PROJECT_SOURCE_CONTEXT_CONFIG')
|
||||
for values in [c.code_prefixes,c.schema_prefixes,c.historical_prefixes]:
|
||||
if not isinstance(values,(tuple,list)) or len(values)>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:
|
||||
seed=pending.pop(0)
|
||||
for prefix in prefixes:
|
||||
key=(seed['term'].casefold(),prefix)
|
||||
if key in seen:continue
|
||||
if 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 count<end-start+1:limits.add('line_limit')
|
||||
segment=lines[start-1:start-1+count];truncated=[start+i for i,t in enumerate(segment) if len(t)>2048]
|
||||
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
|
||||
@@ -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']
|
||||
Reference in new issue
Block a user