continuum-code 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
continuum/engine.py ADDED
@@ -0,0 +1,214 @@
1
+ """Scope matcher and evaluator: match_scope() and evaluate()."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import fnmatch
6
+ import re
7
+ from dataclasses import dataclass, field
8
+ from typing import Literal
9
+
10
+ from continuum.contracts import (
11
+ AskFirstContract,
12
+ BanContract,
13
+ ContinuumConfig,
14
+ Contract,
15
+ DefineContract,
16
+ RequireContract,
17
+ )
18
+ from continuum.change_summary import ChangeSummary
19
+
20
+
21
+ def _path_matches(path: str, pattern: str) -> bool:
22
+ """Return True if path matches glob pattern (supports **)."""
23
+ path = path.replace("\\", "/")
24
+ pattern = pattern.replace("\\", "/")
25
+ if "**" not in pattern:
26
+ return fnmatch.fnmatch(path, pattern)
27
+ parts = pattern.split("**")
28
+ if len(parts) == 1:
29
+ return fnmatch.fnmatch(path, pattern)
30
+ start = parts[0].rstrip("/")
31
+ end = parts[-1].lstrip("/")
32
+ if start and not path.startswith(start if start.endswith("/") else start + "/"):
33
+ if not path.startswith(start):
34
+ return False
35
+ if end and not fnmatch.fnmatch(path, "*" + end):
36
+ return False
37
+ return True
38
+
39
+
40
+ def _path_matches_any(path: str, patterns: list[str]) -> bool:
41
+ return any(_path_matches(path, p) for p in patterns)
42
+
43
+
44
+ def _scope_applies_to_changes(scope_match_patterns: list[str], change_summary: ChangeSummary) -> bool:
45
+ """True if this scope applies to the change (any path or dep change considered for contract matching)."""
46
+ if not change_summary.paths_changed and not change_summary.deps_added and not change_summary.deps_removed:
47
+ return False
48
+ # Dependency changes apply to all scopes so dep-based contracts (e.g. ban) can fire
49
+ if change_summary.deps_added or change_summary.deps_removed:
50
+ return True
51
+ for path in change_summary.paths_changed:
52
+ if _path_matches_any(path, scope_match_patterns):
53
+ return True
54
+ return False
55
+
56
+
57
+ def _match_diff_patterns(
58
+ diff_content: str, diff_patterns: list[str], max_offending_lines: int = 10
59
+ ) -> tuple[bool, list[str]]:
60
+ """Run regexes over unified diff; return (True, offending_lines) if any match. Bounded output."""
61
+ offending: list[str] = []
62
+ for pattern in diff_patterns:
63
+ try:
64
+ pat = re.compile(pattern)
65
+ except re.error:
66
+ continue
67
+ for line in diff_content.splitlines():
68
+ stripped = line.strip()
69
+ if stripped.startswith("+") and not stripped.startswith("+++"):
70
+ if pat.search(line):
71
+ offending.append(stripped[:200]) # bound line length
72
+ if len(offending) >= max_offending_lines:
73
+ return (True, offending)
74
+ return (bool(offending), offending)
75
+
76
+
77
+ def _contract_match_applies(match, change_summary: ChangeSummary) -> bool:
78
+ """True if the contract's match applies to this change."""
79
+ if match.deps:
80
+ all_deps = set(change_summary.deps_added) | set(change_summary.deps_removed)
81
+ for d in match.deps:
82
+ for dep in all_deps:
83
+ if fnmatch.fnmatch(dep.lower(), d.lower()) or d.lower() in dep.lower():
84
+ return True
85
+ if match.paths:
86
+ for path in change_summary.paths_changed:
87
+ if _path_matches_any(path, match.paths):
88
+ return True
89
+ if match.commands and change_summary.commands_requested:
90
+ for cmd in change_summary.commands_requested:
91
+ for pat in match.commands:
92
+ if fnmatch.fnmatch(cmd, pat) or pat in cmd:
93
+ return True
94
+ if match.patterns:
95
+ for path in change_summary.paths_changed:
96
+ for pat in match.patterns:
97
+ if fnmatch.fnmatch(path, pat):
98
+ return True
99
+ if getattr(match, "diff_patterns", None) and change_summary.diff_content:
100
+ hit, _ = _match_diff_patterns(change_summary.diff_content, match.diff_patterns)
101
+ if hit:
102
+ return True
103
+ return False
104
+
105
+
106
+ def match_scope(config: ContinuumConfig, change_summary: ChangeSummary) -> list[tuple[str, Contract]]:
107
+ """Return applicable (scope_id, contract) ordered by precedence (higher first). Excludes define (metadata)."""
108
+ applicable: list[tuple[int, str, Contract]] = []
109
+ for scope in config.scopes:
110
+ if not _scope_applies_to_changes(scope.match, change_summary):
111
+ continue
112
+ for contract in scope.contracts:
113
+ if isinstance(contract, DefineContract):
114
+ continue
115
+ if not _contract_match_applies(contract.match, change_summary):
116
+ continue
117
+ applicable.append((scope.precedence, scope.id, contract))
118
+ applicable.sort(key=lambda x: (-x[0], x[1], getattr(x[2], "id", "")))
119
+ return [(scope_id, c) for _, scope_id, c in applicable]
120
+
121
+
122
+ @dataclass
123
+ class FiredRule:
124
+ contract_id: str
125
+ scope_id: str
126
+ message: str
127
+ contract_type: str
128
+ prompt: str | None = None
129
+ options: list[str] | None = None
130
+ offending_lines: list[str] = field(default_factory=list)
131
+
132
+
133
+ class EnforcementResult:
134
+ """Result of evaluate(): status and list of fired rules."""
135
+
136
+ status: Literal["allowed", "warn", "blocked", "clarification_required"]
137
+ fired: list[FiredRule]
138
+
139
+ def __init__(
140
+ self,
141
+ status: Literal["allowed", "warn", "blocked", "clarification_required"],
142
+ fired: list[FiredRule] | None = None,
143
+ ):
144
+ self.status = status
145
+ self.fired = fired or []
146
+
147
+
148
+ def _require_satisfied(contract: RequireContract, change_summary: ChangeSummary) -> bool:
149
+ """For kind=tests: require that some test path was touched when src was touched. Uses test_globs if set."""
150
+ for spec in contract.require:
151
+ if spec.kind == "tests":
152
+ if getattr(contract, "test_globs", None):
153
+ has_test_change = any(
154
+ _path_matches_any(p, contract.test_globs) for p in change_summary.paths_changed
155
+ )
156
+ else:
157
+ test_indicators = ("test", "tests", "_test", "spec.", "specs/")
158
+ has_test_change = any(
159
+ any(ind in p for ind in test_indicators) for p in change_summary.paths_changed
160
+ )
161
+ if not has_test_change and contract.match.paths:
162
+ matching = [p for p in change_summary.paths_changed if _path_matches_any(p, contract.match.paths)]
163
+ if matching:
164
+ return False # src changed but no test change
165
+ # logging / ADR: v0.1 we don't check content; consider satisfied if paths match and we have changes
166
+ return True
167
+
168
+
169
+ def evaluate(contracts_with_scope: list[tuple[str, Contract]], change_summary: ChangeSummary) -> EnforcementResult:
170
+ """Evaluate ordered (scope_id, contract) against the change. First blocking/clarification wins."""
171
+ fired: list[FiredRule] = []
172
+ for scope_id, contract in contracts_with_scope:
173
+ if isinstance(contract, BanContract):
174
+ if _contract_match_applies(contract.match, change_summary):
175
+ offending: list[str] = []
176
+ if getattr(contract.match, "diff_patterns", None) and change_summary.diff_content:
177
+ _, offending = _match_diff_patterns(
178
+ change_summary.diff_content, contract.match.diff_patterns
179
+ )
180
+ fired.append(FiredRule(
181
+ contract_id=contract.id,
182
+ scope_id=scope_id,
183
+ message=contract.message,
184
+ contract_type="ban",
185
+ offending_lines=offending,
186
+ ))
187
+ return EnforcementResult(status="blocked", fired=fired)
188
+ elif isinstance(contract, RequireContract):
189
+ if _contract_match_applies(contract.match, change_summary) and not _require_satisfied(contract, change_summary):
190
+ hint = contract.hint or (contract.require[0].hint if contract.require else "Satisfy the requirement.")
191
+ severity = getattr(contract, "severity", "block")
192
+ fired.append(FiredRule(
193
+ contract_id=contract.id,
194
+ scope_id=scope_id,
195
+ message=hint,
196
+ contract_type="require",
197
+ ))
198
+ if severity == "block":
199
+ return EnforcementResult(status="blocked", fired=fired)
200
+ # severity == "warn": continue evaluating; we'll return warn at the end if nothing else fires
201
+ elif isinstance(contract, AskFirstContract):
202
+ if _contract_match_applies(contract.match, change_summary):
203
+ fired.append(FiredRule(
204
+ contract_id=contract.id,
205
+ scope_id=scope_id,
206
+ message=contract.prompt,
207
+ contract_type="ask_first",
208
+ prompt=contract.prompt,
209
+ options=contract.options,
210
+ ))
211
+ return EnforcementResult(status="clarification_required", fired=fired)
212
+ if fired:
213
+ return EnforcementResult(status="warn", fired=fired)
214
+ return EnforcementResult(status="allowed", fired=fired)
@@ -0,0 +1,127 @@
1
+ """MCP server exposing continuum.check, continuum.explain, continuum.ask_first for Cursor/agents."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from pathlib import Path
7
+
8
+ from mcp.server.fastmcp import FastMCP
9
+
10
+ from continuum.contracts import load_config, normalize_for_stable_ids
11
+ from continuum.change_summary import from_plan
12
+ from continuum.engine import match_scope, evaluate, EnforcementResult
13
+
14
+
15
+ # Module-level state for "last result" so explain/ask_first can use it
16
+ _last_result: tuple[EnforcementResult, Path] | None = None
17
+
18
+
19
+ def _find_config() -> Path | None:
20
+ p = Path.cwd()
21
+ for _ in range(10):
22
+ if (p / "continuum.yaml").exists():
23
+ return p / "continuum.yaml"
24
+ if (p / "continuum.yml").exists():
25
+ return p / "continuum.yml"
26
+ if p.parent == p:
27
+ break
28
+ p = p.parent
29
+ return None
30
+
31
+
32
+ mcp = FastMCP(
33
+ "continuum",
34
+ description="Continuum rule engine: check plans against repo rules, explain fired rules, get clarification prompts.",
35
+ )
36
+
37
+
38
+ @mcp.tool()
39
+ def continuum_check(plan: dict) -> str:
40
+ """Check a plan (paths_changed, deps_added, deps_removed, commands_requested) against repo rules.
41
+ Returns JSON with status: allowed | warn | blocked | clarification_required, and fired rules if any."""
42
+ global _last_result
43
+ cfg_path = _find_config()
44
+ if not cfg_path or not cfg_path.exists():
45
+ return json.dumps({"error": "No continuum.yaml found. Run continuum init first.", "status": "error"})
46
+ config = load_config(cfg_path)
47
+ config = normalize_for_stable_ids(config)
48
+ change_summary = from_plan(plan)
49
+ contracts_with_scope = match_scope(config, change_summary)
50
+ result = evaluate(contracts_with_scope, change_summary)
51
+ _last_result = (result, cfg_path)
52
+ out = {
53
+ "status": result.status,
54
+ "fired": [
55
+ {
56
+ "contract_id": r.contract_id,
57
+ "scope_id": r.scope_id,
58
+ "message": r.message,
59
+ "contract_type": r.contract_type,
60
+ "prompt": r.prompt,
61
+ "options": r.options,
62
+ "offending_lines": getattr(r, "offending_lines", None) or [],
63
+ }
64
+ for r in result.fired
65
+ ],
66
+ }
67
+ return json.dumps(out, indent=2)
68
+
69
+
70
+ @mcp.tool()
71
+ def continuum_explain(contract_id: str | None = None) -> str:
72
+ """Explain why a rule fired and how to fix. Use contract_id from a previous check, or omit to explain last check result."""
73
+ global _last_result
74
+ cfg_path = _find_config()
75
+ if not cfg_path or not cfg_path.exists():
76
+ return json.dumps({"error": "No continuum.yaml found."})
77
+ config = load_config(cfg_path)
78
+ if contract_id:
79
+ for scope in config.scopes:
80
+ for c in scope.contracts:
81
+ if getattr(c, "id", None) == contract_id:
82
+ return json.dumps({
83
+ "contract_id": contract_id,
84
+ "scope_id": scope.id,
85
+ "type": c.type,
86
+ "message": getattr(c, "message", None) or getattr(c, "hint", None),
87
+ "prompt": getattr(c, "prompt", None),
88
+ "hint": getattr(c, "hint", None),
89
+ }, indent=2)
90
+ return json.dumps({"error": f"No contract with id {contract_id!r}."})
91
+ if _last_result is None:
92
+ return json.dumps({"error": "No previous check result. Run continuum_check first."})
93
+ result, _ = _last_result
94
+ if not result.fired:
95
+ return json.dumps({"message": "Last check had no fired rules.", "status": result.status})
96
+ explanations = []
97
+ for r in result.fired:
98
+ explanations.append({
99
+ "contract_id": r.contract_id,
100
+ "scope_id": r.scope_id,
101
+ "type": r.contract_type,
102
+ "message": r.message,
103
+ "prompt": r.prompt,
104
+ "options": r.options,
105
+ "offending_lines": getattr(r, "offending_lines", None) or [],
106
+ })
107
+ return json.dumps({"status": result.status, "fired": explanations}, indent=2)
108
+
109
+
110
+ @mcp.tool()
111
+ def continuum_ask_first(prompt: str, options: list[str] | None = None) -> str:
112
+ """Get the clarification prompt and options to present to the user. The agent should show these to the user and get their selection; then revise the plan or proceed accordingly.
113
+ Returns the same prompt and options (for the client to display). User selection is done in conversation."""
114
+ return json.dumps({
115
+ "prompt": prompt,
116
+ "options": options or [],
117
+ "instruction": "Present the prompt and options to the user; use their selection to decide intent (e.g. add-only, destructive, refactor) and proceed or revise the plan.",
118
+ }, indent=2)
119
+
120
+
121
+ def run_mcp_server(transport: str = "stdio") -> None:
122
+ """Run the MCP server. Default stdio for Cursor."""
123
+ mcp.run(transport=transport)
124
+
125
+
126
+ if __name__ == "__main__":
127
+ run_mcp_server()
@@ -0,0 +1,42 @@
1
+ # data-dbt-airflow pack
2
+
3
+ Starter Continuum config for repos that use **dbt** and **Apache Airflow**. Enforces rules so agents don’t change marts without tests, don’t touch DAGs without confirmation, and don’t embed risky commands (e.g. `dbt run --full-refresh`, `airflow backfill`).
4
+
5
+ ## 5-minute adoption (dbt + Airflow repos)
6
+
7
+ 1. **Install Continuum** (from repo or PyPI):
8
+ ```bash
9
+ pip install -e . # or: pip install continuum-code
10
+ ```
11
+
12
+ 2. **Bootstrap config from this pack:**
13
+ ```bash
14
+ continuum init --pack data-dbt-airflow
15
+ ```
16
+ This writes `continuum.yaml` at the repo root.
17
+
18
+ 3. **Validate the file:**
19
+ ```bash
20
+ continuum validate
21
+ ```
22
+
23
+ 4. **Run a check** (e.g. on your current diff):
24
+ ```bash
25
+ continuum check
26
+ continuum check --base origin/main # for PRs
27
+ ```
28
+
29
+ 5. **Wire CI** (GitHub Actions): add the Continuum check action with `base_ref` set to the PR base so every PR is evaluated.
30
+
31
+ 6. **Optional – Cursor/MCP:** Run `continuum mcp --transport stdio` and add the MCP server to Cursor so agents call `continuum_check(plan)` before applying changes.
32
+
33
+ ## What this pack enforces
34
+
35
+ - **dbt**
36
+ - **ask_first** on `models/marts/**`: you must confirm additive / breaking / refactor-only.
37
+ - **require tests** on `models/marts/**`: changes to marts should include or update tests. You can set `severity: warn` on that contract in your repo’s `continuum.yaml` so missing tests are reported but do not block.
38
+ - **Airflow**
39
+ - **ask_first** on `dags/**`: confirm when changing schedule/retry/SLA.
40
+ - **ban** on risky commands (e.g. `full-refresh`, `backfill`, `clear`) in matched content; MCP and (after Phase 2) CI/local check will enforce.
41
+
42
+ Adjust paths or add scopes in `continuum.yaml` to match your layout (e.g. different marts path or extra dbt dirs).
@@ -0,0 +1,42 @@
1
+ version: 0.1
2
+ scopes:
3
+ - id: repo
4
+ match: ["**/*"]
5
+ contracts:
6
+ - type: define
7
+ key: prod_ready
8
+ value: "dbt models tested; DAG changes reviewed; no full-refresh or backfill in code"
9
+
10
+ - id: dbt
11
+ match: ["dbt_project.yml", "models/**", "macros/**", "snapshots/**"]
12
+ precedence: 10
13
+ contracts:
14
+ - type: ask_first
15
+ id: confirm_marts
16
+ match:
17
+ paths: ["models/marts/**"]
18
+ prompt: "Touching dbt marts. Confirm intent: (A) additive (B) breaking (C) refactor-only"
19
+ options: ["additive", "breaking", "refactor-only"]
20
+ - type: require
21
+ id: require_tests_marts
22
+ match:
23
+ paths: ["models/marts/**"]
24
+ require:
25
+ - kind: tests
26
+ hint: "Add or update dbt tests for changed marts models."
27
+
28
+ - id: airflow
29
+ match: ["dags/**"]
30
+ precedence: 10
31
+ contracts:
32
+ - type: ask_first
33
+ id: confirm_dag_changes
34
+ match:
35
+ paths: ["dags/**"]
36
+ prompt: "Touching DAGs (schedule/retry/SLA). Confirm you intend to change orchestration."
37
+ options: ["yes", "no"]
38
+ - type: ban
39
+ id: ban_risky_commands
40
+ match:
41
+ commands: ["*full-refresh*", "*backfill*", "*clear*"]
42
+ message: "Risky commands (dbt full-refresh, airflow backfill/clear) must not be embedded in DAGs; use parameterized runs or manual approval."
@@ -0,0 +1,29 @@
1
+ version: 0.1
2
+ scopes:
3
+ - id: repo
4
+ match: ["**/*"]
5
+ contracts:
6
+ - type: define
7
+ key: prod_ready
8
+ value: "tests + error handling + logging; no banned deps"
9
+ - type: ban
10
+ id: ban_lodash
11
+ match:
12
+ deps: ["lodash"]
13
+ message: "Use native JS or approved utils."
14
+ - id: backend
15
+ match: ["src/**", "lib/**", "server/**"]
16
+ precedence: 10
17
+ contracts:
18
+ - type: require
19
+ id: require_tests
20
+ match:
21
+ paths: ["src/**", "lib/**", "server/**"]
22
+ require:
23
+ - kind: tests
24
+ hint: "Add or adjust unit tests for changed modules."
25
+ - type: ask_first
26
+ id: confirm_migrations
27
+ match:
28
+ paths: ["**/migrations/**", "**/schema/**"]
29
+ prompt: "This touches migrations/schema. Confirm: (A) add-only (B) destructive (C) refactor"
@@ -0,0 +1,29 @@
1
+ version: 0.1
2
+ scopes:
3
+ - id: repo
4
+ match: ["**/*"]
5
+ contracts:
6
+ - type: define
7
+ key: prod_ready
8
+ value: "tests + error handling + logging + metrics; migrations need a plan"
9
+ - type: ban
10
+ id: ban_requests
11
+ match:
12
+ deps: ["requests"]
13
+ message: "Use httpx for async HTTP in FastAPI."
14
+ - id: backend
15
+ match: ["app/**", "src/**", "backend/**"]
16
+ precedence: 10
17
+ contracts:
18
+ - type: require
19
+ id: require_tests
20
+ match:
21
+ paths: ["app/**", "src/**", "backend/**"]
22
+ require:
23
+ - kind: tests
24
+ hint: "Add or adjust tests for changed modules (pytest)."
25
+ - type: ask_first
26
+ id: confirm_migrations
27
+ match:
28
+ paths: ["**/migrations/**", "**/alembic/**", "**/schema/**"]
29
+ prompt: "This touches migrations/schema. Confirm: (A) add-only (B) destructive (C) refactor"
@@ -0,0 +1,29 @@
1
+ version: 0.1
2
+ scopes:
3
+ - id: repo
4
+ match: ["**/*"]
5
+ contracts:
6
+ - type: define
7
+ key: prod_ready
8
+ value: "tests + error handling + logging; use workspace packages"
9
+ - type: ban
10
+ id: ban_lodash
11
+ match:
12
+ deps: ["lodash"]
13
+ message: "Use native JS/TS or workspace utils."
14
+ - id: packages
15
+ match: ["packages/**"]
16
+ precedence: 10
17
+ contracts:
18
+ - type: require
19
+ id: require_tests
20
+ match:
21
+ paths: ["packages/**/src/**"]
22
+ require:
23
+ - kind: tests
24
+ hint: "Add or adjust tests when changing package src."
25
+ - type: ask_first
26
+ id: confirm_migrations
27
+ match:
28
+ paths: ["**/migrations/**", "**/schema/**"]
29
+ prompt: "This touches migrations/schema. Confirm: (A) add-only (B) destructive (C) refactor"
continuum/recommend.py ADDED
@@ -0,0 +1,63 @@
1
+ """Recommend: suggest policy changes from .continuum/state.json. Never auto-writes."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from pathlib import Path
7
+
8
+
9
+ def state_path(repo_root: str | Path) -> Path:
10
+ return Path(repo_root) / ".continuum" / "state.json"
11
+
12
+
13
+ def load_state(repo_root: str | Path) -> dict:
14
+ path = state_path(repo_root)
15
+ if not path.exists():
16
+ return {}
17
+ try:
18
+ return json.loads(path.read_text(encoding="utf-8"))
19
+ except Exception:
20
+ return {}
21
+
22
+
23
+ def recommend(repo_root: str | Path) -> list[str]:
24
+ """Produce suggested YAML/policy changes from state. Never writes. Returns list of suggestion strings."""
25
+ state = load_state(repo_root)
26
+ suggestions: list[str] = []
27
+ results = state.get("check_results") or []
28
+ choices = state.get("choices") or []
29
+ # If user always chose same option for an ask_first contract, suggest encoding default
30
+ by_contract: dict[str, list[str]] = {}
31
+ for c in choices:
32
+ cid = c.get("contract_id") or ""
33
+ choice = c.get("choice") or ""
34
+ if cid:
35
+ by_contract.setdefault(cid, []).append(choice)
36
+ for cid, opts in by_contract.items():
37
+ if len(opts) >= 2 and len(set(opts)) == 1:
38
+ suggestions.append(
39
+ f"You always choose '{opts[0]}' for {cid} → consider adding options/default in continuum.yaml"
40
+ )
41
+ # If we had repeated warnings that were later fixed, suggest block
42
+ warn_count = sum(1 for r in results if r.get("status") == "warn")
43
+ if warn_count >= 3:
44
+ suggestions.append(
45
+ "Multiple 'warn' outcomes in history → consider severity: block for stricter rules"
46
+ )
47
+ # If repeated violations in a path, suggest ask_first
48
+ fired_paths: list[str] = []
49
+ for r in results:
50
+ for f in r.get("fired") or []:
51
+ sid = f.get("scope_id") or ""
52
+ if sid:
53
+ fired_paths.append(sid)
54
+ if len(fired_paths) >= 2:
55
+ from collections import Counter
56
+ most = Counter(fired_paths).most_common(1)
57
+ if most and most[0][1] >= 2:
58
+ suggestions.append(
59
+ f"Repeated violations in scope '{most[0][0]}' → consider adding ask_first for those paths"
60
+ )
61
+ if not suggestions:
62
+ suggestions.append("No suggestions from state yet. Run continuum check a few times and use ask_first choices.")
63
+ return suggestions