ctxverify 0.1.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.
- ctxverify/__init__.py +47 -0
- ctxverify/__main__.py +8 -0
- ctxverify/adapters/__init__.py +14 -0
- ctxverify/adapters/generic.py +190 -0
- ctxverify/adapters/llmwiki.py +199 -0
- ctxverify/adapters/obsidian.py +253 -0
- ctxverify/checks/__init__.py +203 -0
- ctxverify/checks/actions.py +291 -0
- ctxverify/checks/consistency.py +202 -0
- ctxverify/checks/duplicates.py +43 -0
- ctxverify/checks/freshness.py +102 -0
- ctxverify/checks/hygiene.py +147 -0
- ctxverify/checks/identity.py +137 -0
- ctxverify/checks/links.py +77 -0
- ctxverify/checks/orphans.py +43 -0
- ctxverify/checks/provenance.py +114 -0
- ctxverify/cli.py +357 -0
- ctxverify/frontmatter.py +149 -0
- ctxverify/loader.py +598 -0
- ctxverify/mcp/__init__.py +12 -0
- ctxverify/mcp/server.py +279 -0
- ctxverify/model.py +531 -0
- ctxverify/report.py +252 -0
- ctxverify/resolve.py +118 -0
- ctxverify/rules.py +258 -0
- ctxverify/scaffold.py +85 -0
- ctxverify/version.py +1 -0
- ctxverify/writer.py +118 -0
- ctxverify-0.1.0.dist-info/METADATA +472 -0
- ctxverify-0.1.0.dist-info/RECORD +35 -0
- ctxverify-0.1.0.dist-info/WHEEL +4 -0
- ctxverify-0.1.0.dist-info/entry_points.txt +3 -0
- ctxverify-0.1.0.dist-info/licenses/LICENSE +202 -0
- ctxverify-0.1.0.dist-info/licenses/LICENSE-SPEC +396 -0
- ctxverify-0.1.0.dist-info/licenses/NOTICE +12 -0
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
"""Export an Obsidian-style vault into a context pack.
|
|
2
|
+
|
|
3
|
+
The mapping is intentionally flat: one note becomes one claim, with the note
|
|
4
|
+
itself as the evidence. That is not as rich as a compiled pack, but it is
|
|
5
|
+
honest, and it means a vault can be checked on day one instead of after a
|
|
6
|
+
compiler is written.
|
|
7
|
+
|
|
8
|
+
What the adapter does preserve is the part that is expensive to get right:
|
|
9
|
+
the link structure of the vault, resolved the way Obsidian resolves it.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import hashlib
|
|
15
|
+
import re
|
|
16
|
+
from datetime import datetime, timezone
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
|
|
19
|
+
from ..frontmatter import as_list, as_text, parse_frontmatter, split_frontmatter
|
|
20
|
+
from ..model import CLAIM_STATUSES, Claim, Entity, Evidence, Manifest, Pack, Source
|
|
21
|
+
from ..resolve import LinkIndex, strip_wikilink
|
|
22
|
+
from ..writer import evidence_record
|
|
23
|
+
|
|
24
|
+
SKIP_DIRS = {".git", ".obsidian", ".trash", "node_modules", ".venv", "__pycache__"}
|
|
25
|
+
|
|
26
|
+
HEADING_RE = re.compile(r"^#{1,6}\s+(.+?)\s*$", re.MULTILINE)
|
|
27
|
+
WIKILINK_RE = re.compile(r"\[\[([^\[\]]+?)\]\]")
|
|
28
|
+
CODE_FENCE_RE = re.compile(r"```.*?```", re.DOTALL)
|
|
29
|
+
#: A note id that looks like a card number: "C-001", "P-012".
|
|
30
|
+
NUMBER_LIKE = re.compile(r"^[A-Za-z]{1,6}-\d{1,6}$")
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _iter_notes(vault: Path):
|
|
34
|
+
for path in sorted(vault.rglob("*.md")):
|
|
35
|
+
parts = set(path.relative_to(vault).parts[:-1])
|
|
36
|
+
if parts & SKIP_DIRS:
|
|
37
|
+
continue
|
|
38
|
+
if any(part.startswith(".") for part in parts):
|
|
39
|
+
continue
|
|
40
|
+
yield path
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _slug(path: Path) -> str:
|
|
44
|
+
stem = path.stem
|
|
45
|
+
cleaned = re.sub(r"[^\w\-一-鿿]+", "-", stem).strip("-")
|
|
46
|
+
return cleaned or "note"
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _first_paragraph(body: str) -> str:
|
|
50
|
+
text = CODE_FENCE_RE.sub("", body)
|
|
51
|
+
for block in re.split(r"\n\s*\n", text):
|
|
52
|
+
stripped = block.strip()
|
|
53
|
+
if not stripped:
|
|
54
|
+
continue
|
|
55
|
+
if stripped.startswith("#"):
|
|
56
|
+
heading = HEADING_RE.search(stripped)
|
|
57
|
+
if heading:
|
|
58
|
+
return heading.group(1).strip()
|
|
59
|
+
continue
|
|
60
|
+
if stripped.startswith(("```", "|", "-", "*", ">", "<!--")):
|
|
61
|
+
continue
|
|
62
|
+
return re.sub(r"\s+", " ", stripped)[:600]
|
|
63
|
+
return ""
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def export_obsidian(
|
|
67
|
+
vault: str | Path,
|
|
68
|
+
pack_id: str | None = None,
|
|
69
|
+
include: list[str] | None = None,
|
|
70
|
+
exclude: list[str] | None = None,
|
|
71
|
+
) -> Pack:
|
|
72
|
+
"""Read ``vault`` and return a :class:`Pack`."""
|
|
73
|
+
root = Path(vault).expanduser().resolve()
|
|
74
|
+
if not root.exists():
|
|
75
|
+
raise FileNotFoundError(f"vault not found: {root}")
|
|
76
|
+
|
|
77
|
+
include = include or []
|
|
78
|
+
exclude = exclude or []
|
|
79
|
+
|
|
80
|
+
notes = []
|
|
81
|
+
for path in _iter_notes(root):
|
|
82
|
+
rel = path.relative_to(root).as_posix()
|
|
83
|
+
if include and not any(pattern in rel for pattern in include):
|
|
84
|
+
continue
|
|
85
|
+
if exclude and any(pattern in rel for pattern in exclude):
|
|
86
|
+
continue
|
|
87
|
+
notes.append((path, rel))
|
|
88
|
+
|
|
89
|
+
# Obsidian resolves a link to a note's alias as well as to its filename.
|
|
90
|
+
# Reading them is the difference between reporting a real broken link and
|
|
91
|
+
# reporting 1,254 false ones.
|
|
92
|
+
aliases: dict[str, str] = {}
|
|
93
|
+
note_frontmatter: dict[str, dict] = {}
|
|
94
|
+
for path, rel in notes:
|
|
95
|
+
text = path.read_text(encoding="utf-8", errors="replace")
|
|
96
|
+
front = parse_frontmatter(text)
|
|
97
|
+
note_frontmatter[rel] = front
|
|
98
|
+
for alias in as_list(front.get("aliases")) + as_list(front.get("alias")):
|
|
99
|
+
alias = alias.strip()
|
|
100
|
+
if alias:
|
|
101
|
+
aliases.setdefault(alias, rel)
|
|
102
|
+
aliases.setdefault(alias.lower(), rel)
|
|
103
|
+
|
|
104
|
+
index = LinkIndex(paths=[rel for _, rel in notes], aliases=aliases)
|
|
105
|
+
|
|
106
|
+
claims: list[Claim] = []
|
|
107
|
+
evidence: list[Evidence] = []
|
|
108
|
+
entities: list[Entity] = []
|
|
109
|
+
sources: list[Source] = []
|
|
110
|
+
entity_ids: dict[str, str] = {}
|
|
111
|
+
|
|
112
|
+
for number, (path, rel) in enumerate(notes, start=1):
|
|
113
|
+
text = path.read_text(encoding="utf-8", errors="replace")
|
|
114
|
+
front = note_frontmatter.get(rel) or parse_frontmatter(text)
|
|
115
|
+
_, body = split_frontmatter(text)
|
|
116
|
+
|
|
117
|
+
source_id = f"src-{number:03d}"
|
|
118
|
+
claim_id = f"clm-{number:03d}"
|
|
119
|
+
evidence_id = f"ev-{number:03d}"
|
|
120
|
+
|
|
121
|
+
title = as_text(front.get("title")).strip() or path.stem
|
|
122
|
+
summary = (
|
|
123
|
+
as_text(front.get("summary")).strip()
|
|
124
|
+
or as_text(front.get("description")).strip()
|
|
125
|
+
or as_text(front.get("abstract")).strip()
|
|
126
|
+
or _first_paragraph(body)
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
raw_status = as_text(front.get("status")).strip().lower()
|
|
130
|
+
status = raw_status if raw_status in CLAIM_STATUSES else "asserted"
|
|
131
|
+
|
|
132
|
+
confidence = front.get("confidence")
|
|
133
|
+
if not isinstance(confidence, (int, float)) or isinstance(confidence, bool):
|
|
134
|
+
confidence = None
|
|
135
|
+
|
|
136
|
+
topics = as_list(front.get("topics")) or as_list(front.get("domain")) or as_list(
|
|
137
|
+
front.get("tags")
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
links = []
|
|
141
|
+
for raw in WIKILINK_RE.findall(body):
|
|
142
|
+
target = strip_wikilink(raw)
|
|
143
|
+
if not target:
|
|
144
|
+
continue
|
|
145
|
+
resolved = index.resolve(raw)
|
|
146
|
+
links.append({"target": raw, "resolved": resolved})
|
|
147
|
+
|
|
148
|
+
claim_entities: list[str] = []
|
|
149
|
+
for name in as_list(front.get("entities")):
|
|
150
|
+
key = name.strip().lower()
|
|
151
|
+
if not key:
|
|
152
|
+
continue
|
|
153
|
+
if key not in entity_ids:
|
|
154
|
+
entity_id = f"ent-{len(entity_ids) + 1:03d}"
|
|
155
|
+
entity_ids[key] = entity_id
|
|
156
|
+
entities.append(Entity(id=entity_id, name=name.strip()))
|
|
157
|
+
claim_entities.append(entity_ids[key])
|
|
158
|
+
|
|
159
|
+
created = (
|
|
160
|
+
as_text(front.get("created")).strip()
|
|
161
|
+
or as_text(front.get("createdAt")).strip()
|
|
162
|
+
or as_text(front.get("date")).strip()
|
|
163
|
+
or None
|
|
164
|
+
)
|
|
165
|
+
updated = (
|
|
166
|
+
as_text(front.get("updated")).strip()
|
|
167
|
+
or as_text(front.get("updatedAt")).strip()
|
|
168
|
+
or as_text(front.get("modified")).strip()
|
|
169
|
+
or None
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
claims.append(
|
|
173
|
+
Claim(
|
|
174
|
+
id=claim_id,
|
|
175
|
+
text=summary or title,
|
|
176
|
+
status=status,
|
|
177
|
+
confidence=float(confidence) if confidence is not None else None,
|
|
178
|
+
evidence=[evidence_id],
|
|
179
|
+
entities=claim_entities,
|
|
180
|
+
topics=topics,
|
|
181
|
+
links=links,
|
|
182
|
+
created_at=created,
|
|
183
|
+
updated_at=updated,
|
|
184
|
+
)
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
quote = summary or title
|
|
188
|
+
record = evidence_record(
|
|
189
|
+
evidence_id,
|
|
190
|
+
claim_id,
|
|
191
|
+
source_id,
|
|
192
|
+
quote,
|
|
193
|
+
locator={"path": rel, "line_start": 1, "line_end": 1},
|
|
194
|
+
retrieved_at=datetime.now(timezone.utc).isoformat(timespec="seconds"),
|
|
195
|
+
)
|
|
196
|
+
evidence.append(Evidence.from_dict(record))
|
|
197
|
+
|
|
198
|
+
# A corpus that keeps several numbering spaces side by side declares
|
|
199
|
+
# them: a source in namespace "billing" may carry C-001 without
|
|
200
|
+
# colliding with the global C-001.
|
|
201
|
+
extra: dict = {}
|
|
202
|
+
aliases = as_list(front.get("aliases"))
|
|
203
|
+
if aliases:
|
|
204
|
+
extra["aliases"] = aliases
|
|
205
|
+
namespace = as_text(front.get("namespace")).strip() or as_text(
|
|
206
|
+
front.get("编号空间")
|
|
207
|
+
).strip()
|
|
208
|
+
if namespace:
|
|
209
|
+
extra["namespace"] = namespace
|
|
210
|
+
# A note's own number. It is kept under a distinct key so that it can
|
|
211
|
+
# never be mistaken for the pack's source id.
|
|
212
|
+
number = as_text(front.get("number")).strip() or as_text(front.get("编号")).strip()
|
|
213
|
+
if not number:
|
|
214
|
+
note_id = as_text(front.get("id")).strip()
|
|
215
|
+
if note_id and NUMBER_LIKE.match(note_id):
|
|
216
|
+
number = note_id
|
|
217
|
+
if number:
|
|
218
|
+
extra["number"] = number
|
|
219
|
+
|
|
220
|
+
sources.append(
|
|
221
|
+
Source(
|
|
222
|
+
id=source_id,
|
|
223
|
+
path=rel,
|
|
224
|
+
title=title,
|
|
225
|
+
kind="markdown",
|
|
226
|
+
sha256=hashlib.sha256(text.encode("utf-8")).hexdigest(),
|
|
227
|
+
modified_at=datetime.fromtimestamp(path.stat().st_mtime, timezone.utc).isoformat(
|
|
228
|
+
timespec="seconds"
|
|
229
|
+
),
|
|
230
|
+
extra=extra,
|
|
231
|
+
)
|
|
232
|
+
)
|
|
233
|
+
|
|
234
|
+
manifest = Manifest(
|
|
235
|
+
pack_id=pack_id or root.name,
|
|
236
|
+
title=f"{root.name} (Obsidian export)",
|
|
237
|
+
description=f"Context pack exported from the Obsidian vault at {root}.",
|
|
238
|
+
generated_at=datetime.now(timezone.utc).isoformat(timespec="seconds"),
|
|
239
|
+
generator={"name": "ctxverify-adapter-obsidian", "version": "0.1.0"},
|
|
240
|
+
sources=sources,
|
|
241
|
+
)
|
|
242
|
+
|
|
243
|
+
return Pack(
|
|
244
|
+
root=str(root),
|
|
245
|
+
manifest=manifest,
|
|
246
|
+
claims=claims,
|
|
247
|
+
evidence=evidence,
|
|
248
|
+
entities=entities,
|
|
249
|
+
relations=[],
|
|
250
|
+
contradictions=[],
|
|
251
|
+
freshness={},
|
|
252
|
+
freshness_policy={"default_ttl_days": 180},
|
|
253
|
+
)
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
"""Check registry and runner."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from datetime import datetime, timezone
|
|
7
|
+
import time
|
|
8
|
+
from typing import Any, Callable, Iterable
|
|
9
|
+
|
|
10
|
+
from ..model import Pack
|
|
11
|
+
from ..report import Finding, Report, sort_findings
|
|
12
|
+
from ..resolve import LinkIndex, build_index
|
|
13
|
+
from ..rules import ERROR, RULES_BY_ID, RULE_GROUPS
|
|
14
|
+
|
|
15
|
+
CheckFn = Callable[[Pack, "CheckContext"], Iterable[Finding]]
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass
|
|
19
|
+
class CheckContext:
|
|
20
|
+
"""Everything a check may need beyond the pack itself."""
|
|
21
|
+
|
|
22
|
+
now: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
|
|
23
|
+
options: dict[str, Any] = field(default_factory=dict)
|
|
24
|
+
index: LinkIndex | None = None
|
|
25
|
+
entities: dict[str, str] = field(default_factory=dict)
|
|
26
|
+
|
|
27
|
+
def option(self, key: str, default: Any = None) -> Any:
|
|
28
|
+
return self.options.get(key, default)
|
|
29
|
+
|
|
30
|
+
def resolve_entity(self, name: str) -> str | None:
|
|
31
|
+
"""Resolve an entity name or alias to an entity id."""
|
|
32
|
+
return self.entities.get(name) or self.entities.get(name.strip().lower())
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
_CHECKS: list[tuple[str, CheckFn]] = []
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def register(name: str) -> Callable[[CheckFn], CheckFn]:
|
|
39
|
+
def decorator(fn: CheckFn) -> CheckFn:
|
|
40
|
+
_CHECKS.append((name, fn))
|
|
41
|
+
return fn
|
|
42
|
+
|
|
43
|
+
return decorator
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def check_names() -> list[str]:
|
|
47
|
+
return [name for name, _ in _CHECKS]
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def run_checks(pack: Pack, options: dict[str, Any] | None = None) -> Report:
|
|
51
|
+
"""Run every registered check against ``pack`` and return the report."""
|
|
52
|
+
options = dict(options or {})
|
|
53
|
+
now = options.pop("now", None) or datetime.now(timezone.utc)
|
|
54
|
+
started = time.perf_counter()
|
|
55
|
+
|
|
56
|
+
# Entity names and aliases are a separate namespace from file paths.
|
|
57
|
+
# They resolve entity references, not links to files, so they must not
|
|
58
|
+
# make a link to a missing file look resolvable.
|
|
59
|
+
entity_alias_map: dict[str, str] = {}
|
|
60
|
+
for entity in pack.entities:
|
|
61
|
+
if entity.name:
|
|
62
|
+
entity_alias_map.setdefault(entity.name, entity.id)
|
|
63
|
+
entity_alias_map.setdefault(entity.id, entity.id)
|
|
64
|
+
for alias in entity.aliases:
|
|
65
|
+
entity_alias_map.setdefault(alias, entity.id)
|
|
66
|
+
|
|
67
|
+
# File aliases live on the source record: a note can be linked by a name
|
|
68
|
+
# other than its path, exactly as Obsidian resolves an alias.
|
|
69
|
+
file_aliases: dict[str, str] = {}
|
|
70
|
+
for source in pack.manifest.sources:
|
|
71
|
+
for alias in source.extra.get("aliases") or []:
|
|
72
|
+
alias = str(alias).strip()
|
|
73
|
+
if alias:
|
|
74
|
+
file_aliases.setdefault(alias, source.path)
|
|
75
|
+
|
|
76
|
+
index = build_index([s.path for s in pack.manifest.sources], file_aliases)
|
|
77
|
+
|
|
78
|
+
ctx = CheckContext(now=now, options=options, index=index, entities=entity_alias_map)
|
|
79
|
+
ctx.options["invalid_ttls"] = set(getattr(pack, "invalid_ttls", set()) or set())
|
|
80
|
+
|
|
81
|
+
findings: list[Finding] = list(getattr(pack, "load_findings", []) or [])
|
|
82
|
+
enabled_groups, enabled_rule_ids = _resolve_selection(options.get("rules"))
|
|
83
|
+
disabled_groups = _explicit_groups(options.get("exclude_rules"))
|
|
84
|
+
_, disabled_rule_ids = _resolve_selection(options.get("exclude_rules"))
|
|
85
|
+
for name, fn in _CHECKS:
|
|
86
|
+
if enabled_groups is not None and name not in enabled_groups:
|
|
87
|
+
continue
|
|
88
|
+
if name in disabled_groups:
|
|
89
|
+
continue
|
|
90
|
+
try:
|
|
91
|
+
findings.extend(fn(pack, ctx))
|
|
92
|
+
except Exception as exc: # a broken check must not hide the rest
|
|
93
|
+
# This is a bug in ctxverify, not a defect in the pack. It gets
|
|
94
|
+
# its own rule id so that the two are never confused, and it is
|
|
95
|
+
# never filtered away: a tool that crashed still has to say so.
|
|
96
|
+
findings.append(
|
|
97
|
+
Finding(
|
|
98
|
+
rule="ctxverify.internal",
|
|
99
|
+
severity=ERROR,
|
|
100
|
+
message=(
|
|
101
|
+
f"internal error while running check {name}: "
|
|
102
|
+
f"{type(exc).__name__}: {exc}"
|
|
103
|
+
),
|
|
104
|
+
location=pack.root,
|
|
105
|
+
hint="this is a bug in ctxverify, not a problem with the pack; please report it",
|
|
106
|
+
)
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
# A selection may name a single rule id. Selecting `ids.duplicate` must
|
|
110
|
+
# report `ids.duplicate` and not its neighbour `ids.collision`.
|
|
111
|
+
# Structural findings are never filtered away: a pack that cannot be
|
|
112
|
+
# judged cannot be judged "clean" by narrowing the run.
|
|
113
|
+
if enabled_rule_ids:
|
|
114
|
+
findings = [
|
|
115
|
+
f
|
|
116
|
+
for f in findings
|
|
117
|
+
if f.rule in enabled_rule_ids
|
|
118
|
+
or f.rule in ("pack.structure", "ctxverify.internal")
|
|
119
|
+
]
|
|
120
|
+
if disabled_rule_ids:
|
|
121
|
+
findings = [f for f in findings if f.rule not in disabled_rule_ids]
|
|
122
|
+
|
|
123
|
+
findings = sort_findings(findings)
|
|
124
|
+
report = Report(
|
|
125
|
+
pack=pack.root,
|
|
126
|
+
findings=findings,
|
|
127
|
+
stats=pack_stats(pack),
|
|
128
|
+
checked_at=now.astimezone(timezone.utc).isoformat(timespec="seconds"),
|
|
129
|
+
duration_ms=int((time.perf_counter() - started) * 1000),
|
|
130
|
+
tool_version=tool_version(),
|
|
131
|
+
)
|
|
132
|
+
return report
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _resolve_selection(selection: Any) -> tuple[set[str] | None, set[str]]:
|
|
136
|
+
"""Turn a user's selection into check groups and rule ids.
|
|
137
|
+
|
|
138
|
+
A selection may name a check group (``identity``) or a rule id
|
|
139
|
+
(``ids.duplicate``). A rule id resolves to the group that implements it,
|
|
140
|
+
so the check still runs; the rule ids are returned separately so that the
|
|
141
|
+
findings can be narrowed to exactly the rules that were named.
|
|
142
|
+
"""
|
|
143
|
+
if not selection:
|
|
144
|
+
return None, set()
|
|
145
|
+
groups: set[str] = set()
|
|
146
|
+
rule_ids: set[str] = set()
|
|
147
|
+
for item in selection:
|
|
148
|
+
key = str(item).strip()
|
|
149
|
+
if not key:
|
|
150
|
+
continue
|
|
151
|
+
if key in RULES_BY_ID:
|
|
152
|
+
groups.add(RULE_GROUPS.get(key, key))
|
|
153
|
+
rule_ids.add(key)
|
|
154
|
+
continue
|
|
155
|
+
groups.add(key)
|
|
156
|
+
return (groups or None), rule_ids
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def _explicit_groups(selection: Any) -> set[str]:
|
|
160
|
+
"""The check groups named directly, ignoring rule ids.
|
|
161
|
+
|
|
162
|
+
Excluding a group silences every rule in it. Excluding a single rule id
|
|
163
|
+
must not: the check still runs and the other rules it produces are still
|
|
164
|
+
reported.
|
|
165
|
+
"""
|
|
166
|
+
if not selection:
|
|
167
|
+
return set()
|
|
168
|
+
groups: set[str] = set()
|
|
169
|
+
for item in selection:
|
|
170
|
+
key = str(item).strip()
|
|
171
|
+
if key and key not in RULES_BY_ID:
|
|
172
|
+
groups.add(key)
|
|
173
|
+
return groups
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def pack_stats(pack: Pack) -> dict[str, Any]:
|
|
177
|
+
return {
|
|
178
|
+
"claims": len(pack.claims),
|
|
179
|
+
"evidence": len(pack.evidence),
|
|
180
|
+
"entities": len(pack.entities),
|
|
181
|
+
"relations": len(pack.relations),
|
|
182
|
+
"contradictions": len(pack.contradictions),
|
|
183
|
+
"sources": len(pack.manifest.sources),
|
|
184
|
+
"freshness_entries": len(pack.freshness),
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def tool_version() -> str:
|
|
189
|
+
from ..version import __version__
|
|
190
|
+
|
|
191
|
+
return __version__
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
# Importing the modules registers their checks.
|
|
195
|
+
from . import actions as _actions # noqa: E402,F401
|
|
196
|
+
from . import consistency as _consistency # noqa: E402,F401
|
|
197
|
+
from . import duplicates as _duplicates # noqa: E402,F401
|
|
198
|
+
from . import freshness as _freshness # noqa: E402,F401
|
|
199
|
+
from . import hygiene as _hygiene # noqa: E402,F401
|
|
200
|
+
from . import identity as _identity # noqa: E402,F401
|
|
201
|
+
from . import links as _links # noqa: E402,F401
|
|
202
|
+
from . import orphans as _orphans # noqa: E402,F401
|
|
203
|
+
from . import provenance as _provenance # noqa: E402,F401
|