xrefkit 0.3.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.
- xrefkit/__init__.py +5 -0
- xrefkit/__main__.py +5 -0
- xrefkit/catalog_cli.py +57 -0
- xrefkit/cli.py +71 -0
- xrefkit/contracts.py +297 -0
- xrefkit/ctx.py +160 -0
- xrefkit/dashboard.py +1220 -0
- xrefkit/discovery.py +85 -0
- xrefkit/gate.py +428 -0
- xrefkit/goalstate.py +555 -0
- xrefkit/hashing.py +18 -0
- xrefkit/import_skill.py +469 -0
- xrefkit/instance.py +133 -0
- xrefkit/loaders.py +77 -0
- xrefkit/mcp/__init__.py +26 -0
- xrefkit/mcp/audit.py +168 -0
- xrefkit/mcp/bootstrap.py +337 -0
- xrefkit/mcp/catalog.py +2638 -0
- xrefkit/mcp/cli.py +173 -0
- xrefkit/mcp/client_cache.py +356 -0
- xrefkit/mcp/context_registry.py +277 -0
- xrefkit/mcp/contracts.py +243 -0
- xrefkit/mcp/dist.py +234 -0
- xrefkit/mcp/ownership.py +246 -0
- xrefkit/mcp/repository.py +217 -0
- xrefkit/mcp/schemas.py +349 -0
- xrefkit/mcp/server.py +773 -0
- xrefkit/mcp/startup_contract_pack.py +154 -0
- xrefkit/mcp_tools.py +47 -0
- xrefkit/models/__init__.py +41 -0
- xrefkit/models/common.py +185 -0
- xrefkit/models/effective_bundle.py +131 -0
- xrefkit/models/local_manifest.py +217 -0
- xrefkit/models/package_manifest.py +126 -0
- xrefkit/models/run_log.py +276 -0
- xrefkit/models/server_config.py +160 -0
- xrefkit/models/skill_definition.py +131 -0
- xrefkit/operations_cli.py +670 -0
- xrefkit/ownership.py +276 -0
- xrefkit/packmeta.py +289 -0
- xrefkit/registry.py +334 -0
- xrefkit/resolver.py +252 -0
- xrefkit/resource_provider.py +187 -0
- xrefkit/resources/base/contracts.json +178 -0
- xrefkit/resources/base/current.json +6 -0
- xrefkit/resources/base/generations/7a682a5272907354/contracts.json +178 -0
- xrefkit/resources/base/generations/7a682a5272907354/model_body.md +22 -0
- xrefkit/resources/base/generations/9929294385ccb7b0/contracts.json +178 -0
- xrefkit/resources/base/generations/9929294385ccb7b0/model_body.md +22 -0
- xrefkit/resources/base/model_body.md +22 -0
- xrefkit/runlog.py +45 -0
- xrefkit/skillmeta.py +1034 -0
- xrefkit/skillrun.py +2381 -0
- xrefkit/structure_catalog.py +199 -0
- xrefkit/tools/__init__.py +119 -0
- xrefkit/tools/__main__.py +4 -0
- xrefkit/v2_cli.py +130 -0
- xrefkit/workspace.py +117 -0
- xrefkit/xref.py +1048 -0
- xrefkit-0.3.0.dist-info/METADATA +203 -0
- xrefkit-0.3.0.dist-info/RECORD +65 -0
- xrefkit-0.3.0.dist-info/WHEEL +5 -0
- xrefkit-0.3.0.dist-info/entry_points.txt +2 -0
- xrefkit-0.3.0.dist-info/licenses/LICENSE +21 -0
- xrefkit-0.3.0.dist-info/top_level.txt +1 -0
xrefkit/discovery.py
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
"""Python entry point discovery for installed XRefKit Skill Packages."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Callable, Iterable
|
|
8
|
+
|
|
9
|
+
from importlib import metadata
|
|
10
|
+
|
|
11
|
+
from .loaders import load_package_manifest
|
|
12
|
+
from .models import PackageManifest
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
ENTRY_POINT_GROUP = "xrefkit.skill_packages"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass(frozen=True)
|
|
19
|
+
class DiscoveredSkillPackage:
|
|
20
|
+
entry_point_name: str
|
|
21
|
+
package_root: Path
|
|
22
|
+
manifest_path: Path
|
|
23
|
+
manifest: PackageManifest
|
|
24
|
+
|
|
25
|
+
@property
|
|
26
|
+
def package_id(self) -> str:
|
|
27
|
+
return self.manifest.package_id
|
|
28
|
+
|
|
29
|
+
@property
|
|
30
|
+
def version(self) -> str:
|
|
31
|
+
return self.manifest.version
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _select_entry_points(group: str) -> Iterable[metadata.EntryPoint]:
|
|
35
|
+
entry_points = metadata.entry_points()
|
|
36
|
+
if hasattr(entry_points, "select"):
|
|
37
|
+
return entry_points.select(group=group)
|
|
38
|
+
return entry_points.get(group, []) # type: ignore[union-attr]
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def discover_skill_packages(group: str = ENTRY_POINT_GROUP) -> list[DiscoveredSkillPackage]:
|
|
42
|
+
discovered: list[DiscoveredSkillPackage] = []
|
|
43
|
+
for entry_point in _select_entry_points(group):
|
|
44
|
+
package_root_factory = entry_point.load()
|
|
45
|
+
if not callable(package_root_factory):
|
|
46
|
+
raise ValueError(f"entry point {entry_point.name} did not load a callable")
|
|
47
|
+
package_root = Path(package_root_factory())
|
|
48
|
+
manifest_path = package_root / "package_manifest.yaml"
|
|
49
|
+
manifest = load_package_manifest(manifest_path)
|
|
50
|
+
discovered.append(
|
|
51
|
+
DiscoveredSkillPackage(
|
|
52
|
+
entry_point_name=entry_point.name,
|
|
53
|
+
package_root=package_root,
|
|
54
|
+
manifest_path=manifest_path,
|
|
55
|
+
manifest=manifest,
|
|
56
|
+
)
|
|
57
|
+
)
|
|
58
|
+
return discovered
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def enabled_discovered_packages(
|
|
62
|
+
*,
|
|
63
|
+
enabled_package_ids: set[str],
|
|
64
|
+
discovered: list[DiscoveredSkillPackage] | None = None,
|
|
65
|
+
) -> list[DiscoveredSkillPackage]:
|
|
66
|
+
candidates = discovered if discovered is not None else discover_skill_packages()
|
|
67
|
+
return [candidate for candidate in candidates if candidate.package_id in enabled_package_ids]
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def package_list_rows(
|
|
71
|
+
*,
|
|
72
|
+
enabled_package_ids: set[str],
|
|
73
|
+
discovered: list[DiscoveredSkillPackage] | None = None,
|
|
74
|
+
) -> list[dict]:
|
|
75
|
+
candidates = discovered if discovered is not None else discover_skill_packages()
|
|
76
|
+
return [
|
|
77
|
+
{
|
|
78
|
+
"entry_point": candidate.entry_point_name,
|
|
79
|
+
"package_id": candidate.package_id,
|
|
80
|
+
"version": candidate.version,
|
|
81
|
+
"manifest_path": str(candidate.manifest_path),
|
|
82
|
+
"enabled": candidate.package_id in enabled_package_ids,
|
|
83
|
+
}
|
|
84
|
+
for candidate in candidates
|
|
85
|
+
]
|
xrefkit/gate.py
ADDED
|
@@ -0,0 +1,428 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import fnmatch
|
|
4
|
+
import json
|
|
5
|
+
import re
|
|
6
|
+
import subprocess
|
|
7
|
+
import hashlib
|
|
8
|
+
from dataclasses import dataclass, field
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
# Deterministic, machine-only diff-content checks for the Agent Diff Review Gate.
|
|
12
|
+
# See knowledge/organization/180_agent_diff_review_gate_design.md (xid 7A2F4C8D1801).
|
|
13
|
+
#
|
|
14
|
+
# These checks are a forced-attention trigger, not a correctness judgment.
|
|
15
|
+
# They run with no LLM and are decoupled from the producer context: a clean
|
|
16
|
+
# result is a precondition for a `proceed` verdict, never a proof of correctness.
|
|
17
|
+
|
|
18
|
+
# block -> hard condition; gate verdict must be `blocked`
|
|
19
|
+
# review -> must be looked at; gate verdict must be at least `needs-review`
|
|
20
|
+
DISPOSITION_BLOCK = "block"
|
|
21
|
+
DISPOSITION_REVIEW = "review"
|
|
22
|
+
|
|
23
|
+
# Aggregate eval verdicts. `clean` is the only state that lets the gate reach
|
|
24
|
+
# `proceed`; it still does not prove correctness.
|
|
25
|
+
EVAL_CLEAN = "clean"
|
|
26
|
+
EVAL_NEEDS_REVIEW = "needs-review"
|
|
27
|
+
EVAL_BLOCKED = "blocked"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
TEST_PATH_PATTERNS = (
|
|
31
|
+
"*test*.cs",
|
|
32
|
+
"*tests.cs",
|
|
33
|
+
"test_*.py",
|
|
34
|
+
"*_test.py",
|
|
35
|
+
"*.spec.ts",
|
|
36
|
+
"*.test.ts",
|
|
37
|
+
"*.spec.js",
|
|
38
|
+
"*.test.js",
|
|
39
|
+
)
|
|
40
|
+
TEST_PATH_SEGMENTS = ("test", "tests", "__tests__", "spec")
|
|
41
|
+
|
|
42
|
+
# Removed test declarations (on `-` lines) signal a deleted test.
|
|
43
|
+
TEST_DECL_RE = re.compile(
|
|
44
|
+
r"\[\s*Fact\b|\[\s*Theory\b|\[\s*Test\b|\[\s*TestMethod\b"
|
|
45
|
+
r"|\bdef\s+test_\w+|\bit\s*\(|\btest\s*\(|\bdescribe\s*\("
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
# Added lines that disable or neuter a test.
|
|
49
|
+
TEST_DISABLE_RES = (
|
|
50
|
+
re.compile(r"\[\s*Fact\s*\(\s*Skip\s*="),
|
|
51
|
+
re.compile(r"\[\s*Theory\s*\(\s*Skip\s*="),
|
|
52
|
+
re.compile(r"\[\s*Ignore\b"),
|
|
53
|
+
re.compile(r"@pytest\.mark\.skip\b"),
|
|
54
|
+
re.compile(r"@pytest\.mark\.xfail\b"),
|
|
55
|
+
re.compile(r"@unittest\.skip\b"),
|
|
56
|
+
re.compile(r"\b(?:xit|xdescribe)\s*\("),
|
|
57
|
+
re.compile(r"\b(?:it|describe|test)\.skip\s*\("),
|
|
58
|
+
re.compile(r"Assert\.True\s*\(\s*true\s*\)", re.IGNORECASE),
|
|
59
|
+
re.compile(r"Assert\.IsTrue\s*\(\s*true\s*\)", re.IGNORECASE),
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
# Schema / migration change signals: by path or by added DDL statements.
|
|
63
|
+
MIGRATION_PATH_PATTERNS = (
|
|
64
|
+
"*/migrations/*",
|
|
65
|
+
"*/migration/*",
|
|
66
|
+
"*.sql",
|
|
67
|
+
"*.edmx",
|
|
68
|
+
"*schema*.sql",
|
|
69
|
+
"*_migration.*",
|
|
70
|
+
)
|
|
71
|
+
DDL_RE = re.compile(
|
|
72
|
+
r"\b(?:ALTER\s+TABLE|CREATE\s+TABLE|DROP\s+TABLE|ADD\s+COLUMN|DROP\s+COLUMN"
|
|
73
|
+
r"|RENAME\s+COLUMN|ALTER\s+COLUMN|CREATE\s+INDEX|DROP\s+INDEX)\b",
|
|
74
|
+
re.IGNORECASE,
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
# Secret / credential leakage on added lines.
|
|
78
|
+
SECRET_RES = (
|
|
79
|
+
("private_key", re.compile(r"-----BEGIN (?:RSA |EC |OPENSSH |DSA )?PRIVATE KEY-----")),
|
|
80
|
+
("aws_access_key", re.compile(r"\bAKIA[0-9A-Z]{16}\b")),
|
|
81
|
+
("connection_string", re.compile(r"(?:Server|Data Source)\s*=.*?(?:Password|Pwd)\s*=", re.IGNORECASE)),
|
|
82
|
+
("password_assignment", re.compile(r"\b(?:password|passwd|pwd)\s*[:=]\s*[\"']?[^\s\"']{6,}", re.IGNORECASE)),
|
|
83
|
+
("api_token", re.compile(r"\b(?:api[_-]?key|api[_-]?token|access[_-]?token|secret[_-]?key)\s*[:=]\s*[\"']?[A-Za-z0-9_\-]{16,}", re.IGNORECASE)),
|
|
84
|
+
("bearer_token", re.compile(r"\bBearer\s+[A-Za-z0-9_\-\.]{20,}")),
|
|
85
|
+
("slack_token", re.compile(r"\bxox[baprs]-[A-Za-z0-9-]{10,}")),
|
|
86
|
+
("github_token", re.compile(r"\bgh[pousr]_[A-Za-z0-9]{36,}")),
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
# Placeholder values that should not be flagged as real secrets.
|
|
90
|
+
SECRET_PLACEHOLDER_RE = re.compile(
|
|
91
|
+
r"(?:xxx+|\.\.\.|<[^>]+>|\$\{[^}]+\}|example|changeme|placeholder|dummy|your[_-]?\w+|redacted|\*\*\*)",
|
|
92
|
+
re.IGNORECASE,
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
@dataclass
|
|
97
|
+
class DiffFile:
|
|
98
|
+
path: str
|
|
99
|
+
status: str # added | deleted | modified | renamed
|
|
100
|
+
added: list[tuple[int, str]] = field(default_factory=list) # (new line no, text)
|
|
101
|
+
removed: list[str] = field(default_factory=list)
|
|
102
|
+
old_path: str | None = None
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
@dataclass
|
|
106
|
+
class Finding:
|
|
107
|
+
check: str
|
|
108
|
+
disposition: str
|
|
109
|
+
path: str
|
|
110
|
+
line: int | None
|
|
111
|
+
evidence: str
|
|
112
|
+
|
|
113
|
+
def to_dict(self) -> dict[str, object]:
|
|
114
|
+
return {
|
|
115
|
+
"check": self.check,
|
|
116
|
+
"disposition": self.disposition,
|
|
117
|
+
"path": self.path,
|
|
118
|
+
"line": self.line,
|
|
119
|
+
"evidence": self.evidence,
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _read_diff_text(args) -> tuple[str | None, str | None]:
|
|
124
|
+
"""Return (diff_text, error)."""
|
|
125
|
+
if args.diff:
|
|
126
|
+
if args.diff == "-":
|
|
127
|
+
import sys
|
|
128
|
+
|
|
129
|
+
return sys.stdin.read(), None
|
|
130
|
+
p = Path(args.diff)
|
|
131
|
+
if not p.is_file():
|
|
132
|
+
return None, f"diff file not found: {args.diff}"
|
|
133
|
+
return p.read_text(encoding="utf-8", errors="replace"), None
|
|
134
|
+
|
|
135
|
+
cmd = ["git", "-C", args.root, "diff", "--no-color", "--unified=3"]
|
|
136
|
+
if args.staged:
|
|
137
|
+
cmd.append("--cached")
|
|
138
|
+
if args.base:
|
|
139
|
+
cmd.append(args.base)
|
|
140
|
+
try:
|
|
141
|
+
out = subprocess.run(
|
|
142
|
+
cmd,
|
|
143
|
+
capture_output=True,
|
|
144
|
+
text=True,
|
|
145
|
+
encoding="utf-8",
|
|
146
|
+
errors="replace",
|
|
147
|
+
check=False,
|
|
148
|
+
)
|
|
149
|
+
except FileNotFoundError:
|
|
150
|
+
return None, "git not found; pass --diff to supply a unified diff file"
|
|
151
|
+
if out.returncode != 0:
|
|
152
|
+
return None, f"git diff failed: {out.stderr.strip()}"
|
|
153
|
+
return out.stdout, None
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def parse_unified_diff(text: str) -> list[DiffFile]:
|
|
157
|
+
files: list[DiffFile] = []
|
|
158
|
+
cur: DiffFile | None = None
|
|
159
|
+
new_lineno = 0
|
|
160
|
+
pending_status = "modified"
|
|
161
|
+
old_path: str | None = None
|
|
162
|
+
rename_from: str | None = None
|
|
163
|
+
for raw in text.splitlines():
|
|
164
|
+
if raw.startswith("diff --git"):
|
|
165
|
+
cur = None
|
|
166
|
+
pending_status = "modified"
|
|
167
|
+
old_path = None
|
|
168
|
+
rename_from = None
|
|
169
|
+
continue
|
|
170
|
+
if raw.startswith("new file mode"):
|
|
171
|
+
pending_status = "added"
|
|
172
|
+
continue
|
|
173
|
+
if raw.startswith("deleted file mode"):
|
|
174
|
+
pending_status = "deleted"
|
|
175
|
+
continue
|
|
176
|
+
if raw.startswith("rename from "):
|
|
177
|
+
pending_status = "renamed"
|
|
178
|
+
rename_from = raw[len("rename from "):].strip()
|
|
179
|
+
continue
|
|
180
|
+
if raw.startswith("rename to "):
|
|
181
|
+
pending_status = "renamed"
|
|
182
|
+
rename_to = raw[len("rename to "):].strip()
|
|
183
|
+
cur = DiffFile(path=rename_to, status="renamed", old_path=rename_from)
|
|
184
|
+
files.append(cur)
|
|
185
|
+
continue
|
|
186
|
+
if raw.startswith("rename "):
|
|
187
|
+
pending_status = "renamed"
|
|
188
|
+
continue
|
|
189
|
+
if raw.startswith("--- "):
|
|
190
|
+
old_path = raw[4:].strip()
|
|
191
|
+
if old_path.startswith("a/"):
|
|
192
|
+
old_path = old_path[2:]
|
|
193
|
+
continue
|
|
194
|
+
if raw.startswith("+++ "):
|
|
195
|
+
path = raw[4:].strip()
|
|
196
|
+
if path.startswith("b/"):
|
|
197
|
+
path = path[2:]
|
|
198
|
+
if path == "/dev/null":
|
|
199
|
+
# Deleted files have no new path; retain the old path for gates.
|
|
200
|
+
pending_status = "deleted"
|
|
201
|
+
path = old_path or "(deleted)"
|
|
202
|
+
cur = DiffFile(path=path, status=pending_status, old_path=old_path if pending_status == "deleted" else None)
|
|
203
|
+
files.append(cur)
|
|
204
|
+
continue
|
|
205
|
+
if raw.startswith("@@"):
|
|
206
|
+
m = re.search(r"\+(\d+)", raw)
|
|
207
|
+
new_lineno = int(m.group(1)) if m else 0
|
|
208
|
+
continue
|
|
209
|
+
if cur is None:
|
|
210
|
+
continue
|
|
211
|
+
if raw.startswith("+") and not raw.startswith("+++"):
|
|
212
|
+
cur.added.append((new_lineno, raw[1:]))
|
|
213
|
+
new_lineno += 1
|
|
214
|
+
elif raw.startswith("-") and not raw.startswith("---"):
|
|
215
|
+
cur.removed.append(raw[1:])
|
|
216
|
+
elif raw.startswith(" "):
|
|
217
|
+
new_lineno += 1
|
|
218
|
+
return files
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def _is_test_path(path: str) -> bool:
|
|
222
|
+
low = path.lower()
|
|
223
|
+
name = low.rsplit("/", 1)[-1]
|
|
224
|
+
if any(fnmatch.fnmatch(name, pat) for pat in TEST_PATH_PATTERNS):
|
|
225
|
+
return True
|
|
226
|
+
return any(seg in low.split("/") for seg in TEST_PATH_SEGMENTS)
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def _matches_any(path: str, patterns: tuple[str, ...]) -> bool:
|
|
230
|
+
low = path.lower()
|
|
231
|
+
return any(fnmatch.fnmatch(low, pat) for pat in patterns)
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def check_removed_tests(files: list[DiffFile]) -> list[Finding]:
|
|
235
|
+
out: list[Finding] = []
|
|
236
|
+
for f in files:
|
|
237
|
+
source_path = f.old_path or f.path
|
|
238
|
+
if f.status == "deleted" and _is_test_path(source_path):
|
|
239
|
+
out.append(Finding("test_removed", DISPOSITION_REVIEW, source_path, None, "test file deleted"))
|
|
240
|
+
continue
|
|
241
|
+
if f.status == "renamed" and _is_test_path(source_path) and not _is_test_path(f.path):
|
|
242
|
+
out.append(Finding("test_removed", DISPOSITION_REVIEW, source_path, None, f"test file renamed to {f.path}"))
|
|
243
|
+
continue
|
|
244
|
+
if not _is_test_path(f.path):
|
|
245
|
+
continue
|
|
246
|
+
for text in f.removed:
|
|
247
|
+
if TEST_DECL_RE.search(text):
|
|
248
|
+
out.append(Finding("test_removed", DISPOSITION_REVIEW, f.path, None, text.strip()[:160]))
|
|
249
|
+
return out
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def check_disabled_tests(files: list[DiffFile]) -> list[Finding]:
|
|
253
|
+
out: list[Finding] = []
|
|
254
|
+
for f in files:
|
|
255
|
+
for lineno, text in f.added:
|
|
256
|
+
for rx in TEST_DISABLE_RES:
|
|
257
|
+
if rx.search(text):
|
|
258
|
+
out.append(Finding("test_disabled", DISPOSITION_REVIEW, f.path, lineno, text.strip()[:160]))
|
|
259
|
+
break
|
|
260
|
+
return out
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def check_schema_migration(files: list[DiffFile]) -> list[Finding]:
|
|
264
|
+
out: list[Finding] = []
|
|
265
|
+
for f in files:
|
|
266
|
+
if _matches_any(f.path, MIGRATION_PATH_PATTERNS):
|
|
267
|
+
out.append(Finding("schema_migration", DISPOSITION_REVIEW, f.path, None, f"migration/schema path ({f.status})"))
|
|
268
|
+
continue
|
|
269
|
+
for lineno, text in f.added:
|
|
270
|
+
if DDL_RE.search(text):
|
|
271
|
+
out.append(Finding("schema_migration", DISPOSITION_REVIEW, f.path, lineno, text.strip()[:160]))
|
|
272
|
+
break
|
|
273
|
+
return out
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def check_out_of_scope(files: list[DiffFile], scope: list[str]) -> list[Finding]:
|
|
277
|
+
if not scope:
|
|
278
|
+
return []
|
|
279
|
+
out: list[Finding] = []
|
|
280
|
+
for f in files:
|
|
281
|
+
low = f.path.lower()
|
|
282
|
+
if not any(fnmatch.fnmatch(low, pat.lower()) for pat in scope):
|
|
283
|
+
out.append(Finding("out_of_scope", DISPOSITION_REVIEW, f.path, None, "changed file outside declared scope"))
|
|
284
|
+
return out
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def check_secret_leak(files: list[DiffFile]) -> list[Finding]:
|
|
288
|
+
out: list[Finding] = []
|
|
289
|
+
for f in files:
|
|
290
|
+
for lineno, text in f.added:
|
|
291
|
+
for name, rx in SECRET_RES:
|
|
292
|
+
if rx.search(text) and not SECRET_PLACEHOLDER_RE.search(text):
|
|
293
|
+
out.append(Finding(f"secret_leak:{name}", DISPOSITION_BLOCK, f.path, lineno, text.strip()[:120]))
|
|
294
|
+
break
|
|
295
|
+
return out
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def run_evals(files: list[DiffFile], scope: list[str]) -> list[Finding]:
|
|
299
|
+
findings: list[Finding] = []
|
|
300
|
+
findings += check_removed_tests(files)
|
|
301
|
+
findings += check_disabled_tests(files)
|
|
302
|
+
findings += check_schema_migration(files)
|
|
303
|
+
findings += check_out_of_scope(files, scope)
|
|
304
|
+
findings += check_secret_leak(files)
|
|
305
|
+
return findings
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
def aggregate(findings: list[Finding]) -> str:
|
|
309
|
+
if any(f.disposition == DISPOSITION_BLOCK for f in findings):
|
|
310
|
+
return EVAL_BLOCKED
|
|
311
|
+
if findings:
|
|
312
|
+
return EVAL_NEEDS_REVIEW
|
|
313
|
+
return EVAL_CLEAN
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
def cmd_gate(args) -> int:
|
|
317
|
+
if args.gate_cmd != "eval":
|
|
318
|
+
return 2
|
|
319
|
+
|
|
320
|
+
if getattr(args, "profile", None) == "command-cutover-readiness":
|
|
321
|
+
return _command_cutover_readiness(args)
|
|
322
|
+
|
|
323
|
+
text, err = _read_diff_text(args)
|
|
324
|
+
if err is not None:
|
|
325
|
+
result = {"ok": False, "errors": [err], "eval_verdict": EVAL_BLOCKED, "findings": []}
|
|
326
|
+
if args.json:
|
|
327
|
+
print(json.dumps(result, ensure_ascii=False, indent=2))
|
|
328
|
+
else:
|
|
329
|
+
print(f"error: {err}")
|
|
330
|
+
return 1
|
|
331
|
+
|
|
332
|
+
files = parse_unified_diff(text or "")
|
|
333
|
+
scope = list(args.scope or [])
|
|
334
|
+
findings = run_evals(files, scope)
|
|
335
|
+
verdict = aggregate(findings)
|
|
336
|
+
|
|
337
|
+
result = {
|
|
338
|
+
"ok": True,
|
|
339
|
+
"errors": [],
|
|
340
|
+
"eval_verdict": verdict,
|
|
341
|
+
"files_changed": len(files),
|
|
342
|
+
"scope": scope,
|
|
343
|
+
"findings": [f.to_dict() for f in findings],
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
if args.json:
|
|
347
|
+
print(json.dumps(result, ensure_ascii=False, indent=2))
|
|
348
|
+
else:
|
|
349
|
+
print(f"eval_verdict: {verdict}")
|
|
350
|
+
print(f"files_changed: {len(files)}")
|
|
351
|
+
if scope:
|
|
352
|
+
print(f"scope: {' '.join(scope)}")
|
|
353
|
+
if not findings:
|
|
354
|
+
print("findings: none (clean; precondition for proceed met, not proof of correctness)")
|
|
355
|
+
else:
|
|
356
|
+
print(f"findings: {len(findings)}")
|
|
357
|
+
for f in findings:
|
|
358
|
+
loc = f"{f.path}:{f.line}" if f.line else f.path
|
|
359
|
+
print(f" [{f.disposition}] {f.check} @ {loc}")
|
|
360
|
+
print(f" {f.evidence}")
|
|
361
|
+
|
|
362
|
+
# Non-zero exit when the diff cannot proceed to CI, so the gate can run in
|
|
363
|
+
# scripts and pre-CI hooks.
|
|
364
|
+
if verdict != EVAL_CLEAN:
|
|
365
|
+
return 1
|
|
366
|
+
return 0
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
def _command_cutover_readiness(args) -> int:
|
|
370
|
+
root = Path(args.root).resolve()
|
|
371
|
+
checks = [
|
|
372
|
+
("package_manifest", (root / "pyproject.toml").is_file()),
|
|
373
|
+
("instance_manifest", (root / "xrefkit.toml").is_file()),
|
|
374
|
+
("base_generation_pointer", (root / "xrefkit/resources/base/current.json").is_file()),
|
|
375
|
+
("compiled_contract", (root / "xrefkit/resources/base/contracts.json").is_file()),
|
|
376
|
+
("compiled_model_body", (root / "xrefkit/resources/base/model_body.md").is_file()),
|
|
377
|
+
("tool_contracts", (root / "tools/contracts.yaml").is_file()),
|
|
378
|
+
("target_catalog", (root / "knowledge/source_analysis/source_structure_catalog.yaml").is_file()),
|
|
379
|
+
("integrated_mcp", (root / "xrefkit/mcp/server.py").is_file()),
|
|
380
|
+
("site_builder", (root / "tools/site_build.py").is_file()),
|
|
381
|
+
("site_manifest", (root / "site/source_manifest.json").is_file()),
|
|
382
|
+
]
|
|
383
|
+
failed = [name for name, ok in checks if not ok]
|
|
384
|
+
authority = "missing"
|
|
385
|
+
instance_path = root / "xrefkit.toml"
|
|
386
|
+
if instance_path.is_file():
|
|
387
|
+
match = re.search(
|
|
388
|
+
r'^command_authority\s*=\s*"([^"]+)"',
|
|
389
|
+
instance_path.read_text(encoding="utf-8"),
|
|
390
|
+
re.MULTILINE,
|
|
391
|
+
)
|
|
392
|
+
authority = match.group(1) if match else "missing"
|
|
393
|
+
if authority != "legacy_authoritative":
|
|
394
|
+
failed.append("legacy_authority_entry_state")
|
|
395
|
+
|
|
396
|
+
inputs = sorted(
|
|
397
|
+
str(path.relative_to(root)).replace("\\", "/")
|
|
398
|
+
for path in root.rglob("*")
|
|
399
|
+
if path.is_file()
|
|
400
|
+
and not any(part in {".git", "__pycache__", ".pytest_cache"} for part in path.parts)
|
|
401
|
+
and (
|
|
402
|
+
path.name in {"pyproject.toml", "xrefkit.toml", "contracts.yaml", "source_manifest.json"}
|
|
403
|
+
or "xrefkit" in path.parts
|
|
404
|
+
or path.suffix in {".yaml", ".yml"}
|
|
405
|
+
)
|
|
406
|
+
)
|
|
407
|
+
digest = hashlib.sha256()
|
|
408
|
+
for relative in inputs:
|
|
409
|
+
digest.update(relative.encode("utf-8"))
|
|
410
|
+
digest.update((root / relative).read_bytes())
|
|
411
|
+
result = {
|
|
412
|
+
"ok": not failed,
|
|
413
|
+
"profile": "command-cutover-readiness",
|
|
414
|
+
"entry_authority": authority,
|
|
415
|
+
"checks": [{"id": name, "ok": ok} for name, ok in checks],
|
|
416
|
+
"failed": failed,
|
|
417
|
+
"evidence_hash": digest.hexdigest(),
|
|
418
|
+
"input_count": len(inputs),
|
|
419
|
+
}
|
|
420
|
+
if args.json:
|
|
421
|
+
print(json.dumps(result, ensure_ascii=False, indent=2))
|
|
422
|
+
else:
|
|
423
|
+
print(f"profile: {result['profile']}")
|
|
424
|
+
print(f"readiness: {'passed' if result['ok'] else 'failed'}")
|
|
425
|
+
print(f"evidence_hash: {result['evidence_hash']}")
|
|
426
|
+
for name in failed:
|
|
427
|
+
print(f"- failed: {name}")
|
|
428
|
+
return 0 if result["ok"] else 1
|