differix 0.1.0a1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. agents/editor.py +455 -0
  2. agents/researcher.py +405 -0
  3. agents/reviewer.py +374 -0
  4. cli/model_cli.py +430 -0
  5. config.py +557 -0
  6. differix-0.1.0a1.dist-info/METADATA +123 -0
  7. differix-0.1.0a1.dist-info/RECORD +62 -0
  8. differix-0.1.0a1.dist-info/WHEEL +5 -0
  9. differix-0.1.0a1.dist-info/entry_points.txt +2 -0
  10. differix-0.1.0a1.dist-info/top_level.txt +6 -0
  11. main.py +2796 -0
  12. memory/__init__.py +59 -0
  13. memory/embeddings/__init__.py +29 -0
  14. memory/embeddings/factory.py +178 -0
  15. memory/embeddings/providers.py +259 -0
  16. memory/manager.py +186 -0
  17. memory/migrator.py +253 -0
  18. memory/ranker.py +203 -0
  19. memory/schemas.py +127 -0
  20. memory/storage.py +552 -0
  21. tools/agents_chat.py +152 -0
  22. tools/asset_extractor.py +545 -0
  23. tools/asset_manager.py +115 -0
  24. tools/atomic_changeset.py +264 -0
  25. tools/backup_manager.py +122 -0
  26. tools/changelog_engine.py +261 -0
  27. tools/coherence_checker.py +771 -0
  28. tools/compiler.py +531 -0
  29. tools/component_normalizer.py +130 -0
  30. tools/context_assembler.py +631 -0
  31. tools/contract_validator.py +478 -0
  32. tools/cost_predictor.py +141 -0
  33. tools/drift_guard.py +196 -0
  34. tools/file_manager.py +119 -0
  35. tools/handshake.py +351 -0
  36. tools/lesson_manager.py +570 -0
  37. tools/lesson_memory_bridge.py +398 -0
  38. tools/llm_client.py +103 -0
  39. tools/llm_selector.py +362 -0
  40. tools/model_ranker.py +535 -0
  41. tools/palette_extractor.py +1043 -0
  42. tools/plugin_manager.py +204 -0
  43. tools/project_context_gatherer.py +404 -0
  44. tools/project_scanner.py +41 -0
  45. tools/project_structure.py +244 -0
  46. tools/reasoning_logger.py +78 -0
  47. tools/reflection.py +452 -0
  48. tools/sandbox_utils.py +56 -0
  49. tools/security/__init__.py +0 -0
  50. tools/security/behavior_guard.py +83 -0
  51. tools/security/verification_firewall.py +113 -0
  52. tools/security_filter.py +213 -0
  53. tools/session_adr.py +311 -0
  54. tools/skill_manager.py +129 -0
  55. tools/state_manager.py +383 -0
  56. tools/structure_index.py +570 -0
  57. tools/test_schema_consistency.py +19 -0
  58. tools/verification_firewall.py +113 -0
  59. tools/visual_capture.py +65 -0
  60. tools/visual_diff.py +220 -0
  61. tools/visual_reviewer.py +79 -0
  62. tools/web_search.py +371 -0
