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/__init__.py +8 -0
- continuum/change_summary.py +416 -0
- continuum/cli.py +414 -0
- continuum/contracts.py +183 -0
- continuum/engine.py +214 -0
- continuum/mcp_server.py +127 -0
- continuum/packs/data-dbt-airflow/README.md +42 -0
- continuum/packs/data-dbt-airflow/continuum.yaml +42 -0
- continuum/packs/node-backend/continuum.yaml +29 -0
- continuum/packs/python-fastapi/continuum.yaml +29 -0
- continuum/packs/typescript-monorepo/continuum.yaml +29 -0
- continuum/recommend.py +63 -0
- continuum/scan.py +129 -0
- continuum_code-0.2.0.dist-info/METADATA +209 -0
- continuum_code-0.2.0.dist-info/RECORD +18 -0
- continuum_code-0.2.0.dist-info/WHEEL +5 -0
- continuum_code-0.2.0.dist-info/entry_points.txt +2 -0
- continuum_code-0.2.0.dist-info/top_level.txt +1 -0
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)
|
continuum/mcp_server.py
ADDED
|
@@ -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
|