wup 0.2.20__tar.gz → 0.2.21__tar.gz
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.
- {wup-0.2.20/wup.egg-info → wup-0.2.21}/PKG-INFO +31 -9
- {wup-0.2.20 → wup-0.2.21}/README.md +30 -8
- {wup-0.2.20 → wup-0.2.21}/pyproject.toml +1 -1
- {wup-0.2.20 → wup-0.2.21}/wup/__init__.py +1 -1
- wup-0.2.21/wup/_ast_detector.py +124 -0
- wup-0.2.21/wup/_hash_detector.py +72 -0
- wup-0.2.21/wup/_yaml_detector.py +128 -0
- wup-0.2.21/wup/anomaly_detector.py +175 -0
- wup-0.2.21/wup/anomaly_models.py +35 -0
- {wup-0.2.20 → wup-0.2.21}/wup/assistant.py +42 -40
- {wup-0.2.20 → wup-0.2.21}/wup/core.py +20 -34
- {wup-0.2.20 → wup-0.2.21}/wup/testql_watcher.py +80 -91
- {wup-0.2.20 → wup-0.2.21/wup.egg-info}/PKG-INFO +31 -9
- {wup-0.2.20 → wup-0.2.21}/wup.egg-info/SOURCES.txt +4 -0
- wup-0.2.20/wup/anomaly_detector.py +0 -593
- {wup-0.2.20 → wup-0.2.21}/LICENSE +0 -0
- {wup-0.2.20 → wup-0.2.21}/setup.cfg +0 -0
- {wup-0.2.20 → wup-0.2.21}/tests/test_e2e.py +0 -0
- {wup-0.2.20 → wup-0.2.21}/tests/test_testql_watcher.py +0 -0
- {wup-0.2.20 → wup-0.2.21}/tests/test_web_client.py +0 -0
- {wup-0.2.20 → wup-0.2.21}/tests/test_wup.py +0 -0
- {wup-0.2.20 → wup-0.2.21}/wup/cli.py +0 -0
- {wup-0.2.20 → wup-0.2.21}/wup/config.py +0 -0
- {wup-0.2.20 → wup-0.2.21}/wup/dependency_mapper.py +0 -0
- {wup-0.2.20 → wup-0.2.21}/wup/models/__init__.py +0 -0
- {wup-0.2.20 → wup-0.2.21}/wup/models/config.py +0 -0
- {wup-0.2.20 → wup-0.2.21}/wup/testql_discovery.py +0 -0
- {wup-0.2.20 → wup-0.2.21}/wup/visual_diff.py +0 -0
- {wup-0.2.20 → wup-0.2.21}/wup/web_client.py +0 -0
- {wup-0.2.20 → wup-0.2.21}/wup.egg-info/dependency_links.txt +0 -0
- {wup-0.2.20 → wup-0.2.21}/wup.egg-info/entry_points.txt +0 -0
- {wup-0.2.20 → wup-0.2.21}/wup.egg-info/requires.txt +0 -0
- {wup-0.2.20 → wup-0.2.21}/wup.egg-info/top_level.txt +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: wup
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.21
|
|
4
4
|
Summary: WUP (What's Up) - Intelligent file watcher for regression testing in large projects
|
|
5
5
|
Author-email: Tom Sapletta <tom@sapletta.com>
|
|
6
6
|
License: Apache-2.0
|
|
@@ -29,17 +29,17 @@ Dynamic: license-file
|
|
|
29
29
|
|
|
30
30
|
## AI Cost Tracking
|
|
31
31
|
|
|
32
|
-
    
|
|
33
|
+
  
|
|
34
34
|
|
|
35
|
-
- 🤖 **LLM usage:** $4.
|
|
36
|
-
- 👤 **Human dev:** ~$
|
|
35
|
+
- 🤖 **LLM usage:** $4.8000 (32 commits)
|
|
36
|
+
- 👤 **Human dev:** ~$913 (9.1h @ $100/h, 30min dedup)
|
|
37
37
|
|
|
38
|
-
Generated on 2026-
|
|
38
|
+
Generated on 2026-05-01 using [openrouter/qwen/qwen3-coder-next](https://openrouter.ai/qwen/qwen3-coder-next)
|
|
39
39
|
|
|
40
40
|
---
|
|
41
41
|
|
|
42
|
-
    
|
|
43
43
|
|
|
44
44
|
**WUP (What's Up)** - Intelligent file watcher for regression testing in large projects.
|
|
45
45
|
|
|
@@ -303,6 +303,26 @@ export WUP_CPU_THROTTLE=0.5
|
|
|
303
303
|
export WUP_DEBOUNCE=3
|
|
304
304
|
```
|
|
305
305
|
|
|
306
|
+
Full list of supported variables (see `.env.example`):
|
|
307
|
+
|
|
308
|
+
| Variable | Default | Description |
|
|
309
|
+
|----------|---------|-------------|
|
|
310
|
+
| `WUP_CPU_THROTTLE` | — | CPU usage threshold (0.0-1.0) |
|
|
311
|
+
| `WUP_DEBOUNCE` | — | Debounce time in seconds |
|
|
312
|
+
| `WUPBRO_ENDPOINT` | — | wupbro backend URL |
|
|
313
|
+
| `WUP_BASE_URL` | — | Base URL for visual diff page scanning |
|
|
314
|
+
| `OPENROUTER_API_KEY` | *(not set)* | Required for LLM features (https://openrouter.ai/keys) |
|
|
315
|
+
| `LLM_MODEL` | `openrouter/qwen/qwen3-coder-next` | LLM model for assistant |
|
|
316
|
+
| `PFIX_AUTO_APPLY` | `true` | Apply fixes without asking |
|
|
317
|
+
| `PFIX_AUTO_INSTALL_DEPS` | `true` | Auto pip/uv install missing deps |
|
|
318
|
+
| `PFIX_AUTO_RESTART` | `false` | Restart process after fix |
|
|
319
|
+
| `PFIX_MAX_RETRIES` | `3` | Max fix retries |
|
|
320
|
+
| `PFIX_DRY_RUN` | `false` | Dry-run mode (no changes written) |
|
|
321
|
+
| `PFIX_ENABLED` | `true` | Enable/disable pfix |
|
|
322
|
+
| `PFIX_GIT_COMMIT` | `false` | Auto-commit fixes |
|
|
323
|
+
| `PFIX_GIT_PREFIX` | `pfix:` | Commit message prefix |
|
|
324
|
+
| `PFIX_CREATE_BACKUPS` | `false` | Create .pfix_backups/ directory |
|
|
325
|
+
|
|
306
326
|
## Visual DOM Diff
|
|
307
327
|
|
|
308
328
|
WUP optionally scans configured pages with Playwright after each successful quick test, compares the DOM structure to the previous snapshot, and reports significant changes.
|
|
@@ -390,7 +410,9 @@ See `wupbro/README.md` for full API reference and driver endpoints (DOM diff, br
|
|
|
390
410
|
wup/
|
|
391
411
|
├── wup/
|
|
392
412
|
│ ├── __init__.py # Package exports
|
|
393
|
-
│ ├──
|
|
413
|
+
│ ├── anomaly_detector.py # AnomalyDetector: hash, YAML structure, AST diff
|
|
414
|
+
│ ├── assistant.py # WupAssistant: interactive configuration wizard
|
|
415
|
+
│ ├── cli.py # CLI: watch, map-deps, status, init, testql-endpoints, assistant, version
|
|
394
416
|
│ ├── config.py # Config loading/saving + .wup.env support
|
|
395
417
|
│ ├── core.py # WupWatcher: detection, inference, scheduling
|
|
396
418
|
│ ├── dependency_mapper.py # DependencyMapper: codebase → deps.json
|
|
@@ -400,7 +422,7 @@ wup/
|
|
|
400
422
|
│ ├── web_client.py # WebClient: async HTTP event sink → wupbro
|
|
401
423
|
│ └── models/
|
|
402
424
|
│ ├── __init__.py
|
|
403
|
-
│ └── config.py # Dataclasses: WupConfig, VisualDiffConfig, WebConfig...
|
|
425
|
+
│ └── config.py # Dataclasses: WupConfig, ServiceConfig, WatchConfig, TestStrategyConfig, TestQLConfig, VisualDiffConfig, WebConfig, AnomalyDetectionConfig...
|
|
404
426
|
├── wupbro/ # Optional FastAPI dashboard (separate package)
|
|
405
427
|
│ ├── wupbro/
|
|
406
428
|
│ │ ├── main.py # FastAPI app
|
|
@@ -3,17 +3,17 @@
|
|
|
3
3
|
|
|
4
4
|
## AI Cost Tracking
|
|
5
5
|
|
|
6
|
-
    
|
|
7
|
+
  
|
|
8
8
|
|
|
9
|
-
- 🤖 **LLM usage:** $4.
|
|
10
|
-
- 👤 **Human dev:** ~$
|
|
9
|
+
- 🤖 **LLM usage:** $4.8000 (32 commits)
|
|
10
|
+
- 👤 **Human dev:** ~$913 (9.1h @ $100/h, 30min dedup)
|
|
11
11
|
|
|
12
|
-
Generated on 2026-
|
|
12
|
+
Generated on 2026-05-01 using [openrouter/qwen/qwen3-coder-next](https://openrouter.ai/qwen/qwen3-coder-next)
|
|
13
13
|
|
|
14
14
|
---
|
|
15
15
|
|
|
16
|
-
    
|
|
17
17
|
|
|
18
18
|
**WUP (What's Up)** - Intelligent file watcher for regression testing in large projects.
|
|
19
19
|
|
|
@@ -277,6 +277,26 @@ export WUP_CPU_THROTTLE=0.5
|
|
|
277
277
|
export WUP_DEBOUNCE=3
|
|
278
278
|
```
|
|
279
279
|
|
|
280
|
+
Full list of supported variables (see `.env.example`):
|
|
281
|
+
|
|
282
|
+
| Variable | Default | Description |
|
|
283
|
+
|----------|---------|-------------|
|
|
284
|
+
| `WUP_CPU_THROTTLE` | — | CPU usage threshold (0.0-1.0) |
|
|
285
|
+
| `WUP_DEBOUNCE` | — | Debounce time in seconds |
|
|
286
|
+
| `WUPBRO_ENDPOINT` | — | wupbro backend URL |
|
|
287
|
+
| `WUP_BASE_URL` | — | Base URL for visual diff page scanning |
|
|
288
|
+
| `OPENROUTER_API_KEY` | *(not set)* | Required for LLM features (https://openrouter.ai/keys) |
|
|
289
|
+
| `LLM_MODEL` | `openrouter/qwen/qwen3-coder-next` | LLM model for assistant |
|
|
290
|
+
| `PFIX_AUTO_APPLY` | `true` | Apply fixes without asking |
|
|
291
|
+
| `PFIX_AUTO_INSTALL_DEPS` | `true` | Auto pip/uv install missing deps |
|
|
292
|
+
| `PFIX_AUTO_RESTART` | `false` | Restart process after fix |
|
|
293
|
+
| `PFIX_MAX_RETRIES` | `3` | Max fix retries |
|
|
294
|
+
| `PFIX_DRY_RUN` | `false` | Dry-run mode (no changes written) |
|
|
295
|
+
| `PFIX_ENABLED` | `true` | Enable/disable pfix |
|
|
296
|
+
| `PFIX_GIT_COMMIT` | `false` | Auto-commit fixes |
|
|
297
|
+
| `PFIX_GIT_PREFIX` | `pfix:` | Commit message prefix |
|
|
298
|
+
| `PFIX_CREATE_BACKUPS` | `false` | Create .pfix_backups/ directory |
|
|
299
|
+
|
|
280
300
|
## Visual DOM Diff
|
|
281
301
|
|
|
282
302
|
WUP optionally scans configured pages with Playwright after each successful quick test, compares the DOM structure to the previous snapshot, and reports significant changes.
|
|
@@ -364,7 +384,9 @@ See `wupbro/README.md` for full API reference and driver endpoints (DOM diff, br
|
|
|
364
384
|
wup/
|
|
365
385
|
├── wup/
|
|
366
386
|
│ ├── __init__.py # Package exports
|
|
367
|
-
│ ├──
|
|
387
|
+
│ ├── anomaly_detector.py # AnomalyDetector: hash, YAML structure, AST diff
|
|
388
|
+
│ ├── assistant.py # WupAssistant: interactive configuration wizard
|
|
389
|
+
│ ├── cli.py # CLI: watch, map-deps, status, init, testql-endpoints, assistant, version
|
|
368
390
|
│ ├── config.py # Config loading/saving + .wup.env support
|
|
369
391
|
│ ├── core.py # WupWatcher: detection, inference, scheduling
|
|
370
392
|
│ ├── dependency_mapper.py # DependencyMapper: codebase → deps.json
|
|
@@ -374,7 +396,7 @@ wup/
|
|
|
374
396
|
│ ├── web_client.py # WebClient: async HTTP event sink → wupbro
|
|
375
397
|
│ └── models/
|
|
376
398
|
│ ├── __init__.py
|
|
377
|
-
│ └── config.py # Dataclasses: WupConfig, VisualDiffConfig, WebConfig...
|
|
399
|
+
│ └── config.py # Dataclasses: WupConfig, ServiceConfig, WatchConfig, TestStrategyConfig, TestQLConfig, VisualDiffConfig, WebConfig, AnomalyDetectionConfig...
|
|
378
400
|
├── wupbro/ # Optional FastAPI dashboard (separate package)
|
|
379
401
|
│ ├── wupbro/
|
|
380
402
|
│ │ ├── main.py # FastAPI app
|
|
@@ -7,7 +7,7 @@ WUP monitors file changes and runs intelligent regression tests using a 3-layer
|
|
|
7
7
|
3. Detail Layer: Full tests with blame reports (only on failure)
|
|
8
8
|
"""
|
|
9
9
|
|
|
10
|
-
__version__ = "0.2.
|
|
10
|
+
__version__ = "0.2.21"
|
|
11
11
|
__author__ = "Tom Sapletta"
|
|
12
12
|
|
|
13
13
|
from .config import load_config, save_config, get_default_config
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
"""Python AST-based anomaly detection."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import ast
|
|
6
|
+
import json
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Dict, List, Optional
|
|
9
|
+
|
|
10
|
+
from .anomaly_models import AnomalyResult
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class ASTDetector:
|
|
14
|
+
"""Detect changes in Python files using AST comparison."""
|
|
15
|
+
|
|
16
|
+
def __init__(self, snapshot_dir: Path):
|
|
17
|
+
self.snapshot_dir = snapshot_dir / 'ast_snapshots'
|
|
18
|
+
self.snapshot_dir.mkdir(parents=True, exist_ok=True)
|
|
19
|
+
|
|
20
|
+
@staticmethod
|
|
21
|
+
def _collect_import(node: ast.Import) -> List[str]:
|
|
22
|
+
return [f"import {alias.name}" for alias in node.names]
|
|
23
|
+
|
|
24
|
+
@staticmethod
|
|
25
|
+
def _collect_import_from(node: ast.ImportFrom) -> str:
|
|
26
|
+
module = node.module or ''
|
|
27
|
+
names = ', '.join(a.name for a in node.names)
|
|
28
|
+
return f"from {module} import {names}"
|
|
29
|
+
|
|
30
|
+
@staticmethod
|
|
31
|
+
def _collect_class(node: ast.ClassDef) -> Dict:
|
|
32
|
+
methods = [n.name for n in node.body if isinstance(n, ast.FunctionDef)]
|
|
33
|
+
bases = [ast.unparse(b) for b in node.bases] if hasattr(ast, 'unparse') else []
|
|
34
|
+
return {'name': node.name, 'methods': methods, 'bases': bases}
|
|
35
|
+
|
|
36
|
+
@staticmethod
|
|
37
|
+
def _collect_function(node: ast.FunctionDef) -> Dict:
|
|
38
|
+
return {'name': node.name, 'args': len(node.args.args),
|
|
39
|
+
'decorators': len(node.decorator_list)}
|
|
40
|
+
|
|
41
|
+
def _extract_ast_info(self, tree: ast.AST) -> Dict:
|
|
42
|
+
info: Dict = {'imports': [], 'classes': [], 'functions': [], 'top_level': []}
|
|
43
|
+
_handlers = {
|
|
44
|
+
ast.Import: lambda n: info['imports'].extend(self._collect_import(n)),
|
|
45
|
+
ast.ImportFrom: lambda n: info['imports'].append(self._collect_import_from(n)),
|
|
46
|
+
ast.ClassDef: lambda n: info['classes'].append(self._collect_class(n)),
|
|
47
|
+
ast.FunctionDef: lambda n: info['functions'].append(self._collect_function(n)),
|
|
48
|
+
}
|
|
49
|
+
for node in ast.iter_child_nodes(tree):
|
|
50
|
+
handler = _handlers.get(type(node))
|
|
51
|
+
if handler:
|
|
52
|
+
handler(node)
|
|
53
|
+
elif isinstance(node, ast.Assign):
|
|
54
|
+
for target in node.targets:
|
|
55
|
+
if isinstance(target, ast.Name):
|
|
56
|
+
info['top_level'].append(target.id)
|
|
57
|
+
return info
|
|
58
|
+
|
|
59
|
+
def _snapshot_path(self, file_path: Path) -> Path:
|
|
60
|
+
rel_path = str(file_path).replace('/', '_').replace('\\', '_')
|
|
61
|
+
return self.snapshot_dir / f"{rel_path}.ast.json"
|
|
62
|
+
|
|
63
|
+
def _compute_changes(self, old_info: Dict, new_info: Dict) -> List[str]:
|
|
64
|
+
changes: List[str] = []
|
|
65
|
+
old_classes = {c['name']: c for c in old_info.get('classes', [])}
|
|
66
|
+
new_classes = {c['name']: c for c in new_info.get('classes', [])}
|
|
67
|
+
|
|
68
|
+
for name in set(old_classes) | set(new_classes):
|
|
69
|
+
if name not in new_classes:
|
|
70
|
+
changes.append(f"Klasa usunięta: {name}")
|
|
71
|
+
elif name not in old_classes:
|
|
72
|
+
changes.append(f"Nowa klasa: {name}")
|
|
73
|
+
elif old_classes[name] != new_classes[name]:
|
|
74
|
+
changes.append(f"Klasa zmieniona: {name}")
|
|
75
|
+
|
|
76
|
+
old_funcs = {f['name'] for f in old_info.get('functions', [])}
|
|
77
|
+
new_funcs = {f['name'] for f in new_info.get('functions', [])}
|
|
78
|
+
for name in old_funcs - new_funcs:
|
|
79
|
+
changes.append(f"Funkcja usunięta: {name}")
|
|
80
|
+
for name in new_funcs - old_funcs:
|
|
81
|
+
changes.append(f"Nowa funkcja: {name}")
|
|
82
|
+
|
|
83
|
+
return changes
|
|
84
|
+
|
|
85
|
+
def detect(self, file_path: Path) -> Optional[AnomalyResult]:
|
|
86
|
+
"""Detect changes in Python file structure."""
|
|
87
|
+
if not str(file_path).endswith('.py'):
|
|
88
|
+
return None
|
|
89
|
+
|
|
90
|
+
try:
|
|
91
|
+
content = file_path.read_text(encoding='utf-8')
|
|
92
|
+
tree = ast.parse(content)
|
|
93
|
+
new_info = self._extract_ast_info(tree)
|
|
94
|
+
snap_path = self._snapshot_path(file_path)
|
|
95
|
+
|
|
96
|
+
if snap_path.exists():
|
|
97
|
+
old_info = json.loads(snap_path.read_text())
|
|
98
|
+
changes = self._compute_changes(old_info, new_info)
|
|
99
|
+
if changes:
|
|
100
|
+
snap_path.write_text(json.dumps(new_info, indent=2))
|
|
101
|
+
return AnomalyResult(
|
|
102
|
+
detector='ast',
|
|
103
|
+
file_path=str(file_path),
|
|
104
|
+
anomaly_type='changed',
|
|
105
|
+
severity='high',
|
|
106
|
+
message=f"Struktura Python zmieniona ({len(changes)} zmian)",
|
|
107
|
+
details={'changes': changes[:10]},
|
|
108
|
+
suggestions=["Przejrzyj zmiany w API przed deploymentem"],
|
|
109
|
+
)
|
|
110
|
+
else:
|
|
111
|
+
snap_path.write_text(json.dumps(new_info, indent=2))
|
|
112
|
+
|
|
113
|
+
return None
|
|
114
|
+
|
|
115
|
+
except SyntaxError as e:
|
|
116
|
+
return AnomalyResult(
|
|
117
|
+
detector='ast',
|
|
118
|
+
file_path=str(file_path),
|
|
119
|
+
anomaly_type='error',
|
|
120
|
+
severity='critical',
|
|
121
|
+
message=f"Błąd składni Python: {e}",
|
|
122
|
+
)
|
|
123
|
+
except Exception:
|
|
124
|
+
return None
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"""Fast hash-based anomaly detection."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Optional
|
|
8
|
+
|
|
9
|
+
from .anomaly_models import AnomalyResult
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class HashDetector:
|
|
13
|
+
"""Fast anomaly detection using file hashes."""
|
|
14
|
+
|
|
15
|
+
def __init__(self, snapshot_dir: Path):
|
|
16
|
+
self.snapshot_dir = snapshot_dir / 'hash_snapshots'
|
|
17
|
+
self.snapshot_dir.mkdir(parents=True, exist_ok=True)
|
|
18
|
+
|
|
19
|
+
def _compute_hash(self, content: str) -> str:
|
|
20
|
+
return hashlib.sha256(content.encode('utf-8')).hexdigest()[:16]
|
|
21
|
+
|
|
22
|
+
def _snapshot_path(self, file_path: Path) -> Path:
|
|
23
|
+
rel_path = str(file_path).replace('/', '_').replace('\\', '_')
|
|
24
|
+
return self.snapshot_dir / f"{rel_path}.hash"
|
|
25
|
+
|
|
26
|
+
def detect(self, file_path: Path) -> Optional[AnomalyResult]:
|
|
27
|
+
"""Detect changes using hash comparison."""
|
|
28
|
+
try:
|
|
29
|
+
if not file_path.exists():
|
|
30
|
+
return None
|
|
31
|
+
|
|
32
|
+
content = file_path.read_text(encoding='utf-8')
|
|
33
|
+
current_hash = self._compute_hash(content)
|
|
34
|
+
snap_path = self._snapshot_path(file_path)
|
|
35
|
+
|
|
36
|
+
if snap_path.exists():
|
|
37
|
+
old_hash = snap_path.read_text().strip()
|
|
38
|
+
if old_hash != current_hash:
|
|
39
|
+
snap_path.write_text(current_hash)
|
|
40
|
+
return AnomalyResult(
|
|
41
|
+
detector='hash',
|
|
42
|
+
file_path=str(file_path),
|
|
43
|
+
anomaly_type='changed',
|
|
44
|
+
severity='medium',
|
|
45
|
+
message=f"Plik zmieniony (hash: {old_hash[:8]} → {current_hash[:8]})",
|
|
46
|
+
details={
|
|
47
|
+
'old_hash': old_hash,
|
|
48
|
+
'new_hash': current_hash,
|
|
49
|
+
'file_size': len(content),
|
|
50
|
+
},
|
|
51
|
+
)
|
|
52
|
+
else:
|
|
53
|
+
snap_path.write_text(current_hash)
|
|
54
|
+
return AnomalyResult(
|
|
55
|
+
detector='hash',
|
|
56
|
+
file_path=str(file_path),
|
|
57
|
+
anomaly_type='added',
|
|
58
|
+
severity='low',
|
|
59
|
+
message=f"Nowy plik wykryty (hash: {current_hash[:8]})",
|
|
60
|
+
details={'new_hash': current_hash},
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
return None
|
|
64
|
+
|
|
65
|
+
except Exception as e:
|
|
66
|
+
return AnomalyResult(
|
|
67
|
+
detector='hash',
|
|
68
|
+
file_path=str(file_path),
|
|
69
|
+
anomaly_type='error',
|
|
70
|
+
severity='low',
|
|
71
|
+
message=f"Błąd hash detection: {e}",
|
|
72
|
+
)
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
"""YAML structure anomaly detection."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any, Dict, List, Optional
|
|
8
|
+
|
|
9
|
+
from rich.console import Console
|
|
10
|
+
|
|
11
|
+
from .anomaly_models import AnomalyResult
|
|
12
|
+
|
|
13
|
+
console = Console()
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class YAMLStructureDetector:
|
|
17
|
+
"""Detect structural changes in YAML files."""
|
|
18
|
+
|
|
19
|
+
def __init__(self, snapshot_dir: Path):
|
|
20
|
+
self.snapshot_dir = snapshot_dir / 'yaml_snapshots'
|
|
21
|
+
self.snapshot_dir.mkdir(parents=True, exist_ok=True)
|
|
22
|
+
|
|
23
|
+
def _load_yaml(self, file_path: Path) -> Optional[Dict]:
|
|
24
|
+
try:
|
|
25
|
+
import yaml
|
|
26
|
+
return yaml.safe_load(file_path.read_text(encoding='utf-8'))
|
|
27
|
+
except Exception:
|
|
28
|
+
return None
|
|
29
|
+
|
|
30
|
+
def _extract_structure(self, data: Any, depth: int = 0, max_depth: int = 5) -> Dict:
|
|
31
|
+
if depth > max_depth:
|
|
32
|
+
return {'type': type(data).__name__, 'truncated': True}
|
|
33
|
+
if isinstance(data, dict):
|
|
34
|
+
return {
|
|
35
|
+
'type': 'dict',
|
|
36
|
+
'keys': {
|
|
37
|
+
k: self._extract_structure(v, depth + 1, max_depth)
|
|
38
|
+
for k, v in list(data.items())[:50]
|
|
39
|
+
},
|
|
40
|
+
}
|
|
41
|
+
if isinstance(data, list):
|
|
42
|
+
return {
|
|
43
|
+
'type': 'list',
|
|
44
|
+
'length': len(data),
|
|
45
|
+
'sample': self._extract_structure(data[0], depth + 1, max_depth) if data else None,
|
|
46
|
+
}
|
|
47
|
+
return {'type': type(data).__name__}
|
|
48
|
+
|
|
49
|
+
def _snapshot_path(self, file_path: Path) -> Path:
|
|
50
|
+
rel_path = str(file_path).replace('/', '_').replace('\\', '_')
|
|
51
|
+
return self.snapshot_dir / f"{rel_path}.struct.json"
|
|
52
|
+
|
|
53
|
+
def _compare_structures(self, old: Dict, new: Dict, path: str = "") -> List[Dict]:
|
|
54
|
+
"""Compare two structures and return differences."""
|
|
55
|
+
diffs: List[Dict] = []
|
|
56
|
+
|
|
57
|
+
if old.get('type') != new.get('type'):
|
|
58
|
+
diffs.append({'path': path or 'root', 'change': 'type_changed',
|
|
59
|
+
'old': old.get('type'), 'new': new.get('type')})
|
|
60
|
+
return diffs
|
|
61
|
+
|
|
62
|
+
if old.get('type') == 'dict':
|
|
63
|
+
diffs.extend(self._compare_dict_structures(old, new, path))
|
|
64
|
+
elif old.get('type') == 'list':
|
|
65
|
+
if old.get('length') != new.get('length'):
|
|
66
|
+
diffs.append({'path': path or 'root', 'change': 'list_length_changed',
|
|
67
|
+
'old_length': old.get('length'), 'new_length': new.get('length')})
|
|
68
|
+
return diffs
|
|
69
|
+
|
|
70
|
+
def _compare_dict_structures(self, old: Dict, new: Dict, path: str) -> List[Dict]:
|
|
71
|
+
diffs: List[Dict] = []
|
|
72
|
+
old_keys = set(old.get('keys', {}).keys())
|
|
73
|
+
new_keys = set(new.get('keys', {}).keys())
|
|
74
|
+
|
|
75
|
+
for key in old_keys - new_keys:
|
|
76
|
+
diffs.append({'path': f"{path}.{key}" if path else key, 'change': 'key_removed', 'key': key})
|
|
77
|
+
for key in new_keys - old_keys:
|
|
78
|
+
diffs.append({'path': f"{path}.{key}" if path else key, 'change': 'key_added', 'key': key})
|
|
79
|
+
for key in old_keys & new_keys:
|
|
80
|
+
diffs.extend(self._compare_structures(
|
|
81
|
+
old['keys'][key], new['keys'][key],
|
|
82
|
+
f"{path}.{key}" if path else key,
|
|
83
|
+
))
|
|
84
|
+
return diffs
|
|
85
|
+
|
|
86
|
+
def detect(self, file_path: Path) -> Optional[AnomalyResult]:
|
|
87
|
+
data = self._load_yaml(file_path)
|
|
88
|
+
if data is None:
|
|
89
|
+
return None
|
|
90
|
+
|
|
91
|
+
snap_path = self._snapshot_path(file_path)
|
|
92
|
+
new_struct = self._extract_structure(data)
|
|
93
|
+
|
|
94
|
+
if snap_path.exists():
|
|
95
|
+
try:
|
|
96
|
+
old_struct = json.loads(snap_path.read_text())
|
|
97
|
+
diffs = self._compare_structures(old_struct, new_struct)
|
|
98
|
+
if diffs:
|
|
99
|
+
snap_path.write_text(json.dumps(new_struct, indent=2))
|
|
100
|
+
critical = [d for d in diffs if d['change'] in ['type_changed', 'key_removed']]
|
|
101
|
+
return AnomalyResult(
|
|
102
|
+
detector='structure',
|
|
103
|
+
file_path=str(file_path),
|
|
104
|
+
anomaly_type='drift',
|
|
105
|
+
severity='high' if critical else 'medium',
|
|
106
|
+
message=f"Struktura YAML zmieniona ({len(diffs)} zmian)",
|
|
107
|
+
details={'diffs': diffs[:10], 'total_diffs': len(diffs)},
|
|
108
|
+
suggestions=self._generate_suggestions(diffs),
|
|
109
|
+
)
|
|
110
|
+
except Exception as e:
|
|
111
|
+
console.print(f"[yellow]YAML structure error: {e}[/yellow]")
|
|
112
|
+
|
|
113
|
+
snap_path.write_text(json.dumps(new_struct, indent=2))
|
|
114
|
+
return None
|
|
115
|
+
|
|
116
|
+
def _generate_suggestions(self, diffs: List[Dict]) -> List[str]:
|
|
117
|
+
suggestions = []
|
|
118
|
+
for diff in diffs:
|
|
119
|
+
change_type = diff.get('change')
|
|
120
|
+
if change_type == 'key_removed':
|
|
121
|
+
suggestions.append(f"Sprawdź czy usunięcie klucza '{diff['key']}' nie zepsuje integracji")
|
|
122
|
+
elif change_type == 'key_added':
|
|
123
|
+
suggestions.append(f"Nowy klucz '{diff['key']}' - upewnij się że jest poprawnie skonfigurowany")
|
|
124
|
+
elif change_type == 'type_changed':
|
|
125
|
+
suggestions.append(f"Typ zmieniony w '{diff['path']}' - może to wpłynąć na parsing")
|
|
126
|
+
elif change_type == 'list_length_changed':
|
|
127
|
+
suggestions.append("Liczba elementów zmieniona - sprawdź czy wszystkie wymagane elementy są obecne")
|
|
128
|
+
return suggestions[:5]
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
"""Fast anomaly detection for YAML files without Playwright.
|
|
2
|
+
|
|
3
|
+
Provides lightweight alternatives to visual DOM diff for detecting
|
|
4
|
+
configuration drift and structural anomalies in YAML files.
|
|
5
|
+
|
|
6
|
+
Methods:
|
|
7
|
+
- AST diff: Compare Python AST trees for code changes
|
|
8
|
+
- YAML diff: Deep comparison of YAML structures
|
|
9
|
+
- Hash diff: Checksum-based change detection
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Dict, List, Optional, Union
|
|
16
|
+
|
|
17
|
+
from rich.console import Console
|
|
18
|
+
from rich.table import Table
|
|
19
|
+
|
|
20
|
+
from .anomaly_models import AnomalyResult, YAMLAnomalyConfig
|
|
21
|
+
from ._hash_detector import HashDetector
|
|
22
|
+
from ._yaml_detector import YAMLStructureDetector
|
|
23
|
+
from ._ast_detector import ASTDetector
|
|
24
|
+
|
|
25
|
+
__all__ = [
|
|
26
|
+
"AnomalyResult",
|
|
27
|
+
"YAMLAnomalyConfig",
|
|
28
|
+
"HashDetector",
|
|
29
|
+
"YAMLStructureDetector",
|
|
30
|
+
"ASTDetector",
|
|
31
|
+
"AnomalyDetector",
|
|
32
|
+
"quick_scan",
|
|
33
|
+
"scan_yaml_changes",
|
|
34
|
+
]
|
|
35
|
+
|
|
36
|
+
console = Console()
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class AnomalyDetector:
|
|
40
|
+
"""Main anomaly detector combining multiple detection methods."""
|
|
41
|
+
|
|
42
|
+
def __init__(self, project_root: Union[str, Path], config: Optional[YAMLAnomalyConfig] = None):
|
|
43
|
+
self.project_root = Path(project_root)
|
|
44
|
+
self.config = config or YAMLAnomalyConfig()
|
|
45
|
+
self.snapshot_dir = self.project_root / '.wup' / 'anomaly_snapshots'
|
|
46
|
+
self.snapshot_dir.mkdir(parents=True, exist_ok=True)
|
|
47
|
+
|
|
48
|
+
self.detectors: Dict = {}
|
|
49
|
+
if 'hash' in self.config.methods:
|
|
50
|
+
self.detectors['hash'] = HashDetector(self.snapshot_dir)
|
|
51
|
+
if 'structure' in self.config.methods or 'keys' in self.config.methods:
|
|
52
|
+
self.detectors['structure'] = YAMLStructureDetector(self.snapshot_dir)
|
|
53
|
+
if 'ast' in self.config.methods:
|
|
54
|
+
self.detectors['ast'] = ASTDetector(self.snapshot_dir)
|
|
55
|
+
|
|
56
|
+
def _should_scan(self, file_path: Path) -> bool:
|
|
57
|
+
try:
|
|
58
|
+
if file_path.stat().st_size / 1024 > self.config.max_file_size_kb:
|
|
59
|
+
return False
|
|
60
|
+
except Exception:
|
|
61
|
+
return False
|
|
62
|
+
|
|
63
|
+
path_str = str(file_path)
|
|
64
|
+
for pattern in self.config.ignore_patterns:
|
|
65
|
+
if pattern.endswith('/*'):
|
|
66
|
+
if pattern.rstrip('/*') in path_str:
|
|
67
|
+
return False
|
|
68
|
+
elif pattern in path_str:
|
|
69
|
+
return False
|
|
70
|
+
return True
|
|
71
|
+
|
|
72
|
+
def scan_file(self, file_path: Union[str, Path]) -> List[AnomalyResult]:
|
|
73
|
+
"""Scan a single file with all enabled detectors."""
|
|
74
|
+
file_path = Path(file_path)
|
|
75
|
+
if not file_path.exists() or not self._should_scan(file_path):
|
|
76
|
+
return []
|
|
77
|
+
|
|
78
|
+
results = []
|
|
79
|
+
for name, detector in self.detectors.items():
|
|
80
|
+
try:
|
|
81
|
+
result = detector.detect(file_path)
|
|
82
|
+
if result:
|
|
83
|
+
results.append(result)
|
|
84
|
+
except Exception as e:
|
|
85
|
+
console.print(f"[red]Detector {name} failed: {e}[/red]")
|
|
86
|
+
return results
|
|
87
|
+
|
|
88
|
+
def scan_directory(
|
|
89
|
+
self,
|
|
90
|
+
directory: Union[str, Path],
|
|
91
|
+
pattern: str = "*.yaml",
|
|
92
|
+
recursive: bool = True,
|
|
93
|
+
) -> List[AnomalyResult]:
|
|
94
|
+
"""Scan directory for anomalies."""
|
|
95
|
+
directory = Path(directory)
|
|
96
|
+
if not directory.exists():
|
|
97
|
+
return []
|
|
98
|
+
|
|
99
|
+
files = list(directory.rglob(pattern) if recursive else directory.glob(pattern))
|
|
100
|
+
if 'ast' in self.config.methods:
|
|
101
|
+
py_files = list(directory.rglob('*.py') if recursive else directory.glob('*.py'))
|
|
102
|
+
files.extend(py_files)
|
|
103
|
+
|
|
104
|
+
console.print(f"[dim]Scanning {len(files)} files with {len(self.detectors)} detectors...[/dim]")
|
|
105
|
+
|
|
106
|
+
results = []
|
|
107
|
+
for file_path in files:
|
|
108
|
+
results.extend(self.scan_file(file_path))
|
|
109
|
+
return results
|
|
110
|
+
|
|
111
|
+
def get_summary(self, results: List[AnomalyResult]) -> Dict:
|
|
112
|
+
"""Generate summary of results."""
|
|
113
|
+
by_detector: Dict = {}
|
|
114
|
+
by_severity: Dict = {'low': 0, 'medium': 0, 'high': 0, 'critical': 0}
|
|
115
|
+
by_type: Dict = {}
|
|
116
|
+
|
|
117
|
+
for r in results:
|
|
118
|
+
by_detector[r.detector] = by_detector.get(r.detector, 0) + 1
|
|
119
|
+
by_severity[r.severity] = by_severity.get(r.severity, 0) + 1
|
|
120
|
+
by_type[r.anomaly_type] = by_type.get(r.anomaly_type, 0) + 1
|
|
121
|
+
|
|
122
|
+
return {'total': len(results), 'by_detector': by_detector,
|
|
123
|
+
'by_severity': by_severity, 'by_type': by_type}
|
|
124
|
+
|
|
125
|
+
def print_report(self, results: List[AnomalyResult]) -> None:
|
|
126
|
+
"""Print formatted report of anomalies."""
|
|
127
|
+
if not results:
|
|
128
|
+
console.print("[green]✓ No anomalies detected[/green]")
|
|
129
|
+
return
|
|
130
|
+
|
|
131
|
+
summary = self.get_summary(results)
|
|
132
|
+
console.print(f"\n[bold]Anomaly Report[/bold] - {summary['total']} issues found")
|
|
133
|
+
sev = summary['by_severity']
|
|
134
|
+
console.print(
|
|
135
|
+
f"Severity: critical={sev.get('critical', 0)}, high={sev.get('high', 0)}, "
|
|
136
|
+
f"medium={sev.get('medium', 0)}, low={sev.get('low', 0)}"
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
table = Table(title="Detected Anomalies")
|
|
140
|
+
table.add_column("Detector", style="cyan")
|
|
141
|
+
table.add_column("File", style="green")
|
|
142
|
+
table.add_column("Type", style="yellow")
|
|
143
|
+
table.add_column("Severity", style="red")
|
|
144
|
+
table.add_column("Message")
|
|
145
|
+
|
|
146
|
+
severity_colors = {'critical': 'bold red', 'high': 'red', 'medium': 'yellow', 'low': 'dim'}
|
|
147
|
+
_order = ['low', 'medium', 'high', 'critical']
|
|
148
|
+
for r in sorted(results, key=lambda x: _order.index(x.severity), reverse=True):
|
|
149
|
+
color = severity_colors.get(r.severity, 'white')
|
|
150
|
+
file_short = str(r.file_path).replace(str(self.project_root), '.')
|
|
151
|
+
table.add_row(r.detector, file_short[:50], r.anomaly_type,
|
|
152
|
+
f"[{color}]{r.severity}[/{color}]", r.message[:60])
|
|
153
|
+
console.print(table)
|
|
154
|
+
|
|
155
|
+
all_suggestions = [s for r in results for s in r.suggestions]
|
|
156
|
+
if all_suggestions:
|
|
157
|
+
console.print("\n[bold]Suggestions:[/bold]")
|
|
158
|
+
for i, s in enumerate(set(all_suggestions)[:10], 1):
|
|
159
|
+
console.print(f" {i}. {s}")
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def quick_scan(project_root: str, files: List[str]) -> List[AnomalyResult]:
|
|
163
|
+
"""Quick scan of specific files."""
|
|
164
|
+
detector = AnomalyDetector(project_root)
|
|
165
|
+
results = []
|
|
166
|
+
for f in files:
|
|
167
|
+
results.extend(detector.scan_file(f))
|
|
168
|
+
return results
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def scan_yaml_changes(project_root: str, yaml_dir: str = '.') -> List[AnomalyResult]:
|
|
172
|
+
"""Scan YAML directory for structural changes."""
|
|
173
|
+
config = YAMLAnomalyConfig(methods=['hash', 'structure', 'keys'])
|
|
174
|
+
detector = AnomalyDetector(project_root, config)
|
|
175
|
+
return detector.scan_directory(yaml_dir, "*.yaml")
|