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.
Files changed (33) hide show
  1. {wup-0.2.20/wup.egg-info → wup-0.2.21}/PKG-INFO +31 -9
  2. {wup-0.2.20 → wup-0.2.21}/README.md +30 -8
  3. {wup-0.2.20 → wup-0.2.21}/pyproject.toml +1 -1
  4. {wup-0.2.20 → wup-0.2.21}/wup/__init__.py +1 -1
  5. wup-0.2.21/wup/_ast_detector.py +124 -0
  6. wup-0.2.21/wup/_hash_detector.py +72 -0
  7. wup-0.2.21/wup/_yaml_detector.py +128 -0
  8. wup-0.2.21/wup/anomaly_detector.py +175 -0
  9. wup-0.2.21/wup/anomaly_models.py +35 -0
  10. {wup-0.2.20 → wup-0.2.21}/wup/assistant.py +42 -40
  11. {wup-0.2.20 → wup-0.2.21}/wup/core.py +20 -34
  12. {wup-0.2.20 → wup-0.2.21}/wup/testql_watcher.py +80 -91
  13. {wup-0.2.20 → wup-0.2.21/wup.egg-info}/PKG-INFO +31 -9
  14. {wup-0.2.20 → wup-0.2.21}/wup.egg-info/SOURCES.txt +4 -0
  15. wup-0.2.20/wup/anomaly_detector.py +0 -593
  16. {wup-0.2.20 → wup-0.2.21}/LICENSE +0 -0
  17. {wup-0.2.20 → wup-0.2.21}/setup.cfg +0 -0
  18. {wup-0.2.20 → wup-0.2.21}/tests/test_e2e.py +0 -0
  19. {wup-0.2.20 → wup-0.2.21}/tests/test_testql_watcher.py +0 -0
  20. {wup-0.2.20 → wup-0.2.21}/tests/test_web_client.py +0 -0
  21. {wup-0.2.20 → wup-0.2.21}/tests/test_wup.py +0 -0
  22. {wup-0.2.20 → wup-0.2.21}/wup/cli.py +0 -0
  23. {wup-0.2.20 → wup-0.2.21}/wup/config.py +0 -0
  24. {wup-0.2.20 → wup-0.2.21}/wup/dependency_mapper.py +0 -0
  25. {wup-0.2.20 → wup-0.2.21}/wup/models/__init__.py +0 -0
  26. {wup-0.2.20 → wup-0.2.21}/wup/models/config.py +0 -0
  27. {wup-0.2.20 → wup-0.2.21}/wup/testql_discovery.py +0 -0
  28. {wup-0.2.20 → wup-0.2.21}/wup/visual_diff.py +0 -0
  29. {wup-0.2.20 → wup-0.2.21}/wup/web_client.py +0 -0
  30. {wup-0.2.20 → wup-0.2.21}/wup.egg-info/dependency_links.txt +0 -0
  31. {wup-0.2.20 → wup-0.2.21}/wup.egg-info/entry_points.txt +0 -0
  32. {wup-0.2.20 → wup-0.2.21}/wup.egg-info/requires.txt +0 -0
  33. {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.20
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
- ![PyPI](https://img.shields.io/badge/pypi-costs-blue) ![Version](https://img.shields.io/badge/version-0.2.20-blue) ![Python](https://img.shields.io/badge/python-3.9+-blue) ![License](https://img.shields.io/badge/license-Apache--2.0-green)
33
- ![AI Cost](https://img.shields.io/badge/AI%20Cost-$4.65-orange) ![Human Time](https://img.shields.io/badge/Human%20Time-7.3h-blue) ![Model](https://img.shields.io/badge/Model-openrouter%2Fqwen%2Fqwen3--coder--next-lightgrey)
32
+ ![PyPI](https://img.shields.io/badge/pypi-costs-blue) ![Version](https://img.shields.io/badge/version-0.2.21-blue) ![Python](https://img.shields.io/badge/python-3.9+-blue) ![License](https://img.shields.io/badge/license-Apache--2.0-green)
33
+ ![AI Cost](https://img.shields.io/badge/AI%20Cost-$4.80-orange) ![Human Time](https://img.shields.io/badge/Human%20Time-9.1h-blue) ![Model](https://img.shields.io/badge/Model-openrouter%2Fqwen%2Fqwen3--coder--next-lightgrey)
34
34
 
35
- - 🤖 **LLM usage:** $4.6500 (31 commits)
36
- - 👤 **Human dev:** ~$732 (7.3h @ $100/h, 30min dedup)
35
+ - 🤖 **LLM usage:** $4.8000 (32 commits)
36
+ - 👤 **Human dev:** ~$913 (9.1h @ $100/h, 30min dedup)
37
37
 
38
- Generated on 2026-04-29 using [openrouter/qwen/qwen3-coder-next](https://openrouter.ai/qwen/qwen3-coder-next)
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
- ![PyPI](https://img.shields.io/badge/pypi-wup-blue) ![Version](https://img.shields.io/badge/version-0.2.20-blue) ![Python](https://img.shields.io/badge/python-3.9+-blue) ![License](https://img.shields.io/badge/license-Apache--2.0-green)
42
+ ![PyPI](https://img.shields.io/badge/pypi-wup-blue) ![Version](https://img.shields.io/badge/version-0.2.21-blue) ![Python](https://img.shields.io/badge/python-3.9+-blue) ![License](https://img.shields.io/badge/license-Apache--2.0-green)
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
- │ ├── cli.py # CLI: watch, map-deps, status, init, testql-endpoints
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
- ![PyPI](https://img.shields.io/badge/pypi-costs-blue) ![Version](https://img.shields.io/badge/version-0.2.20-blue) ![Python](https://img.shields.io/badge/python-3.9+-blue) ![License](https://img.shields.io/badge/license-Apache--2.0-green)
7
- ![AI Cost](https://img.shields.io/badge/AI%20Cost-$4.65-orange) ![Human Time](https://img.shields.io/badge/Human%20Time-7.3h-blue) ![Model](https://img.shields.io/badge/Model-openrouter%2Fqwen%2Fqwen3--coder--next-lightgrey)
6
+ ![PyPI](https://img.shields.io/badge/pypi-costs-blue) ![Version](https://img.shields.io/badge/version-0.2.21-blue) ![Python](https://img.shields.io/badge/python-3.9+-blue) ![License](https://img.shields.io/badge/license-Apache--2.0-green)
7
+ ![AI Cost](https://img.shields.io/badge/AI%20Cost-$4.80-orange) ![Human Time](https://img.shields.io/badge/Human%20Time-9.1h-blue) ![Model](https://img.shields.io/badge/Model-openrouter%2Fqwen%2Fqwen3--coder--next-lightgrey)
8
8
 
9
- - 🤖 **LLM usage:** $4.6500 (31 commits)
10
- - 👤 **Human dev:** ~$732 (7.3h @ $100/h, 30min dedup)
9
+ - 🤖 **LLM usage:** $4.8000 (32 commits)
10
+ - 👤 **Human dev:** ~$913 (9.1h @ $100/h, 30min dedup)
11
11
 
12
- Generated on 2026-04-29 using [openrouter/qwen/qwen3-coder-next](https://openrouter.ai/qwen/qwen3-coder-next)
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
- ![PyPI](https://img.shields.io/badge/pypi-wup-blue) ![Version](https://img.shields.io/badge/version-0.2.20-blue) ![Python](https://img.shields.io/badge/python-3.9+-blue) ![License](https://img.shields.io/badge/license-Apache--2.0-green)
16
+ ![PyPI](https://img.shields.io/badge/pypi-wup-blue) ![Version](https://img.shields.io/badge/version-0.2.21-blue) ![Python](https://img.shields.io/badge/python-3.9+-blue) ![License](https://img.shields.io/badge/license-Apache--2.0-green)
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
- │ ├── cli.py # CLI: watch, map-deps, status, init, testql-endpoints
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
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "wup"
7
- version = "0.2.20"
7
+ version = "0.2.21"
8
8
  description = "WUP (What's Up) - Intelligent file watcher for regression testing in large projects"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.9"
@@ -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.20"
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")