intract 0.1.1__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.
intract/__init__.py ADDED
@@ -0,0 +1,31 @@
1
+ """Intract — intent contracts for codebases."""
2
+
3
+ from .models import (
4
+ Contract,
5
+ ContractRecord,
6
+ ContractSignature,
7
+ ProjectReport,
8
+ ValidationResult,
9
+ ValidationStatus,
10
+ )
11
+ from .parser import extract_contract_records_from_text, parse_contract_line
12
+ from .project import validate_project, validate_sources
13
+ from .signature import build_signature
14
+ from .validation import validate_contract_against_source
15
+
16
+ __all__ = [
17
+ "Contract",
18
+ "ContractRecord",
19
+ "ContractSignature",
20
+ "ProjectReport",
21
+ "ValidationResult",
22
+ "ValidationStatus",
23
+ "parse_contract_line",
24
+ "extract_contract_records_from_text",
25
+ "build_signature",
26
+ "validate_contract_against_source",
27
+ "validate_project",
28
+ "validate_sources",
29
+ ]
30
+
31
+ __version__ = "0.1.1"
intract/__main__.py ADDED
@@ -0,0 +1,4 @@
1
+ from .cli import app
2
+
3
+ if __name__ == "__main__":
4
+ app()
intract/cli.py ADDED
@@ -0,0 +1,93 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from pathlib import Path
5
+
6
+ import typer
7
+ from rich.console import Console
8
+ from rich.table import Table
9
+
10
+ from . import __version__
11
+ from .parser import extract_contract_records_from_text
12
+ from .project import validate_project
13
+ from .yaml_manifest import create_sample_manifest
14
+
15
+ app = typer.Typer(help="Intract — intent contracts for codebases.")
16
+ console = Console()
17
+
18
+
19
+ @app.callback(invoke_without_command=True)
20
+ def main(version: bool = typer.Option(False, "--version", help="Show version.")):
21
+ if version:
22
+ console.print(f"intract {__version__}")
23
+ raise typer.Exit()
24
+
25
+
26
+ @app.command()
27
+ def init(path: Path = typer.Argument(Path("."), help="Project path."), force: bool = typer.Option(False, "--force", help="Overwrite existing intent.yaml.")):
28
+ """Create a sample intent.yaml manifest."""
29
+ target = path / "intent.yaml"
30
+ if target.exists() and not force:
31
+ console.print(f"[red]File already exists:[/] {target}")
32
+ raise typer.Exit(1)
33
+ target.write_text(create_sample_manifest(), encoding="utf-8")
34
+ console.print(f"[green]Created[/] {target}")
35
+
36
+
37
+ @app.command()
38
+ def scan(path: Path = typer.Argument(Path("."), help="File or directory."), json_output: bool = typer.Option(False, "--json", help="Print JSON.")):
39
+ """Scan files for inline @intract contracts."""
40
+ records = []
41
+ if path.is_file():
42
+ records.extend(extract_contract_records_from_text(path.read_text(encoding="utf-8"), file_path=str(path)))
43
+ else:
44
+ for file_path in path.rglob("*"):
45
+ if not file_path.is_file():
46
+ continue
47
+ if file_path.suffix not in {".py", ".js", ".ts", ".cs", ".java", ".go", ".rs", ".md"}:
48
+ continue
49
+ try:
50
+ records.extend(extract_contract_records_from_text(file_path.read_text(encoding="utf-8"), file_path=str(file_path.relative_to(path))))
51
+ except UnicodeDecodeError:
52
+ continue
53
+
54
+ data = [{"file": r.file_path, "line": r.start_line, "scope": r.contract.scope, "intent": r.contract.key, "priority": r.contract.priority, "domain": r.contract.domain} for r in records]
55
+ if json_output:
56
+ console.print_json(json.dumps(data, ensure_ascii=False))
57
+ return
58
+
59
+ table = Table(title="Intract contracts")
60
+ table.add_column("File")
61
+ table.add_column("Line")
62
+ table.add_column("Scope")
63
+ table.add_column("Intent")
64
+ table.add_column("Priority")
65
+ table.add_column("Domain")
66
+ for item in data:
67
+ table.add_row(item["file"], str(item["line"]), item["scope"], item["intent"], str(item["priority"]), item["domain"])
68
+ console.print(table)
69
+
70
+
71
+ @app.command()
72
+ def validate(path: Path = typer.Argument(Path("."), help="Project path."), manifest: Path | None = typer.Option(None, "--manifest", help="intent.yaml / intract.yaml."), json_output: bool = typer.Option(False, "--json", help="Print JSON report.")):
73
+ """Validate project contracts."""
74
+ report = validate_project(path, manifest_path=manifest)
75
+ if json_output:
76
+ console.print_json(json.dumps(report.to_dict(), ensure_ascii=False))
77
+ return
78
+
79
+ console.print(f"[bold]Project:[/] {report.project_path}")
80
+ console.print(f"[bold]Status:[/] {report.status.value}")
81
+ table = Table(title="Validation results")
82
+ table.add_column("Status")
83
+ table.add_column("Scope")
84
+ table.add_column("Contract")
85
+ table.add_column("Score")
86
+ table.add_column("File")
87
+ table.add_column("Missing / Violations")
88
+ for result in report.results:
89
+ issues = []
90
+ issues.extend(result.missing)
91
+ issues.extend(issue.message for issue in result.violations)
92
+ table.add_row(result.status.value, result.scope, result.contract, str(result.score), result.file_path or "", "\\n".join(issues))
93
+ console.print(table)
intract/effects.py ADDED
@@ -0,0 +1,21 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+
5
+ NETWORK_PATTERNS = [r"\brequests\.", r"\bhttpx\.", r"\burllib\.", r"\bsocket\.", r"\bfetch\s*\(", r"\bHttpClient\b"]
6
+ WRITE_PATTERNS = [r"\.write\s*\(", r"\.write_text\s*\(", r"\bopen\s*\([^)]*['\"]w", r"\bFile\.Write", r"\bDirectory\.Create", r"\bINSERT\b", r"\bUPDATE\b", r"\bDELETE\b"]
7
+ READ_PATTERNS = [r"\.read\s*\(", r"\.read_text\s*\(", r"\bopen\s*\(", r"\bFile\.Read", r"\bDirectory\.GetFiles", r"\bSELECT\b"]
8
+ LOG_PATTERNS = [r"\bprint\s*\(", r"\blogger\.", r"\bconsole\.log\s*\(", r"\bConsole\.Write"]
9
+
10
+
11
+ def detect_effects(source: str) -> set[str]:
12
+ effects: set[str] = set()
13
+ if any(re.search(pattern, source, re.IGNORECASE) for pattern in NETWORK_PATTERNS):
14
+ effects.add("network")
15
+ if any(re.search(pattern, source, re.IGNORECASE) for pattern in WRITE_PATTERNS):
16
+ effects.add("write")
17
+ if any(re.search(pattern, source, re.IGNORECASE) for pattern in READ_PATTERNS):
18
+ effects.add("read")
19
+ if any(re.search(pattern, source, re.IGNORECASE) for pattern in LOG_PATTERNS):
20
+ effects.add("log")
21
+ return effects
intract/models.py ADDED
@@ -0,0 +1,153 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import asdict, dataclass, field
4
+ from enum import Enum
5
+ from typing import Any
6
+
7
+
8
+ class ValidationStatus(str, Enum):
9
+ PASS = "pass"
10
+ PARTIAL = "partial"
11
+ FAIL = "fail"
12
+ VIOLATION = "violation"
13
+ UNKNOWN = "unknown"
14
+
15
+
16
+ VALID_SCOPES = {
17
+ "line",
18
+ "block",
19
+ "function",
20
+ "method",
21
+ "class",
22
+ "file",
23
+ "module",
24
+ "package",
25
+ "project",
26
+ }
27
+
28
+
29
+ @dataclass(frozen=True)
30
+ class Contract:
31
+ action: str
32
+ object: str
33
+ scope: str = "block"
34
+ priority: int = 3
35
+ domain: str = ""
36
+ inputs: tuple[str, ...] = field(default_factory=tuple)
37
+ outputs: tuple[str, ...] = field(default_factory=tuple)
38
+ effects: tuple[str, ...] = field(default_factory=tuple)
39
+ forbidden: tuple[str, ...] = field(default_factory=tuple)
40
+ required: tuple[str, ...] = field(default_factory=tuple)
41
+ validators: tuple[str, ...] = field(default_factory=tuple)
42
+ tags: tuple[str, ...] = field(default_factory=tuple)
43
+ algorithms: tuple[str, ...] = field(default_factory=tuple)
44
+ relations: tuple[str, ...] = field(default_factory=tuple)
45
+ contract_id: str = ""
46
+ meaning: str = ""
47
+ raw: str = ""
48
+
49
+ @property
50
+ def key(self) -> str:
51
+ return f"{self.action}.{self.object}"
52
+
53
+
54
+ @dataclass(frozen=True)
55
+ class ContractRecord:
56
+ contract: Contract
57
+ file_path: str
58
+ start_line: int
59
+ end_line: int
60
+ owner: str = ""
61
+
62
+
63
+ @dataclass(frozen=True)
64
+ class ContractSignature:
65
+ block_id: str
66
+ file_path: str
67
+ start_line: int
68
+ end_line: int
69
+ scope: str
70
+ action: str
71
+ object: str
72
+ domain: str
73
+ priority: int
74
+ inputs: frozenset[str] = field(default_factory=frozenset)
75
+ outputs: frozenset[str] = field(default_factory=frozenset)
76
+ effects: frozenset[str] = field(default_factory=frozenset)
77
+ forbidden: frozenset[str] = field(default_factory=frozenset)
78
+ required: frozenset[str] = field(default_factory=frozenset)
79
+ validators: frozenset[str] = field(default_factory=frozenset)
80
+ tags: frozenset[str] = field(default_factory=frozenset)
81
+ algorithms: frozenset[str] = field(default_factory=frozenset)
82
+ relations: frozenset[str] = field(default_factory=frozenset)
83
+ features: frozenset[str] = field(default_factory=frozenset)
84
+ exact_hash: str = ""
85
+ raw: str = ""
86
+ meaning: str = ""
87
+
88
+ @property
89
+ def key(self) -> str:
90
+ return f"{self.action}.{self.object}"
91
+
92
+
93
+ @dataclass(frozen=True)
94
+ class ValidationIssue:
95
+ kind: str
96
+ message: str
97
+ severity: str = "error"
98
+
99
+
100
+ @dataclass
101
+ class ValidationResult:
102
+ contract: str
103
+ scope: str
104
+ status: ValidationStatus
105
+ score: float = 0.0
106
+ file_path: str | None = None
107
+ lines: tuple[int, int] | None = None
108
+ matched: dict[str, Any] = field(default_factory=dict)
109
+ missing: list[str] = field(default_factory=list)
110
+ violations: list[ValidationIssue] = field(default_factory=list)
111
+ evidence: dict[str, Any] = field(default_factory=dict)
112
+
113
+ def to_dict(self) -> dict[str, Any]:
114
+ data = asdict(self)
115
+ data["status"] = self.status.value
116
+ return data
117
+
118
+
119
+ @dataclass
120
+ class ProjectReport:
121
+ project_path: str
122
+ status: ValidationStatus
123
+ results: list[ValidationResult] = field(default_factory=list)
124
+
125
+ @property
126
+ def passed(self) -> list[ValidationResult]:
127
+ return [item for item in self.results if item.status == ValidationStatus.PASS]
128
+
129
+ @property
130
+ def partial(self) -> list[ValidationResult]:
131
+ return [item for item in self.results if item.status == ValidationStatus.PARTIAL]
132
+
133
+ @property
134
+ def failed(self) -> list[ValidationResult]:
135
+ return [item for item in self.results if item.status == ValidationStatus.FAIL]
136
+
137
+ @property
138
+ def violations(self) -> list[ValidationResult]:
139
+ return [item for item in self.results if item.status == ValidationStatus.VIOLATION]
140
+
141
+ def to_dict(self) -> dict[str, Any]:
142
+ return {
143
+ "project_path": self.project_path,
144
+ "status": self.status.value,
145
+ "summary": {
146
+ "total": len(self.results),
147
+ "passed": len(self.passed),
148
+ "partial": len(self.partial),
149
+ "failed": len(self.failed),
150
+ "violations": len(self.violations),
151
+ },
152
+ "results": [item.to_dict() for item in self.results],
153
+ }
intract/normalizer.py ADDED
@@ -0,0 +1,69 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ from collections.abc import Iterable
5
+
6
+
7
+ ACTION_SYNONYMS = {
8
+ "validate": {"validate", "check", "verify", "ensure", "authorize", "guard"},
9
+ "build": {"build", "create", "make", "construct", "generate", "assemble"},
10
+ "parse": {"parse", "read", "load", "deserialize", "extract"},
11
+ "compare": {"compare", "diff", "match"},
12
+ "scan": {"scan", "collect", "discover", "find"},
13
+ "render": {"render", "format", "serialize", "export"},
14
+ "persist": {"persist", "save", "write", "store"},
15
+ "transform": {"transform", "normalize", "convert", "map"},
16
+ "detect": {"detect", "identify", "recognize", "classify"},
17
+ "calculate": {"calculate", "compute", "count", "measure", "estimate"},
18
+ "implement": {"implement", "provide", "expose"},
19
+ "analyze": {"analyze", "inspect", "evaluate"},
20
+ "group": {"group", "cluster", "merge"},
21
+ "plan": {"plan", "suggest", "recommend"},
22
+ }
23
+
24
+ STOP_WORDS = {"a", "an", "the", "to", "from", "of", "for", "before", "after", "with", "without", "and", "or", "by"}
25
+
26
+
27
+ def normalize_label(value: str | None) -> str:
28
+ if not value:
29
+ return ""
30
+ text = str(value).strip()
31
+ text = re.sub(r"(?<=[a-z])(?=[A-Z])", "_", text)
32
+ text = text.replace("-", "_").replace("/", "_").replace(".", "_").replace(":", "_")
33
+ text = re.sub(r"[^A-Za-z0-9_]+", "_", text)
34
+ text = re.sub(r"_+", "_", text).strip("_").lower()
35
+ parts = []
36
+ for part in text.split("_"):
37
+ if not part or part in STOP_WORDS:
38
+ continue
39
+ if len(part) > 4 and part.endswith("s"):
40
+ part = part[:-1]
41
+ parts.append(part)
42
+ return "_".join(parts)
43
+
44
+
45
+ def normalize_action(action: str | None) -> str:
46
+ normalized = normalize_label(action)
47
+ for canonical, variants in ACTION_SYNONYMS.items():
48
+ if normalized in {normalize_label(item) for item in variants}:
49
+ return canonical
50
+ return normalized
51
+
52
+
53
+ def normalize_many(values: Iterable[str] | None) -> tuple[str, ...]:
54
+ if not values:
55
+ return ()
56
+ normalized = {normalize_label(value) for value in values if normalize_label(value)}
57
+ return tuple(sorted(normalized))
58
+
59
+
60
+ def normalize_requirement(value: str) -> str:
61
+ value = str(value).strip().replace(":", ".")
62
+ parts = [part for part in value.split(".") if part]
63
+ if len(parts) == 1:
64
+ return normalize_label(parts[0])
65
+ action = normalize_action(parts[0])
66
+ obj = normalize_label("_".join(parts[1:]))
67
+ if not action or not obj:
68
+ return normalize_label(value)
69
+ return f"{action}.{obj}"
intract/parser.py ADDED
@@ -0,0 +1,194 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ import shlex
5
+
6
+ from .models import Contract, ContractRecord, VALID_SCOPES
7
+
8
+
9
+ CONTRACT_MARKERS = ("@intract.v1", "@intract", "@ridl.v1")
10
+
11
+
12
+ def clean_comment_line(line: str) -> str:
13
+ text = line.strip()
14
+ if text.startswith("<!--"):
15
+ text = text[4:].strip()
16
+ if text.endswith("-->"):
17
+ text = text[:-3].strip()
18
+ if text.startswith("/*"):
19
+ text = text[2:].strip()
20
+ if text.endswith("*/"):
21
+ text = text[:-2].strip()
22
+ while text.startswith("*"):
23
+ text = text[1:].strip()
24
+ for prefix in ("#", "//", "--", ";"):
25
+ if text.startswith(prefix):
26
+ return text[len(prefix):].strip()
27
+ return text
28
+
29
+
30
+ def split_csv(value: str | None) -> tuple[str, ...]:
31
+ if value is None:
32
+ return ()
33
+ text = str(value).strip()
34
+ if not text or text.lower() in {"none", "null", "empty", "-"}:
35
+ return ()
36
+ return tuple(part.strip() for part in text.split(",") if part.strip())
37
+
38
+
39
+ def parse_priority(token: str) -> int | None:
40
+ text = token.strip().lower()
41
+ if text.startswith("!p") and text[2:].isdigit():
42
+ raw = text[2:]
43
+ elif text.startswith("p") and text[1:].isdigit():
44
+ raw = text[1:]
45
+ else:
46
+ return None
47
+ return max(1, min(5, int(raw)))
48
+
49
+
50
+ def parse_key_value(token: str) -> tuple[str, str] | None:
51
+ match = re.match(r"^([A-Za-z_][A-Za-z0-9_-]*)(:|=)(.+)$", token)
52
+ if not match:
53
+ return None
54
+ return match.group(1).lower(), match.group(3).strip()
55
+
56
+
57
+ def marker_payload(line: str) -> str | None:
58
+ cleaned = clean_comment_line(line)
59
+ for marker in CONTRACT_MARKERS:
60
+ if marker in cleaned:
61
+ return cleaned.split(marker, 1)[1].strip()
62
+ return None
63
+
64
+
65
+ def parse_contract_line(line: str, *, default_scope: str = "block") -> Contract | None:
66
+ payload = marker_payload(line)
67
+ if payload is None or not payload:
68
+ return None
69
+
70
+ tokens = shlex.split(payload, comments=False, posix=True)
71
+ action = ""
72
+ object_name = ""
73
+ priority = 3
74
+ scope = default_scope
75
+ domain = ""
76
+ inputs: list[str] = []
77
+ outputs: list[str] = []
78
+ effects: list[str] = []
79
+ forbidden: list[str] = []
80
+ required: list[str] = []
81
+ validators: list[str] = []
82
+ tags: list[str] = []
83
+ algorithms: list[str] = []
84
+ relations: list[str] = []
85
+ contract_id = ""
86
+ meaning = ""
87
+ positional: list[str] = []
88
+
89
+ for token in tokens:
90
+ priority_value = parse_priority(token)
91
+ if priority_value is not None:
92
+ priority = priority_value
93
+ continue
94
+ if token.startswith("@") and len(token) > 1:
95
+ domain = token[1:]
96
+ continue
97
+ if token.startswith("#") and len(token) > 1:
98
+ tags.append(token[1:])
99
+ continue
100
+ if token.startswith("~") and len(token) > 1:
101
+ algorithms.append(token[1:])
102
+ continue
103
+
104
+ kv = parse_key_value(token)
105
+ if kv is None:
106
+ if ":" in token and not action and not object_name:
107
+ action, object_name = token.split(":", 1)
108
+ else:
109
+ positional.append(token)
110
+ continue
111
+
112
+ key, value = kv
113
+ if key == "intent":
114
+ if ":" in value:
115
+ action, object_name = value.split(":", 1)
116
+ else:
117
+ positional.append(value)
118
+ elif key in {"action", "act"}:
119
+ action = value
120
+ elif key in {"object", "obj", "target"}:
121
+ object_name = value
122
+ elif key == "scope":
123
+ scope = value if value in VALID_SCOPES else default_scope
124
+ elif key == "priority":
125
+ if value.isdigit():
126
+ priority = max(1, min(5, int(value)))
127
+ elif key == "domain":
128
+ domain = value
129
+ elif key in {"input", "inputs", "in"}:
130
+ inputs.extend(split_csv(value))
131
+ elif key in {"output", "outputs", "out"}:
132
+ outputs.extend(split_csv(value))
133
+ elif key in {"effect", "effects", "fx"}:
134
+ effects.extend(split_csv(value))
135
+ elif key in {"forbid", "forbidden", "no"}:
136
+ forbidden.extend(split_csv(value))
137
+ elif key in {"require", "requires", "req"}:
138
+ required.extend(split_csv(value))
139
+ elif key in {"validate", "validators", "rules"}:
140
+ validators.extend(split_csv(value))
141
+ elif key in {"tag", "tags"}:
142
+ tags.extend(split_csv(value))
143
+ elif key in {"algorithm", "algorithms", "alg", "algo"}:
144
+ algorithms.extend(split_csv(value))
145
+ elif key in {"relation", "relations", "rel", "uses", "partof", "before", "after"}:
146
+ relations.extend(f"{key}:{item}" for item in split_csv(value))
147
+ elif key in {"id", "contract_id"}:
148
+ contract_id = value
149
+ elif key == "meaning":
150
+ meaning = value
151
+ else:
152
+ tags.append(f"{key}:{value}")
153
+
154
+ if not action and positional:
155
+ action = positional[0]
156
+ if not object_name and len(positional) > 1:
157
+ object_name = "_".join(positional[1:])
158
+ if not action or not object_name:
159
+ return None
160
+
161
+ return Contract(
162
+ action=action,
163
+ object=object_name,
164
+ scope=scope,
165
+ priority=priority,
166
+ domain=domain,
167
+ inputs=tuple(inputs),
168
+ outputs=tuple(outputs),
169
+ effects=tuple(effects),
170
+ forbidden=tuple(forbidden),
171
+ required=tuple(required),
172
+ validators=tuple(validators),
173
+ tags=tuple(tags),
174
+ algorithms=tuple(algorithms),
175
+ relations=tuple(relations),
176
+ contract_id=contract_id,
177
+ meaning=meaning,
178
+ raw=payload,
179
+ )
180
+
181
+
182
+ def extract_contract_records_from_text(
183
+ source: str,
184
+ *,
185
+ file_path: str,
186
+ default_scope: str = "block",
187
+ ) -> list[ContractRecord]:
188
+ records: list[ContractRecord] = []
189
+ for index, line in enumerate(source.splitlines(), start=1):
190
+ contract = parse_contract_line(line, default_scope=default_scope)
191
+ if contract is None:
192
+ continue
193
+ records.append(ContractRecord(contract=contract, file_path=file_path, start_line=index, end_line=index))
194
+ return records
intract/project.py ADDED
@@ -0,0 +1,88 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+
5
+ from .models import ProjectReport, ValidationResult, ValidationStatus
6
+ from .parser import extract_contract_records_from_text
7
+ from .signature import build_signatures
8
+ from .validation import validate_contract_against_source, validate_required_contracts
9
+ from .yaml_manifest import load_manifest_records
10
+
11
+ DEFAULT_EXTENSIONS = (".py", ".js", ".ts", ".tsx", ".jsx", ".java", ".cs", ".go", ".rs", ".php", ".rb", ".sh", ".sql", ".html", ".css", ".md", ".yaml", ".yml")
12
+ SKIP_DIRS = {".git", ".venv", "venv", "__pycache__", "node_modules", "dist", "build"}
13
+
14
+
15
+ def load_project_sources(root: Path, *, extensions: tuple[str, ...] = DEFAULT_EXTENSIONS) -> dict[str, str]:
16
+ sources: dict[str, str] = {}
17
+ for path in root.rglob("*"):
18
+ if not path.is_file():
19
+ continue
20
+ if any(part in SKIP_DIRS for part in path.parts):
21
+ continue
22
+ if path.suffix not in extensions:
23
+ continue
24
+ rel_path = str(path.relative_to(root))
25
+ try:
26
+ sources[rel_path] = path.read_text(encoding="utf-8")
27
+ except UnicodeDecodeError:
28
+ continue
29
+ return sources
30
+
31
+
32
+ def extract_signatures_from_sources(sources: dict[str, str]):
33
+ records = []
34
+ for file_path, source in sources.items():
35
+ records.extend(extract_contract_records_from_text(source, file_path=file_path, default_scope="block"))
36
+ return build_signatures(records)
37
+
38
+
39
+ def validate_sources(sources: dict[str, str], *, manifest_records=None) -> ProjectReport:
40
+ observed_signatures = extract_signatures_from_sources(sources)
41
+ observed_by_file = {file_path: source for file_path, source in sources.items()}
42
+ results: list[ValidationResult] = []
43
+
44
+ for signature in observed_signatures:
45
+ source = observed_by_file.get(signature.file_path, "")
46
+ results.append(validate_contract_against_source(signature, source))
47
+
48
+ if manifest_records:
49
+ manifest_signatures = build_signatures(manifest_records)
50
+ for required_signature in manifest_signatures:
51
+ satisfied, missing = validate_required_contracts(required_signature, observed_signatures)
52
+ source = observed_by_file.get(required_signature.file_path, "")
53
+ result = validate_contract_against_source(required_signature, source)
54
+ if missing:
55
+ result.status = ValidationStatus.PARTIAL if satisfied else ValidationStatus.FAIL
56
+ result.missing.extend(f"require:{item}" for item in missing)
57
+ result.matched["satisfied_requirements"] = satisfied
58
+ result.evidence["manifest_contract"] = True
59
+ results.append(result)
60
+
61
+ if any(item.status == ValidationStatus.VIOLATION for item in results):
62
+ status = ValidationStatus.VIOLATION
63
+ elif results and all(item.status == ValidationStatus.PASS for item in results):
64
+ status = ValidationStatus.PASS
65
+ elif any(item.status in {ValidationStatus.PASS, ValidationStatus.PARTIAL} for item in results):
66
+ status = ValidationStatus.PARTIAL
67
+ elif results:
68
+ status = ValidationStatus.FAIL
69
+ else:
70
+ status = ValidationStatus.UNKNOWN
71
+ return ProjectReport(project_path="<sources>", status=status, results=results)
72
+
73
+
74
+ def validate_project(root: Path | str, *, manifest_path: Path | str | None = None) -> ProjectReport:
75
+ project_root = Path(root)
76
+ sources = load_project_sources(project_root)
77
+ manifest_records = None
78
+ if manifest_path:
79
+ manifest_records = load_manifest_records(Path(manifest_path))
80
+ else:
81
+ for candidate in ("intent.yaml", "intract.yaml", ".intract.yaml"):
82
+ path = project_root / candidate
83
+ if path.exists():
84
+ manifest_records = load_manifest_records(path)
85
+ break
86
+ report = validate_sources(sources, manifest_records=manifest_records)
87
+ report.project_path = str(project_root)
88
+ return report
intract/signature.py ADDED
@@ -0,0 +1,78 @@
1
+ from __future__ import annotations
2
+
3
+ import hashlib
4
+
5
+ from .models import ContractRecord, ContractSignature
6
+ from .normalizer import normalize_action, normalize_label, normalize_many, normalize_requirement
7
+
8
+
9
+ def make_block_id(file_path: str, start_line: int, end_line: int, scope: str) -> str:
10
+ raw = f"{file_path}:{start_line}:{end_line}:{scope}"
11
+ return hashlib.sha1(raw.encode("utf-8")).hexdigest()[:16]
12
+
13
+
14
+ def build_signature(record: ContractRecord) -> ContractSignature:
15
+ contract = record.contract
16
+ action = normalize_action(contract.action)
17
+ object_name = normalize_label(contract.object)
18
+ domain = normalize_label(contract.domain)
19
+ inputs = frozenset(normalize_many(contract.inputs))
20
+ outputs = frozenset(normalize_many(contract.outputs))
21
+ effects = frozenset(normalize_many(contract.effects))
22
+ forbidden = frozenset(normalize_many(contract.forbidden))
23
+ required = frozenset(normalize_requirement(item) for item in contract.required)
24
+ validators = frozenset(normalize_many(contract.validators))
25
+ tags = frozenset(normalize_many(contract.tags))
26
+ algorithms = frozenset(normalize_many(contract.algorithms))
27
+ relations = frozenset(normalize_many(contract.relations))
28
+
29
+ features: set[str] = {
30
+ f"action:{action}",
31
+ f"object:{object_name}",
32
+ f"priority:{contract.priority}",
33
+ f"scope:{contract.scope}",
34
+ }
35
+ if domain:
36
+ features.add(f"domain:{domain}")
37
+
38
+ features.update(f"input:{item}" for item in inputs)
39
+ features.update(f"output:{item}" for item in outputs)
40
+ features.update(f"effect:{item}" for item in effects)
41
+ features.update(f"forbid:{item}" for item in forbidden)
42
+ features.update(f"require:{item}" for item in required)
43
+ features.update(f"validate:{item}" for item in validators)
44
+ features.update(f"tag:{item}" for item in tags)
45
+ features.update(f"algorithm:{item}" for item in algorithms)
46
+ features.update(f"relation:{item}" for item in relations)
47
+
48
+ exact_hash = hashlib.sha256("\\n".join(sorted(features)).encode("utf-8")).hexdigest()
49
+ block_id = contract.contract_id or make_block_id(record.file_path, record.start_line, record.end_line, contract.scope)
50
+
51
+ return ContractSignature(
52
+ block_id=block_id,
53
+ file_path=record.file_path,
54
+ start_line=record.start_line,
55
+ end_line=record.end_line,
56
+ scope=contract.scope,
57
+ action=action,
58
+ object=object_name,
59
+ domain=domain,
60
+ priority=contract.priority,
61
+ inputs=inputs,
62
+ outputs=outputs,
63
+ effects=effects,
64
+ forbidden=forbidden,
65
+ required=frozenset(item for item in required if item),
66
+ validators=validators,
67
+ tags=tags,
68
+ algorithms=algorithms,
69
+ relations=relations,
70
+ features=frozenset(features),
71
+ exact_hash=exact_hash,
72
+ raw=contract.raw,
73
+ meaning=contract.meaning,
74
+ )
75
+
76
+
77
+ def build_signatures(records: list[ContractRecord]) -> list[ContractSignature]:
78
+ return [build_signature(record) for record in records]
intract/validation.py ADDED
@@ -0,0 +1,102 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+
5
+ from .effects import detect_effects
6
+ from .models import ContractSignature, ValidationIssue, ValidationResult, ValidationStatus
7
+ from .normalizer import normalize_label
8
+
9
+ DEFAULT_VALIDATORS = {"input_presence", "output_presence", "return_value", "no_forbidden_effect"}
10
+
11
+
12
+ def contains_token_like(source: str, token: str) -> bool:
13
+ normalized_source = normalize_label(source)
14
+ normalized_token = normalize_label(token)
15
+ if not normalized_token:
16
+ return True
17
+ if normalized_token in normalized_source:
18
+ return True
19
+ parts = [part for part in normalized_token.split("_") if part]
20
+ return bool(parts) and all(part in normalized_source for part in parts)
21
+
22
+
23
+ def has_return_value(source: str) -> bool:
24
+ return bool(re.search(r"\breturn\b", source) or re.search(r"=>", source) or re.search(r"\byield\b", source))
25
+
26
+
27
+ def validate_contract_against_source(
28
+ signature: ContractSignature,
29
+ source: str,
30
+ *,
31
+ pass_threshold: float = 0.84,
32
+ partial_threshold: float = 0.65,
33
+ ) -> ValidationResult:
34
+ validators = set(signature.validators) or DEFAULT_VALIDATORS
35
+ missing: list[str] = []
36
+ violations: list[ValidationIssue] = []
37
+ matched: dict[str, object] = {}
38
+ score_parts: list[float] = []
39
+
40
+ if "input_presence" in validators:
41
+ found = [item for item in signature.inputs if contains_token_like(source, item)]
42
+ missing_inputs = sorted(signature.inputs - set(found))
43
+ matched["inputs_found"] = found
44
+ if missing_inputs:
45
+ missing.extend(f"input:{item}" for item in missing_inputs)
46
+ score_parts.append(len(found) / max(1, len(signature.inputs)) if signature.inputs else 1.0)
47
+
48
+ if "output_presence" in validators:
49
+ found = [item for item in signature.outputs if contains_token_like(source, item)]
50
+ missing_outputs = sorted(signature.outputs - set(found))
51
+ matched["outputs_found"] = found
52
+ if missing_outputs:
53
+ missing.extend(f"output:{item}" for item in missing_outputs)
54
+ score_parts.append(len(found) / max(1, len(signature.outputs)) if signature.outputs else 1.0)
55
+
56
+ if "return_value" in validators:
57
+ has_return = has_return_value(source)
58
+ matched["has_return"] = has_return
59
+ if not has_return:
60
+ missing.append("return_value")
61
+ score_parts.append(1.0 if has_return else 0.0)
62
+
63
+ observed_effects = detect_effects(source)
64
+ matched["observed_effects"] = sorted(observed_effects)
65
+
66
+ if "no_forbidden_effect" in validators:
67
+ forbidden_hits = sorted(signature.forbidden & observed_effects)
68
+ for item in forbidden_hits:
69
+ violations.append(
70
+ ValidationIssue(kind="forbidden_effect", message=f"Declared forbid:{item}, but effect '{item}' was detected.")
71
+ )
72
+ score_parts.append(1.0 if not forbidden_hits else 0.0)
73
+
74
+ score = sum(score_parts) / max(1, len(score_parts))
75
+ if violations:
76
+ status = ValidationStatus.VIOLATION
77
+ elif score >= pass_threshold and not missing:
78
+ status = ValidationStatus.PASS
79
+ elif score >= partial_threshold or missing:
80
+ status = ValidationStatus.PARTIAL
81
+ else:
82
+ status = ValidationStatus.FAIL
83
+
84
+ return ValidationResult(
85
+ contract=signature.key,
86
+ scope=signature.scope,
87
+ status=status,
88
+ score=round(score, 4),
89
+ file_path=signature.file_path,
90
+ lines=(signature.start_line, signature.end_line),
91
+ matched=matched,
92
+ missing=missing,
93
+ violations=violations,
94
+ evidence={"meaning": signature.meaning, "validators": sorted(validators)},
95
+ )
96
+
97
+
98
+ def validate_required_contracts(required_signature: ContractSignature, observed_signatures: list[ContractSignature]) -> tuple[list[str], list[str]]:
99
+ observed_keys = {item.key for item in observed_signatures}
100
+ satisfied = sorted(item for item in required_signature.required if item in observed_keys)
101
+ missing = sorted(item for item in required_signature.required if item not in observed_keys)
102
+ return satisfied, missing
@@ -0,0 +1,113 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+ from typing import Any
5
+
6
+ import yaml
7
+
8
+ from .models import Contract, ContractRecord
9
+
10
+
11
+ def _to_tuple(value: Any) -> tuple[str, ...]:
12
+ if value is None:
13
+ return ()
14
+ if isinstance(value, str):
15
+ if value.lower() in {"none", "null", "-"}:
16
+ return ()
17
+ return tuple(part.strip() for part in value.split(",") if part.strip())
18
+ if isinstance(value, (list, tuple, set)):
19
+ return tuple(str(item) for item in value if str(item).strip())
20
+ return (str(value),)
21
+
22
+
23
+ def _parse_intent(value: str) -> tuple[str, str]:
24
+ if ":" in value:
25
+ left, right = value.split(":", 1)
26
+ return left.strip(), right.strip()
27
+ if "." in value:
28
+ left, right = value.split(".", 1)
29
+ return left.strip(), right.strip()
30
+ return value.strip(), "unknown"
31
+
32
+
33
+ def contract_from_mapping(data: dict[str, Any]) -> Contract:
34
+ action = str(data.get("action", "")).strip()
35
+ object_name = str(data.get("object", data.get("obj", ""))).strip()
36
+ if not action or not object_name:
37
+ intent = str(data.get("intent", "")).strip()
38
+ action, object_name = _parse_intent(intent)
39
+ return Contract(
40
+ action=action,
41
+ object=object_name,
42
+ scope=str(data.get("scope", "block")),
43
+ priority=int(data.get("priority", 3)),
44
+ domain=str(data.get("domain", "")),
45
+ inputs=_to_tuple(data.get("input", data.get("inputs", data.get("in")))),
46
+ outputs=_to_tuple(data.get("output", data.get("outputs", data.get("out")))),
47
+ effects=_to_tuple(data.get("effect", data.get("effects", data.get("fx")))),
48
+ forbidden=_to_tuple(data.get("forbid", data.get("forbidden", data.get("no")))),
49
+ required=_to_tuple(data.get("require", data.get("requires", data.get("req")))),
50
+ validators=_to_tuple(data.get("validate", data.get("validators", data.get("rules")))),
51
+ tags=_to_tuple(data.get("tags", data.get("tag"))),
52
+ algorithms=_to_tuple(data.get("algorithms", data.get("algorithm", data.get("alg")))),
53
+ relations=_to_tuple(data.get("relations", data.get("relation", data.get("rel")))),
54
+ contract_id=str(data.get("id", data.get("contract_id", ""))),
55
+ meaning=str(data.get("meaning", "")),
56
+ raw=str(data),
57
+ )
58
+
59
+
60
+ def load_manifest_records(path: Path) -> list[ContractRecord]:
61
+ data = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
62
+ records: list[ContractRecord] = []
63
+ for index, item in enumerate(data.get("contracts", []) or [], start=1):
64
+ if isinstance(item, dict):
65
+ records.append(ContractRecord(contract=contract_from_mapping(item), file_path=str(path), start_line=index, end_line=index))
66
+ files = data.get("files", {}) or {}
67
+ if isinstance(files, dict):
68
+ for file_path, file_contracts in files.items():
69
+ if not isinstance(file_contracts, list):
70
+ continue
71
+ for index, item in enumerate(file_contracts, start=1):
72
+ if isinstance(item, dict):
73
+ records.append(ContractRecord(contract=contract_from_mapping(item), file_path=str(file_path), start_line=index, end_line=index))
74
+ return records
75
+
76
+
77
+ def create_sample_manifest() -> str:
78
+ return """project:
79
+ name: intract-sample
80
+
81
+ contracts:
82
+ - id: project.analysis
83
+ scope: project
84
+ intent: analyze:code_duplication
85
+ priority: 1
86
+ domain: project
87
+ input: [source_tree]
88
+ output: [DuplicationMap, RefactorSuggestion]
89
+ effect: [read]
90
+ forbid: [network]
91
+ require:
92
+ - scan.project_files
93
+ - extract.code_blocks
94
+ - detect.duplicates
95
+ - render.report
96
+ validate:
97
+ - required_intents
98
+ - no_forbidden_effect
99
+ meaning: "Project should analyze source code duplication and produce refactoring guidance."
100
+
101
+ files:
102
+ src/scanner.py:
103
+ - scope: file
104
+ intent: scan:project_files
105
+ priority: 1
106
+ domain: scanner
107
+ input: [ScanConfig]
108
+ output: [file_list]
109
+ effect: [read]
110
+ forbid: [network]
111
+ validate: [no_forbidden_effect]
112
+ meaning: "Scanner should collect project files."
113
+ """
@@ -0,0 +1,141 @@
1
+ Metadata-Version: 2.4
2
+ Name: intract
3
+ Version: 0.1.1
4
+ Summary: Intent contract tagging, validation and semantic mapping for codebases.
5
+ Author: Intract Contributors
6
+ Author-email: Tom Sapletta <tom@sapletta.com>
7
+ License-Expression: Apache-2.0
8
+ License-File: LICENSE
9
+ Keywords: code-quality,contract,intent,refactoring,static-analysis,validation
10
+ Classifier: Development Status :: 3 - Alpha
11
+ Classifier: Environment :: Console
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Programming Language :: Python :: 3.10
15
+ Classifier: Programming Language :: Python :: 3.11
16
+ Classifier: Programming Language :: Python :: 3.12
17
+ Classifier: Topic :: Software Development :: Quality Assurance
18
+ Requires-Python: >=3.10
19
+ Requires-Dist: pyyaml>=6.0
20
+ Requires-Dist: rich>=13.0
21
+ Requires-Dist: typer>=0.12.0
22
+ Provides-Extra: dev
23
+ Requires-Dist: costs>=0.1.20; extra == 'dev'
24
+ Requires-Dist: goal>=2.1.0; extra == 'dev'
25
+ Requires-Dist: mypy>=1.8; extra == 'dev'
26
+ Requires-Dist: pfix>=0.1.60; extra == 'dev'
27
+ Requires-Dist: pytest>=7.0; extra == 'dev'
28
+ Requires-Dist: ruff>=0.4; extra == 'dev'
29
+ Description-Content-Type: text/markdown
30
+
31
+ # Intract
32
+
33
+
34
+ ## AI Cost Tracking
35
+
36
+ ![PyPI](https://img.shields.io/badge/pypi-costs-blue) ![Version](https://img.shields.io/badge/version-0.1.1-blue) ![Python](https://img.shields.io/badge/python-3.9+-blue) ![License](https://img.shields.io/badge/license-Apache--2.0-green)
37
+ ![AI Cost](https://img.shields.io/badge/AI%20Cost-$0.15-orange) ![Human Time](https://img.shields.io/badge/Human%20Time-1.0h-blue) ![Model](https://img.shields.io/badge/Model-openrouter%2Fqwen%2Fqwen3--coder--next-lightgrey)
38
+
39
+ - 🤖 **LLM usage:** $0.1500 (1 commits)
40
+ - 👤 **Human dev:** ~$100 (1.0h @ $100/h, 30min dedup)
41
+
42
+ Generated on 2026-05-29 using [openrouter/qwen/qwen3-coder-next](https://openrouter.ai/qwen/qwen3-coder-next)
43
+
44
+ ---
45
+
46
+
47
+
48
+ **Intract** is a lightweight intent-contract system for codebases.
49
+
50
+ Intract is not primarily a programming language. It is a **contract layer** for code intent.
51
+ A contract may be a single line, an inline comment, or a multi-file `intent.yaml` manifest.
52
+
53
+ ## Inline contract
54
+
55
+ ```python
56
+ # @intract.v1 scope:function intent:validate:user_permission priority:1 domain:security input:user,resource output:allowed effect:none forbid:write,network require:none validate:input_presence,return_value,no_forbidden_effect meaning:"check whether user can modify resource without changing state"
57
+ def can_update_resource(user, resource):
58
+ return user.is_admin or resource.owner_id == user.id
59
+ ```
60
+
61
+ ## Multi-file manifest
62
+
63
+ ```yaml
64
+ project:
65
+ name: redup
66
+
67
+ contracts:
68
+ - id: project.analysis
69
+ scope: project
70
+ intent: analyze:code_duplication
71
+ priority: 1
72
+ domain: project
73
+ input: [source_tree]
74
+ output: [DuplicationMap, RefactorSuggestion]
75
+ effect: [read]
76
+ forbid: [network]
77
+ require:
78
+ - scan.project_files
79
+ - extract.code_blocks
80
+ - detect.duplicates
81
+ - group.duplicates
82
+ - render.report
83
+ validate:
84
+ - required_intents
85
+ - no_forbidden_effect
86
+ meaning: "Project should analyze source code duplication and produce refactoring guidance."
87
+
88
+ files:
89
+ src/redup/core/scanner/__init__.py:
90
+ - scope: file
91
+ intent: scan:project_files
92
+ priority: 1
93
+ domain: scanner
94
+ input: [ScanConfig]
95
+ output: [file_list]
96
+ effect: [read]
97
+ forbid: [network]
98
+ meaning: "Scanner file should collect project source files."
99
+ ```
100
+
101
+ ## CLI
102
+
103
+ ```bash
104
+ pip install -e .[dev]
105
+
106
+ intract scan .
107
+ intract validate .
108
+ intract validate . --manifest intent.yaml --json
109
+ intract init .
110
+ ```
111
+
112
+ ## Validation statuses
113
+
114
+ | Status | Meaning |
115
+ |---|---|
116
+ | `pass` | Contract is satisfied. |
117
+ | `partial` | Contract is partly satisfied but missing evidence or sub-intents. |
118
+ | `fail` | Contract is not satisfied. |
119
+ | `violation` | Contract matches but violates a forbidden constraint. |
120
+ | `unknown` | Not enough information to decide. |
121
+
122
+ ## Contract fields
123
+
124
+ | Field | Example | Meaning |
125
+ |---|---|---|
126
+ | `scope` | `function` | Level: `line`, `block`, `function`, `class`, `file`, `module`, `project`. |
127
+ | `intent` | `validate:user_permission` | Main action and object. |
128
+ | `priority` | `1` | Importance from 1 to 5. |
129
+ | `domain` | `security` | Architectural/business domain. |
130
+ | `input` | `user,resource` | Expected inputs. |
131
+ | `output` | `allowed` | Expected output. |
132
+ | `effect` | `read` | Allowed/declared side effects. |
133
+ | `forbid` | `write,network` | Forbidden effects. |
134
+ | `require` | `scan.project_files` | Required sub-intents. |
135
+ | `validate` | `input_presence,no_forbidden_effect` | Validation rules to apply. |
136
+ | `meaning` | `"..."` | Human-readable explanation. |
137
+
138
+
139
+ ## License
140
+
141
+ Licensed under Apache-2.0.
@@ -0,0 +1,16 @@
1
+ intract/__init__.py,sha256=QY2GWIHmCMyQB3oaR9GBiVlENaRTwFnJevsGl7fRRhY,773
2
+ intract/__main__.py,sha256=Qd-f8z2Q2vpiEP2x6PBFsJrpACWDVxFKQk820MhFmHo,59
3
+ intract/cli.py,sha256=Or_crSfLsANbzj8ah4ReQuMOqqGy3LDatfkaXFaR_Yc,3829
4
+ intract/effects.py,sha256=GxlsgFxNYhDgGIH-X8PzMIf45KgnVMYHOjhkD_vAZ1M,1106
5
+ intract/models.py,sha256=XS5s3_99Sp3M6lRrbDMTmtof7CYMepI0nYiPI7GZlzE,4343
6
+ intract/normalizer.py,sha256=UcmY1c5BYtaobnCcCN8J_2KngPc3FJv43IOabqHV4iI,2564
7
+ intract/parser.py,sha256=jIxEKteg5O2sDnLS_PKiq47J1mf3DuRhAxFxlF8tQHQ,6172
8
+ intract/project.py,sha256=7gR4LmUTcy7MDd3dtU-a64y-lZbfZe_B7M2VXuIBEAE,3940
9
+ intract/signature.py,sha256=6xlR89HR_ubs1byj-bRCt07ktAUwL2ZY34PQC60lfqQ,3045
10
+ intract/validation.py,sha256=x6nrCjRYhN3jg8Wb4x91JAGBeJ4sPYCaJOueEhj-Axs,4093
11
+ intract/yaml_manifest.py,sha256=I3rD9Sz6ZTn3xyI72dbpCkU_ADbIxGaONrqTcTjbW5M,4123
12
+ intract-0.1.1.dist-info/METADATA,sha256=GBXIxVdnVC74iY_FmCc42t4mpGpB75BrB_S7DROLmnE,4714
13
+ intract-0.1.1.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
14
+ intract-0.1.1.dist-info/entry_points.txt,sha256=1sQtmpFl2Bixux66V-bNqO7Cnnv_q5uLikp1X6pqeo4,44
15
+ intract-0.1.1.dist-info/licenses/LICENSE,sha256=dUHw21hoZ6Lp28rPomB2trHWwBi2N1RaFtnfPtvQmYo,228
16
+ intract-0.1.1.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.29.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ intract = intract.cli:app
@@ -0,0 +1,7 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Intract Contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files to deal in the Software
7
+ without restriction.