@@ -0,0 +1,570 @@
1
+ """
2
+ tools/structure_index.py
3
+ -------------------------
4
+ Byte-deterministic AST structure index for the VegeX codebase.
5
+
6
+ Per your directive (Override → Option 1), main.py will call rebuild() on
7
+ every validation step. That means freshness is guaranteed and the cost is
8
+ 20–200ms of local CPU — cheap insurance against an LLM retry caused by a
9
+ stale index.
10
+
11
+ Public API:
12
+ - StructureIndex.rebuild(project_root, deprecations_path=None) -> StructureIndex
13
+ - index.save(path) / StructureIndex.load(path)
14
+ - index.fingerprint -> str # sha256 of all module content, for cache checks
15
+ - index.modules # Dict[module_name, ModuleInfo]
16
+
17
+ Determinism contract: two calls to rebuild() on unchanged source produce
18
+ byte-identical `save()` output *except* for the `generated_at` field. The
19
+ `fingerprint` field ignores `generated_at`, so cache comparisons use it.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import ast
25
+ import hashlib
26
+ import json
27
+ import os
28
+ import sys
29
+ import time
30
+ from dataclasses import dataclass, field, asdict
31
+ from pathlib import Path
32
+ from typing import Any, Dict, List, Optional, Tuple
33
+
34
+ SCHEMA_VERSION = "2.0.0"
35
+
36
+ # Files we never index.
37
+ _IGNORED_DIRS = {
38
+ ".git", ".vegex", "__pycache__", ".pytest_cache", ".mypy_cache",
39
+ ".venv", "venv", "env", "node_modules", "bin", "obj", "build",
40
+ "dist", ".idea", ".vscode", "iteration_logs", "AI_Orchestrator_Backups",
41
+ }
42
+ _IGNORED_FILE_SUFFIXES = (".pyc", ".pyo", ".pyd", ".so", ".dll")
43
+ _IGNORED_FILENAMES = {
44
+ "state.json", "structure_meta.json", "projects_registry.json",
45
+ "skills_config.json", "active_models.json", "models_cache.json",
46
+ ".agent_bindings.json", ".openrouter_models_cache.json",
47
+ }
48
+
49
+
50
+ # ---------------------------------------------------------------------------
51
+ # Data classes
52
+ # ---------------------------------------------------------------------------
53
+ @dataclass
54
+ class ParamInfo:
55
+ name: str
56
+ kind: str # positional_only|positional_or_keyword|keyword_only|var_positional|var_keyword
57
+ annotation: Optional[str] = None
58
+ default: Optional[str] = None
59
+ has_default: bool = False
60
+
61
+
62
+ @dataclass
63
+ class FunctionInfo:
64
+ name: str
65
+ params: List[ParamInfo] = field(default_factory=list)
66
+ returns: Optional[str] = None
67
+ decorators: List[str] = field(default_factory=list)
68
+ is_async: bool = False
69
+ is_static: bool = False
70
+ is_class: bool = False
71
+ is_property: bool = False
72
+ line: int = 0
73
+ has_var_positional: bool = False
74
+ has_var_keyword: bool = False
75
+
76
+ @property
77
+ def positional_params(self) -> List[ParamInfo]:
78
+ return [
79
+ p for p in self.params
80
+ if p.kind in ("positional_only", "positional_or_keyword")
81
+ ]
82
+
83
+ @property
84
+ def keyword_params(self) -> List[ParamInfo]:
85
+ return [
86
+ p for p in self.params
87
+ if p.kind in ("positional_or_keyword", "keyword_only")
88
+ ]
89
+
90
+
91
+ @dataclass
92
+ class ClassInfo:
93
+ name: str
94
+ bases: List[str] = field(default_factory=list)
95
+ methods: Dict[str, FunctionInfo] = field(default_factory=dict)
96
+ line: int = 0
97
+
98
+
99
+ @dataclass
100
+ class ImportInfo:
101
+ module: str = "" # e.g. "memory.storage"
102
+ names: List[str] = field(default_factory=list) # imported symbols
103
+ alias_of_names: Dict[str, str] = field(default_factory=dict) # local alias -> original
104
+ level: int = 0 # 0 = absolute, 1+ = relative
105
+ inside_try: bool = False
106
+ type_checking_only: bool = False
107
+
108
+
109
+ @dataclass
110
+ class ModuleInfo:
111
+ name: str # canonical dotted module name
112
+ file: str # relative path with forward slashes
113
+ content_hash: str
114
+ classes: Dict[str, ClassInfo] = field(default_factory=dict)
115
+ functions: Dict[str, FunctionInfo] = field(default_factory=dict)
116
+ imports: List[ImportInfo] = field(default_factory=list)
117
+ top_level_names: List[str] = field(default_factory=list)
118
+ syntax_error: Optional[str] = None
119
+
120
+
121
+ # ---------------------------------------------------------------------------
122
+ # AST extraction
123
+ # ---------------------------------------------------------------------------
124
+ def _annotation_to_str(node: Optional[ast.AST]) -> Optional[str]:
125
+ if node is None:
126
+ return None
127
+ try:
128
+ return ast.unparse(node)
129
+ except Exception:
130
+ return None
131
+
132
+
133
+ def _extract_params(func: ast.FunctionDef | ast.AsyncFunctionDef) -> Tuple[List[ParamInfo], bool, bool]:
134
+ args = func.args
135
+ out: List[ParamInfo] = []
136
+ has_var_pos = args.vararg is not None
137
+ has_var_kw = args.kwarg is not None
138
+
139
+ # positional-only
140
+ for a in args.posonlyargs:
141
+ out.append(ParamInfo(name=a.arg, kind="positional_only",
142
+ annotation=_annotation_to_str(a.annotation)))
143
+ # positional-or-keyword
144
+ pos_args = list(args.args)
145
+ defaults = list(args.defaults)
146
+ num_defaults = len(defaults)
147
+ for i, a in enumerate(pos_args):
148
+ default_idx = i - (len(pos_args) - num_defaults)
149
+ has_default = default_idx >= 0
150
+ out.append(ParamInfo(
151
+ name=a.arg,
152
+ kind="positional_or_keyword",
153
+ annotation=_annotation_to_str(a.annotation),
154
+ default=_annotation_to_str(defaults[default_idx]) if has_default else None,
155
+ has_default=has_default,
156
+ ))
157
+ # var-positional
158
+ if args.vararg is not None:
159
+ out.append(ParamInfo(name=args.vararg.arg, kind="var_positional",
160
+ annotation=_annotation_to_str(args.vararg.annotation)))
161
+ # keyword-only
162
+ for i, a in enumerate(args.kwonlyargs):
163
+ kw_default = args.kw_defaults[i] if i < len(args.kw_defaults) else None
164
+ out.append(ParamInfo(
165
+ name=a.arg, kind="keyword_only",
166
+ annotation=_annotation_to_str(a.annotation),
167
+ default=_annotation_to_str(kw_default) if kw_default is not None else None,
168
+ has_default=kw_default is not None,
169
+ ))
170
+ # var-keyword
171
+ if args.kwarg is not None:
172
+ out.append(ParamInfo(name=args.kwarg.arg, kind="var_keyword",
173
+ annotation=_annotation_to_str(args.kwarg.annotation)))
174
+
175
+ return out, has_var_pos, has_var_kw
176
+
177
+
178
+ def _extract_decorators(node: ast.FunctionDef | ast.AsyncFunctionDef) -> List[str]:
179
+ return [_annotation_to_str(d) or "" for d in node.decorator_list]
180
+
181
+
182
+ def _extract_function(node, *, is_async: bool, class_context: Optional[str] = None) -> FunctionInfo:
183
+ params, has_vp, has_vk = _extract_params(node)
184
+ decorators = _extract_decorators(node)
185
+ return FunctionInfo(
186
+ name=node.name,
187
+ params=params,
188
+ returns=_annotation_to_str(node.returns),
189
+ decorators=decorators,
190
+ is_async=is_async,
191
+ is_static=any("staticmethod" in d for d in decorators),
192
+ is_class=any("classmethod" in d for d in decorators),
193
+ is_property=any("property" in d for d in decorators),
194
+ line=node.lineno,
195
+ has_var_positional=has_vp,
196
+ has_var_keyword=has_vk,
197
+ )
198
+
199
+
200
+ def _extract_imports(tree: ast.Module) -> List[ImportInfo]:
201
+ out: List[ImportInfo] = []
202
+
203
+ def visit(node: ast.AST, inside_try: bool, type_checking_only: bool):
204
+ for child in ast.iter_child_nodes(node):
205
+ if isinstance(child, ast.Import):
206
+ for alias in child.names:
207
+ info = ImportInfo(
208
+ module=alias.name,
209
+ names=[],
210
+ alias_of_names={alias.asname or alias.name.split(".")[0]: alias.name},
211
+ level=0,
212
+ inside_try=inside_try,
213
+ type_checking_only=type_checking_only,
214
+ )
215
+ out.append(info)
216
+ elif isinstance(child, ast.ImportFrom):
217
+ module = child.module or ""
218
+ names = [a.name for a in child.names]
219
+ alias_map = {(a.asname or a.name): a.name for a in child.names}
220
+ out.append(ImportInfo(
221
+ module=module,
222
+ names=names,
223
+ alias_of_names=alias_map,
224
+ level=child.level or 0,
225
+ inside_try=inside_try,
226
+ type_checking_only=type_checking_only,
227
+ ))
228
+ elif isinstance(child, ast.Try):
229
+ for stmt in child.body:
230
+ visit(stmt, True, type_checking_only)
231
+ for handler in child.handlers:
232
+ for stmt in handler.body:
233
+ visit(stmt, True, type_checking_only)
234
+ for stmt in child.orelse:
235
+ visit(stmt, inside_try, type_checking_only)
236
+ for stmt in child.finalbody:
237
+ visit(stmt, inside_try, type_checking_only)
238
+ elif isinstance(child, ast.If):
239
+ is_type_checking = _is_type_checking_guard(child.test)
240
+ for stmt in child.body:
241
+ visit(stmt, inside_try, type_checking_only or is_type_checking)
242
+ for stmt in child.orelse:
243
+ visit(stmt, inside_try, type_checking_only)
244
+
245
+ visit(tree, False, False)
246
+ return out
247
+
248
+
249
+ def _is_type_checking_guard(test: ast.AST) -> bool:
250
+ if isinstance(test, ast.Name) and test.id == "TYPE_CHECKING":
251
+ return True
252
+ if isinstance(test, ast.Attribute) and test.attr == "TYPE_CHECKING":
253
+ return True
254
+ return False
255
+
256
+
257
+ def _extract_module(
258
+ file_path: Path, rel_path: str, module_name: str
259
+ ) -> ModuleInfo:
260
+ try:
261
+ raw = file_path.read_bytes()
262
+ except Exception as exc:
263
+ return ModuleInfo(
264
+ name=module_name, file=rel_path,
265
+ content_hash="", syntax_error=f"read failed: {exc}",
266
+ )
267
+
268
+ content_hash = hashlib.sha256(raw).hexdigest()
269
+ try:
270
+ source = raw.decode("utf-8")
271
+ except UnicodeDecodeError as exc:
272
+ return ModuleInfo(
273
+ name=module_name, file=rel_path,
274
+ content_hash=content_hash, syntax_error=f"decode failed: {exc}",
275
+ )
276
+
277
+ try:
278
+ tree = ast.parse(source, filename=rel_path)
279
+ except SyntaxError as exc:
280
+ return ModuleInfo(
281
+ name=module_name, file=rel_path,
282
+ content_hash=content_hash,
283
+ syntax_error=f"line {exc.lineno}: {exc.msg}",
284
+ )
285
+
286
+ info = ModuleInfo(name=module_name, file=rel_path, content_hash=content_hash)
287
+
288
+ for node in tree.body:
289
+ if isinstance(node, ast.ClassDef):
290
+ cls = ClassInfo(
291
+ name=node.name,
292
+ bases=[_annotation_to_str(b) or "" for b in node.bases],
293
+ line=node.lineno,
294
+ )
295
+ for item in node.body:
296
+ if isinstance(item, (ast.FunctionDef, ast.AsyncFunctionDef)):
297
+ cls.methods[item.name] = _extract_function(
298
+ item,
299
+ is_async=isinstance(item, ast.AsyncFunctionDef),
300
+ class_context=node.name,
301
+ )
302
+ info.classes[node.name] = cls
303
+ info.top_level_names.append(node.name)
304
+ elif isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
305
+ info.functions[node.name] = _extract_function(
306
+ node, is_async=isinstance(node, ast.AsyncFunctionDef)
307
+ )
308
+ info.top_level_names.append(node.name)
309
+ elif isinstance(node, (ast.Assign, ast.AnnAssign)):
310
+ for target in _assign_targets(node):
311
+ if isinstance(target, ast.Name):
312
+ info.top_level_names.append(target.id)
313
+ elif isinstance(node, (ast.Import, ast.ImportFrom)):
314
+ pass # handled by _extract_imports
315
+
316
+ info.imports = _extract_imports(tree)
317
+ return info
318
+
319
+
320
+ def _assign_targets(node) -> List[ast.AST]:
321
+ targets: List[ast.AST] = []
322
+ if isinstance(node, ast.Assign):
323
+ for t in node.targets:
324
+ if isinstance(t, (ast.Tuple, ast.List)):
325
+ targets.extend(t.elts)
326
+ else:
327
+ targets.append(t)
328
+ elif isinstance(node, ast.AnnAssign):
329
+ targets.append(node.target)
330
+ return targets
331
+
332
+
333
+ # ---------------------------------------------------------------------------
334
+ # Path → module name
335
+ # ---------------------------------------------------------------------------
336
+ def _path_to_module(rel_path: str) -> str:
337
+ p = rel_path.replace("\\", "/")
338
+ if p.endswith(".py"):
339
+ p = p[:-3]
340
+ if p.endswith("/__init__"):
341
+ p = p[: -len("/__init__")]
342
+ return p.replace("/", ".")
343
+
344
+
345
+ # ---------------------------------------------------------------------------
346
+ # StructureIndex
347
+ # ---------------------------------------------------------------------------
348
+ @dataclass
349
+ class StructureIndex:
350
+ schema_version: str = SCHEMA_VERSION
351
+ generated_at: str = ""
352
+ project_root_hash: str = ""
353
+ modules: Dict[str, ModuleInfo] = field(default_factory=dict)
354
+
355
+ # ------------------------------------------------------------------ #
356
+ # Build / load / save
357
+ # ------------------------------------------------------------------ #
358
+ @classmethod
359
+ def rebuild(cls, project_root: str) -> "StructureIndex":
360
+ root = Path(project_root).resolve()
361
+ idx = cls(generated_at=_utc_now_iso())
362
+
363
+ files = list(_iter_python_files(root))
364
+ combined_hasher = hashlib.sha256()
365
+
366
+ for abs_path in sorted(files):
367
+ rel = abs_path.relative_to(root).as_posix()
368
+ module_name = _path_to_module(rel)
369
+ mod_info = _extract_module(abs_path, rel, module_name)
370
+ idx.modules[module_name] = mod_info
371
+ combined_hasher.update(rel.encode("utf-8"))
372
+ combined_hasher.update(b"\0")
373
+ combined_hasher.update(mod_info.content_hash.encode("utf-8"))
374
+ combined_hasher.update(b"\n")
375
+
376
+ idx.project_root_hash = combined_hasher.hexdigest()
377
+ return idx
378
+
379
+ def save(self, path: str) -> None:
380
+ target = Path(path)
381
+ target.parent.mkdir(parents=True, exist_ok=True)
382
+ tmp = target.with_suffix(target.suffix + ".tmp")
383
+ with open(tmp, "w", encoding="utf-8") as f:
384
+ json.dump(self.to_dict(), f, sort_keys=True, indent=2)
385
+ os.replace(tmp, target)
386
+
387
+ @classmethod
388
+ def load(cls, path: str) -> Optional["StructureIndex"]:
389
+ target = Path(path)
390
+ if not target.exists():
391
+ return None
392
+ try:
393
+ with open(target, "r", encoding="utf-8") as f:
394
+ data = json.load(f)
395
+ except Exception:
396
+ return None
397
+ return cls.from_dict(data)
398
+
399
+ # ------------------------------------------------------------------ #
400
+ # Serialization (deterministic)
401
+ # ------------------------------------------------------------------ #
402
+ def to_dict(self) -> Dict[str, Any]:
403
+ return {
404
+ "schema_version": self.schema_version,
405
+ "generated_at": self.generated_at,
406
+ "project_root_hash": self.project_root_hash,
407
+ "fingerprint": self.fingerprint,
408
+ "modules": {
409
+ name: _module_to_dict(mod)
410
+ for name, mod in sorted(self.modules.items())
411
+ },
412
+ }
413
+
414
+ @classmethod
415
+ def from_dict(cls, data: Dict[str, Any]) -> "StructureIndex":
416
+ idx = cls(
417
+ schema_version=data.get("schema_version", SCHEMA_VERSION),
418
+ generated_at=data.get("generated_at", ""),
419
+ project_root_hash=data.get("project_root_hash", ""),
420
+ )
421
+ for name, mod_dict in (data.get("modules") or {}).items():
422
+ idx.modules[name] = _module_from_dict(mod_dict)
423
+ return idx
424
+
425
+ @property
426
+ def fingerprint(self) -> str:
427
+ """
428
+ Fingerprint that ignores generated_at so identical source → identical
429
+ fingerprint. Use this for prompt-cache checks.
430
+ """
431
+ h = hashlib.sha256()
432
+ h.update(self.project_root_hash.encode("utf-8"))
433
+ h.update(b"\0")
434
+ h.update(SCHEMA_VERSION.encode("utf-8"))
435
+ return h.hexdigest()
436
+
437
+
438
+ # ---------------------------------------------------------------------------
439
+ # Deterministic file walking
440
+ # ---------------------------------------------------------------------------
441
+ def _iter_python_files(root: Path):
442
+ for dirpath, dirnames, filenames in os.walk(root):
443
+ dirnames[:] = sorted(
444
+ d for d in dirnames
445
+ if d not in _IGNORED_DIRS and not d.startswith(".")
446
+ )
447
+ for fname in sorted(filenames):
448
+ if not fname.endswith(".py"):
449
+ continue
450
+ if fname in _IGNORED_FILENAMES:
451
+ continue
452
+ if any(fname.endswith(sfx) for sfx in _IGNORED_FILE_SUFFIXES):
453
+ continue
454
+ yield Path(dirpath) / fname
455
+
456
+
457
+ # ---------------------------------------------------------------------------
458
+ # Serialization helpers
459
+ # ---------------------------------------------------------------------------
460
+ def _module_to_dict(mod: ModuleInfo) -> Dict[str, Any]:
461
+ return {
462
+ "file": mod.file,
463
+ "content_hash": mod.content_hash,
464
+ "syntax_error": mod.syntax_error,
465
+ "top_level_names": sorted(set(mod.top_level_names)),
466
+ "classes": {
467
+ name: {
468
+ "name": c.name,
469
+ "bases": c.bases,
470
+ "line": c.line,
471
+ "methods": {mname: _func_to_dict(m) for mname, m in sorted(c.methods.items())},
472
+ }
473
+ for name, c in sorted(mod.classes.items())
474
+ },
475
+ "functions": {
476
+ name: _func_to_dict(f) for name, f in sorted(mod.functions.items())
477
+ },
478
+ "imports": [
479
+ {
480
+ "module": imp.module,
481
+ "names": sorted(imp.names),
482
+ "alias_of_names": dict(sorted(imp.alias_of_names.items())),
483
+ "level": imp.level,
484
+ "inside_try": imp.inside_try,
485
+ "type_checking_only": imp.type_checking_only,
486
+ }
487
+ for imp in mod.imports
488
+ ],
489
+ }
490
+
491
+
492
+ def _module_from_dict(d: Dict[str, Any]) -> ModuleInfo:
493
+ mod = ModuleInfo(
494
+ name=d.get("name", ""),
495
+ file=d.get("file", ""),
496
+ content_hash=d.get("content_hash", ""),
497
+ syntax_error=d.get("syntax_error"),
498
+ top_level_names=list(d.get("top_level_names", [])),
499
+ )
500
+ for name, cd in (d.get("classes") or {}).items():
501
+ cls = ClassInfo(name=cd.get("name", name),
502
+ bases=list(cd.get("bases", [])),
503
+ line=cd.get("line", 0))
504
+ for mname, md in (cd.get("methods") or {}).items():
505
+ cls.methods[mname] = _func_from_dict(md)
506
+ mod.classes[name] = cls
507
+ for name, fd in (d.get("functions") or {}).items():
508
+ mod.functions[name] = _func_from_dict(fd)
509
+ for imp in (d.get("imports") or []):
510
+ mod.imports.append(ImportInfo(
511
+ module=imp.get("module", ""),
512
+ names=list(imp.get("names", [])),
513
+ alias_of_names=dict(imp.get("alias_of_names", {})),
514
+ level=imp.get("level", 0),
515
+ inside_try=imp.get("inside_try", False),
516
+ type_checking_only=imp.get("type_checking_only", False),
517
+ ))
518
+ return mod
519
+
520
+
521
+ def _func_to_dict(f: FunctionInfo) -> Dict[str, Any]:
522
+ return {
523
+ "name": f.name,
524
+ "params": [
525
+ {"name": p.name, "kind": p.kind,
526
+ "annotation": p.annotation, "default": p.default,
527
+ "has_default": p.has_default}
528
+ for p in f.params
529
+ ],
530
+ "returns": f.returns,
531
+ "decorators": f.decorators,
532
+ "is_async": f.is_async,
533
+ "is_static": f.is_static,
534
+ "is_class": f.is_class,
535
+ "is_property": f.is_property,
536
+ "line": f.line,
537
+ "has_var_positional": f.has_var_positional,
538
+ "has_var_keyword": f.has_var_keyword,
539
+ }
540
+
541
+
542
+ def _func_from_dict(d: Dict[str, Any]) -> FunctionInfo:
543
+ return FunctionInfo(
544
+ name=d.get("name", ""),
545
+ params=[ParamInfo(**p) for p in d.get("params", [])],
546
+ returns=d.get("returns"),
547
+ decorators=list(d.get("decorators", [])),
548
+ is_async=d.get("is_async", False),
549
+ is_static=d.get("is_static", False),
550
+ is_class=d.get("is_class", False),
551
+ is_property=d.get("is_property", False),
552
+ line=d.get("line", 0),
553
+ has_var_positional=d.get("has_var_positional", False),
554
+ has_var_keyword=d.get("has_var_keyword", False),
555
+ )
556
+
557
+
558
+ def _utc_now_iso() -> str:
559
+ return time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
560
+
561
+
562
+ __all__ = [
563
+ "StructureIndex",
564
+ "ModuleInfo",
565
+ "ClassInfo",
566
+ "FunctionInfo",
567
+ "ParamInfo",
568
+ "ImportInfo",
569
+ "SCHEMA_VERSION",
570
+ ]
@@ -0,0 +1,19 @@
1
+ import re
2
+ import pytest
3
+ from tools.state_manager import ExecutionControl
4
+
5
+ @pytest.mark.unit
6
+ def test_all_steps_in_main_are_valid_literals():
7
+ # Читаем main.py и ищем все строки вида step == "something"
8
+ with open("main.py", "r", encoding="utf-8") as f:
9
+ main_code = f.read()
10
+
11
+ used_steps = set(re.findall(r'step\s*==\s*"([^"]+)"', main_code))
12
+ # Получаем допустимые шаги из Pydantic Literal
13
+ allowed_steps = set(ExecutionControl.model_fields["current_step"].annotation.__args__)
14
+
15
+ # Игнорируем "done" и "completed", так как они часто обрабатываются вместе
16
+ used_steps = {s for s in used_steps if s not in ("done", "completed")}
17
+
18
+ missing = used_steps - allowed_steps
19
+ assert not missing, f"Steps used in main.py but not in ExecutionControl Literal: {missing}"
@@ -0,0 +1,113 @@
1
+ """
2
+ tools/security/verification_firewall.py
3
+ ----------------------------------------
4
+ Agent self-reports are ADVISORY. Only external signals are AUTHORITATIVE.
5
+
6
+ Problem this solves (August 2026 incident):
7
+ An agent in a loop hallucinated "test passed" and the pipeline believed it,
8
+ because `state.get("compiler_output")` was populated by the agent itself.
9
+ The agent then spent tokens on simulated work instead of real work.
10
+
11
+ Solution:
12
+ build_success MUST come from run_universal_build(), never from state.
13
+ apply_result.ok MUST be verified via fs-hash before/after, not trusted.
14
+ Anything the agent claims about success must be cross-checked.
15
+
16
+ This module provides those cross-checks.
17
+ """
18
+
19
+ import hashlib
20
+ import os
21
+ from typing import Optional
22
+
23
+
24
+ def fs_hash(path: str) -> str:
25
+ """SHA-256 of file content, or empty string if file doesn't exist."""
26
+ if not os.path.exists(path):
27
+ return ""
28
+ try:
29
+ with open(path, "rb") as f:
30
+ return hashlib.sha256(f.read()).hexdigest()
31
+ except OSError:
32
+ return ""
33
+
34
+
35
+ def verify_file_changed(path: str, before_hash: str) -> tuple[bool, str]:
36
+ """
37
+ Verify that the file actually changed after the agent claimed a write.
38
+
39
+ Returns:
40
+ (changed, current_hash)
41
+ """
42
+ after_hash = fs_hash(path)
43
+ changed = (after_hash != before_hash)
44
+ return changed, after_hash
45
+
46
+
47
+ def verify_compiler_output(
48
+ compiler_output_from_state: str,
49
+ actual_compiler_output: str,
50
+ ) -> dict:
51
+ """
52
+ Cross-check what state says vs what the compiler actually said.
53
+
54
+ In a healthy run, these match. In a confused-deputy attack, they diverge.
55
+
56
+ Returns:
57
+ {"match": bool, "state_claimed": str, "actual": str, "tampered": bool}
58
+ """
59
+ # Normalize whitespace for comparison
60
+ state_norm = (compiler_output_from_state or "").strip()
61
+ actual_norm = (actual_compiler_output or "").strip()
62
+
63
+ match = state_norm == actual_norm
64
+ tampered = bool(state_norm) and bool(actual_norm) and not match
65
+
66
+ return {
67
+ "match": match,
68
+ "state_claimed": state_norm[:200],
69
+ "actual": actual_norm[:200],
70
+ "tampered": tampered,
71
+ }
72
+
73
+
74
+ def verify_build_result(
75
+ claimed_success: bool,
76
+ actual_success: bool,
77
+ actual_log: str,
78
+ ) -> dict:
79
+ """
80
+ Cross-check what state/build_success says vs what run_universal_build returned.
81
+
82
+ Returns:
83
+ {"consistent": bool, "trusted_success": bool, "warnings": [str]}
84
+ """
85
+ warnings = []
86
+
87
+ if claimed_success != actual_success:
88
+ warnings.append(
89
+ f"build_success mismatch: state={claimed_success} "
90
+ f"but run_universal_build={actual_success}"
91
+ )
92
+
93
+ trusted_success = actual_success # Only trust run_universal_build
94
+
95
+ if claimed_success and not actual_success:
96
+ warnings.append(
97
+ "AGENT CLAIMED SUCCESS BUT COMPILER FAILED. "
98
+ "This is the 'simulated test passed' incident pattern."
99
+ )
100
+
101
+ return {
102
+ "consistent": claimed_success == actual_success,
103
+ "trusted_success": trusted_success,
104
+ "warnings": warnings,
105
+ }
106
+
107
+
108
+ __all__ = [
109
+ "fs_hash",
110
+ "verify_file_changed",
111
+ "verify_compiler_output",
112
+ "verify_build_result",
113
+ ]