wup 0.2.70__tar.gz → 0.2.72__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 (82) hide show
  1. {wup-0.2.70/wup.egg-info → wup-0.2.72}/PKG-INFO +6 -6
  2. {wup-0.2.70 → wup-0.2.72}/README.md +5 -5
  3. {wup-0.2.70 → wup-0.2.72}/pyproject.toml +1 -1
  4. wup-0.2.72/tests/test_genericity.py +84 -0
  5. {wup-0.2.70 → wup-0.2.72}/wup/__init__.py +1 -1
  6. {wup-0.2.70 → wup-0.2.72}/wup/config.py +4 -0
  7. {wup-0.2.70 → wup-0.2.72}/wup/dependency_mapper.py +56 -39
  8. wup-0.2.72/wup/discovery.py +271 -0
  9. {wup-0.2.70 → wup-0.2.72}/wup/models/config.py +6 -0
  10. {wup-0.2.70 → wup-0.2.72}/wup/monitoring_manifest.py +57 -15
  11. {wup-0.2.70 → wup-0.2.72/wup.egg-info}/PKG-INFO +6 -6
  12. {wup-0.2.70 → wup-0.2.72}/wup.egg-info/SOURCES.txt +2 -0
  13. {wup-0.2.70 → wup-0.2.72}/LICENSE +0 -0
  14. {wup-0.2.70 → wup-0.2.72}/setup.cfg +0 -0
  15. {wup-0.2.70 → wup-0.2.72}/tests/test_assistant.py +0 -0
  16. {wup-0.2.70 → wup-0.2.72}/tests/test_auto_detection.py +0 -0
  17. {wup-0.2.70 → wup-0.2.72}/tests/test_cli_bridge.py +0 -0
  18. {wup-0.2.70 → wup-0.2.72}/tests/test_cli_filtering.py +0 -0
  19. {wup-0.2.70 → wup-0.2.72}/tests/test_control.py +0 -0
  20. {wup-0.2.70 → wup-0.2.72}/tests/test_e2e.py +0 -0
  21. {wup-0.2.70 → wup-0.2.72}/tests/test_endpoints_init_cli.py +0 -0
  22. {wup-0.2.70 → wup-0.2.72}/tests/test_health_summary_passed.py +0 -0
  23. {wup-0.2.70 → wup-0.2.72}/tests/test_monitoring_manifest.py +0 -0
  24. {wup-0.2.70 → wup-0.2.72}/tests/test_multi_project.py +0 -0
  25. {wup-0.2.70 → wup-0.2.72}/tests/test_planfile_reporter_dedupe.py +0 -0
  26. {wup-0.2.70 → wup-0.2.72}/tests/test_probe_mutex.py +0 -0
  27. {wup-0.2.70 → wup-0.2.72}/tests/test_service_inference.py +0 -0
  28. {wup-0.2.70 → wup-0.2.72}/tests/test_status_data.py +0 -0
  29. {wup-0.2.70 → wup-0.2.72}/tests/test_sync.py +0 -0
  30. {wup-0.2.70 → wup-0.2.72}/tests/test_testql_monitor.py +0 -0
  31. {wup-0.2.70 → wup-0.2.72}/tests/test_testql_watcher.py +0 -0
  32. {wup-0.2.70 → wup-0.2.72}/tests/test_visual_diff_periodic_skip.py +0 -0
  33. {wup-0.2.70 → wup-0.2.72}/tests/test_visual_diff_progress.py +0 -0
  34. {wup-0.2.70 → wup-0.2.72}/tests/test_watch_exclude.py +0 -0
  35. {wup-0.2.70 → wup-0.2.72}/tests/test_watch_no_paths_exit.py +0 -0
  36. {wup-0.2.70 → wup-0.2.72}/tests/test_web_client.py +0 -0
  37. {wup-0.2.70 → wup-0.2.72}/tests/test_wup.py +0 -0
  38. {wup-0.2.70 → wup-0.2.72}/tests/test_wup_generate.py +0 -0
  39. {wup-0.2.70 → wup-0.2.72}/wup/_ast_detector.py +0 -0
  40. {wup-0.2.70 → wup-0.2.72}/wup/_base_detector.py +0 -0
  41. {wup-0.2.70 → wup-0.2.72}/wup/_hash_detector.py +0 -0
  42. {wup-0.2.70 → wup-0.2.72}/wup/_yaml_detector.py +0 -0
  43. {wup-0.2.70 → wup-0.2.72}/wup/anomaly_detector.py +0 -0
  44. {wup-0.2.70 → wup-0.2.72}/wup/anomaly_models.py +0 -0
  45. {wup-0.2.70 → wup-0.2.72}/wup/assistant.py +0 -0
  46. {wup-0.2.70 → wup-0.2.72}/wup/assistant_discovery.py +0 -0
  47. {wup-0.2.70 → wup-0.2.72}/wup/assistant_validator.py +0 -0
  48. {wup-0.2.70 → wup-0.2.72}/wup/bus.py +0 -0
  49. {wup-0.2.70 → wup-0.2.72}/wup/cli.py +0 -0
  50. {wup-0.2.70 → wup-0.2.72}/wup/cli_bridge.py +0 -0
  51. {wup-0.2.70 → wup-0.2.72}/wup/cli_config_generator.py +0 -0
  52. {wup-0.2.70 → wup-0.2.72}/wup/cli_scanner.py +0 -0
  53. {wup-0.2.70 → wup-0.2.72}/wup/control.py +0 -0
  54. {wup-0.2.70 → wup-0.2.72}/wup/core.py +0 -0
  55. {wup-0.2.70 → wup-0.2.72}/wup/endpoints.py +0 -0
  56. {wup-0.2.70 → wup-0.2.72}/wup/event_store.py +0 -0
  57. {wup-0.2.70 → wup-0.2.72}/wup/file_watcher/events/file_events.py +0 -0
  58. {wup-0.2.70 → wup-0.2.72}/wup/generate.py +0 -0
  59. {wup-0.2.70 → wup-0.2.72}/wup/init_cli.py +0 -0
  60. {wup-0.2.70 → wup-0.2.72}/wup/models/__init__.py +0 -0
  61. {wup-0.2.70 → wup-0.2.72}/wup/models/target.py +0 -0
  62. {wup-0.2.70 → wup-0.2.72}/wup/multi.py +0 -0
  63. {wup-0.2.70 → wup-0.2.72}/wup/paths.py +0 -0
  64. {wup-0.2.70 → wup-0.2.72}/wup/planfile_reporter.py +0 -0
  65. {wup-0.2.70 → wup-0.2.72}/wup/status_data.py +0 -0
  66. {wup-0.2.70 → wup-0.2.72}/wup/sync.py +0 -0
  67. {wup-0.2.70 → wup-0.2.72}/wup/testing/events/health_events.py +0 -0
  68. {wup-0.2.70 → wup-0.2.72}/wup/testing/events/test_results.py +0 -0
  69. {wup-0.2.70 → wup-0.2.72}/wup/testing/handlers/event_handlers.py +0 -0
  70. {wup-0.2.70 → wup-0.2.72}/wup/testing/handlers/health_handlers.py +0 -0
  71. {wup-0.2.70 → wup-0.2.72}/wup/testing/queries/health_queries.py +0 -0
  72. {wup-0.2.70 → wup-0.2.72}/wup/testql_cli_generator.py +0 -0
  73. {wup-0.2.70 → wup-0.2.72}/wup/testql_discovery.py +0 -0
  74. {wup-0.2.70 → wup-0.2.72}/wup/testql_monitor.py +0 -0
  75. {wup-0.2.70 → wup-0.2.72}/wup/testql_watcher.py +0 -0
  76. {wup-0.2.70 → wup-0.2.72}/wup/validate.py +0 -0
  77. {wup-0.2.70 → wup-0.2.72}/wup/visual_diff.py +0 -0
  78. {wup-0.2.70 → wup-0.2.72}/wup/web_client.py +0 -0
  79. {wup-0.2.70 → wup-0.2.72}/wup.egg-info/dependency_links.txt +0 -0
  80. {wup-0.2.70 → wup-0.2.72}/wup.egg-info/entry_points.txt +0 -0
  81. {wup-0.2.70 → wup-0.2.72}/wup.egg-info/requires.txt +0 -0
  82. {wup-0.2.70 → wup-0.2.72}/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.70
