logseq-md 0.1.0__tar.gz → 0.2.0__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.
Files changed (65) hide show
  1. {logseq_md-0.1.0 → logseq_md-0.2.0}/.pre-commit-config.yaml +11 -0
  2. logseq_md-0.2.0/.scripts/check_fixture_evidence.py +102 -0
  3. logseq_md-0.2.0/.scripts/check_fixture_leaks.py +151 -0
  4. logseq_md-0.2.0/.scripts/survey_graph.py +490 -0
  5. {logseq_md-0.1.0 → logseq_md-0.2.0}/IMPLEMENTATION.md +2 -2
  6. logseq_md-0.2.0/JOURNAL.md +27 -0
  7. {logseq_md-0.1.0 → logseq_md-0.2.0}/PKG-INFO +4 -3
  8. {logseq_md-0.1.0 → logseq_md-0.2.0}/README.md +3 -2
  9. {logseq_md-0.1.0 → logseq_md-0.2.0}/pyproject.toml +1 -1
  10. logseq_md-0.2.0/src/logseq_md/cli.py +243 -0
  11. logseq_md-0.2.0/src/logseq_md/exits.py +34 -0
  12. logseq_md-0.2.0/src/logseq_md/lines.py +80 -0
  13. logseq_md-0.2.0/tests/evidence/fixtures-lines.json +74 -0
  14. logseq_md-0.2.0/tests/fixtures/lines/blank-only-no-final-newline.md +3 -0
  15. logseq_md-0.2.0/tests/fixtures/lines/blank-only.md +4 -0
  16. logseq_md-0.2.0/tests/fixtures/lines/bom.md +2 -0
  17. logseq_md-0.2.0/tests/fixtures/lines/crlf-no-final-newline.md +2 -0
  18. logseq_md-0.2.0/tests/fixtures/lines/crlf.md +2 -0
  19. logseq_md-0.2.0/tests/fixtures/lines/empty.md +0 -0
  20. logseq_md-0.2.0/tests/fixtures/lines/final-newline.md +2 -0
  21. logseq_md-0.2.0/tests/fixtures/lines/front-matter.md +5 -0
  22. logseq_md-0.2.0/tests/fixtures/lines/lone-cr.md +1 -0
  23. logseq_md-0.2.0/tests/fixtures/lines/mixed-endings.md +4 -1
  24. logseq_md-0.2.0/tests/fixtures/lines/mixed-prefix.md +8 -0
  25. logseq_md-0.2.0/tests/fixtures/lines/no-final-newline.md +2 -0
  26. logseq_md-0.2.0/tests/fixtures/lines/not-utf8.md +1 -0
  27. logseq_md-0.2.0/tests/fixtures/lines/spaces-only.md +3 -0
  28. logseq_md-0.2.0/tests/fixtures/lines/tabs-only.md +3 -0
  29. logseq_md-0.2.0/tests/fixtures/lines/whitespace-lines.md +5 -0
  30. logseq_md-0.2.0/tests/test_cli.py +109 -0
  31. logseq_md-0.2.0/tests/test_fixture_scripts.py +237 -0
  32. logseq_md-0.2.0/tests/test_lines.py +62 -0
  33. logseq_md-0.2.0/tests/test_opacity.py +78 -0
  34. logseq_md-0.2.0/tests/test_round_trip.py +46 -0
  35. logseq_md-0.1.0/src/logseq_md/cli.py +0 -51
  36. logseq_md-0.1.0/tests/test_cli.py +0 -57
  37. {logseq_md-0.1.0 → logseq_md-0.2.0}/.biome.json +0 -0
  38. {logseq_md-0.1.0 → logseq_md-0.2.0}/.checkmake.ini +0 -0
  39. {logseq_md-0.1.0 → logseq_md-0.2.0}/.editorconfig +0 -0
  40. {logseq_md-0.1.0 → logseq_md-0.2.0}/.gitattributes +0 -0
  41. {logseq_md-0.1.0 → logseq_md-0.2.0}/.github/dependabot.yml +0 -0
  42. {logseq_md-0.1.0 → logseq_md-0.2.0}/.github/workflows/governance.yml +0 -0
  43. {logseq_md-0.1.0 → logseq_md-0.2.0}/.github/workflows/rehearsal.yml +0 -0
  44. {logseq_md-0.1.0 → logseq_md-0.2.0}/.github/workflows/release.yml +0 -0
  45. {logseq_md-0.1.0 → logseq_md-0.2.0}/.github/workflows/tests.yml +0 -0
  46. {logseq_md-0.1.0 → logseq_md-0.2.0}/.gitignore +0 -0
  47. {logseq_md-0.1.0 → logseq_md-0.2.0}/.linkspector.yml +0 -0
  48. {logseq_md-0.1.0 → logseq_md-0.2.0}/.mailmap +0 -0
  49. {logseq_md-0.1.0 → logseq_md-0.2.0}/.markdownlint-cli2.jsonc +0 -0
  50. {logseq_md-0.1.0 → logseq_md-0.2.0}/.pymarkdown.json +0 -0
  51. {logseq_md-0.1.0 → logseq_md-0.2.0}/.ruff.toml +0 -0
  52. {logseq_md-0.1.0 → logseq_md-0.2.0}/.scripts/check_markdown_style.py +0 -0
  53. {logseq_md-0.1.0 → logseq_md-0.2.0}/.scripts/check_no_symlinks.py +0 -0
  54. {logseq_md-0.1.0 → logseq_md-0.2.0}/.scripts/check_uvx_invocation.py +0 -0
  55. {logseq_md-0.1.0 → logseq_md-0.2.0}/.scripts/verify-published.py +0 -0
  56. {logseq_md-0.1.0 → logseq_md-0.2.0}/.scripts/version.py +0 -0
  57. {logseq_md-0.1.0 → logseq_md-0.2.0}/.taplo.toml +0 -0
  58. {logseq_md-0.1.0 → logseq_md-0.2.0}/.yamlfmt +0 -0
  59. {logseq_md-0.1.0 → logseq_md-0.2.0}/CONTRIBUTING.md +0 -0
  60. {logseq_md-0.1.0 → logseq_md-0.2.0}/DESIGN.md +0 -0
  61. {logseq_md-0.1.0 → logseq_md-0.2.0}/LICENSE +0 -0
  62. {logseq_md-0.1.0 → logseq_md-0.2.0}/Makefile +0 -0
  63. {logseq_md-0.1.0 → logseq_md-0.2.0}/src/logseq_md/__init__.py +0 -0
  64. {logseq_md-0.1.0 → logseq_md-0.2.0}/src/logseq_md/__main__.py +0 -0
  65. {logseq_md-0.1.0 → logseq_md-0.2.0}/tests/test_release_scripts.py +0 -0
