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.
- {logseq_md-0.1.0 → logseq_md-0.2.0}/.pre-commit-config.yaml +11 -0
- logseq_md-0.2.0/.scripts/check_fixture_evidence.py +102 -0
- logseq_md-0.2.0/.scripts/check_fixture_leaks.py +151 -0
- logseq_md-0.2.0/.scripts/survey_graph.py +490 -0
- {logseq_md-0.1.0 → logseq_md-0.2.0}/IMPLEMENTATION.md +2 -2
- logseq_md-0.2.0/JOURNAL.md +27 -0
- {logseq_md-0.1.0 → logseq_md-0.2.0}/PKG-INFO +4 -3
- {logseq_md-0.1.0 → logseq_md-0.2.0}/README.md +3 -2
- {logseq_md-0.1.0 → logseq_md-0.2.0}/pyproject.toml +1 -1
- logseq_md-0.2.0/src/logseq_md/cli.py +243 -0
- logseq_md-0.2.0/src/logseq_md/exits.py +34 -0
- logseq_md-0.2.0/src/logseq_md/lines.py +80 -0
- logseq_md-0.2.0/tests/evidence/fixtures-lines.json +74 -0
- logseq_md-0.2.0/tests/fixtures/lines/blank-only-no-final-newline.md +3 -0
- logseq_md-0.2.0/tests/fixtures/lines/blank-only.md +4 -0
- logseq_md-0.2.0/tests/fixtures/lines/bom.md +2 -0
- logseq_md-0.2.0/tests/fixtures/lines/crlf-no-final-newline.md +2 -0
- logseq_md-0.2.0/tests/fixtures/lines/crlf.md +2 -0
- logseq_md-0.2.0/tests/fixtures/lines/empty.md +0 -0
- logseq_md-0.2.0/tests/fixtures/lines/final-newline.md +2 -0
- logseq_md-0.2.0/tests/fixtures/lines/front-matter.md +5 -0
- logseq_md-0.2.0/tests/fixtures/lines/lone-cr.md +1 -0
- logseq_md-0.2.0/tests/fixtures/lines/mixed-endings.md +4 -1
- logseq_md-0.2.0/tests/fixtures/lines/mixed-prefix.md +8 -0
- logseq_md-0.2.0/tests/fixtures/lines/no-final-newline.md +2 -0
- logseq_md-0.2.0/tests/fixtures/lines/not-utf8.md +1 -0
- logseq_md-0.2.0/tests/fixtures/lines/spaces-only.md +3 -0
- logseq_md-0.2.0/tests/fixtures/lines/tabs-only.md +3 -0
- logseq_md-0.2.0/tests/fixtures/lines/whitespace-lines.md +5 -0
- logseq_md-0.2.0/tests/test_cli.py +109 -0
- logseq_md-0.2.0/tests/test_fixture_scripts.py +237 -0
- logseq_md-0.2.0/tests/test_lines.py +62 -0
- logseq_md-0.2.0/tests/test_opacity.py +78 -0
- logseq_md-0.2.0/tests/test_round_trip.py +46 -0
- logseq_md-0.1.0/src/logseq_md/cli.py +0 -51
- logseq_md-0.1.0/tests/test_cli.py +0 -57
- {logseq_md-0.1.0 → logseq_md-0.2.0}/.biome.json +0 -0
- {logseq_md-0.1.0 → logseq_md-0.2.0}/.checkmake.ini +0 -0
- {logseq_md-0.1.0 → logseq_md-0.2.0}/.editorconfig +0 -0
- {logseq_md-0.1.0 → logseq_md-0.2.0}/.gitattributes +0 -0
- {logseq_md-0.1.0 → logseq_md-0.2.0}/.github/dependabot.yml +0 -0
- {logseq_md-0.1.0 → logseq_md-0.2.0}/.github/workflows/governance.yml +0 -0
- {logseq_md-0.1.0 → logseq_md-0.2.0}/.github/workflows/rehearsal.yml +0 -0
- {logseq_md-0.1.0 → logseq_md-0.2.0}/.github/workflows/release.yml +0 -0
- {logseq_md-0.1.0 → logseq_md-0.2.0}/.github/workflows/tests.yml +0 -0
- {logseq_md-0.1.0 → logseq_md-0.2.0}/.gitignore +0 -0
- {logseq_md-0.1.0 → logseq_md-0.2.0}/.linkspector.yml +0 -0
- {logseq_md-0.1.0 → logseq_md-0.2.0}/.mailmap +0 -0
- {logseq_md-0.1.0 → logseq_md-0.2.0}/.markdownlint-cli2.jsonc +0 -0
- {logseq_md-0.1.0 → logseq_md-0.2.0}/.pymarkdown.json +0 -0
- {logseq_md-0.1.0 → logseq_md-0.2.0}/.ruff.toml +0 -0
- {logseq_md-0.1.0 → logseq_md-0.2.0}/.scripts/check_markdown_style.py +0 -0
- {logseq_md-0.1.0 → logseq_md-0.2.0}/.scripts/check_no_symlinks.py +0 -0
- {logseq_md-0.1.0 → logseq_md-0.2.0}/.scripts/check_uvx_invocation.py +0 -0
- {logseq_md-0.1.0 → logseq_md-0.2.0}/.scripts/verify-published.py +0 -0
- {logseq_md-0.1.0 → logseq_md-0.2.0}/.scripts/version.py +0 -0
- {logseq_md-0.1.0 → logseq_md-0.2.0}/.taplo.toml +0 -0
- {logseq_md-0.1.0 → logseq_md-0.2.0}/.yamlfmt +0 -0
- {logseq_md-0.1.0 → logseq_md-0.2.0}/CONTRIBUTING.md +0 -0
- {logseq_md-0.1.0 → logseq_md-0.2.0}/DESIGN.md +0 -0
- {logseq_md-0.1.0 → logseq_md-0.2.0}/LICENSE +0 -0
- {logseq_md-0.1.0 → logseq_md-0.2.0}/Makefile +0 -0
- {logseq_md-0.1.0 → logseq_md-0.2.0}/src/logseq_md/__init__.py +0 -0
- {logseq_md-0.1.0 → logseq_md-0.2.0}/src/logseq_md/__main__.py +0 -0
- {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:]))
|