Add bounded source context analysis with lexical follow-up searches
This commit is contained in:
1 parent
4b9f263627
commit
20d191bad6
13 files changed
+656
-9
No files matched your search
@@ -234,3 +234,14 @@ table = "creature_template_locale"
|
|||||||
id_column = "entry"
|
id_column = "entry"
|
||||||
name_column = "Name"
|
name_column = "Name"
|
||||||
columns = ["entry", "locale", "Name", "Title"]
|
columns = ["entry", "locale", "Name", "Title"]
|
||||||
|
|
||||||
|
# Larger read-only source tool; registration naming is project-specific.
|
||||||
|
[source_analysis]
|
||||||
|
enabled = true
|
||||||
|
context_lines = 8
|
||||||
|
max_searches = 4
|
||||||
|
max_matches = 12
|
||||||
|
max_files = 8
|
||||||
|
max_blocks = 12
|
||||||
|
max_total_lines = 160
|
||||||
|
registration_prefixes = ["AddSC_"]
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
# Source-Kontextanalyse v1
|
||||||
|
|
||||||
|
`project.query` kann mehrere zusammenhängende Suchen und Kontextabfragen bündeln:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"kind":"source.context","text":"23487","path_prefix":"src/server/"}
|
||||||
|
```
|
||||||
|
|
||||||
|
Das Werkzeug durchsucht ausschließlich Git-Objekte am vom Betreiber festgelegten
|
||||||
|
Commit. Arbeitskopie, Branchwahl, Remote und Git-Optionen sind nicht durch die LLM
|
||||||
|
änderbar. Ein vorhandener lokaler Checkout wird gelesen; dieses Werkzeug führt
|
||||||
|
weder Fetch noch Build oder Sourceänderungen aus.
|
||||||
|
|
||||||
|
## Ablauf und Ergebnis
|
||||||
|
|
||||||
|
1. Ausgangsbegriff suchen; gültige Bezeichner werden als ganze Wörter gesucht,
|
||||||
|
andere Begriffe als feste, groß-/kleinschreibungssensitive Zeichenfolge.
|
||||||
|
2. Begrenzt Kandidaten verfolgen: Bezeichner links einer einfachen Zuweisung des
|
||||||
|
Suchbegriffs sowie Funktionsnamen mit konfigurierten Registrierungsprefixen in
|
||||||
|
gefundenen Dateien. Das ist eine lexikalische Heuristik, keine Typauflösung.
|
||||||
|
3. Kontextfenster um Treffer und Registrierungskandidaten lesen. Überlappende oder
|
||||||
|
angrenzende Fenster innerhalb derselben Datei zusammenführen.
|
||||||
|
|
||||||
|
Dateipfad, Git-Blob, SHA-256 und Dateigröße werden pro Datei einmal angegeben.
|
||||||
|
Die Ausschnitte bestehen aus `start_line` und einer Liste einfacher Codezeilen.
|
||||||
|
Treffer enthalten Suchreferenzen und eine Angabe, ob ihr Kontext zurückgegeben
|
||||||
|
wurde. Pro Suche bleiben Begriff, Bereich, Vollständigkeit und übersprungene
|
||||||
|
Inhalte nachvollziehbar. Weitere Namen in zentralen Loader-Dateien werden nicht
|
||||||
|
allein deshalb weiterverfolgt, weil dort die gesuchte Registrierung vorkommt.
|
||||||
|
|
||||||
|
Das Ergebnis ist Kontext zu möglichen Definitionen, Verweisen und Registrierungen.
|
||||||
|
Es behauptet keinen verifizierten Aufrufgraphen, keine tatsächliche Ausführung und
|
||||||
|
keine Fehlerursache. Insbesondere können identische Zahlen andere Entitäten oder
|
||||||
|
völlig andere Bedeutungen bezeichnen. Das zeigte auch der lokale Probeaufruf für
|
||||||
|
23487: Die Fundstelle lag im Wintergrasp-Code; eine Verbindung zum Wolpertinger
|
||||||
|
wird daraus nicht abgeleitet.
|
||||||
|
|
||||||
|
## Konfiguration
|
||||||
|
|
||||||
|
Das Werkzeug wird im Projektprofil ausdrücklich aktiviert:
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[source_analysis]
|
||||||
|
enabled = true
|
||||||
|
context_lines = 8
|
||||||
|
max_searches = 4
|
||||||
|
max_matches = 12
|
||||||
|
max_files = 8
|
||||||
|
max_blocks = 12
|
||||||
|
max_total_lines = 160
|
||||||
|
registration_prefixes = ["AddSC_"]
|
||||||
|
```
|
||||||
|
|
||||||
|
`AddSC_` ist Adapterkonfiguration für Vcore, kein eingebautes Fachwissen im
|
||||||
|
Kernsystem. Ohne solche Prefixe bleibt die allgemeine Such-/Aliasfunktion nutzbar.
|
||||||
|
`extensions` kann die zugelassenen Textdateiendungen bestimmen; die Defaults
|
||||||
|
enthalten unter anderem C/C++, SQL, Python, JavaScript/TypeScript, C#, Java, Go,
|
||||||
|
Rust und Lua. Für größere Codebasen ist ein gezielter `path_prefix` sinnvoll.
|
||||||
|
|
||||||
|
Die oben genannten Zahlen sind konfigurierbare Sicherheitsdefaults, keine
|
||||||
|
Grenzen des Case-Paketformats. Der gemeinsame Zeitrahmen, Dateigrößen-, Suchbytes-
|
||||||
|
und Ergebnisbytes-Grenzen kommen zusätzlich aus `ProjectLimits`. Interne Suchen
|
||||||
|
erhalten ein begrenztes Teilbudget der Suchbytes. Höchstens die konfigurierte Zahl
|
||||||
|
von Suchen, Dateien, Blöcken und Zeilen wird bearbeitet. Überlange Codezeilen
|
||||||
|
werden gekürzt und mit ihrer ursprünglichen Zeilennummer als gekürzt markiert.
|
||||||
|
Ein überschrittenes Ergebnisbytes-Limit oder ein Timeout erzeugt einen
|
||||||
|
kontrollierten Fehler; es wird kein unmarkiert unvollständiger Capture gespeichert.
|
||||||
|
|
||||||
|
## Vollständigkeit und Grenzen
|
||||||
|
|
||||||
|
`limits_reached` nennt beispielsweise Treffer-, Folgeabfrage-, Datei-, Block- oder
|
||||||
|
Zeilengrenzen. Solche Ergebnisse tragen `partial_configured_source_context`.
|
||||||
|
`complete_configured_source_context` besagt nur, dass die gewählte begrenzte
|
||||||
|
lexikalische Suchkette und ihre Kontextfenster ohne gemeldete Auslassungen
|
||||||
|
verarbeitet wurden. Es bedeutet keine vollständige Implementierungsanalyse.
|
||||||
|
|
||||||
|
Eine nicht gefundene Zahl schließt weder symbolische Konstanten mit anderer
|
||||||
|
Schreibweise, Datenbanksteuerung, generierten Code, andere Dateitypen noch einen
|
||||||
|
dynamischen Pfad aus. Eine Registrierungsfunktion ist ein Kandidat; die LLM muss
|
||||||
|
ihre tatsächliche Rolle anhand des Codes bewerten. Bei unzureichenden Ausschnitten
|
||||||
|
bleiben die bisherigen gezielten Source-Werkzeuge verfügbar.
|
||||||
|
|
||||||
|
## Integration und Prüfung
|
||||||
|
|
||||||
|
Neue Kontexte mit aktiviertem Werkzeug enthalten dessen Vertrag, Einstellungen
|
||||||
|
und die Empfehlung, es vor getrennten Such-/Leseaufrufen zu verwenden. Eine Sperre
|
||||||
|
der Grundwerkzeuge gibt es nicht. Bestehende Kontexte behalten ihren damaligen
|
||||||
|
Vertrag. Der stabile Werkzeugkontext kann vom bestehenden Cache genutzt werden.
|
||||||
|
|
||||||
|
Ein Aufruf ist eine budgetierte `project.query`-Aktion mit begrenzter interner
|
||||||
|
Arbeit, ohne zusätzliche LLM-Zusammenfassung. Captures enthalten die ursprüngliche
|
||||||
|
Anfrage, Commit-Bindung, Einstellungen samt Hash sowie sämtliche zurückgegebenen
|
||||||
|
Codeausschnitte. Sie bleiben als unveränderliche Evidenz im Paket transportierbar.
|
||||||
|
Abweichende Einstellungen gegenüber einem begonnenen Kontext werden abgelehnt.
|
||||||
|
|
||||||
|
Lokale Tests prüfen Commitbindung trotz veränderter Arbeitskopie, Alias- und
|
||||||
|
Registrierungskette, zusammengeführte Fenster, Python-Suche, leere Ergebnisse,
|
||||||
|
Path Traversal, Argumentinjektion, Konfigurationsfehler, Zeit-/Bytegrenzen,
|
||||||
|
gekennzeichnete Auslassungen, Budget und portable Wiederaufnahme. Ein simulierter
|
||||||
|
LLM-Durchlauf prüft die Einbindung in die hybride Schleife. Die opt-in Prüfung am
|
||||||
|
Vcore-Checkout vergleicht Hashes und Zeilen direkt mit den festgelegten Git-Objekten.
|
||||||
|
Die Auswahl und Bewertung durch eine echte LLM bleibt Gegenstand des nächsten
|
||||||
|
regulären Tests; eine Kostenersparnis wird damit noch nicht behauptet.
|
||||||
@@ -18,6 +18,9 @@ def output_schema(context=None, *, strict=False):
|
|||||||
if context and context['tool_schema'].get('x-project-bindings', {}).get('relation_profiles'):
|
if context and context['tool_schema'].get('x-project-bindings', {}).get('relation_profiles'):
|
||||||
from viano_bug_analysis.tools.database_relations import extend_tool_schema
|
from viano_bug_analysis.tools.database_relations import extend_tool_schema
|
||||||
schema['properties']['next'] = extend_tool_schema(schema['properties']['next'])
|
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'])
|
||||||
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'}:
|
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(
|
schema['properties']['update']['properties']['causal_review'] = json.loads(
|
||||||
files('viano_bug_analysis.schemas').joinpath('investigation-causal-review-v1.schema.json').read_text())
|
files('viano_bug_analysis.schemas').joinpath('investigation-causal-review-v1.schema.json').read_text())
|
||||||
|
|||||||
@@ -29,6 +29,9 @@ def _result_status(capture):
|
|||||||
status['completeness']='complete_for_configured_edges_only' if complete and result.get('coverage')=='complete_configured_relations' else 'partial_or_unknown'
|
status['completeness']='complete_for_configured_edges_only' if complete and result.get('coverage')=='complete_configured_relations' else 'partial_or_unknown'
|
||||||
status['nodes']=[{k:node.get(k) for k in ('node','table','returned_rows','complete','upstream_complete','local_truncated')}
|
status['nodes']=[{k:node.get(k) for k in ('node','table','returned_rows','complete','upstream_complete','local_truncated')}
|
||||||
for node in items] if isinstance(items,list) else []
|
for node in items] if isinstance(items,list) else []
|
||||||
|
elif query.get('kind')=='source.context':
|
||||||
|
status['completeness']='complete_for_selected_lexical_context_only' if complete and result.get('coverage')=='complete_configured_source_context' else 'partial_or_unknown'
|
||||||
|
status['limits_reached']=result.get('limits_reached',[])
|
||||||
# Unknown providers and selected source ranges never imply complete absence.
|
# Unknown providers and selected source ranges never imply complete absence.
|
||||||
return status
|
return status
|
||||||
|
|
||||||
|
|||||||
@@ -256,6 +256,11 @@ class Investigator:
|
|||||||
'upstream truncation invalidates downstream absence claims.')
|
'upstream truncation invalidates downstream absence claims.')
|
||||||
items = context['tool_schema']['properties']['requests']['items']
|
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)
|
||||||
|
if service.source_context:
|
||||||
|
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)
|
||||||
else:
|
else:
|
||||||
variants=context["tool_schema"]["properties"]["requests"]["items"]["oneOf"]
|
variants=context["tool_schema"]["properties"]["requests"]["items"]["oneOf"]
|
||||||
context["tool_schema"]["properties"]["requests"]["items"]["oneOf"]=[
|
context["tool_schema"]["properties"]["requests"]["items"]["oneOf"]=[
|
||||||
@@ -583,6 +588,8 @@ class Investigator:
|
|||||||
raise ToolError("PROJECT_BINDING_CHANGED")
|
raise ToolError("PROJECT_BINDING_CHANGED")
|
||||||
if binding.get('relation_profiles') is not None and binding['relation_profiles'] != (service.relations.catalog() if service.relations else None):
|
if binding.get('relation_profiles') is not None and binding['relation_profiles'] != (service.relations.catalog() if service.relations else None):
|
||||||
raise ToolError('PROJECT_BINDING_CHANGED')
|
raise ToolError('PROJECT_BINDING_CHANGED')
|
||||||
|
if binding.get('source_analysis') is not None and binding['source_analysis'] != (service.source_context.catalog() if service.source_context else None):
|
||||||
|
raise ToolError('PROJECT_BINDING_CHANGED')
|
||||||
|
|
||||||
def _invalid(self,state,budget,code,llm_ref,*,feedback=None):
|
def _invalid(self,state,budget,code,llm_ref,*,feedback=None):
|
||||||
active=state["active"];h=state["hypotheses"][active["hypothesis_id"]]
|
active=state["active"];h=state["hypotheses"][active["hypothesis_id"]]
|
||||||
|
|||||||
@@ -709,6 +709,29 @@
|
|||||||
"maxLength": 200
|
"maxLength": 200
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"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
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1710,6 +1710,29 @@
|
|||||||
"maxLength": 200
|
"maxLength": 200
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"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
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -264,6 +264,29 @@
|
|||||||
"maxLength": 200
|
"maxLength": 200
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"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
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
@@ -298,7 +321,9 @@
|
|||||||
"partial_search",
|
"partial_search",
|
||||||
"complete_selected_text_search",
|
"complete_selected_text_search",
|
||||||
"complete_configured_relations",
|
"complete_configured_relations",
|
||||||
"partial_configured_relations"
|
"partial_configured_relations",
|
||||||
|
"complete_configured_source_context",
|
||||||
|
"partial_configured_source_context"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"truncated": {
|
"truncated": {
|
||||||
@@ -348,6 +373,18 @@
|
|||||||
"null"
|
"null"
|
||||||
],
|
],
|
||||||
"minimum": 1
|
"minimum": 1
|
||||||
|
},
|
||||||
|
"searches": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "object"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"limits_reached": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ class ProjectResearch:
|
|||||||
a URL, directory, credentials, database, branch, or arbitrary SQL.
|
a URL, directory, credentials, database, branch, or arbitrary SQL.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, project, *, source=None, database=None, limits=None, relations=None):
|
def __init__(self, project, *, source=None, database=None, limits=None, relations=None, source_analysis=None):
|
||||||
self.project = copy.deepcopy(project)
|
self.project = copy.deepcopy(project)
|
||||||
self.source, self.database = source, database
|
self.source, self.database = source, database
|
||||||
self.limits = limits or ProjectLimits()
|
self.limits = limits or ProjectLimits()
|
||||||
@@ -47,6 +47,10 @@ class ProjectResearch:
|
|||||||
self.relations = DatabaseRelations(database, relations) if relations else None
|
self.relations = DatabaseRelations(database, relations) if relations else None
|
||||||
except (ValueError, TypeError, KeyError):
|
except (ValueError, TypeError, KeyError):
|
||||||
raise ToolError('PROJECT_RELATIONS_CONFIG') from None
|
raise ToolError('PROJECT_RELATIONS_CONFIG') from None
|
||||||
|
from .source_context import SourceContextAnalysis
|
||||||
|
self.source_context = SourceContextAnalysis(source, source_analysis) if source_analysis is not None else None
|
||||||
|
if self.source_context and not self.source_context.config.enabled:
|
||||||
|
self.source_context = None
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def load(cls, path):
|
def load(cls, path):
|
||||||
@@ -55,7 +59,7 @@ class ProjectResearch:
|
|||||||
try:
|
try:
|
||||||
path = Path(path).resolve()
|
path = Path(path).resolve()
|
||||||
config = tomllib.loads(path.read_text(encoding="utf-8"))
|
config = tomllib.loads(path.read_text(encoding="utf-8"))
|
||||||
if set(config) - {"project", "source", "database", "limits", "enrichment", "entity_resolution", "relations", "relations_file"}:
|
if set(config) - {"project", "source", "database", "limits", "enrichment", "entity_resolution", "relations", "relations_file", "source_analysis"}:
|
||||||
raise ValueError()
|
raise ValueError()
|
||||||
limits = ProjectLimits(**config.get("limits", {}))
|
limits = ProjectLimits(**config.get("limits", {}))
|
||||||
source = None
|
source = None
|
||||||
@@ -72,7 +76,8 @@ class ProjectResearch:
|
|||||||
relations = config.get('relations', {})
|
relations = config.get('relations', {})
|
||||||
if 'relations_file' in config:
|
if 'relations_file' in config:
|
||||||
relations = tomllib.loads((path.parent / config['relations_file']).read_text(encoding='utf-8'))['relations']
|
relations = tomllib.loads((path.parent / config['relations_file']).read_text(encoding='utf-8'))['relations']
|
||||||
service = cls(config["project"], source=source, database=database, limits=limits, relations=relations)
|
service = cls(config["project"], source=source, database=database, limits=limits, relations=relations,
|
||||||
|
source_analysis=config.get('source_analysis'))
|
||||||
service.enrichment = copy.deepcopy(config.get("enrichment", {}))
|
service.enrichment = copy.deepcopy(config.get("enrichment", {}))
|
||||||
service.entity_resolution = copy.deepcopy(config.get("entity_resolution", {}))
|
service.entity_resolution = copy.deepcopy(config.get("entity_resolution", {}))
|
||||||
return service
|
return service
|
||||||
@@ -89,7 +94,11 @@ class ProjectResearch:
|
|||||||
|
|
||||||
def query_schema(self):
|
def query_schema(self):
|
||||||
from .database_relations import extend_query_schema
|
from .database_relations import extend_query_schema
|
||||||
return extend_query_schema(self.schema()) if self.relations else self.schema()
|
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)
|
||||||
|
return schema
|
||||||
|
|
||||||
def prepare(self, context, request):
|
def prepare(self, context, request):
|
||||||
self.check_project(context.reader.manifest["project"])
|
self.check_project(context.reader.manifest["project"])
|
||||||
@@ -111,6 +120,9 @@ class ProjectResearch:
|
|||||||
if arguments['kind'] == 'database.relations':
|
if arguments['kind'] == 'database.relations':
|
||||||
self.relations.validate(arguments)
|
self.relations.validate(arguments)
|
||||||
return
|
return
|
||||||
|
if arguments['kind'] == 'source.context':
|
||||||
|
self.source_context.validate(arguments)
|
||||||
|
return
|
||||||
provider.validate(arguments)
|
provider.validate(arguments)
|
||||||
|
|
||||||
def collect(self, arguments):
|
def collect(self, arguments):
|
||||||
@@ -122,12 +134,20 @@ class ProjectResearch:
|
|||||||
'consistency': 'single_repeatable_read_read_only_transaction',
|
'consistency': 'single_repeatable_read_read_only_transaction',
|
||||||
'relation_plan': copy.deepcopy(self.relations.profiles[arguments['profile']]),
|
'relation_plan': copy.deepcopy(self.relations.profiles[arguments['profile']]),
|
||||||
'relation_profile_sha256': self.relations.binding(arguments['profile'])}
|
'relation_profile_sha256': self.relations.binding(arguments['profile'])}
|
||||||
|
if arguments['kind'] == 'source.context':
|
||||||
|
settings = self.source_context.catalog()
|
||||||
|
binding = {**binding, 'source_analysis': settings,
|
||||||
|
'source_analysis_sha256': hashlib.sha256(json_bytes(settings)).hexdigest()}
|
||||||
cache_key = hashlib.sha256(json_bytes({"arguments": arguments, "binding": binding})).hexdigest()
|
cache_key = hashlib.sha256(json_bytes({"arguments": arguments, "binding": binding})).hexdigest()
|
||||||
# A request is frozen for the controller's validation/persistence cycle.
|
# A request is frozen for the controller's validation/persistence cycle.
|
||||||
# Durable resume uses the captured reply in the package, never this cache.
|
# Durable resume uses the captured reply in the package, never this cache.
|
||||||
if cache_key not in self._captures:
|
if cache_key not in self._captures:
|
||||||
result = (self.relations.query(copy.deepcopy(arguments)) if arguments['kind'] == 'database.relations'
|
if arguments['kind'] == 'database.relations':
|
||||||
else provider.query(copy.deepcopy(arguments)))
|
result = self.relations.query(copy.deepcopy(arguments))
|
||||||
|
elif arguments['kind'] == 'source.context':
|
||||||
|
result = self.source_context.query(copy.deepcopy(arguments))
|
||||||
|
else:
|
||||||
|
result = provider.query(copy.deepcopy(arguments))
|
||||||
capture = {"format": "viano-project-capture", "format_version": 1,
|
capture = {"format": "viano-project-capture", "format_version": 1,
|
||||||
"query": copy.deepcopy(arguments), "origin": binding, "result": result}
|
"query": copy.deepcopy(arguments), "origin": binding, "result": result}
|
||||||
from viano_bug_analysis.case.contracts import validate
|
from viano_bug_analysis.case.contracts import validate
|
||||||
|
|||||||
@@ -112,7 +112,7 @@ class GitSourceProvider:
|
|||||||
if prefix != ".":
|
if prefix != ".":
|
||||||
command.append(":(literal)" + prefix)
|
command.append(":(literal)" + prefix)
|
||||||
else:
|
else:
|
||||||
extensions = ["sql"] if args["kind"] == "source.schema" else ["cpp", "h", "hpp", "c", "sql", "txt", "md", "conf", "cmake"]
|
extensions = args.get('_extensions') or (["sql"] if args["kind"] == "source.schema" else ["cpp", "h", "hpp", "c", "sql", "txt", "md", "conf", "cmake"])
|
||||||
command.extend(":(glob)**/*." + ext for ext in extensions)
|
command.extend(":(glob)**/*." + ext for ext in extensions)
|
||||||
stream_queue = queue.Queue(maxsize=2)
|
stream_queue = queue.Queue(maxsize=2)
|
||||||
stop = threading.Event()
|
stop = threading.Event()
|
||||||
@@ -165,6 +165,8 @@ class GitSourceProvider:
|
|||||||
continue
|
continue
|
||||||
if not name.startswith(args["path_prefix"]) or (args["kind"] == "source.schema" and not name.endswith(".sql")):
|
if not name.startswith(args["path_prefix"]) or (args["kind"] == "source.schema" and not name.endswith(".sql")):
|
||||||
continue
|
continue
|
||||||
|
if '_extensions' in args and name.rsplit('.', 1)[-1] not in args['_extensions']:
|
||||||
|
continue
|
||||||
if name not in sources:
|
if name not in sources:
|
||||||
try:
|
try:
|
||||||
_, sources[name] = self._read(name)
|
_, sources[name] = self._read(name)
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ class InvestigationToolRouter:
|
|||||||
if branch['properties']['operation'].get('const') == 'project.query':
|
if branch['properties']['operation'].get('const') == 'project.query':
|
||||||
variants = branch['properties']['arguments']['oneOf']
|
variants = branch['properties']['arguments']['oneOf']
|
||||||
branch['properties']['arguments']['oneOf'] = [v for v in variants
|
branch['properties']['arguments']['oneOf'] = [v for v in variants
|
||||||
if v['properties']['kind'].get('const') != 'database.relations']
|
if v['properties']['kind'].get('const') not in {'database.relations', 'source.context'}]
|
||||||
return schema
|
return schema
|
||||||
|
|
||||||
def query(self, package, batch):
|
def query(self, package, batch):
|
||||||
|
|||||||
@@ -0,0 +1,211 @@
|
|||||||
|
"""Bounded lexical context chains over immutable Git objects, never call graphs."""
|
||||||
|
import copy
|
||||||
|
import math
|
||||||
|
import re
|
||||||
|
import time
|
||||||
|
from dataclasses import asdict, dataclass, replace
|
||||||
|
|
||||||
|
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,
|
||||||
|
'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())
|
||||||
|
return schema
|
||||||
|
|
||||||
|
|
||||||
|
def extend_tool_schema(schema):
|
||||||
|
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'])
|
||||||
|
return schema
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class SourceContextConfig:
|
||||||
|
enabled: bool = True
|
||||||
|
context_lines: int = 8
|
||||||
|
max_searches: int = 4
|
||||||
|
max_matches: int = 12
|
||||||
|
max_files: int = 8
|
||||||
|
max_blocks: int = 12
|
||||||
|
max_total_lines: int = 160
|
||||||
|
registration_prefixes: tuple = ()
|
||||||
|
extensions: tuple = ('cpp', 'h', 'hpp', 'c', 'sql', 'py', 'js', 'ts', 'tsx',
|
||||||
|
'cs', 'go', 'rs', 'lua', 'java', 'cmake', 'txt')
|
||||||
|
|
||||||
|
def __post_init__(self):
|
||||||
|
bounds = {'context_lines': 100, 'max_searches': 12, 'max_matches': 100,
|
||||||
|
'max_files': 30, 'max_blocks': 100, 'max_total_lines': 2000}
|
||||||
|
if type(self.enabled) is not bool:
|
||||||
|
raise ToolError('PROJECT_SOURCE_CONTEXT_CONFIG')
|
||||||
|
for key, maximum in bounds.items():
|
||||||
|
value = getattr(self, key)
|
||||||
|
if type(value) is not int or not 1 <= value <= maximum:
|
||||||
|
raise ToolError('PROJECT_SOURCE_CONTEXT_CONFIG')
|
||||||
|
for values, maximum, pattern in ((self.registration_prefixes, 8, r'[A-Za-z_][A-Za-z0-9_]{0,63}'),
|
||||||
|
(self.extensions, 30, r'[A-Za-z0-9]{1,12}')):
|
||||||
|
if (not isinstance(values, (list, tuple)) or len(values) > maximum
|
||||||
|
or any(not isinstance(v, str) or not re.fullmatch(pattern, v) for v in values)):
|
||||||
|
raise ToolError('PROJECT_SOURCE_CONTEXT_CONFIG')
|
||||||
|
if not self.extensions:
|
||||||
|
raise ToolError('PROJECT_SOURCE_CONTEXT_CONFIG')
|
||||||
|
|
||||||
|
|
||||||
|
class SourceContextAnalysis:
|
||||||
|
def __init__(self, provider, settings):
|
||||||
|
self.provider = provider
|
||||||
|
try:
|
||||||
|
self.config = SourceContextConfig(**settings)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
raise ToolError('PROJECT_SOURCE_CONTEXT_CONFIG') from None
|
||||||
|
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()}
|
||||||
|
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, '
|
||||||
|
'not verified calls, registration semantics or exclusions of dynamic paths.'}
|
||||||
|
|
||||||
|
def validate(self, args):
|
||||||
|
safe_path(args['path_prefix'], empty=True)
|
||||||
|
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):
|
||||||
|
self.validate(args)
|
||||||
|
config, provider = self.config, self.provider
|
||||||
|
deadline = time.monotonic() + provider.limits.timeout_seconds
|
||||||
|
# Independent limits: do not mutate the shared provider during a query.
|
||||||
|
probe = copy.copy(provider)
|
||||||
|
files, anchors, searches, reached = {}, {}, [], set()
|
||||||
|
pending = [(args['text'], 'requested_term', None)]
|
||||||
|
seen, queued, registration_scanned = set(), {args['text']}, set()
|
||||||
|
read_bytes = 0
|
||||||
|
|
||||||
|
def remaining():
|
||||||
|
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 read_file(path):
|
||||||
|
nonlocal read_bytes
|
||||||
|
if path in files:
|
||||||
|
return files[path]
|
||||||
|
if len(files) >= config.max_files:
|
||||||
|
reached.add('file_limit'); return None
|
||||||
|
remaining()
|
||||||
|
text, origin = probe._read(path)
|
||||||
|
read_bytes += origin['size']
|
||||||
|
if read_bytes > provider.limits.max_search_bytes:
|
||||||
|
raise ToolError('PROJECT_SOURCE_INPUT_LIMIT')
|
||||||
|
files[path] = (text.splitlines(), origin)
|
||||||
|
return files[path]
|
||||||
|
|
||||||
|
def candidate(term, reason, parent):
|
||||||
|
if term in seen or term in queued: return
|
||||||
|
if len(queued) >= config.max_searches * config.max_matches:
|
||||||
|
reached.add('candidate_limit'); return
|
||||||
|
queued.add(term); pending.append((term, reason, parent))
|
||||||
|
|
||||||
|
while pending and len(searches) < config.max_searches:
|
||||||
|
term, reason, parent = pending.pop(0)
|
||||||
|
if term in seen:
|
||||||
|
continue
|
||||||
|
seen.add(term); remaining()
|
||||||
|
query = {'kind': 'source.symbol' if re.fullmatch(r'[A-Za-z_][A-Za-z0-9_]*', term) else 'source.search',
|
||||||
|
'text': term, 'path_prefix': args['path_prefix'], 'case_sensitive': True,
|
||||||
|
'offset': 0, 'limit': min(config.max_matches, provider.limits.max_rows),
|
||||||
|
'_extensions': list(config.extensions)}
|
||||||
|
result = probe.query(query)
|
||||||
|
qid = 'S' + str(len(searches) + 1)
|
||||||
|
search = {'id': qid, 'reason': reason, 'parent_search': parent, 'query': query,
|
||||||
|
'coverage': result['coverage'], 'truncated': result['truncated'],
|
||||||
|
'skipped_files': result.get('skipped_files', []),
|
||||||
|
'returned_matches': len(result['items'])}
|
||||||
|
searches.append(search)
|
||||||
|
if result['truncated']: reached.add('match_limit')
|
||||||
|
if result.get('skipped_files') or result['coverage'] != 'complete_selected_text_search':
|
||||||
|
reached.add('incomplete_search')
|
||||||
|
for hit in result['items']:
|
||||||
|
path, line = hit['path'], hit['line']
|
||||||
|
content = read_file(path)
|
||||||
|
if content is None: continue
|
||||||
|
lines, origin = content
|
||||||
|
if not 1 <= line <= len(lines):
|
||||||
|
raise ToolError('PROJECT_SOURCE_RANGE')
|
||||||
|
anchors.setdefault(path, {}).setdefault(line, set()).add(qid)
|
||||||
|
# Equality syntax is a candidate alias, not a semantic identity proof.
|
||||||
|
match = re.search(r'\b([A-Za-z_][A-Za-z0-9_]*)\s*=\s*' + re.escape(term) + r'(?![A-Za-z0-9_])', lines[line-1])
|
||||||
|
if match and match[1] != term:
|
||||||
|
candidate(match[1], 'lexical_assignment_candidate', qid)
|
||||||
|
# Trusted project-specific prefixes select nearby registration context.
|
||||||
|
if (reason == 'lexical_registration_candidate' or path in registration_scanned
|
||||||
|
or any(term.startswith(prefix) for prefix in config.registration_prefixes)): continue
|
||||||
|
registration_scanned.add(path)
|
||||||
|
for number, text in enumerate(lines, 1):
|
||||||
|
if number % 256 == 0 and time.monotonic() >= deadline:
|
||||||
|
raise ToolError('PROJECT_SOURCE_TIMEOUT')
|
||||||
|
if len(anchors.get(path, {})) >= config.max_searches * config.max_matches:
|
||||||
|
reached.add('registration_anchor_limit'); break
|
||||||
|
for prefix in config.registration_prefixes:
|
||||||
|
for found in re.finditer(r'\b(' + re.escape(prefix) + r'[A-Za-z0-9_]*)\s*\(', text):
|
||||||
|
symbol = found[1]
|
||||||
|
anchors.setdefault(path, {}).setdefault(number, set()).update({qid, 'registration_candidate'})
|
||||||
|
if symbol != term: candidate(symbol, 'lexical_registration_candidate', qid)
|
||||||
|
if any(term not in seen for term, _, _ in pending): reached.add('followup_search_limit')
|
||||||
|
|
||||||
|
items, used_lines, used_blocks = [], 0, 0
|
||||||
|
for path, (lines, origin) in files.items():
|
||||||
|
ranges = []
|
||||||
|
for line in sorted(anchors.get(path, {})):
|
||||||
|
start, end = max(1, line-config.context_lines), min(len(lines), 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])
|
||||||
|
blocks = []
|
||||||
|
for start, end in ranges:
|
||||||
|
if used_blocks >= config.max_blocks: reached.add('block_limit'); break
|
||||||
|
count = min(end-start+1, config.max_total_lines-used_lines)
|
||||||
|
if count <= 0: reached.add('line_limit'); break
|
||||||
|
if count < end-start+1: reached.add('line_limit')
|
||||||
|
selected = lines[start-1:start-1+count]
|
||||||
|
truncated = [start+i for i, text in enumerate(selected) if len(text) > 2048]
|
||||||
|
if truncated: reached.add('line_text_limit')
|
||||||
|
blocks.append({'start_line': start, 'lines': [text[:2048] for text in selected],
|
||||||
|
'truncated_lines': truncated})
|
||||||
|
used_lines += count; used_blocks += 1
|
||||||
|
visible = {start+i for b in blocks for start in [b['start_line']] for i in range(len(b['lines']))}
|
||||||
|
selected_anchors = [{'line': line, 'search_ids': sorted(ids-{'registration_candidate'}),
|
||||||
|
'registration_candidate': 'registration_candidate' in ids, 'context_returned': line in visible}
|
||||||
|
for line, ids in sorted(anchors.get(path, {}).items())]
|
||||||
|
omitted = sum(not a['context_returned'] for a in selected_anchors)
|
||||||
|
if omitted: reached.add('omitted_anchors')
|
||||||
|
if blocks:
|
||||||
|
items.append({**origin, 'total_lines': len(lines), 'blocks': blocks,
|
||||||
|
'anchors': selected_anchors, 'omitted_anchors': omitted})
|
||||||
|
remaining()
|
||||||
|
result = {'items': items, 'searches': searches, 'limits_reached': sorted(reached),
|
||||||
|
'coverage': 'partial_configured_source_context' if reached else 'complete_configured_source_context',
|
||||||
|
'truncated': bool(reached),
|
||||||
|
'interpretation': 'Selected lexical searches and bounded context windows at the configured commit only. '
|
||||||
|
'Assignments and registration names are candidates, not verified semantic relationships. '
|
||||||
|
'No complete call graph, runtime observation, root cause or global absence claim.'}
|
||||||
|
if len(json_bytes(result)) > provider.limits.max_result_bytes:
|
||||||
|
raise ToolError('PROJECT_RESULT_LIMIT')
|
||||||
|
return result
|
||||||
@@ -0,0 +1,204 @@
|
|||||||
|
import copy
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
import subprocess
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from jsonschema import Draft202012Validator
|
||||||
|
|
||||||
|
from viano_bug_analysis.tools import ToolError
|
||||||
|
from viano_bug_analysis.tools.project_query import ProjectResearch, ProjectLimits
|
||||||
|
from viano_bug_analysis.tools.repository import GitSourceProvider
|
||||||
|
from test_case_builder import case_setup
|
||||||
|
from test_investigation_tools import tool_case
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def source(tmp_path):
|
||||||
|
root=tmp_path/'source';(root/'src').mkdir(parents=True)
|
||||||
|
(root/'src/events.cpp').write_text('enum Ids {\nNPC_WILD = 23487,\n};\nvoid spawn() {\nsummon(NPC_WILD);\n}\nvoid AddSC_events() {\nregister_handler("npc_wild",spawn);\n}\n',encoding='utf-8')
|
||||||
|
(root/'src/loader.cpp').write_text('void AddSC_events();\nvoid load() { AddSC_events(); }\nvoid AddSC_unrelated();\n',encoding='utf-8')
|
||||||
|
(root/'src/unrelated.cpp').write_text('int NPC_WILD_LONGER;\n',encoding='utf-8')
|
||||||
|
(root/'src/module.py').write_text('def activate():\n return "python context"\n\nactivate()\n',encoding='utf-8')
|
||||||
|
(root/'src/long.cpp').write_text('LONG_TARGET '+('x'*3000)+'\n',encoding='utf-8')
|
||||||
|
(root/'src/gaps.cpp').write_text('GAPPED\n'+('\n'*25)+'GAPPED\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')
|
||||||
|
commit=git('rev-parse','HEAD')
|
||||||
|
(root/'src/events.cpp').write_text('mutable worktree content must be ignored\n',encoding='utf-8')
|
||||||
|
return GitSourceProvider(root,commit,'https://example.invalid/repo')
|
||||||
|
|
||||||
|
|
||||||
|
def service(source,**settings):
|
||||||
|
return ProjectResearch({},source=source,source_analysis={'registration_prefixes':['AddSC_'],**settings})
|
||||||
|
|
||||||
|
|
||||||
|
def query(text='23487',prefix='src/'):
|
||||||
|
return {'kind':'source.context','text':text,'path_prefix':prefix}
|
||||||
|
|
||||||
|
|
||||||
|
def test_chain_alias_registration_context_is_compact_and_pinned(source):
|
||||||
|
s=service(source);capture=json.loads(s.collect(query()));result=capture['result']
|
||||||
|
assert [x['query']['text'] for x in result['searches']]==['23487','NPC_WILD','AddSC_events']
|
||||||
|
assert not result['truncated']
|
||||||
|
assert {i['path'] for i in result['items']}=={'src/events.cpp','src/loader.cpp'}
|
||||||
|
assert all(len(i['blocks'])==1 for i in result['items'])
|
||||||
|
events=result['items'][0]
|
||||||
|
assert events['blocks'][0]['start_line']==1
|
||||||
|
assert 'NPC_WILD = 23487,' in events['blocks'][0]['lines']
|
||||||
|
assert 'mutable worktree' not in json.dumps(result)
|
||||||
|
assert capture['origin']['commit']==source.commit
|
||||||
|
assert 'not verified semantic' in result['interpretation']
|
||||||
|
assert 'AddSC_unrelated' not in [s['query']['text'] for s in result['searches']]
|
||||||
|
assert s.collect(query())==s.collect(query())
|
||||||
|
|
||||||
|
|
||||||
|
def test_python_and_empty_scoped_search(source):
|
||||||
|
s=service(source)
|
||||||
|
r=json.loads(s.collect(query('activate','')))['result']
|
||||||
|
assert [i['path'] for i in r['items']]==['src/module.py']
|
||||||
|
r=json.loads(s.collect(query('absent','src/module.py')))['result']
|
||||||
|
assert r['items']==[] and not r['truncated']
|
||||||
|
assert 'global absence' in r['interpretation']
|
||||||
|
|
||||||
|
|
||||||
|
def test_registration_name_does_not_expand_unrelated_loader_symbols(source):
|
||||||
|
r=json.loads(service(source).collect(query('AddSC_events')))['result']
|
||||||
|
assert [q['query']['text'] for q in r['searches']]==['AddSC_events']
|
||||||
|
assert not r['truncated']
|
||||||
|
|
||||||
|
|
||||||
|
def test_long_lines_and_missing_blocks_are_explicit(source):
|
||||||
|
r=json.loads(service(source).collect(query('LONG_TARGET')))['result']
|
||||||
|
assert 'line_text_limit' in r['limits_reached']
|
||||||
|
assert r['items'][0]['blocks'][0]['truncated_lines']==[1]
|
||||||
|
assert len(r['items'][0]['blocks'][0]['lines'][0])==2048
|
||||||
|
r=json.loads(service(source,context_lines=1,max_blocks=1).collect(query('GAPPED')))['result']
|
||||||
|
assert {'block_limit','omitted_anchors'} <= set(r['limits_reached'])
|
||||||
|
assert any(not a['context_returned'] for a in r['items'][0]['anchors'])
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize('prefix',['../outside','/etc','C:/outside','.git/','a\\b','src/\x00'])
|
||||||
|
def test_unsafe_paths_rejected_before_git(source,monkeypatch,prefix):
|
||||||
|
s=service(source)
|
||||||
|
monkeypatch.setattr(source,'query',lambda args:pytest.fail('must not access repository'))
|
||||||
|
with pytest.raises(ToolError):s.collect(query(prefix=prefix))
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize('text',['',' ','x\ny','x\x00y','x\ty'])
|
||||||
|
def test_invalid_text_is_controlled(source,text):
|
||||||
|
with pytest.raises(ToolError):service(source).collect(query(text))
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_regex_shell_or_git_options(source):
|
||||||
|
s=service(source)
|
||||||
|
assert json.loads(s.collect(query('.*')))['result']['items']==[]
|
||||||
|
assert json.loads(s.collect(query('--help')))['result']['items']==[]
|
||||||
|
with pytest.raises(ToolError):s.collect({**query(),'command':'git fetch'})
|
||||||
|
with pytest.raises(ToolError):s.collect({**query(),'_extensions':['cpp']})
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize('settings',[{'max_searches':0},{'max_matches':True},{'max_files':1.0},
|
||||||
|
{'registration_prefixes':['.*']},{'extensions':[]},{'extensions':['cpp;bad']},{'unknown':1}])
|
||||||
|
def test_invalid_configuration(source,settings):
|
||||||
|
with pytest.raises(ToolError):service(source,**settings)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize('settings,reason',[({'max_searches':1},'followup_search_limit'),
|
||||||
|
({'max_matches':1},'match_limit'),({'max_files':1},'file_limit'),
|
||||||
|
({'max_total_lines':1},'line_limit')])
|
||||||
|
def test_limits_are_reported_without_claiming_complete(source,settings,reason):
|
||||||
|
r=json.loads(service(source,**settings).collect(query()))['result']
|
||||||
|
assert r['truncated'] and reason in r['limits_reached']
|
||||||
|
assert r['coverage']=='partial_configured_source_context'
|
||||||
|
|
||||||
|
|
||||||
|
def test_nondefault_settings_binding_changes_and_disabled_tool_stays_hidden(source):
|
||||||
|
one=service(source);two=service(source,context_lines=2)
|
||||||
|
assert json.loads(one.collect(query()))['origin']['source_analysis_sha256']!=json.loads(two.collect(query()))['origin']['source_analysis_sha256']
|
||||||
|
disabled=ProjectResearch({},source=source,source_analysis={'enabled':False})
|
||||||
|
assert disabled.query_schema()==ProjectResearch.schema()
|
||||||
|
with pytest.raises(ToolError):disabled.collect(query())
|
||||||
|
|
||||||
|
|
||||||
|
def test_byte_and_time_limits_are_controlled(source,monkeypatch):
|
||||||
|
source.limits=ProjectLimits(max_result_bytes=100)
|
||||||
|
s=service(source)
|
||||||
|
with pytest.raises(ToolError,match='RESULT_LIMIT'):s.collect(query())
|
||||||
|
assert not s._captures
|
||||||
|
import viano_bug_analysis.tools.source_context as module
|
||||||
|
clock=iter([0,100])
|
||||||
|
monkeypatch.setattr(module.time,'monotonic',lambda:next(clock))
|
||||||
|
with pytest.raises(ToolError,match='TIMEOUT'):s.collect(query())
|
||||||
|
|
||||||
|
|
||||||
|
def test_source_context_strict_contract_and_old_replay(source):
|
||||||
|
from test_investigation_lean import context
|
||||||
|
from viano_bug_analysis.analysis.investigation_prompt import output_schema
|
||||||
|
c=context();c.pop('lean_session',None)
|
||||||
|
action={'kind':'tool','operation':'project.query','arguments':query()}
|
||||||
|
assert not Draft202012Validator(output_schema(c,strict=True)['properties']['next']).is_valid(action)
|
||||||
|
c['tool_schema']['x-project-bindings']['source_analysis']=service(source).source_context.catalog()
|
||||||
|
assert Draft202012Validator(output_schema(c,strict=True)['properties']['next']).is_valid(action)
|
||||||
|
|
||||||
|
|
||||||
|
def test_changed_source_analysis_cannot_execute_a_frozen_context(source):
|
||||||
|
from types import SimpleNamespace
|
||||||
|
from viano_bug_analysis.analysis import Investigator
|
||||||
|
s=service(source)
|
||||||
|
c={'tool_schema':{'x-project-bindings':{'source':source.binding(),'database':None,
|
||||||
|
'source_analysis':s.source_context.catalog()}}}
|
||||||
|
loop=Investigator.__new__(Investigator);loop.router=SimpleNamespace(project_research=s)
|
||||||
|
loop._check_project_binding(c)
|
||||||
|
s.source_context=service(source,context_lines=1).source_context
|
||||||
|
with pytest.raises(ToolError,match='BINDING_CHANGED'):loop._check_project_binding(c)
|
||||||
|
|
||||||
|
|
||||||
|
def test_budgeted_evidence_and_portable_hybrid_resume(source,tool_case,tmp_path):
|
||||||
|
from test_investigation_hybrid import HybridAdapter
|
||||||
|
from test_case_interpreter import gateway
|
||||||
|
from test_investigator import run,state
|
||||||
|
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 CheckpointStore,Workspace
|
||||||
|
from viano_bug_analysis.budget import BudgetManager
|
||||||
|
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()})
|
||||||
|
router=InvestigationToolRouter(project_research=s)
|
||||||
|
loop=Investigator(tool_case[0],gateway(adapter),InvestigationConfig(lean_loop=True,lean_protocol=2,lean_checkpoint_steps=0),router=router)
|
||||||
|
first=run(loop,steps=3)
|
||||||
|
assert all(h['iterations']==1 for h in state(first)['hypotheses'].values())
|
||||||
|
assert 'source_analysis' in adapter.calls[0]['input']['tool_schema']['project']
|
||||||
|
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
|
||||||
|
measurement=measure_package(final.package)
|
||||||
|
assert measurement['unmatched_requests']==0 and measurement['all_exactly_reconstructed']
|
||||||
|
budget=BudgetManager(portable).snapshot('case-A','stable-package').state
|
||||||
|
assert all(a['status']=='settled' for h in budget['accounts'].values() for a in h['actions'].values())
|
||||||
|
with PackageReader(final.package) as reader:
|
||||||
|
paths=[p for p in reader.files if p.startswith('analysis/evidence/captures/')]
|
||||||
|
capture=next(json.loads(reader.read_bytes(p)) for p in paths if json.loads(reader.read_bytes(p))['query']['kind']=='source.context')
|
||||||
|
assert capture['result']['items'][0]['git_blob']
|
||||||
|
assert capture['origin']['commit']==source.commit
|
||||||
|
|
||||||
|
|
||||||
|
def test_real_pinned_source_context():
|
||||||
|
import os
|
||||||
|
config=os.environ.get('VBA_TEST_PROJECT_CONFIG')
|
||||||
|
if not config:pytest.skip('Opt-in pinned local source checkout')
|
||||||
|
s=ProjectResearch.load(config)
|
||||||
|
capture=json.loads(s.collect(query('enum UnitFlags','src/server/')))
|
||||||
|
assert capture['origin']['commit']==s.source.commit
|
||||||
|
assert capture['result']['items']
|
||||||
|
for item in capture['result']['items']:
|
||||||
|
text,metadata=s.source._read(item['path'])
|
||||||
|
assert item['git_blob']==metadata['git_blob'] and item['sha256']==metadata['sha256']
|
||||||
|
lines=text.splitlines()
|
||||||
|
for block in item['blocks']:
|
||||||
|
assert block['lines']==[line[:2048] for line in lines[block['start_line']-1:block['start_line']-1+len(block['lines'])]]
|
||||||
Reference in new issue
Block a user