3
+ Version: 0.2.72
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-Expression: Apache-2.0
@@ -42,17 +42,17 @@ Dynamic: license-file
42
42
 
43
43
  ## AI Cost Tracking
44
44
 
45
- ![PyPI](https://img.shields.io/badge/pypi-costs-blue) ![Version](https://img.shields.io/badge/version-0.2.70-blue) ![Python](https://img.shields.io/badge/python-3.9+-blue) ![License](https://img.shields.io/badge/license-Apache--2.0-green)
46
- ![AI Cost](https://img.shields.io/badge/AI%20Cost-$6.52-orange) ![Human Time](https://img.shields.io/badge/Human%20Time-51.1h-blue) ![Model](https://img.shields.io/badge/Model-openrouter%2Fqwen%2Fqwen3--coder--next-lightgrey)
45
+ ![PyPI](https://img.shields.io/badge/pypi-costs-blue) ![Version](https://img.shields.io/badge/version-0.2.72-blue) ![Python](https://img.shields.io/badge/python-3.9+-blue) ![License](https://img.shields.io/badge/license-Apache--2.0-green)
46
+ ![AI Cost](https://img.shields.io/badge/AI%20Cost-$7.24-orange) ![Human Time](https://img.shields.io/badge/Human%20Time-52.4h-blue) ![Model](https://img.shields.io/badge/Model-openrouter%2Fqwen%2Fqwen3--coder--next-lightgrey)
47
47
 
48
- - 🤖 **LLM usage:** $6.5152 (98 commits)
49
- - 👤 **Human dev:** ~$5115 (51.1h @ $100/h, 30min dedup)
48
+ - 🤖 **LLM usage:** $7.2386 (100 commits)
49
+ - 👤 **Human dev:** ~$5235 (52.4h @ $100/h, 30min dedup)
50
50
 
51
51
  Generated on 2026-07-17 using [openrouter/qwen/qwen3-coder-next](https://openrouter.ai/qwen/qwen3-coder-next)
52
52
 
53
53
  ---
54
54
 
55
- ![PyPI](https://img.shields.io/badge/pypi-wup-blue) ![Version](https://img.shields.io/badge/version-0.2.70-blue) ![Python](https://img.shields.io/badge/python-3.9+-blue) ![License](https://img.shields.io/badge/license-Apache--2.0-green)
55
+ ![PyPI](https://img.shields.io/badge/pypi-wup-blue) ![Version](https://img.shields.io/badge/version-0.2.72-blue) ![Python](https://img.shields.io/badge/python-3.9+-blue) ![License](https://img.shields.io/badge/license-Apache--2.0-green)
56
56
 
57
57
  **WUP (What's Up)** - Intelligent file watcher for regression testing in large projects.
58
58
 
@@ -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.70-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-$6.52-orange) ![Human Time](https://img.shields.io/badge/Human%20Time-51.1h-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.72-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-$7.24-orange) ![Human Time](https://img.shields.io/badge/Human%20Time-52.4h-blue) ![Model](https://img.shields.io/badge/Model-openrouter%2Fqwen%2Fqwen3--coder--next-lightgrey)
8
8
 
9
- - 🤖 **LLM usage:** $6.5152 (98 commits)
10
- - 👤 **Human dev:** ~$5115 (51.1h @ $100/h, 30min dedup)
9
+ - 🤖 **LLM usage:** $7.2386 (100 commits)
10
+ - 👤 **Human dev:** ~$5235 (52.4h @ $100/h, 30min dedup)
11
11
 
12
12
  Generated on 2026-07-17 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.70-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.72-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
 
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "wup"
7
- version = "0.2.70"
7
+ version = "0.2.72"
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.10"
@@ -0,0 +1,84 @@
1
+ """Tests for language-agnostic deps.json discovery and config-driven docker mapping."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ from wup.config import validate_config
8
+ from wup.dependency_mapper import DependencyMapper
9
+ from wup.monitoring_manifest import DockerComposeService, _map_docker_to_wup_service
10
+
11
+
12
+ def _dc(compose: str, container: str = "") -> DockerComposeService:
13
+ return DockerComposeService(compose_service=compose, container_name=container)
14
+
15
+
16
+ # --- deps.json genericity -------------------------------------------------
17
+
18
+ def test_express_ts_endpoints_discovered(tmp_path: Path) -> None:
19
+ """A TypeScript Express project yields a non-empty deps map (regression: rglob brace bug)."""
20
+ svc = tmp_path / "services" / "users"
21
+ svc.mkdir(parents=True)
22
+ (svc / "routes.ts").write_text('router.get("/api/users", h)\nrouter.post("/api/users", h)\n')
23
+
24
+ mapper = DependencyMapper(str(tmp_path))
25
+ result = mapper.build_from_codebase()
26
+
27
+ assert mapper._detect_framework() == "express"
28
+ assert "services/users" in result["services"]
29
+ assert "/api/users" in result["services"]["services/users"]["endpoints"]
30
+
31
+
32
+ def test_infer_service_uses_services_dir(tmp_path: Path) -> None:
33
+ mapper = DependencyMapper(str(tmp_path))
34
+ assert mapper._infer_service("services/payments/handler.ts") == "services/payments"
35
+
36
+
37
+ def test_to_dict_handles_service_without_endpoints(tmp_path: Path) -> None:
38
+ """Services with files but no endpoints must not corrupt the map (regression: fragile zip)."""
39
+ mapper = DependencyMapper(str(tmp_path))
40
+ mapper.service_to_files["orphan"].add("a.py")
41
+ mapper.service_to_endpoints["web"].append("/x")
42
+
43
+ d = mapper.to_dict()
44
+ assert d["services"]["orphan"]["files"] == ["a.py"]
45
+ assert d["services"]["orphan"]["endpoints"] == []
46
+ assert d["services"]["web"]["endpoints"] == ["/x"]
47
+
48
+
49
+ # --- config-driven docker mapping ----------------------------------------
50
+
51
+ def test_docker_map_default_is_generic() -> None:
52
+ """Without a profile, project-specific fleet literals do not auto-map."""
53
+ services = ["backend", "frontend", "payments"]
54
+ # 'connect-scenario' carries no generic token for these services -> unmapped.
55
+ assert _map_docker_to_wup_service(_dc("connect-scenario"), services) is None
56
+
57
+
58
+ def test_docker_map_connect_profile_opt_in() -> None:
59
+ services = ["backend", "frontend"]
60
+ assert _map_docker_to_wup_service(_dc("connect-scenario"), services, profile="connect") == "backend"
61
+
62
+
63
+ def test_docker_map_user_rules() -> None:
64
+ services = ["payments", "web"]
65
+ got = _map_docker_to_wup_service(_dc("stripe-worker"), services, user_map={"stripe": "payments"})
66
+ assert got == "payments"
67
+
68
+
69
+ def test_docker_map_generic_token_match() -> None:
70
+ services = ["payments", "web"]
71
+ assert _map_docker_to_wup_service(_dc("payments-api"), services) == "payments"
72
+
73
+
74
+ def test_config_roundtrips_docker_service_map() -> None:
75
+ raw = {
76
+ "project": {"name": "p"},
77
+ "testql": {
78
+ "docker_service_map": {"stripe": "payments"},
79
+ "service_map_profile": "connect",
80
+ },
81
+ }
82
+ cfg = validate_config(raw)
83
+ assert cfg.testql.docker_service_map == {"stripe": "payments"}
84
+ assert cfg.testql.service_map_profile == "connect"
@@ -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.70"
10
+ __version__ = "0.2.72"
11
11
  __author__ = "Tom Sapletta"
12
12
 
13
13
  from .config import load_config, save_config, get_default_config
@@ -230,6 +230,8 @@ def _parse_testql_config(raw: dict) -> TestQLConfig:
230
230
  health_scenario_strict=bool(testql_raw.get("health_scenario_strict", False)),
231
231
  quick_smoke_only=bool(testql_raw.get("quick_smoke_only", False)),
232
232
  service_map_globs=testql_raw.get("service_map_globs", []),
233
+ docker_service_map=testql_raw.get("docker_service_map", {}),
234
+ service_map_profile=testql_raw.get("service_map_profile", ""),
233
235
  base_url=testql_raw.get("base_url", ""),
234
236
  api_base_url=testql_raw.get("api_base_url", ""),
235
237
  base_url_env=testql_raw.get("base_url_env", "WUP_BASE_URL"),
@@ -493,6 +495,8 @@ def save_config(config: WupConfig, output_path: Path):
493
495
  "health_scenario": config.testql.health_scenario,
494
496
  "health_scenario_strict": config.testql.health_scenario_strict,
495
497
  "service_map_globs": config.testql.service_map_globs,
498
+ "docker_service_map": config.testql.docker_service_map,
499
+ "service_map_profile": config.testql.service_map_profile,
496
500
  "base_url": config.testql.base_url,
497
501
  "api_base_url": config.testql.api_base_url,
498
502
  "base_url_env": config.testql.base_url_env,
@@ -11,11 +11,24 @@ This enables intelligent testing by only testing related services when files cha
11
11
 
12
12
  import json
13
13
  from pathlib import Path
14
- from typing import Dict, List, Set, Optional
14
+ from typing import Dict, Iterable, Iterator, List, Set, Optional
15
15
  from collections import defaultdict
16
16
  import re
17
17
  from .testql_discovery import TestQLEndpointDiscovery
18
18
 
19
+ # Source file extensions scanned when discovering endpoints / detecting the
20
+ # framework. Kept broad so JS/TS projects are covered, not just Python.
21
+ PY_EXTENSIONS = (".py",)
22
+ JS_EXTENSIONS = (".js", ".ts", ".jsx", ".tsx", ".mjs", ".cjs")
23
+ SOURCE_EXTENSIONS = PY_EXTENSIONS + JS_EXTENSIONS
24
+
25
+ # Directory names that commonly sit directly above a service/module. Used by
26
+ # _infer_service to derive a service name from a file path in a language-agnostic
27
+ # way (Python `app/`, Node `services/`, monorepo `packages/`, …).
28
+ SERVICE_ROOT_DIRS = (
29
+ "app", "src", "services", "packages", "lib", "modules", "apps", "cmd", "internal", "pkg",
30
+ )
31
+
19
32
 
20
33
  class DependencyMapper:
21
34
  """Maps project dependencies for intelligent testing."""
@@ -66,27 +79,35 @@ class DependencyMapper:
66
79
 
67
80
  def _detect_framework(self) -> str:
68
81
  """Detect the web framework used in the project."""
69
- # Check for common framework indicators
82
+ # Indicators are language-scoped so Python patterns are only matched in
83
+ # Python files and JS patterns only in JS/TS files — otherwise a frontend
84
+ # file that merely mentions e.g. "Blueprint" would false-positive as Flask.
70
85
  indicators = {
71
- "fastapi": ["FastAPI", "APIRouter", "from fastapi"],
72
- "flask": ["Flask", "Blueprint", "from flask"],
73
- "django": ["urlpatterns", "path(", "from django.urls"],
74
- "express": ["express()", "app.get(", "router."],
86
+ "fastapi": (["FastAPI", "APIRouter", "from fastapi"], PY_EXTENSIONS),
87
+ "flask": (["Flask", "Blueprint", "from flask"], PY_EXTENSIONS),
88
+ "django": (["urlpatterns", "from django.urls"], PY_EXTENSIONS),
89
+ "express": (["express()", "app.get(", "router.get(", "router.post("], JS_EXTENSIONS),
75
90
  }
76
-
77
- for framework, patterns in indicators.items():
91
+
92
+ for framework, (patterns, extensions) in indicators.items():
78
93
  for pattern in patterns:
79
- if self._search_codebase(pattern):
94
+ if self._search_codebase(pattern, extensions):
80
95
  return framework
81
-
96
+
82
97
  return "generic"
83
98
 
84
- def _search_codebase(self, pattern: str) -> bool:
85
- """Search for a pattern in the codebase."""
86
- for py_file in self.project_root.rglob("*.py"):
99
+ def _iter_source_files(self, extensions: Iterable[str]) -> Iterator[Path]:
100
+ """Yield source files with any of the given extensions (recursively)."""
101
+ wanted = tuple(extensions)
102
+ for path in self.project_root.rglob("*"):
103
+ if path.is_file() and path.suffix in wanted:
104
+ yield path
105
+
106
+ def _search_codebase(self, pattern: str, extensions: Iterable[str] = SOURCE_EXTENSIONS) -> bool:
107
+ """Search for a pattern across source files of the given extensions."""
108
+ for source_file in self._iter_source_files(extensions):
87
109
  try:
88
- content = py_file.read_text()
89
- if pattern in content:
110
+ if pattern in source_file.read_text():
90
111
  return True
91
112
  except (UnicodeDecodeError, PermissionError):
92
113
  continue
@@ -106,8 +127,8 @@ class DependencyMapper:
106
127
  def _scan_python_endpoints(self, framework: str) -> List[Dict]:
107
128
  """Scan Python files for endpoint definitions."""
108
129
  endpoints = []
109
-
110
- for py_file in self.project_root.rglob("*.py"):
130
+
131
+ for py_file in self._iter_source_files(PY_EXTENSIONS):
111
132
  try:
112
133
  content = py_file.read_text()
113
134
  rel_path = str(py_file.relative_to(self.project_root))
@@ -154,8 +175,9 @@ class DependencyMapper:
154
175
  def _scan_js_endpoints(self) -> List[Dict]:
155
176
  """Scan JavaScript/TypeScript files for endpoint definitions."""
156
177
  endpoints = []
157
-
158
- for js_file in self.project_root.rglob("*.{js,ts,jsx,tsx}"):
178
+
179
+ # NOTE: pathlib.rglob does not brace-expand, so we iterate real extensions.
180
+ for js_file in self._iter_source_files(JS_EXTENSIONS):
159
181
  try:
160
182
  content = js_file.read_text()
161
183
  rel_path = str(js_file.relative_to(self.project_root))
@@ -185,22 +207,19 @@ class DependencyMapper:
185
207
  src/components/auth.ts → "components/auth"
186
208
  """
187
209
  parts = Path(file_path).parts
188
-
189
- # Common patterns
190
- if "app" in parts:
191
- idx = parts.index("app")
192
- if idx + 1 < len(parts):
193
- return "/".join(parts[idx:idx+2])
194
-
195
- if "src" in parts:
196
- idx = parts.index("src")
197
- if idx + 1 < len(parts):
198
- return "/".join(parts[idx:idx+2])
199
-
210
+
211
+ # Derive the service from the segment following a known service-root dir
212
+ # (app/, src/, services/, packages/, …) — language-agnostic.
213
+ for root in SERVICE_ROOT_DIRS:
214
+ if root in parts:
215
+ idx = parts.index(root)
216
+ if idx + 1 < len(parts):
217
+ return "/".join(parts[idx:idx + 2])
218
+
200
219
  # Fallback: use first two meaningful parts
201
220
  if len(parts) >= 2:
202
221
  return "/".join(parts[:2])
203
-
222
+
204
223
  return None
205
224
 
206
225
  def get_endpoints_for_file(self, file_path: str) -> List[str]:
@@ -228,18 +247,16 @@ class DependencyMapper:
228
247
 
229
248
  def to_dict(self) -> Dict:
230
249
  """Convert the dependency map to a dictionary."""
250
+ services = sorted(set(self.service_to_endpoints) | set(self.service_to_files))
231
251
  return {
232
252
  "services": {
233
253
  service: {
234
- "endpoints": endpoints,
235
- "files": list(files)
254
+ "endpoints": self.service_to_endpoints.get(service, []),
255
+ "files": sorted(self.service_to_files.get(service, set())),
236
256
  }
237
- for service, (endpoints, files) in zip(
238
- self.service_to_endpoints.keys(),
239
- zip(self.service_to_endpoints.values(), self.service_to_files.values())
240
- )
257
+ for service in services
241
258
  },
242
- "files": dict(self.file_to_endpoints)
259
+ "files": dict(self.file_to_endpoints),
243
260
  }
244
261
 
245
262
  def save(self, output_path: str = "deps.json"):
@@ -0,0 +1,271 @@
1
+ """
2
+ Pluggable endpoint-discovery adapters.
3
+
4
+ Each adapter knows how to (a) detect whether a project uses its ecosystem and
5
+ (b) extract HTTP endpoints from the source. `DependencyMapper` delegates to the
6
+ registry here so `deps.json` is language-agnostic: adding support for a new
7
+ framework means adding an adapter, not editing the mapper.
8
+
9
+ Adapters are deliberately regex-based (no imports of the target frameworks) so
10
+ discovery stays fast and dependency-free.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import json
16
+ import re
17
+ from dataclasses import dataclass
18
+ from pathlib import Path
19
+ from typing import Callable, Dict, Iterator, List, Optional, Tuple
20
+
21
+ # Source extensions grouped by ecosystem.
22
+ PY_EXTENSIONS = (".py",)
23
+ JS_EXTENSIONS = (".js", ".ts", ".jsx", ".tsx", ".mjs", ".cjs")
24
+ GO_EXTENSIONS = (".go",)
25
+ OPENAPI_EXTENSIONS = (".yaml", ".yml", ".json")
26
+ ALL_SOURCE_EXTENSIONS = PY_EXTENSIONS + JS_EXTENSIONS + GO_EXTENSIONS
27
+
28
+
29
+ @dataclass
30
+ class Endpoint:
31
+ """A discovered HTTP endpoint."""
32
+ method: str
33
+ path: str
34
+ file: str
35
+ framework: str
36
+
37
+ def as_dict(self) -> Dict[str, str]:
38
+ return {"method": self.method, "path": self.path, "file": self.file, "framework": self.framework}
39
+
40
+
41
+ class SourceIndex:
42
+ """
43
+ Reads and caches project source files once, so every adapter's detect() and
44
+ scan() reuse the same content instead of re-walking the tree.
45
+ """
46
+
47
+ def __init__(self, project_root: Path):
48
+ self.project_root = Path(project_root)
49
+ self._cache: Dict[str, List[Tuple[str, str]]] = {}
50
+
51
+ def _read_ext(self, ext: str) -> List[Tuple[str, str]]:
52
+ if ext in self._cache:
53
+ return self._cache[ext]
54
+ items: List[Tuple[str, str]] = []
55
+ if self.project_root.exists():
56
+ for path in self.project_root.rglob(f"*{ext}"):
57
+ if not path.is_file():
58
+ continue
59
+ try:
60
+ content = path.read_text()
61
+ except (UnicodeDecodeError, PermissionError, OSError):
62
+ continue
63
+ items.append((str(path.relative_to(self.project_root)), content))
64
+ self._cache[ext] = items
65
+ return items
66
+
67
+ def files(self, extensions: Tuple[str, ...]) -> Iterator[Tuple[str, str]]:
68
+ """Yield (relative_path, content) for every file with one of the extensions."""
69
+ for ext in extensions:
70
+ yield from self._read_ext(ext)
71
+
72
+ def contains(self, marker: str, extensions: Tuple[str, ...]) -> bool:
73
+ """True if any file with the given extensions contains the marker string."""
74
+ return any(marker in content for _, content in self.files(extensions))
75
+
76
+
77
+ class DiscoveryAdapter:
78
+ """Base adapter. Subclasses set name/extensions/markers or override scan()."""
79
+
80
+ name: str = "generic"
81
+ extensions: Tuple[str, ...] = ()
82
+ detect_markers: Tuple[str, ...] = ()
83
+ # (compiled regex, method-group index or None, path-group index)
84
+ route_patterns: Tuple[Tuple[re.Pattern, Optional[int], int], ...] = ()
85
+ default_method: str = "GET"
86
+ # When True, a matching route pattern alone counts as detection (for
87
+ # ecosystems whose route syntax is distinctive enough, e.g. Express
88
+ # `router.get(`). Kept False where syntax is too generic (Hono, Fastify's
89
+ # `url:`), which rely on import markers instead to avoid false positives.
90
+ detect_via_routes: bool = False
91
+
92
+ def detect(self, index: SourceIndex) -> bool:
93
+ if any(index.contains(m, self.extensions) for m in self.detect_markers):
94
+ return True
95
+ if self.detect_via_routes:
96
+ for _, content in index.files(self.extensions):
97
+ if any(p.search(content) for p, _, _ in self.route_patterns):
98
+ return True
99
+ return False
100
+
101
+ def scan(self, index: SourceIndex) -> List[Endpoint]:
102
+ endpoints: List[Endpoint] = []
103
+ for rel_path, content in index.files(self.extensions):
104
+ for pattern, method_group, path_group in self.route_patterns:
105
+ for match in pattern.finditer(content):
106
+ method = match.group(method_group).upper() if method_group else self.default_method
107
+ endpoints.append(Endpoint(method, match.group(path_group), rel_path, self.name))
108
+ return endpoints
109
+
110
+
111
+ # --- concrete adapters ----------------------------------------------------
112
+
113
+ class FastAPIAdapter(DiscoveryAdapter):
114
+ name = "fastapi"
115
+ extensions = PY_EXTENSIONS
116
+ detect_markers = ("FastAPI", "APIRouter", "from fastapi")
117
+ route_patterns = (
118
+ (re.compile(r'@(?:app|router)\.(get|post|put|delete|patch)\s*\(\s*["\']([^"\']+)["\']'), 1, 2),
119
+ )
120
+
121
+
122
+ class FlaskAdapter(DiscoveryAdapter):
123
+ name = "flask"
124
+ extensions = PY_EXTENSIONS
125
+ detect_markers = ("from flask", "Flask(", "Blueprint(")
126
+ route_patterns = (
127
+ (re.compile(r'@(?:app|bp)\.(get|post|put|delete|patch)\s*\(\s*["\']([^"\']+)["\']'), 1, 2),
128
+ (re.compile(r'@(?:app|bp)\.route\s*\(\s*["\']([^"\']+)["\']'), None, 1),
129
+ )
130
+
131
+
132
+ class DjangoAdapter(DiscoveryAdapter):
133
+ name = "django"
134
+ extensions = PY_EXTENSIONS
135
+ detect_markers = ("urlpatterns", "from django.urls")
136
+ route_patterns = (
137
+ (re.compile(r'(?:^|\W)(?:path|re_path|url)\s*\(\s*["\']([^"\']*)["\']'), None, 1),
138
+ )
139
+
140
+
141
+ class ExpressAdapter(DiscoveryAdapter):
142
+ name = "express"
143
+ extensions = JS_EXTENSIONS
144
+ detect_markers = ("express()", "require('express')", 'require("express")', "from 'express'", 'from "express"')
145
+ route_patterns = (
146
+ (re.compile(r'(?:app|router)\.(get|post|put|delete|patch|all)\s*\(\s*["\'`]([^"\'`]+)["\'`]'), 1, 2),
147
+ )
148
+
149
+
150
+ class FastifyAdapter(DiscoveryAdapter):
151
+ name = "fastify"
152
+ extensions = JS_EXTENSIONS
153
+ detect_markers = ("require('fastify')", 'require("fastify")', "from 'fastify'", 'from "fastify"')
154
+ route_patterns = (
155
+ (re.compile(r'fastify\.(get|post|put|delete|patch)\s*\(\s*["\'`]([^"\'`]+)["\'`]'), 1, 2),
156
+ # fastify.route({ method: 'GET', url: '/x' })
157
+ (re.compile(r'url\s*:\s*["\'`]([^"\'`]+)["\'`]'), None, 1),
158
+ )
159
+
160
+
161
+ class HonoAdapter(DiscoveryAdapter):
162
+ name = "hono"
163
+ extensions = JS_EXTENSIONS
164
+ detect_markers = ("from 'hono'", 'from "hono"', "new Hono(")
165
+ route_patterns = (
166
+ (re.compile(r'\b\w+\.(get|post|put|delete|patch)\s*\(\s*["\'`](/[^"\'`]*)["\'`]'), 1, 2),
167
+ )
168
+
169
+
170
+ class NestJSAdapter(DiscoveryAdapter):
171
+ name = "nestjs"
172
+ extensions = JS_EXTENSIONS
173
+ detect_markers = ("@nestjs/common", "@Controller(")
174
+ route_patterns = (
175
+ (re.compile(r'@(Get|Post|Put|Delete|Patch)\s*\(\s*["\'`]?([^"\'`)]*)["\'`]?\s*\)'), 1, 2),
176
+ )
177
+
178
+
179
+ class GoAdapter(DiscoveryAdapter):
180
+ name = "go"
181
+ extensions = GO_EXTENSIONS
182
+ detect_markers = ("net/http", "gin-gonic/gin", "labstack/echo", "gorilla/mux")
183
+ route_patterns = (
184
+ # gin/echo: r.GET("/x", ...), e.POST("/x", ...)
185
+ (re.compile(r'\.\b(GET|POST|PUT|DELETE|PATCH)\s*\(\s*"([^"]+)"'), 1, 2),
186
+ # net/http: http.HandleFunc("/x", ...) / mux.HandleFunc("/x", ...)
187
+ (re.compile(r'\.HandleFunc\s*\(\s*"([^"]+)"'), None, 1),
188
+ )
189
+
190
+
191
+ class OpenAPIAdapter(DiscoveryAdapter):
192
+ """Extract paths from an OpenAPI/Swagger document (yaml or json)."""
193
+ name = "openapi"
194
+ extensions = OPENAPI_EXTENSIONS
195
+
196
+ def _load_spec(self, content: str) -> Optional[dict]:
197
+ text = content.lstrip()
198
+ try:
199
+ if text.startswith("{"):
200
+ return json.loads(content)
201
+ import yaml
202
+ data = yaml.safe_load(content)
203
+ return data if isinstance(data, dict) else None
204
+ except Exception:
205
+ return None
206
+
207
+ def detect(self, index: SourceIndex) -> bool:
208
+ for _, content in index.files(self.extensions):
209
+ spec = self._load_spec(content)
210
+ if spec and ("openapi" in spec or "swagger" in spec) and isinstance(spec.get("paths"), dict):
211
+ return True
212
+ return False
213
+
214
+ def scan(self, index: SourceIndex) -> List[Endpoint]:
215
+ endpoints: List[Endpoint] = []
216
+ methods = {"get", "post", "put", "delete", "patch", "head", "options"}
217
+ for rel_path, content in index.files(self.extensions):
218
+ spec = self._load_spec(content)
219
+ if not (spec and isinstance(spec.get("paths"), dict)):
220
+ continue
221
+ for path, ops in spec["paths"].items():
222
+ if not isinstance(ops, dict):
223
+ continue
224
+ for method in ops:
225
+ if str(method).lower() in methods:
226
+ endpoints.append(Endpoint(str(method).upper(), str(path), rel_path, self.name))
227
+ return endpoints
228
+
229
+
230
+ # Order matters only for auto-detection reporting; scanning runs all matches.
231
+ ADAPTERS: Tuple[DiscoveryAdapter, ...] = (
232
+ FastAPIAdapter(),
233
+ FlaskAdapter(),
234
+ DjangoAdapter(),
235
+ NestJSAdapter(), # before Express: @Controller is more specific
236
+ ExpressAdapter(),
237
+ FastifyAdapter(),
238
+ HonoAdapter(),
239
+ GoAdapter(),
240
+ OpenAPIAdapter(),
241
+ )
242
+
243
+ ADAPTERS_BY_NAME: Dict[str, DiscoveryAdapter] = {a.name: a for a in ADAPTERS}
244
+
245
+
246
+ def detect_frameworks(index: SourceIndex) -> List[str]:
247
+ """Return the names of every adapter whose ecosystem is present."""
248
+ return [a.name for a in ADAPTERS if a.detect(index)]
249
+
250
+
251
+ def discover_endpoints(
252
+ project_root: Path,
253
+ framework: str = "auto",
254
+ index: Optional[SourceIndex] = None,
255
+ ) -> List[Endpoint]:
256
+ """
257
+ Discover endpoints across all matching adapters.
258
+
259
+ framework="auto" runs every adapter that detects its ecosystem; an explicit
260
+ framework name runs just that adapter.
261
+ """
262
+ index = index or SourceIndex(project_root)
263
+ if framework and framework != "auto":
264
+ adapter = ADAPTERS_BY_NAME.get(framework)
265
+ return adapter.scan(index) if adapter else []
266
+
267
+ endpoints: List[Endpoint] = []
268
+ for adapter in ADAPTERS:
269
+ if adapter.detect(index):
270
+ endpoints.extend(adapter.scan(index))
271
+ return endpoints
@@ -69,6 +69,12 @@ class TestQLConfig:
69
69
  health_scenario_strict: bool = False # If false, fleet scenario failure is logged but does not block per-service probes
70
70
  quick_smoke_only: bool = False # Quick TestQL uses smoke_scenario only (skip auto-api scoring)
71
71
  service_map_globs: List[str] = field(default_factory=list) # e.g. testql-testing/service-map/*.yaml
72
+ # docker-compose service/container substring → WUP service name. Lets a project
73
+ # map its containers to services without hardcoding names in WUP itself.
74
+ docker_service_map: Dict[str, str] = field(default_factory=dict)
75
+ # Optional built-in mapping profile (e.g. "connect") applied after
76
+ # docker_service_map and before generic token matching. Empty = generic only.
77
+ service_map_profile: str = ""
72
78
  base_url: str = ""
73
79
  api_base_url: str = "" # Core API (c2004: http://localhost:8101) — used for backend probes
74
80
  base_url_env: str = "WUP_BASE_URL"
@@ -124,13 +124,13 @@ def _host_port_from_mapping(mapping: str) -> Optional[int]:
124
124
  return None
125
125
 
126
126
 
127
- def _map_docker_to_wup_service(
128
- docker: DockerComposeService,
129
- wup_services: Sequence[str],
130
- ) -> Optional[str]:
131
- name = docker.compose_service.lower()
132
- container = docker.container_name.lower()
127
+ def _connect_profile_rules(name: str, container: str, wup_services: Sequence[str]) -> List[Any]:
128
+ """
129
+ Built-in "connect" mapping profile (maskservice / c2004 fleet).
133
130
 
131
+ Opt-in via ``testql.service_map_profile: connect``. Kept out of the default
132
+ path so WUP carries no project-specific service names unless asked.
133
+ """
134
134
  def _connect_backend_target() -> Optional[str]:
135
135
  if not (name.endswith("-backend") and "connect" in name):
136
136
  return None
@@ -141,20 +141,53 @@ def _map_docker_to_wup_service(
141
141
  return "backend"
142
142
  return None
143
143
 
144
- # Each rule returns a candidate WUP service name (or None); the first
145
- # candidate that names a configured service wins.
146
- rules = [
144
+ return [
147
145
  lambda: "firmware" if ("firmware" in name or "firmware" in container) else None,
148
146
  lambda: "frontend" if (name == "frontend" or "frontend" in container) else None,
149
147
  lambda: "backend" if (name == "backend" or container == "identification-backend") else None,
150
148
  lambda: "backend" if ("connect-scenario" in name and "backend" not in name) else None,
151
149
  _connect_backend_target,
152
150
  ]
153
- for rule in rules:
154
- candidate = rule()
155
- if candidate and candidate in wup_services:
156
- return candidate
157
151
 
152
+
153
+ _BUILTIN_SERVICE_MAP_PROFILES = {
154
+ "connect": _connect_profile_rules,
155
+ }
156
+
157
+
158
+ def _map_docker_to_wup_service(
159
+ docker: DockerComposeService,
160
+ wup_services: Sequence[str],
161
+ *,
162
+ user_map: Optional[Dict[str, str]] = None,
163
+ profile: str = "",
164
+ ) -> Optional[str]:
165
+ """
166
+ Map a docker-compose service to a configured WUP service.
167
+
168
+ Resolution order (first configured match wins):
169
+ 1. user rules from ``testql.docker_service_map`` (substring → service),
170
+ 2. an opt-in built-in ``profile`` (e.g. "connect"),
171
+ 3. generic token matching (service name appears in compose/container name).
172
+ """
173
+ name = docker.compose_service.lower()
174
+ container = docker.container_name.lower()
175
+
176
+ # 1. User-defined substring rules (fully project-agnostic).
177
+ for needle, target in (user_map or {}).items():
178
+ if needle.lower() in name or needle.lower() in container:
179
+ if target in wup_services:
180
+ return target
181
+
182
+ # 2. Opt-in built-in profile rules.
183
+ profile_factory = _BUILTIN_SERVICE_MAP_PROFILES.get(profile.lower()) if profile else None
184
+ if profile_factory:
185
+ for rule in profile_factory(name, container, wup_services):
186
+ candidate = rule()
187
+ if candidate and candidate in wup_services:
188
+ return candidate
189
+
190
+ # 3. Generic token matching.
158
191
  for svc in wup_services:
159
192
  token = svc.lower().replace("_", "-")
160
193
  if token in name or token in container:
@@ -188,6 +221,9 @@ def _build_docker_rows(
188
221
  docker_all: List[DockerComposeService],
189
222
  wup_names: List[str],
190
223
  by_wup: Dict[str, Dict[str, Any]],
224
+ *,
225
+ user_map: Optional[Dict[str, str]] = None,
226
+ profile: str = "",
191
227
  ) -> List[Dict[str, Any]]:
192
228
  """Group docker-compose rows under WUP services; return unmapped leftovers."""
193
229
  unmapped: List[Dict[str, Any]] = []
@@ -202,7 +238,7 @@ def _build_docker_rows(
202
238
  if d.healthcheck_test:
203
239
  row["compose_healthcheck"] = d.healthcheck_test
204
240
 
205
- mapped = _map_docker_to_wup_service(d, wup_names)
241
+ mapped = _map_docker_to_wup_service(d, wup_names, user_map=user_map, profile=profile)
206
242
  if mapped:
207
243
  by_wup[mapped]["docker"].append(row)
208
244
  else:
@@ -288,7 +324,13 @@ def build_monitoring_manifest(project_root: Path, config: WupConfig) -> Dict[str
288
324
  docker_all = discover_docker_compose_services(project_root)
289
325
 
290
326
  by_wup = _build_wup_service_dicts(config)
291
- unmapped_docker = _build_docker_rows(docker_all, wup_names, by_wup)
327
+ unmapped_docker = _build_docker_rows(
328
+ docker_all,
329
+ wup_names,
330
+ by_wup,
331
+ user_map=config.testql.docker_service_map,
332
+ profile=config.testql.service_map_profile,
333
+ )
292
334
 
293
335
  # Live HTTP probes (exactly what WUP will call)
294
336
  for svc_name in wup_names:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: wup
3
- Version: 0.2.70
3
+ Version: 0.2.72
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-Expression: Apache-2.0
@@ -42,17 +42,17 @@ Dynamic: license-file
42
42
 
43
43
  ## AI Cost Tracking
44
44
 
45
- ![PyPI](https://img.shields.io/badge/pypi-costs-blue) ![Version](https://img.shields.io/badge/version-0.2.70-blue) ![Python](https://img.shields.io/badge/python-3.9+-blue) ![License](https://img.shields.io/badge/license-Apache--2.0-green)
46
- ![AI Cost](https://img.shields.io/badge/AI%20Cost-$6.52-orange) ![Human Time](https://img.shields.io/badge/Human%20Time-51.1h-blue) ![Model](https://img.shields.io/badge/Model-openrouter%2Fqwen%2Fqwen3--coder--next-lightgrey)
45
+ ![PyPI](https://img.shields.io/badge/pypi-costs-blue) ![Version](https://img.shields.io/badge/version-0.2.72-blue) ![Python](https://img.shields.io/badge/python-3.9+-blue) ![License](https://img.shields.io/badge/license-Apache--2.0-green)
46
+ ![AI Cost](https://img.shields.io/badge/AI%20Cost-$7.24-orange) ![Human Time](https://img.shields.io/badge/Human%20Time-52.4h-blue) ![Model](https://img.shields.io/badge/Model-openrouter%2Fqwen%2Fqwen3--coder--next-lightgrey)
47
47
 
48
- - 🤖 **LLM usage:** $6.5152 (98 commits)
49
- - 👤 **Human dev:** ~$5115 (51.1h @ $100/h, 30min dedup)
48
+ - 🤖 **LLM usage:** $7.2386 (100 commits)
49
+ - 👤 **Human dev:** ~$5235 (52.4h @ $100/h, 30min dedup)
50
50
 
51
51
  Generated on 2026-07-17 using [openrouter/qwen/qwen3-coder-next](https://openrouter.ai/qwen/qwen3-coder-next)
52
52
 
53
53
  ---
54
54
 
55
- ![PyPI](https://img.shields.io/badge/pypi-wup-blue) ![Version](https://img.shields.io/badge/version-0.2.70-blue) ![Python](https://img.shields.io/badge/python-3.9+-blue) ![License](https://img.shields.io/badge/license-Apache--2.0-green)
55
+ ![PyPI](https://img.shields.io/badge/pypi-wup-blue) ![Version](https://img.shields.io/badge/version-0.2.72-blue) ![Python](https://img.shields.io/badge/python-3.9+-blue) ![License](https://img.shields.io/badge/license-Apache--2.0-green)
56
56
 
57
57
  **WUP (What's Up)** - Intelligent file watcher for regression testing in large projects.
58
58
 
@@ -8,6 +8,7 @@ tests/test_cli_filtering.py
8
8
  tests/test_control.py
9
9
  tests/test_e2e.py
10
10
  tests/test_endpoints_init_cli.py
11
+ tests/test_genericity.py
11
12
  tests/test_health_summary_passed.py
12
13
  tests/test_monitoring_manifest.py
13
14
  tests/test_multi_project.py
@@ -44,6 +45,7 @@ wup/config.py
44
45
  wup/control.py
45
46
  wup/core.py
46
47
  wup/dependency_mapper.py
48
+ wup/discovery.py
47
49
  wup/endpoints.py
48
50
  wup/event_store.py
49
51
  wup/generate.py
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes