mosaix-format 1.2.1__tar.gz

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,59 @@
1
+ Metadata-Version: 2.4
2
+ Name: mosaix-format
3
+ Version: 1.2.1
4
+ Summary: Python library for the Mosaix Format
5
+ Author-email: Andrea Fiorino <andrea@alfagomma.dev>
6
+ License: MIT
7
+ Requires-Python: >=3.10
8
+ Description-Content-Type: text/markdown
9
+
10
+ # mosaix
11
+
12
+ Python library for the [Mosaix Format](https://mosaix.io) — stdlib only, no dependencies.
13
+
14
+ ## Install (editable)
15
+
16
+ ```bash
17
+ pip install -e .
18
+ ```
19
+
20
+ ## Usage
21
+
22
+ ```python
23
+ from mosaix import parse_note, validate_vault, VaultGraph
24
+
25
+ note = parse_note("path/to/note.md")
26
+ print(note.title, note.id)
27
+
28
+ report = validate_vault("path/to/vault")
29
+ print(report.conformant, len(report.errors))
30
+
31
+ g = VaultGraph("path/to/vault")
32
+ print(g.orphans())
33
+ print(g.components())
34
+ ```
35
+
36
+ ## CLI
37
+
38
+ ```bash
39
+ mosaix check <vault_dir>
40
+ mosaix check <vault_dir> --json
41
+ mosaix check <vault_dir> --check-rev --exclude=exports
42
+ ```
43
+
44
+ Exit codes: `0` clean · `1` errors · `2` warnings only.
45
+
46
+ ## Modules
47
+
48
+ | Module | Purpose |
49
+ |---|---|
50
+ | `parser.py` | `parse_note(path) → Note` |
51
+ | `validator.py` | `validate_note(note)`, `validate_vault(path) → Report` |
52
+ | `crud.py` | `create_note()`, `update_frontmatter()`, `delete_note()` (raises) |
53
+ | `graph.py` | `VaultGraph`: orphans, broken_links, components |
54
+ | `_yaml.py` | Minimal YAML parser (internal, extracted from audit_reference.py) |
55
+ | `cli.py` | `mosaix check` entry point |
56
+
57
+ ## License
58
+
59
+ MIT © 2026 Andrea Fiorino
@@ -0,0 +1,50 @@
1
+ # mosaix
2
+
3
+ Python library for the [Mosaix Format](https://mosaix.io) — stdlib only, no dependencies.
4
+
5
+ ## Install (editable)
6
+
7
+ ```bash
8
+ pip install -e .
9
+ ```
10
+
11
+ ## Usage
12
+
13
+ ```python
14
+ from mosaix import parse_note, validate_vault, VaultGraph
15
+
16
+ note = parse_note("path/to/note.md")
17
+ print(note.title, note.id)
18
+
19
+ report = validate_vault("path/to/vault")
20
+ print(report.conformant, len(report.errors))
21
+
22
+ g = VaultGraph("path/to/vault")
23
+ print(g.orphans())
24
+ print(g.components())
25
+ ```
26
+
27
+ ## CLI
28
+
29
+ ```bash
30
+ mosaix check <vault_dir>
31
+ mosaix check <vault_dir> --json
32
+ mosaix check <vault_dir> --check-rev --exclude=exports
33
+ ```
34
+
35
+ Exit codes: `0` clean · `1` errors · `2` warnings only.
36
+
37
+ ## Modules
38
+
39
+ | Module | Purpose |
40
+ |---|---|
41
+ | `parser.py` | `parse_note(path) → Note` |
42
+ | `validator.py` | `validate_note(note)`, `validate_vault(path) → Report` |
43
+ | `crud.py` | `create_note()`, `update_frontmatter()`, `delete_note()` (raises) |
44
+ | `graph.py` | `VaultGraph`: orphans, broken_links, components |
45
+ | `_yaml.py` | Minimal YAML parser (internal, extracted from audit_reference.py) |
46
+ | `cli.py` | `mosaix check` entry point |
47
+
48
+ ## License
49
+
50
+ MIT © 2026 Andrea Fiorino
@@ -0,0 +1,18 @@
1
+ [build-system]
2
+ requires = ["setuptools>=42"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "mosaix-format"
7
+ version = "1.2.1"
8
+ description = "Python library for the Mosaix Format"
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ license = { text = "MIT" }
12
+ authors = [{ name = "Andrea Fiorino", email = "andrea@alfagomma.dev" }]
13
+
14
+ [project.scripts]
15
+ mosaix = "mosaix.cli:main"
16
+
17
+ [tool.setuptools.packages.find]
18
+ where = ["src"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,12 @@
1
+ """mosaix — Python library for the Mosaix Format."""
2
+ from .parser import Entity, Relation, Note, parse_note
3
+ from .validator import Issue, Report, validate_note, validate_vault
4
+ from .crud import create_note, update_frontmatter, delete_note
5
+ from .graph import VaultGraph
6
+
7
+ __all__ = [
8
+ "Entity", "Relation", "Note", "parse_note",
9
+ "Issue", "Report", "validate_note", "validate_vault",
10
+ "create_note", "update_frontmatter", "delete_note",
11
+ "VaultGraph",
12
+ ]
@@ -0,0 +1,145 @@
1
+ """Minimal YAML subset for Mosaix frontmatter — stdlib only.
2
+
3
+ Extracted from audit_reference.py. Handles the flat scalar + list structures
4
+ that appear in Mosaix CORE keys; does not support full YAML.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ ITEM_ALIASES = {"nome": "name", "tipo": "type", "da": "from", "a": "to"}
9
+ VALUE_ALIASES = {
10
+ "persona": "person", "azienda": "company", "prodotto": "product", "progetto": "project",
11
+ "strumento": "tool", "luogo": "place", "documento": "document", "evento": "event",
12
+ "sintesi": "synthesis",
13
+ "ok": "sourced", "confermare": "to-confirm", "superato": "superseded",
14
+ }
15
+ KEY_ALIASES = {
16
+ "titolo": "title", "aggiornato": "updated", "riassunto": "summary", "parole_chiave": "keywords",
17
+ "mcp_entita": "entities", "mcp_relazioni": "relations", "mcp_collegamenti": "links", "mcp_rev": "rev",
18
+ "mcp_frammenti": "fragments", "mcp_pool": "pool", "mcp_layout": "layout",
19
+ "tipo": "type", "stato": "status",
20
+ }
21
+
22
+
23
+ def _scalar(s: str):
24
+ s = s.strip()
25
+ if not s:
26
+ return ""
27
+ if (s[0] == s[-1]) and s[0] in "\"'":
28
+ return s[1:-1]
29
+ if s.startswith("[") and s.endswith("]"):
30
+ inner = s[1:-1].strip()
31
+ if not inner:
32
+ return []
33
+ items, depth, cur, quote = [], 0, "", ""
34
+ for ch in inner:
35
+ if quote:
36
+ if ch == quote:
37
+ quote = ""
38
+ elif ch in "\"'" and not cur.strip():
39
+ quote = ch
40
+ elif ch in "{[":
41
+ depth += 1
42
+ elif ch in "}]":
43
+ depth -= 1
44
+ if ch == "," and depth == 0 and not quote:
45
+ items.append(_scalar(cur))
46
+ cur = ""
47
+ else:
48
+ cur += ch
49
+ items.append(_scalar(cur))
50
+ return items
51
+ if s.startswith("{") and s.endswith("}"):
52
+ d = {}
53
+ for part in s[1:-1].split(","):
54
+ if ":" in part:
55
+ k, v = part.split(":", 1)
56
+ d[k.strip()] = _scalar(v)
57
+ return d
58
+ return s
59
+
60
+
61
+ def parse_frontmatter(text: str) -> tuple[dict | None, str]:
62
+ """Return (frontmatter_dict | None, body_str)."""
63
+ if not text.startswith("---"):
64
+ return None, text
65
+ end = text.find("\n---", 3)
66
+ if end < 0:
67
+ return None, text
68
+ block = text[3:end].strip("\n")
69
+ body = text[end + 4:]
70
+ fm: dict = {}
71
+ key = None
72
+ lines = block.splitlines()
73
+ i = 0
74
+ while i < len(lines):
75
+ line = lines[i]
76
+ if not line.strip() or line.lstrip().startswith("#"):
77
+ i += 1
78
+ continue
79
+ if not line.startswith((" ", "\t")) and ":" in line:
80
+ key, _, val = line.partition(":")
81
+ key = key.strip()
82
+ val = val.strip()
83
+ if val == "" or val in ("|", ">"):
84
+ fm[key] = [] if val == "" else ""
85
+ else:
86
+ fm[key] = _scalar(val)
87
+ elif key is not None and line.lstrip().startswith("- "):
88
+ item = line.lstrip()[2:].strip()
89
+ if not isinstance(fm.get(key), list):
90
+ fm[key] = []
91
+ if item.startswith("{"):
92
+ fm[key].append(_scalar(item))
93
+ elif ":" in item and not item.startswith(("\"", "'", "[")):
94
+ d: dict = {}
95
+ k, _, v = item.partition(":")
96
+ d[k.strip()] = _scalar(v)
97
+ j = i + 1
98
+ while j < len(lines) and lines[j].startswith((" ", "\t")) and not lines[j].lstrip().startswith("- ") and ":" in lines[j]:
99
+ k2, _, v2 = lines[j].strip().partition(":")
100
+ d[k2.strip()] = _scalar(v2)
101
+ j += 1
102
+ fm[key].append(d)
103
+ i = j
104
+ continue
105
+ else:
106
+ fm[key].append(_scalar(item))
107
+ elif key is not None and isinstance(fm.get(key), str):
108
+ fm[key] = (fm[key] + " " + line.strip()).strip()
109
+ i += 1
110
+ return fm, body
111
+
112
+
113
+ def _canon_items(items: list) -> list:
114
+ out = []
115
+ for it in items or []:
116
+ if isinstance(it, dict):
117
+ d = {ITEM_ALIASES.get(k, k): v for k, v in it.items()}
118
+ if "type" in d:
119
+ t = str(d["type"]).lower()
120
+ d["type"] = VALUE_ALIASES.get(t, t)
121
+ out.append(d)
122
+ else:
123
+ out.append(it)
124
+ return out
125
+
126
+
127
+ def normalise(fm: dict, extra_key_aliases: dict | None = None) -> dict:
128
+ """Map aliased keys/values to canonical Mosaix vocabulary."""
129
+ aliases = dict(KEY_ALIASES)
130
+ if extra_key_aliases:
131
+ aliases.update(extra_key_aliases)
132
+ out: dict = {}
133
+ for k, v in fm.items():
134
+ canon = aliases.get(k, k)
135
+ if canon in out and canon != k:
136
+ continue
137
+ out[canon] = v
138
+ for k in ("entities", "relations"):
139
+ if isinstance(out.get(k), list):
140
+ out[k] = _canon_items(out[k])
141
+ for k in ("type", "status"):
142
+ if k in out and isinstance(out[k], str):
143
+ v2 = out[k].lower()
144
+ out[k] = VALUE_ALIASES.get(v2, v2)
145
+ return out
@@ -0,0 +1,64 @@
1
+ """CLI: mosaix check <vault> [--json] [--check-rev] [--verbose]"""
2
+ from __future__ import annotations
3
+
4
+ import argparse
5
+ import json as _json
6
+ import sys
7
+ from pathlib import Path
8
+
9
+ from .validator import validate_vault
10
+
11
+
12
+ def main() -> None:
13
+ parser = argparse.ArgumentParser(prog="mosaix", description="Mosaix Format tools")
14
+ sub = parser.add_subparsers(dest="command")
15
+
16
+ check = sub.add_parser("check", help="Validate a vault")
17
+ check.add_argument("vault", type=Path)
18
+ check.add_argument("--json", action="store_true", help="Output JSON")
19
+ check.add_argument("--check-rev", action="store_true", help="Check rev digest")
20
+ check.add_argument("--verbose", action="store_true", help="Show all issues")
21
+ check.add_argument(
22
+ "--exclude",
23
+ default="",
24
+ metavar="PATHS",
25
+ help="Comma-separated path prefixes to exclude",
26
+ )
27
+
28
+ args = parser.parse_args()
29
+ if args.command is None:
30
+ parser.print_help()
31
+ sys.exit(0)
32
+
33
+ # command == "check"
34
+ vault = args.vault.resolve()
35
+ exclude = tuple(x.strip() for x in args.exclude.split(",") if x.strip())
36
+ report = validate_vault(vault, check_rev=args.check_rev, exclude=exclude)
37
+
38
+ if args.json:
39
+ data = {
40
+ "vault": str(vault),
41
+ "errors": [{"code": i.code, "message": i.message, "path": i.path} for i in report.errors],
42
+ "warnings": [{"code": i.code, "message": i.message, "path": i.path} for i in report.warnings],
43
+ "conformant": report.conformant,
44
+ }
45
+ print(_json.dumps(data, ensure_ascii=False, indent=2))
46
+ else:
47
+ status = "CONFORMANT" if report.conformant else "NOT CONFORMANT"
48
+ print(f"Mosaix audit — {vault}")
49
+ print(f"errors: {len(report.errors)} warnings: {len(report.warnings)} {status}")
50
+ for issue in report.errors:
51
+ print(f" E [{issue.code}] {issue.message}")
52
+ if args.verbose or not report.errors:
53
+ for issue in report.warnings:
54
+ print(f" W [{issue.code}] {issue.message}")
55
+
56
+ if report.errors:
57
+ sys.exit(1)
58
+ if report.warnings:
59
+ sys.exit(2)
60
+ sys.exit(0)
61
+
62
+
63
+ if __name__ == "__main__":
64
+ main()
@@ -0,0 +1,76 @@
1
+ """CRUD operations for Mosaix notes — stdlib only."""
2
+ from __future__ import annotations
3
+
4
+ import secrets
5
+ import time
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+ from ._yaml import parse_frontmatter
10
+
11
+ _CROCKFORD = "0123456789ABCDEFGHJKMNPQRSTVWXYZ"
12
+
13
+
14
+ def _generate_ulid() -> str:
15
+ """Generate a ULID: 48-bit ms timestamp + 80-bit random, Crockford Base32."""
16
+ ts = int(time.time() * 1000) & 0xFFFFFFFFFFFF # 48 bits
17
+ rand = secrets.randbits(80)
18
+ chars = [_CROCKFORD[(ts >> s) & 0x1F] for s in range(45, -1, -5)]
19
+ chars += [_CROCKFORD[(rand >> s) & 0x1F] for s in range(75, -1, -5)]
20
+ return "".join(chars)
21
+
22
+
23
+ def create_note(path: Path | str, frontmatter: dict[str, Any], body: str = "") -> Path:
24
+ """Write a new Mosaix note. Auto-generates id (ULID) if absent. Raises FileExistsError if it already exists."""
25
+ path = Path(path)
26
+ if path.exists():
27
+ raise FileExistsError(path)
28
+ fm = dict(frontmatter)
29
+ if not fm.get("id"):
30
+ fm["id"] = _generate_ulid()
31
+ fm_block = _serialise_frontmatter(fm)
32
+ path.write_text(f"---\n{fm_block}---\n{body}", encoding="utf-8")
33
+ return path
34
+
35
+
36
+ def update_frontmatter(path: Path | str, changes: dict[str, Any]) -> None:
37
+ """Rewrite only the frontmatter of a note, leaving the body byte-identical."""
38
+ path = Path(path)
39
+ text = path.read_text(encoding="utf-8")
40
+ fm_raw, body = parse_frontmatter(text)
41
+ if fm_raw is None:
42
+ raise ValueError(f"{path}: note has no frontmatter")
43
+ fm_raw.update(changes)
44
+ fm_block = _serialise_frontmatter(fm_raw)
45
+ # body from parse_frontmatter already starts with \n (the separator after ---)
46
+ path.write_text(f"---\n{fm_block}---{body}", encoding="utf-8")
47
+
48
+
49
+ def delete_note(path: Path | str) -> None: # noqa: ARG001
50
+ raise NotImplementedError("Use supersede, don't delete (R7)")
51
+
52
+
53
+ # ---------- internal serialiser ----------
54
+
55
+ def _serialise_frontmatter(fm: dict[str, Any]) -> str:
56
+ lines = []
57
+ for k, v in fm.items():
58
+ if isinstance(v, list):
59
+ if not v:
60
+ lines.append(f"{k}:")
61
+ else:
62
+ lines.append(f"{k}:")
63
+ for item in v:
64
+ if isinstance(item, dict):
65
+ pairs = ", ".join(f"{ik}: {iv}" for ik, iv in item.items())
66
+ lines.append(f" - {{{pairs}}}")
67
+ else:
68
+ lines.append(f" - {item}")
69
+ elif v is None:
70
+ lines.append(f"{k}:")
71
+ else:
72
+ sv = str(v)
73
+ if any(c in sv for c in (':', '#', '[', ']', '{', '}')):
74
+ sv = f'"{sv}"'
75
+ lines.append(f"{k}: {sv}")
76
+ return "\n".join(lines) + "\n"
@@ -0,0 +1,145 @@
1
+ """VaultGraph: orphans, broken_links, connected components — stdlib only."""
2
+ from __future__ import annotations
3
+
4
+ import re
5
+ from pathlib import Path
6
+
7
+ from ._yaml import parse_frontmatter, normalise
8
+
9
+ WIKILINK = re.compile(r"(?<!!)\[\[([^\]|#]+)(?:#[^\]|]*)?(?:\|[^\]]*)?\]\]")
10
+ ULID_RE = re.compile(r"^[0-7][0-9A-HJKMNP-TV-Z]{25}$")
11
+ EXCLUDED_DIRS = {"_inbox", "_private", ".obsidian", ".git", "node_modules", ".trash", "__pycache__", ".vault-ingest"}
12
+
13
+ META_NAMES = {"conventions", "metodo e convenzioni", "claude", "readme", "taxonomy", "tag index", "tassonomia", "indice tag"}
14
+ META_DIRS = {"_meta", "99-meta", "meta"}
15
+ LEDGER_NAMES = {"open questions", "assunzioni da confermare", "domande aperte"}
16
+ MOC_NAMES = {"home", "00-index", "index", "00-indice", "indice"}
17
+
18
+
19
+ class VaultGraph:
20
+ """Directed link graph over a Mosaix vault."""
21
+
22
+ def __init__(self, vault: Path | str) -> None:
23
+ self.vault = Path(vault)
24
+ self._stems: dict[str, Path] = {} # stem → path
25
+ self._id_to_stem: dict[str, str] = {} # ULID id → stem
26
+ self._edges: dict[str, set[str]] = {} # stem → {stem, ...} (outgoing)
27
+ self._incoming: dict[str, set[str]] = {} # stem → {stem, ...}
28
+ self._types: dict[str, str | None] = {} # stem → type field
29
+ self._build()
30
+
31
+ def _build(self) -> None:
32
+ files: list[tuple[Path, str]] = []
33
+ for p in self.vault.rglob("*.md"):
34
+ rel_parts = p.relative_to(self.vault).parts
35
+ if any(part in EXCLUDED_DIRS for part in rel_parts[:-1]):
36
+ continue
37
+ self._stems[p.stem] = p
38
+ files.append((p, p.stem))
39
+
40
+ for p, stem in files:
41
+ text = p.read_text(encoding="utf-8", errors="replace")
42
+ fm_raw, body = parse_frontmatter(text)
43
+ fm = normalise(fm_raw) if fm_raw else {}
44
+ if isinstance(fm.get("id"), str):
45
+ self._id_to_stem[fm["id"]] = stem
46
+ self._types[stem] = (fm.get("type") or None)
47
+ self._edges.setdefault(stem, set())
48
+ self._incoming.setdefault(stem, set())
49
+
50
+ for p, stem in files:
51
+ text = p.read_text(encoding="utf-8", errors="replace")
52
+ fm_raw, body = parse_frontmatter(text)
53
+ fm = normalise(fm_raw) if fm_raw else {}
54
+
55
+ # wikilinks in body
56
+ for target in WIKILINK.findall(body):
57
+ t = target.strip().split("/")[-1]
58
+ if t in self._stems:
59
+ self._edges[stem].add(t)
60
+ self._incoming.setdefault(t, set()).add(stem)
61
+
62
+ # frontmatter links
63
+ for link in fm.get("links", []) or []:
64
+ t = str(link).strip()
65
+ if ULID_RE.match(t) and t in self._id_to_stem:
66
+ dest = self._id_to_stem[t]
67
+ elif t in self._stems:
68
+ dest = t
69
+ else:
70
+ continue
71
+ self._edges[stem].add(dest)
72
+ self._incoming.setdefault(dest, set()).add(stem)
73
+
74
+ # ------------------------------------------------------------------
75
+
76
+ def _is_structural(self, stem: str) -> bool:
77
+ """Return True if the note is a MOC, meta note, or ledger (excluded from orphan check)."""
78
+ s = stem.lower()
79
+ if s in META_NAMES or s in LEDGER_NAMES or s in MOC_NAMES:
80
+ return True
81
+ path = self._stems[stem]
82
+ if path.parent.name.lower() in META_DIRS:
83
+ return True
84
+ typ = (self._types.get(stem) or "").lower()
85
+ if typ in {"moc", "meta", "ledger"}:
86
+ return True
87
+ return False
88
+
89
+ def orphans(self) -> list[Path]:
90
+ """Notes with no incoming links, excluding MOC, meta, and ledger notes."""
91
+ return [
92
+ self._stems[s]
93
+ for s in self._stems
94
+ if not self._incoming.get(s) and not self._is_structural(s)
95
+ ]
96
+
97
+ def broken_links(self) -> list[tuple[Path, str]]:
98
+ """(source_path, target_stem) pairs where target_stem is not in the vault."""
99
+ broken = []
100
+ for p in self.vault.rglob("*.md"):
101
+ rel_parts = p.relative_to(self.vault).parts
102
+ if any(part in EXCLUDED_DIRS for part in rel_parts[:-1]):
103
+ continue
104
+ text = p.read_text(encoding="utf-8", errors="replace")
105
+ _, body = parse_frontmatter(text)
106
+ for target in WIKILINK.findall(body):
107
+ t = target.strip().split("/")[-1]
108
+ if "." not in t and t not in self._stems:
109
+ broken.append((p, target.strip()))
110
+ return broken
111
+
112
+ def components(self) -> list[list[Path]]:
113
+ """Connected components (undirected). Returns list of groups, largest first."""
114
+ undirected: dict[str, set[str]] = {s: set() for s in self._stems}
115
+ for src, dests in self._edges.items():
116
+ for d in dests:
117
+ undirected[src].add(d)
118
+ undirected.setdefault(d, set()).add(src)
119
+
120
+ visited: set[str] = set()
121
+ groups: list[list[Path]] = []
122
+ for stem in self._stems:
123
+ if stem in visited:
124
+ continue
125
+ stack = [stem]
126
+ component: list[Path] = []
127
+ while stack:
128
+ node = stack.pop()
129
+ if node in visited:
130
+ continue
131
+ visited.add(node)
132
+ if node in self._stems:
133
+ component.append(self._stems[node])
134
+ for nbr in undirected.get(node, set()):
135
+ if nbr not in visited:
136
+ stack.append(nbr)
137
+ groups.append(component)
138
+ groups.sort(key=len, reverse=True)
139
+ return groups
140
+
141
+ def note_count(self) -> int:
142
+ return len(self._stems)
143
+
144
+ def edge_count(self) -> int:
145
+ return sum(len(dests) for dests in self._edges.values())
@@ -0,0 +1,86 @@
1
+ """parse_note(path) → Note dataclass."""
2
+ from __future__ import annotations
3
+
4
+ from dataclasses import dataclass, field
5
+ from pathlib import Path
6
+ from typing import Any
7
+
8
+ from ._yaml import parse_frontmatter, normalise
9
+
10
+
11
+ @dataclass
12
+ class Entity:
13
+ name: str
14
+ type: str
15
+
16
+
17
+ @dataclass
18
+ class Relation:
19
+ from_: str # 'from' is a Python keyword
20
+ type: str
21
+ to: str
22
+
23
+
24
+ @dataclass
25
+ class Note:
26
+ path: Path
27
+ title: str
28
+ id: str | None
29
+ updated: str | None
30
+ tags: list[str]
31
+ summary: str | None
32
+ keywords: list[str]
33
+ entities: list[Entity]
34
+ relations: list[Relation]
35
+ links: list[str]
36
+ rev: str | None
37
+ body: str
38
+ frontmatter_raw: dict | None = field(repr=False)
39
+
40
+ @property
41
+ def frontmatter(self) -> dict:
42
+ return self.frontmatter_raw or {}
43
+
44
+
45
+ def _to_entity(d: Any) -> Entity:
46
+ if isinstance(d, dict):
47
+ return Entity(name=str(d.get("name", "")), type=str(d.get("type", "")))
48
+ return Entity(name=str(d), type="")
49
+
50
+
51
+ def _to_relation(d: Any) -> Relation:
52
+ if isinstance(d, dict):
53
+ return Relation(
54
+ from_=str(d.get("from", d.get("from_", ""))),
55
+ type=str(d.get("type", "")),
56
+ to=str(d.get("to", "")),
57
+ )
58
+ return Relation(from_="", type=str(d), to="")
59
+
60
+
61
+ def parse_note(path: Path | str) -> Note:
62
+ """Parse a single Mosaix-format markdown file into a Note."""
63
+ path = Path(path)
64
+ text = path.read_text(encoding="utf-8", errors="replace")
65
+ fm_raw, body = parse_frontmatter(text)
66
+ fm = normalise(fm_raw) if fm_raw is not None else {}
67
+
68
+ def _list(key: str) -> list:
69
+ v = fm.get(key)
70
+ return v if isinstance(v, list) else ([] if v is None else [v])
71
+
72
+ return Note(
73
+ path=path,
74
+ title=str(fm.get("title", path.stem)),
75
+ id=fm.get("id") or None,
76
+ updated=fm.get("updated") or None,
77
+ tags=_list("tags"),
78
+ summary=fm.get("summary") or None,
79
+ keywords=_list("keywords"),
80
+ entities=[_to_entity(e) for e in _list("entities")],
81
+ relations=[_to_relation(r) for r in _list("relations")],
82
+ links=[str(x) for x in _list("links")],
83
+ rev=fm.get("rev") or None,
84
+ body=body,
85
+ frontmatter_raw=fm_raw,
86
+ )
@@ -0,0 +1,307 @@
1
+ """validate_note(note) and validate_vault(path) → Report."""
2
+ from __future__ import annotations
3
+
4
+ import hashlib
5
+ import re
6
+ import sys
7
+ from dataclasses import dataclass, field
8
+ from pathlib import Path
9
+
10
+ from ._yaml import parse_frontmatter, normalise
11
+
12
+ CORE_KEYS = ("title", "updated", "tags", "summary", "keywords", "rev")
13
+ ENTITY_TYPES = {"person", "company", "product", "project", "tool", "place", "document", "event"}
14
+ ULID_RE = re.compile(r"^[0-7][0-9A-HJKMNP-TV-Z]{25}$")
15
+ WIKILINK = re.compile(r"(?<!!)\[\[([^\]|#]+)(?:#[^\]|]*)?(?:\|[^\]]*)?\]\]")
16
+ EMBED = re.compile(r"!\[\[([^\]|#]+)(?:#[^\]|]*)?(?:\|[^\]]*)?\]\]")
17
+ BODY_TAG = re.compile(r"(?<![\w/&])#((?![0-9A-Fa-f]{3,8}\b)[A-Za-z][A-Za-z0-9_/\-]*)")
18
+ RELIABILITY_MARKERS = ("✅", "⚠️", "🟢", "🟡", "❌", "status:", "sourced", "to-confirm",
19
+ "da fonte", "da confermare", "affidabilita", "affidabilità")
20
+ EXCLUDED_DIRS = {"_inbox", "_private", ".obsidian", ".git", "node_modules", ".trash", "__pycache__", ".vault-ingest"}
21
+ META_NAMES = {"conventions", "metodo e convenzioni", "claude", "readme", "taxonomy", "tag index", "tassonomia", "indice tag"}
22
+ META_DIRS = {"_meta", "99-meta", "meta"}
23
+ LEDGER_NAMES = {"open questions", "assunzioni da confermare", "domande aperte"}
24
+ MOC_NAMES = {"home", "00-index", "index", "00-indice", "indice"}
25
+
26
+
27
+ @dataclass
28
+ class Issue:
29
+ code: str
30
+ message: str
31
+ path: str | None = None
32
+
33
+
34
+ @dataclass
35
+ class Report:
36
+ errors: list[Issue] = field(default_factory=list)
37
+ warnings: list[Issue] = field(default_factory=list)
38
+
39
+ @property
40
+ def conformant(self) -> bool:
41
+ return not self.errors
42
+
43
+ @property
44
+ def is_conformant(self) -> bool:
45
+ return not self.errors
46
+
47
+ def _e(self, code: str, path: str | None, **kw: object) -> None:
48
+ msg = _MESSAGES_E.get(code, code)
49
+ try:
50
+ msg = msg.format(**kw)
51
+ except (KeyError, IndexError):
52
+ pass
53
+ self.errors.append(Issue(code=code, message=msg, path=path))
54
+
55
+ def _w(self, code: str, path: str | None, **kw: object) -> None:
56
+ msg = _MESSAGES_W.get(code, code)
57
+ try:
58
+ msg = msg.format(**kw)
59
+ except (KeyError, IndexError):
60
+ pass
61
+ self.warnings.append(Issue(code=code, message=msg, path=path))
62
+
63
+
64
+ _MESSAGES_E: dict[str, str] = {
65
+ "E001": "{rel}: no frontmatter",
66
+ "E002": "{rel}: missing `{key}`",
67
+ "E003": "{rel}: summary length {len} (120–240)",
68
+ "E004": "{rel}: keywords count {count} (6–8)",
69
+ "E005": "{rel}: entity type `{type}` not allowed",
70
+ "E006": "{rel}: broken link [[{target}]]",
71
+ "E007": "{rel}: orphan (no incoming link)",
72
+ "E008": "{rel}: links id `{ulid}` does not resolve to any note",
73
+ "E009": "{rel}: links target `{target}` does not exist",
74
+ "E010": "{rel}: document with {count} fragments (≥2)",
75
+ "E011": "{rel}: fragment `{fragment}` does not exist",
76
+ "E012": "vault: no MOC note (type: moc, or Home/00-Index)",
77
+ "E013": "vault: no meta note (§5.4)",
78
+ "E014": "vault: no open-questions ledger (§5.3)",
79
+ "E015": "vault: entities coverage {n}/{total} = {pct} (<80%)",
80
+ }
81
+ _MESSAGES_W: dict[str, str] = {
82
+ "W001": "{rel}: {count} entities (>12); is this one question? (R1)",
83
+ "W002": "{rel}: relation type `{type}` not in relation_types",
84
+ "W003": "{rel}: missing `id` (required from v2.0; generate a ULID)",
85
+ "W004": "{rel}: `id` is not a valid ULID: `{value}`",
86
+ "W005": "{rel}: tag #{tag} not declared in meta note",
87
+ "W006": "meta note declares no tags: taxonomy check skipped",
88
+ "W007": "{rel}: no reliability marker",
89
+ "W008": "{rel}: rev may be stale (hint only)",
90
+ }
91
+
92
+
93
+ def validate_note(note, *, entity_types: set[str] | None = None) -> Report:
94
+ """Validate a single Note against Mosaix rules. Returns a Report."""
95
+ from .parser import Note # local to avoid circular at module level
96
+ report = Report()
97
+ rel = str(note.path)
98
+ et = entity_types or ENTITY_TYPES
99
+
100
+ if note.frontmatter_raw is None:
101
+ report._e("E001", rel, rel=rel)
102
+ return report
103
+
104
+ fm = normalise(note.frontmatter_raw)
105
+
106
+ for k in CORE_KEYS:
107
+ if k == "title":
108
+ continue
109
+ if not fm.get(k) and fm.get(k) != 0:
110
+ report._e("E002", rel, rel=rel, key=k)
111
+
112
+ s = str(fm.get("summary", ""))
113
+ if s and not (120 <= len(s) <= 240):
114
+ report._e("E003", rel, rel=rel, len=len(s))
115
+
116
+ kw = fm.get("keywords", [])
117
+ if isinstance(kw, list) and kw and not (6 <= len(kw) <= 8):
118
+ report._e("E004", rel, rel=rel, count=len(kw))
119
+
120
+ ents = fm.get("entities", [])
121
+ if isinstance(ents, list):
122
+ for e in ents:
123
+ if isinstance(e, dict) and str(e.get("type", "")).lower() not in et:
124
+ report._e("E005", rel, rel=rel, type=e.get("type"))
125
+
126
+ note_id = fm.get("id")
127
+ if not note_id:
128
+ report._w("W003", rel, rel=rel)
129
+ elif not ULID_RE.match(str(note_id)):
130
+ report._w("W004", rel, rel=rel, value=note_id)
131
+
132
+ text = (note.frontmatter_raw or {})
133
+ body_and_fm = note.body + " " + " ".join(f"{k}:{v}" for k, v in fm.items())
134
+ if not any(m in body_and_fm for m in RELIABILITY_MARKERS):
135
+ report._w("W007", rel, rel=rel)
136
+
137
+ return report
138
+
139
+
140
+ def validate_vault(path: Path | str, *, check_rev: bool = False, exclude: tuple[str, ...] = ()) -> Report:
141
+ """Validate an entire vault directory. Wraps audit() logic."""
142
+ from collections import defaultdict
143
+ vault = Path(path)
144
+ report = Report()
145
+ notes: dict[str, dict] = {}
146
+ all_md: dict[str, Path] = {}
147
+
148
+ files = []
149
+ for p in vault.rglob("*.md"):
150
+ rel_parts = p.relative_to(vault).parts
151
+ rel = "/".join(rel_parts)
152
+ all_md.setdefault(p.stem, p)
153
+ if any(part in EXCLUDED_DIRS for part in rel_parts[:-1]):
154
+ continue
155
+ if any(rel.startswith(x) for x in exclude):
156
+ continue
157
+ files.append((p, rel_parts, rel))
158
+
159
+ meta_decl: dict = {}
160
+ for p, rel_parts, rel in files:
161
+ is_meta = p.stem.lower() in META_NAMES or any(part.lower() in META_DIRS for part in rel_parts[:-1])
162
+ if not is_meta:
163
+ continue
164
+ fm, _ = parse_frontmatter(p.read_text(encoding="utf-8", errors="replace"))
165
+ if fm and "mosaix" in fm:
166
+ meta_decl = fm
167
+ break
168
+
169
+ extra_aliases = meta_decl.get("aliases") if isinstance(meta_decl.get("aliases"), dict) else {}
170
+ entity_types = set(ENTITY_TYPES) | {str(t).lower() for t in (meta_decl.get("entity_types") or [])}
171
+ payload = tuple(str(x) for x in (meta_decl.get("payload") or []))
172
+ relation_types = {str(t).lower() for t in (meta_decl.get("relation_types") or [])}
173
+
174
+ for p, rel_parts, rel in files:
175
+ if any(rel.startswith(x) for x in payload):
176
+ continue
177
+ text = p.read_text(encoding="utf-8", errors="replace")
178
+ fm, body = parse_frontmatter(text)
179
+ if fm is not None:
180
+ fm = normalise(fm, extra_aliases)
181
+ is_meta = p.stem.lower() in META_NAMES or any(part.lower() in META_DIRS for part in rel_parts[:-1])
182
+ notes[p.stem] = {"path": p, "fm": fm, "body": body, "rel": rel, "is_meta": is_meta}
183
+
184
+ id_to_stem: dict[str, str] = {}
185
+ for name, n in notes.items():
186
+ if n["fm"] and isinstance(n["fm"].get("id"), str):
187
+ id_to_stem[n["fm"]["id"]] = name
188
+
189
+ incoming: dict[str, int] = defaultdict(int)
190
+ declared_tags: set[str] = {str(t) for t in (meta_decl.get("tags") or [])}
191
+ has_moc = has_meta = has_ledger = False
192
+ entity_count = 0
193
+
194
+ for name, n in notes.items():
195
+ fm, body, rel = n["fm"], n["body"], n["rel"]
196
+ low = name.lower()
197
+ if n["is_meta"]:
198
+ has_meta = True
199
+ declared_tags |= set(BODY_TAG.findall(body))
200
+ declared_tags |= set(re.findall(r"`#([A-Za-z0-9_/\-]+)`", body))
201
+ if low in LEDGER_NAMES:
202
+ has_ledger = True
203
+
204
+ for target in set(WIKILINK.findall(body)) | set(EMBED.findall(body)):
205
+ t = target.strip().split("/")[-1]
206
+ if t in all_md:
207
+ incoming[t] += 1
208
+ elif "." not in t:
209
+ report._e("E006", rel, rel=rel, target=target)
210
+
211
+ if fm is None:
212
+ report._e("E001", rel, rel=rel)
213
+ continue
214
+
215
+ ntype = str(fm.get("type", "")).lower()
216
+ if ntype == "moc" or "moc" in low or low in MOC_NAMES:
217
+ has_moc = True
218
+
219
+ for k in CORE_KEYS:
220
+ if k == "title":
221
+ continue
222
+ if not fm.get(k) and fm.get(k) != 0:
223
+ report._e("E002", rel, rel=rel, key=k)
224
+
225
+ s = str(fm.get("summary", ""))
226
+ if s and not (120 <= len(s) <= 240):
227
+ report._e("E003", rel, rel=rel, len=len(s))
228
+ kw = fm.get("keywords", [])
229
+ if isinstance(kw, list) and kw and not (6 <= len(kw) <= 8):
230
+ report._e("E004", rel, rel=rel, count=len(kw))
231
+
232
+ ents = fm.get("entities", [])
233
+ if isinstance(ents, list) and ents:
234
+ entity_count += 1
235
+ if len(ents) > 12 and ntype != "moc" and not n["is_meta"]:
236
+ report._w("W001", rel, rel=rel, count=len(ents))
237
+ for e in ents:
238
+ if isinstance(e, dict) and str(e.get("type", "")).lower() not in entity_types:
239
+ report._e("E005", rel, rel=rel, type=e.get("type"))
240
+
241
+ if relation_types:
242
+ for r in fm.get("relations", []) or []:
243
+ if isinstance(r, dict) and str(r.get("type", "")).lower() not in relation_types:
244
+ report._w("W002", rel, rel=rel, type=r.get("type"))
245
+
246
+ note_id = fm.get("id")
247
+ if not note_id:
248
+ report._w("W003", rel, rel=rel)
249
+ elif not ULID_RE.match(str(note_id)):
250
+ report._w("W004", rel, rel=rel, value=note_id)
251
+
252
+ for target in fm.get("links", []) or []:
253
+ t = str(target).strip()
254
+ if ULID_RE.match(t):
255
+ if t in id_to_stem:
256
+ incoming[id_to_stem[t]] += 1
257
+ else:
258
+ report._e("E008", rel, rel=rel, ulid=t)
259
+ elif t in all_md:
260
+ incoming[t] += 1
261
+ else:
262
+ report._e("E009", rel, rel=rel, target=t)
263
+
264
+ if ntype == "document":
265
+ frags = fm.get("fragments", []) or []
266
+ if len(frags) < 2:
267
+ report._e("E010", rel, rel=rel, count=len(frags))
268
+ for f in frags:
269
+ if str(f) not in all_md:
270
+ report._e("E011", rel, rel=rel, fragment=f)
271
+
272
+ if n["is_meta"]:
273
+ declared_tags |= {str(t) for t in (fm.get("tags", []) or [])}
274
+ n["tags"] = set(BODY_TAG.findall(body)) | {str(t) for t in (fm.get("tags", []) or [])}
275
+
276
+ body_and_fm = body + " " + " ".join(f"{k}:{v}" for k, v in fm.items())
277
+ if not any(m in body_and_fm for m in RELIABILITY_MARKERS):
278
+ report._w("W007", rel, rel=rel)
279
+
280
+ if check_rev and fm.get("rev"):
281
+ digest = hashlib.sha256(body.strip().encode("utf-8")).hexdigest()[:12]
282
+ if digest != str(fm["rev"]):
283
+ report._w("W008", rel, rel=rel)
284
+
285
+ for name, n in notes.items():
286
+ low = name.lower()
287
+ if incoming.get(name, 0) == 0 and not n["is_meta"] and low not in LEDGER_NAMES and "moc" not in low and low not in MOC_NAMES:
288
+ report._e("E007", n["rel"], rel=n["rel"])
289
+
290
+ if declared_tags:
291
+ for name, n in notes.items():
292
+ for t in n.get("tags", set()):
293
+ if t not in declared_tags and not t.startswith(("type/", "status/", "tipo/", "stato/")):
294
+ report._w("W005", n["rel"], rel=n["rel"], tag=t)
295
+ else:
296
+ report._w("W006", None)
297
+
298
+ if not has_moc:
299
+ report._e("E012", None)
300
+ if not has_meta:
301
+ report._e("E013", None)
302
+ if not has_ledger:
303
+ report._e("E014", None)
304
+ if notes and entity_count / len(notes) < 0.80:
305
+ report._e("E015", None, n=entity_count, total=len(notes), pct=f"{entity_count/len(notes):.0%}")
306
+
307
+ return report
@@ -0,0 +1,59 @@
1
+ Metadata-Version: 2.4
2
+ Name: mosaix-format
3
+ Version: 1.2.1
4
+ Summary: Python library for the Mosaix Format
5
+ Author-email: Andrea Fiorino <andrea@alfagomma.dev>
6
+ License: MIT
7
+ Requires-Python: >=3.10
8
+ Description-Content-Type: text/markdown
9
+
10
+ # mosaix
11
+
12
+ Python library for the [Mosaix Format](https://mosaix.io) — stdlib only, no dependencies.
13
+
14
+ ## Install (editable)
15
+
16
+ ```bash
17
+ pip install -e .
18
+ ```
19
+
20
+ ## Usage
21
+
22
+ ```python
23
+ from mosaix import parse_note, validate_vault, VaultGraph
24
+
25
+ note = parse_note("path/to/note.md")
26
+ print(note.title, note.id)
27
+
28
+ report = validate_vault("path/to/vault")
29
+ print(report.conformant, len(report.errors))
30
+
31
+ g = VaultGraph("path/to/vault")
32
+ print(g.orphans())
33
+ print(g.components())
34
+ ```
35
+
36
+ ## CLI
37
+
38
+ ```bash
39
+ mosaix check <vault_dir>
40
+ mosaix check <vault_dir> --json
41
+ mosaix check <vault_dir> --check-rev --exclude=exports
42
+ ```
43
+
44
+ Exit codes: `0` clean · `1` errors · `2` warnings only.
45
+
46
+ ## Modules
47
+
48
+ | Module | Purpose |
49
+ |---|---|
50
+ | `parser.py` | `parse_note(path) → Note` |
51
+ | `validator.py` | `validate_note(note)`, `validate_vault(path) → Report` |
52
+ | `crud.py` | `create_note()`, `update_frontmatter()`, `delete_note()` (raises) |
53
+ | `graph.py` | `VaultGraph`: orphans, broken_links, components |
54
+ | `_yaml.py` | Minimal YAML parser (internal, extracted from audit_reference.py) |
55
+ | `cli.py` | `mosaix check` entry point |
56
+
57
+ ## License
58
+
59
+ MIT © 2026 Andrea Fiorino
@@ -0,0 +1,17 @@
1
+ README.md
2
+ pyproject.toml
3
+ src/mosaix/__init__.py
4
+ src/mosaix/_yaml.py
5
+ src/mosaix/cli.py
6
+ src/mosaix/crud.py
7
+ src/mosaix/graph.py
8
+ src/mosaix/parser.py
9
+ src/mosaix/validator.py
10
+ src/mosaix_format.egg-info/PKG-INFO
11
+ src/mosaix_format.egg-info/SOURCES.txt
12
+ src/mosaix_format.egg-info/dependency_links.txt
13
+ src/mosaix_format.egg-info/entry_points.txt
14
+ src/mosaix_format.egg-info/top_level.txt
15
+ tests/test_crud_graph.py
16
+ tests/test_parser.py
17
+ tests/test_validator.py
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ mosaix = mosaix.cli:main
@@ -0,0 +1,142 @@
1
+ """Tests for mosaix.crud and mosaix.graph."""
2
+ from __future__ import annotations
3
+
4
+ import hashlib
5
+ import re
6
+ import tempfile
7
+ from pathlib import Path
8
+
9
+ import pytest
10
+
11
+ from mosaix import parse_note, create_note, update_frontmatter, delete_note, VaultGraph
12
+
13
+ ULID_RE = re.compile(r"^[0-7][0-9A-HJKMNP-TV-Z]{25}$")
14
+
15
+ # Conformance fixtures live two directories above mosaix-py
16
+ CONFORMANCE = Path(__file__).parent.parent.parent / "tests" / "conformance"
17
+ VALID_VAULT = CONFORMANCE / "valid"
18
+ ORPHAN_VAULT = CONFORMANCE / "edge-cases" / "e007_orphan_vault"
19
+
20
+
21
+ # ---------- CRUD tests ----------
22
+
23
+ def test_create_note_generates_ulid(tmp_path):
24
+ p = tmp_path / "note.md"
25
+ fm = {
26
+ "title": "Test",
27
+ "updated": "2026-09-09",
28
+ "tags": ["test"],
29
+ "summary": "x" * 120,
30
+ "keywords": ["a", "b", "c", "d", "e", "f"],
31
+ "rev": "abc123",
32
+ }
33
+ create_note(p, fm)
34
+ note = parse_note(p)
35
+ assert note.id is not None, "id should have been auto-generated"
36
+ assert ULID_RE.match(note.id), f"id {note.id!r} is not a valid ULID"
37
+
38
+
39
+ def test_create_note_preserves_explicit_id(tmp_path):
40
+ p = tmp_path / "note.md"
41
+ explicit = "01JZZZZZZZZZZZZZZZZZZZZZZA"
42
+ create_note(p, {"title": "T", "id": explicit})
43
+ note = parse_note(p)
44
+ assert note.id == explicit
45
+
46
+
47
+ def test_create_note_frontmatter_round_trip(tmp_path):
48
+ p = tmp_path / "note.md"
49
+ fm = {
50
+ "title": "Round Trip",
51
+ "updated": "2026-09-09",
52
+ "tags": ["alpha", "beta"],
53
+ "summary": "y" * 130,
54
+ "keywords": ["k1", "k2", "k3", "k4", "k5", "k6"],
55
+ "rev": "deadbeef",
56
+ }
57
+ create_note(p, fm, body="Hello world.\n")
58
+ note = parse_note(p)
59
+ assert note.title == "Round Trip"
60
+ assert note.tags == ["alpha", "beta"]
61
+ assert len(note.keywords) == 6
62
+ assert ULID_RE.match(note.id)
63
+
64
+
65
+ def test_update_frontmatter_body_byte_identical(tmp_path):
66
+ p = tmp_path / "note.md"
67
+ body = "Hello\nworld\n\nParagraph with special chars: €, ñ, 日本語.\n"
68
+ create_note(p, {"title": "Body Test", "rev": "000"}, body=body)
69
+
70
+ # Body string as parse_frontmatter returns it (what update_frontmatter preserves)
71
+ note_before = parse_note(p)
72
+ h_before = hashlib.sha256(note_before.body.encode("utf-8")).hexdigest()
73
+
74
+ update_frontmatter(p, {"rev": "111", "status": "sourced"})
75
+
76
+ note_after = parse_note(p)
77
+ h_after = hashlib.sha256(note_after.body.encode("utf-8")).hexdigest()
78
+
79
+ assert h_before == h_after, "body changed after update_frontmatter"
80
+
81
+
82
+ def test_update_frontmatter_changes_applied(tmp_path):
83
+ p = tmp_path / "note.md"
84
+ create_note(p, {"title": "Old", "rev": "aaa"})
85
+ update_frontmatter(p, {"title": "New", "rev": "bbb"})
86
+ note = parse_note(p)
87
+ assert note.title == "New"
88
+
89
+
90
+ def test_delete_note_raises(tmp_path):
91
+ p = tmp_path / "del.md"
92
+ with pytest.raises(NotImplementedError):
93
+ delete_note(p)
94
+
95
+
96
+ # ---------- Graph tests ----------
97
+
98
+ def test_valid_vault_zero_orphans():
99
+ assert VALID_VAULT.exists(), f"missing fixture: {VALID_VAULT}"
100
+ g = VaultGraph(VALID_VAULT)
101
+ orphans = g.orphans()
102
+ assert orphans == [], f"unexpected orphans: {[o.name for o in orphans]}"
103
+
104
+
105
+ def test_valid_vault_zero_broken_links():
106
+ assert VALID_VAULT.exists(), f"missing fixture: {VALID_VAULT}"
107
+ g = VaultGraph(VALID_VAULT)
108
+ broken = g.broken_links()
109
+ assert broken == [], f"unexpected broken links: {broken}"
110
+
111
+
112
+ def test_orphan_vault_detects_orphan():
113
+ assert ORPHAN_VAULT.exists(), f"missing fixture: {ORPHAN_VAULT}"
114
+ g = VaultGraph(ORPHAN_VAULT)
115
+ orphans = g.orphans()
116
+ stems = {o.stem for o in orphans}
117
+ assert "orphan_note" in stems, f"orphan_note not detected; got: {stems}"
118
+
119
+
120
+ def test_orphan_vault_excludes_structural():
121
+ """MOC, meta, and ledger notes must not appear in orphans()."""
122
+ assert ORPHAN_VAULT.exists(), f"missing fixture: {ORPHAN_VAULT}"
123
+ g = VaultGraph(ORPHAN_VAULT)
124
+ orphan_stems = {o.stem for o in g.orphans()}
125
+ assert "moc" not in orphan_stems
126
+ assert "conventions" not in orphan_stems
127
+ assert "open questions" not in orphan_stems
128
+
129
+
130
+ def test_graph_note_and_edge_count():
131
+ assert VALID_VAULT.exists(), f"missing fixture: {VALID_VAULT}"
132
+ g = VaultGraph(VALID_VAULT)
133
+ assert g.note_count() > 0
134
+ assert g.edge_count() > 0
135
+
136
+
137
+ def test_graph_components_non_empty():
138
+ assert VALID_VAULT.exists(), f"missing fixture: {VALID_VAULT}"
139
+ g = VaultGraph(VALID_VAULT)
140
+ comps = g.components()
141
+ assert len(comps) > 0
142
+ assert all(len(c) > 0 for c in comps)
@@ -0,0 +1,72 @@
1
+ """Smoke tests for mosaix.parser."""
2
+ import textwrap
3
+ import tempfile
4
+ from pathlib import Path
5
+
6
+ import pytest
7
+
8
+ from mosaix import parse_note, Note
9
+
10
+
11
+ SAMPLE = textwrap.dedent("""\
12
+ ---
13
+ title: Test Note
14
+ id: 01JZZZZZZZZZZZZZZZZZZZZZZA
15
+ updated: "2026-09-09"
16
+ tags: [test, mosaix]
17
+ summary: This is a test summary that must be between 120 and 240 characters long, so here is some additional padding text to make it reach the minimum.
18
+ keywords: [alpha, beta, gamma, delta, epsilon, zeta]
19
+ rev: abc123def456
20
+ ---
21
+ Body content here.
22
+ """)
23
+
24
+
25
+ def _write_tmp(content: str) -> Path:
26
+ tmp = tempfile.NamedTemporaryFile(suffix=".md", delete=False, mode="w", encoding="utf-8")
27
+ tmp.write(content)
28
+ tmp.close()
29
+ return Path(tmp.name)
30
+
31
+
32
+ def test_parse_returns_note():
33
+ p = _write_tmp(SAMPLE)
34
+ note = parse_note(p)
35
+ assert isinstance(note, Note)
36
+ p.unlink()
37
+
38
+
39
+ def test_parse_title():
40
+ p = _write_tmp(SAMPLE)
41
+ note = parse_note(p)
42
+ assert note.title == "Test Note"
43
+ p.unlink()
44
+
45
+
46
+ def test_parse_id():
47
+ p = _write_tmp(SAMPLE)
48
+ note = parse_note(p)
49
+ assert note.id == "01JZZZZZZZZZZZZZZZZZZZZZZA"
50
+ p.unlink()
51
+
52
+
53
+ def test_parse_keywords():
54
+ p = _write_tmp(SAMPLE)
55
+ note = parse_note(p)
56
+ assert len(note.keywords) == 6
57
+ p.unlink()
58
+
59
+
60
+ def test_parse_no_frontmatter():
61
+ p = _write_tmp("Just body text, no frontmatter.")
62
+ note = parse_note(p)
63
+ assert note.frontmatter_raw is None
64
+ assert note.body == "Just body text, no frontmatter."
65
+ p.unlink()
66
+
67
+
68
+ def test_parse_body():
69
+ p = _write_tmp(SAMPLE)
70
+ note = parse_note(p)
71
+ assert "Body content here" in note.body
72
+ p.unlink()
@@ -0,0 +1,12 @@
1
+ """Conformance test: validate_vault on the canonical valid corpus."""
2
+ from pathlib import Path
3
+
4
+ from mosaix import validate_vault
5
+
6
+ VALID_VAULT = Path(__file__).parent.parent.parent / "tests" / "conformance" / "valid"
7
+
8
+
9
+ def test_valid_vault_is_conformant():
10
+ report = validate_vault(VALID_VAULT)
11
+ errors = [f"{i.code}: {i.message}" for i in report.errors]
12
+ assert report.is_conformant, f"Unexpected errors:\n" + "\n".join(errors)