@@ -158,6 +158,17 @@ repos:
158
158
  pass_filenames: false
159
159
  always_run: true
160
160
 
161
+ # Every fixture directory carries leak evidence that still describes it. The
162
+ # leak check needs a real graph and runs on a maintainer's machine; this is
163
+ # the offline half, and it runs on every commit because a fixture edited
164
+ # after its check is exactly the change nobody thinks to re-check.
165
+ - id: fixture-evidence
166
+ name: Fixture leak evidence matches the committed fixtures
167
+ entry: uv run --script .scripts/check_fixture_evidence.py
168
+ language: system
169
+ pass_filenames: false
170
+ always_run: true
171
+
161
172
  # No symlinks in the tree. A symlink is committed as a mode-120000 blob
162
173
  # holding a path, and a clone on Windows without developer mode writes that
163
174
  # path out as an ordinary text file: the link silently becomes a one-line
@@ -0,0 +1,102 @@
1
+ #!/usr/bin/env -S uv run --script
2
+ # /// script
3
+ # requires-python = ">=3.11"
4
+ # ///
5
+ """Check that every fixture directory carries evidence its leak check still describes.
6
+
7
+ Usage:
8
+ check_fixture_evidence.py [path ...]
9
+
10
+ The leak check (`.scripts/check_fixture_leaks.py`) needs a real graph, so it
11
+ runs on a maintainer's machine and records `tests/evidence/fixtures-<topic>.json`.
12
+ This is the offline half, run in CI and as a pre-commit hook. It cannot tell
13
+ that the check ran; it can tell that the recording still describes the fixtures
14
+ that are committed. It fails when:
15
+
16
+ - a fixture directory has no evidence file;
17
+ - a recorded hash no longer matches a fixture, or the leak script itself;
18
+ - the recorded file set differs from the directory's;
19
+ - the recorded outcome is not `pass`.
20
+
21
+ Paths are accepted and ignored, because a directory is checked as a whole.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import hashlib
27
+ import json
28
+ import sys
29
+ from pathlib import Path
30
+
31
+ REPO_ROOT = Path(__file__).resolve().parent.parent
32
+ FIXTURES = "tests/fixtures"
33
+ EVIDENCE = "tests/evidence"
34
+ LEAK_SCRIPT = ".scripts/check_fixture_leaks.py"
35
+ SCHEMA_VERSION = 1
36
+
37
+
38
+ def sha256_of(path: Path) -> str:
39
+ return hashlib.sha256(path.read_bytes()).hexdigest()
40
+
41
+
42
+ def check_topic(topic: str, directory: Path, root: Path) -> list[str]:
43
+ evidence_path = root / EVIDENCE / f"fixtures-{topic}.json"
44
+ relative = evidence_path.relative_to(root).as_posix()
45
+ record = f"run `uv run --script {LEAK_SCRIPT} <graph> --record`"
46
+ if not evidence_path.is_file():
47
+ return [f"{topic}: no evidence at {relative}; {record}"]
48
+ try:
49
+ evidence = json.loads(evidence_path.read_text(encoding="utf-8"))
50
+ except json.JSONDecodeError as error:
51
+ return [f"{relative}: not valid JSON ({error})"]
52
+ if not isinstance(evidence, dict):
53
+ return [f"{relative}: not a JSON object"]
54
+
55
+ problems: list[str] = []
56
+ if evidence.get("schema_version") != SCHEMA_VERSION:
57
+ problems.append(f"{relative}: schema_version is {evidence.get('schema_version')!r}, expected {SCHEMA_VERSION}")
58
+ if evidence.get("script_sha256") != sha256_of(root / LEAK_SCRIPT):
59
+ problems.append(f"{relative}: {LEAK_SCRIPT} changed since the evidence was recorded; {record}")
60
+
61
+ current = {path.relative_to(root).as_posix() for path in directory.rglob("*") if path.is_file()}
62
+ entries = evidence.get("files")
63
+ if not isinstance(entries, list) or not all(isinstance(e, dict) and "path" in e and "sha256" in e for e in entries):
64
+ return [*problems, f"{relative}: `files` must be a list of {{path, sha256}}"]
65
+ recorded = {entry["path"]: entry["sha256"] for entry in entries}
66
+ problems += [
67
+ f"{relative}: {path} is not covered by the evidence; {record}" for path in sorted(current - recorded.keys())
68
+ ]
69
+ problems += [
70
+ f"{relative}: covers {path}, which no longer exists; {record}" for path in sorted(recorded.keys() - current)
71
+ ]
72
+ problems += [
73
+ f"{relative}: {path} changed since the evidence was recorded; {record}"
74
+ for path in sorted(current & recorded.keys())
75
+ if sha256_of(root / path) != recorded[path]
76
+ ]
77
+
78
+ if evidence.get("outcome") != "pass":
79
+ problems.append(f"{relative}: recorded outcome is {evidence.get('outcome')!r}, not 'pass'")
80
+ return problems
81
+
82
+
83
+ def check_all(root: Path = REPO_ROOT) -> list[str]:
84
+ fixtures = root / FIXTURES
85
+ if not fixtures.is_dir():
86
+ return []
87
+ problems: list[str] = []
88
+ for directory in sorted(fixtures.iterdir()):
89
+ if directory.is_dir():
90
+ problems += check_topic(directory.name, directory, root)
91
+ return problems
92
+
93
+
94
+ def main(argv: list[str]) -> int:
95
+ problems = check_all()
96
+ for problem in problems:
97
+ print(f"error: {problem}", file=sys.stderr)
98
+ return 1 if problems else 0
99
+
100
+
101
+ if __name__ == "__main__":
102
+ raise SystemExit(main(sys.argv[1:]))
@@ -0,0 +1,151 @@
1
+ #!/usr/bin/env -S uv run --script
2
+ # /// script
3
+ # requires-python = ">=3.11"
4
+ # ///
5
+ """Check that no fixture line was copied out of a real graph, and record that it was checked.
6
+
7
+ Usage:
8
+ check_fixture_leaks.py <graph> [--record]
9
+
10
+ A fixture is written by somebody who has just read a private graph, which is how
11
+ a real line ends up in one. This compares every line of every file under
12
+ `tests/fixtures/<topic>/` with every line of every markdown file in the graph,
13
+ and fails on any match that is more than structure: a bullet on its own, a `---` rule,
14
+ whitespace, an `id::` or `collapsed::` line, a fence marker or `:END:`.
15
+
16
+ Lines are compared as bytes with surrounding whitespace removed, and once more
17
+ with a leading bullet removed, so a line copied at a different depth or lifted
18
+ out of a block still matches. That catches a copied line and not a paraphrase;
19
+ reading the fixture diff stays a reviewer's job.
20
+
21
+ With `--record`, it writes `tests/evidence/fixtures-<topic>.json` per fixture
22
+ directory: the hash of every fixture file in it, the hash of this script, the
23
+ outcome and how many graph files were scanned. **No path, name or line from the
24
+ graph is written**, and the graph is opened with `"rb"` and nothing else. A test
25
+ reads this source and fails if graph-reading code gains a write call; the only
26
+ writing is in `write_evidence`, which touches the repository.
27
+
28
+ `.scripts/check_fixture_evidence.py` is the offline half that runs in CI.
29
+ """
30
+
31
+ from __future__ import annotations
32
+
33
+ import hashlib
34
+ import json
35
+ import re
36
+ import sys
37
+ from datetime import UTC, datetime
38
+ from pathlib import Path
39
+
40
+ REPO_ROOT = Path(__file__).resolve().parent.parent
41
+ FIXTURES = "tests/fixtures"
42
+ EVIDENCE = "tests/evidence"
43
+ SCHEMA_VERSION = 1
44
+
45
+ LINE_BREAK = re.compile(rb"\r\n|\n|\r")
46
+ BULLET = re.compile(rb"^-[ \t]+")
47
+ STRUCTURE = re.compile(rb"^(-|-{3,}|:END:|(`{3,}|~{3,})[\w+-]*|(id|collapsed)::.*)?$", re.IGNORECASE)
48
+
49
+
50
+ def normalised(line: bytes) -> list[bytes]:
51
+ """The forms of a line worth comparing: stripped, and stripped of its bullet."""
52
+ stripped = line.strip(b" \t\xef\xbb\xbf")
53
+ forms = [stripped]
54
+ unbulleted = BULLET.sub(b"", stripped)
55
+ if unbulleted != stripped:
56
+ forms.append(unbulleted)
57
+ return [form for form in forms if not STRUCTURE.match(form)]
58
+
59
+
60
+ def graph_lines(graph: Path) -> tuple[set[bytes], int]:
61
+ """Every comparable line form in the graph's markdown, and how many files held them."""
62
+ seen: set[bytes] = set()
63
+ scanned = 0
64
+ for path in sorted(graph.rglob("*.md")):
65
+ if not path.is_file():
66
+ continue
67
+ with path.open("rb") as handle:
68
+ data = handle.read()
69
+ scanned += 1
70
+ for line in LINE_BREAK.split(data):
71
+ seen.update(normalised(line))
72
+ return seen, scanned
73
+
74
+
75
+ def fixture_topics(root: Path) -> dict[str, list[Path]]:
76
+ """Each fixture directory, mapped to every file in it."""
77
+ fixtures = root / FIXTURES
78
+ if not fixtures.is_dir():
79
+ return {}
80
+ return {
81
+ directory.name: sorted(path for path in directory.rglob("*") if path.is_file())
82
+ for directory in sorted(fixtures.iterdir())
83
+ if directory.is_dir()
84
+ }
85
+
86
+
87
+ def leaks(files: list[Path], seen: set[bytes], root: Path) -> list[str]:
88
+ """Each fixture line found in the graph, named by fixture path and line number only."""
89
+ found: list[str] = []
90
+ for path in files:
91
+ for number, line in enumerate(LINE_BREAK.split(path.read_bytes()), start=1):
92
+ if any(form in seen for form in normalised(line)):
93
+ found.append(f"{path.relative_to(root).as_posix()}:{number}")
94
+ return found
95
+
96
+
97
+ def sha256_of(path: Path) -> str:
98
+ return hashlib.sha256(path.read_bytes()).hexdigest()
99
+
100
+
101
+ def evidence_for(topic: str, files: list[Path], outcome: str, scanned: int, root: Path) -> dict:
102
+ return {
103
+ "schema_version": SCHEMA_VERSION,
104
+ "topic": topic,
105
+ "files": [{"path": path.relative_to(root).as_posix(), "sha256": sha256_of(path)} for path in files],
106
+ "script_sha256": sha256_of(Path(__file__).resolve()),
107
+ "outcome": outcome,
108
+ "graph_files_scanned": scanned,
109
+ "recorded_at": datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%SZ"),
110
+ }
111
+
112
+
113
+ def write_evidence(evidence: dict, root: Path) -> Path:
114
+ """The one place this script writes, and it writes into the repository."""
115
+ target = root / EVIDENCE / f"fixtures-{evidence['topic']}.json"
116
+ target.parent.mkdir(parents=True, exist_ok=True)
117
+ target.write_bytes((json.dumps(evidence, indent=2, sort_keys=True) + "\n").encode("utf-8"))
118
+ return target
119
+
120
+
121
+ def check(graph: Path, root: Path = REPO_ROOT, *, record: bool = False) -> int:
122
+ seen, scanned = graph_lines(graph)
123
+ if scanned == 0:
124
+ print(f"error: no markdown under {graph}; is it a graph?", file=sys.stderr)
125
+ return 2
126
+ failed = False
127
+ for topic, files in fixture_topics(root).items():
128
+ found = leaks(files, seen, root)
129
+ outcome = "fail" if found else "pass"
130
+ failed |= bool(found)
131
+ for location in found:
132
+ print(f"leak: {location} occurs verbatim in the graph", file=sys.stderr)
133
+ print(f"{topic}: {outcome} ({len(files)} fixture files, {scanned} graph files)")
134
+ if record:
135
+ print(
136
+ f" recorded {write_evidence(evidence_for(topic, files, outcome, scanned, root), root).relative_to(root).as_posix()}"
137
+ )
138
+ return 1 if failed else 0
139
+
140
+
141
+ def main(argv: list[str]) -> int:
142
+ record = "--record" in argv
143
+ paths = [arg for arg in argv if arg != "--record"]
144
+ if len(paths) != 1:
145
+ print(__doc__, file=sys.stderr)
146
+ return 2
147
+ return check(Path(paths[0]), record=record)
148
+
149
+
150
+ if __name__ == "__main__":
151
+ raise SystemExit(main(sys.argv[1:]))