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.
@@ -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