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 +31 -0
- intract/__main__.py +4 -0
- intract/cli.py +93 -0
- intract/effects.py +21 -0
- intract/models.py +153 -0
- intract/normalizer.py +69 -0
- intract/parser.py +194 -0
- intract/project.py +88 -0
- intract/signature.py +78 -0
- intract/validation.py +102 -0
- intract/yaml_manifest.py +113 -0
- intract-0.1.1.dist-info/METADATA +141 -0
- intract-0.1.1.dist-info/RECORD +16 -0
- intract-0.1.1.dist-info/WHEEL +4 -0
- intract-0.1.1.dist-info/entry_points.txt +2 -0
- intract-0.1.1.dist-info/licenses/LICENSE +7 -0
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
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
|
intract/yaml_manifest.py
ADDED
|
@@ -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
|
+
   
|
|
37
|
+
  
|
|
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,,
|