darnlink 0.6.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.
- darnlink/__init__.py +11 -0
- darnlink/cli.py +262 -0
- darnlink/frontmatter_edit.py +75 -0
- darnlink/frontmatter_index.py +108 -0
- darnlink/links.py +205 -0
- darnlink/paths.py +44 -0
- darnlink/repair.py +116 -0
- darnlink/report.py +26 -0
- darnlink/robustify.py +191 -0
- darnlink-0.6.0.dist-info/METADATA +301 -0
- darnlink-0.6.0.dist-info/RECORD +14 -0
- darnlink-0.6.0.dist-info/WHEEL +4 -0
- darnlink-0.6.0.dist-info/entry_points.txt +2 -0
- darnlink-0.6.0.dist-info/licenses/LICENSE +674 -0
darnlink/__init__.py
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
"""darnlink — auto-healing Markdown links.
|
|
2
|
+
|
|
3
|
+
Anchor Markdown links to a UUID so they survive file moves:
|
|
4
|
+
- repair: rewrite a robust link's path to wherever its UUID now lives.
|
|
5
|
+
- robustify: upgrade a plain link to a robust one.
|
|
6
|
+
|
|
7
|
+
Plain Markdown, no database, no editor lock-in. Dry-run by default.
|
|
8
|
+
See the project Constitution in .specify/memory/constitution.md.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
__version__ = "0.5.0"
|
darnlink/cli.py
ADDED
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
"""darnlink CLI. Default is a read-only report; `--write` applies.
|
|
2
|
+
|
|
3
|
+
darnlink [PATH] # dry-run: what repair would do
|
|
4
|
+
darnlink [PATH] --write # apply path repairs
|
|
5
|
+
darnlink [PATH] --robustify [--write] [--create-frontmatter]
|
|
6
|
+
darnlink [PATH] --robustify --create-frontmatter --no-create-frontmatter-for content.md
|
|
7
|
+
darnlink [PATH] --exclude external_repos --json
|
|
8
|
+
darnlink check [PATH] # report-only gate: BOTH checks, exit 0/2/3 (1 on usage)
|
|
9
|
+
"""
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import argparse
|
|
13
|
+
import json
|
|
14
|
+
import sys
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import List, Optional
|
|
17
|
+
|
|
18
|
+
from .frontmatter_index import DEFAULT_EXCLUDES, build_index
|
|
19
|
+
from .repair import apply_repairs, plan_repairs
|
|
20
|
+
from .report import Finding, Kind
|
|
21
|
+
from .robustify import apply_robustify, plan_robustify
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _findings_json(
|
|
25
|
+
findings: List[Finding],
|
|
26
|
+
wrote: int,
|
|
27
|
+
write: bool,
|
|
28
|
+
ignored: Optional[List[Path]] = None,
|
|
29
|
+
invalid: Optional[List[Path]] = None,
|
|
30
|
+
link_ignored: Optional[List[Path]] = None,
|
|
31
|
+
) -> str:
|
|
32
|
+
return json.dumps(
|
|
33
|
+
{
|
|
34
|
+
"wrote": wrote,
|
|
35
|
+
"applied": write,
|
|
36
|
+
"ignored_files": [str(p) for p in (ignored or [])],
|
|
37
|
+
# feature 006: opted out as a SOURCE only — still indexed as a target
|
|
38
|
+
"link_ignored_files": [str(p) for p in (link_ignored or [])],
|
|
39
|
+
"invalid_frontmatter_files": [str(p) for p in (invalid or [])],
|
|
40
|
+
"findings": [{"kind": f.kind.value, "file": str(f.file), "detail": f.detail} for f in findings],
|
|
41
|
+
},
|
|
42
|
+
indent=2,
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _run_repair(root: Path, write: bool, excludes: set, as_json: bool, block_markers: tuple) -> int:
|
|
47
|
+
index = build_index(root, excludes)
|
|
48
|
+
result = plan_repairs(root, index, excludes, block_markers)
|
|
49
|
+
repairs = [f for f in result.findings if f.kind is Kind.REPAIR]
|
|
50
|
+
conflicts = [f for f in result.findings if f.kind is Kind.CONFLICT]
|
|
51
|
+
unresolved = [f for f in result.findings if f.kind in (Kind.UNRESOLVABLE, Kind.AMBIGUOUS)]
|
|
52
|
+
wrote = len(apply_repairs(result)) if write else 0
|
|
53
|
+
|
|
54
|
+
if as_json:
|
|
55
|
+
print(_findings_json(result.findings, wrote, write, result.ignored, index.invalid,
|
|
56
|
+
result.link_ignored))
|
|
57
|
+
else:
|
|
58
|
+
print(f"darnlink repair — root: {root}")
|
|
59
|
+
print(f" indexed uuids: {len(index.by_uuid)} | duplicate uuids: {len(index.duplicates)}")
|
|
60
|
+
print(f" links to repair: {len(repairs)} | conflicts: {len(conflicts)} | unresolved: {len(unresolved)} | ignored files: {len(result.ignored)} | link-ignored: {len(result.link_ignored)} | invalid frontmatter: {len(index.invalid)}")
|
|
61
|
+
for f in repairs:
|
|
62
|
+
print(f" [repair] {f.file}: {f.detail}")
|
|
63
|
+
for f in conflicts:
|
|
64
|
+
print(f" [conflict] {f.file}: {f.detail}")
|
|
65
|
+
for f in unresolved:
|
|
66
|
+
print(f" [{f.kind.value}] {f.file}: {f.detail}")
|
|
67
|
+
for f in [x for x in result.findings if x.kind is Kind.IGNORED_LINKS]:
|
|
68
|
+
print(f" [link-ignored] {f.file}: {f.detail}")
|
|
69
|
+
for p in index.invalid:
|
|
70
|
+
print(f" [invalid-frontmatter] {p}: not valid YAML; not indexed (fix the file)")
|
|
71
|
+
if write:
|
|
72
|
+
print(f" WROTE {wrote} file(s).")
|
|
73
|
+
elif repairs:
|
|
74
|
+
print(" (dry-run — nothing written. Re-run with --write to apply.)")
|
|
75
|
+
|
|
76
|
+
return 1 if conflicts or unresolved or index.invalid or (repairs and not write) else 0
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _run_robustify(root: Path, write: bool, create_frontmatter: bool, excludes: set, as_json: bool, block_markers: tuple, no_create_globs: tuple) -> int:
|
|
80
|
+
result = plan_robustify(root, create_frontmatter=create_frontmatter, excludes=excludes, block_markers=block_markers, no_create_globs=no_create_globs)
|
|
81
|
+
upgrades = [f for f in result.findings if f.kind is Kind.ROBUSTIFY]
|
|
82
|
+
skipped = [f for f in result.findings if f.kind is Kind.NO_FRONTMATTER]
|
|
83
|
+
denied = [f for f in result.findings if f.kind is Kind.DENY_LISTED]
|
|
84
|
+
wrote = len(apply_robustify(result)) if write else 0
|
|
85
|
+
|
|
86
|
+
if as_json:
|
|
87
|
+
print(_findings_json(result.findings, wrote, write, result.ignored, result.invalid,
|
|
88
|
+
result.link_ignored))
|
|
89
|
+
else:
|
|
90
|
+
print(f"darnlink robustify — root: {root}")
|
|
91
|
+
print(f" plain links to robustify: {len(upgrades)} | skipped (no frontmatter): {len(skipped)} | deny-listed: {len(denied)} | ignored files: {len(result.ignored)} | link-ignored: {len(result.link_ignored)} | invalid frontmatter: {len(result.invalid)}")
|
|
92
|
+
for f in upgrades:
|
|
93
|
+
print(f" [robustify] {f.file}: {f.detail}")
|
|
94
|
+
for f in skipped:
|
|
95
|
+
print(f" [no-frontmatter] {f.file}: {f.detail} (use --create-frontmatter to allow)")
|
|
96
|
+
for f in denied:
|
|
97
|
+
print(f" [deny-listed] {f.file}: {f.detail}")
|
|
98
|
+
for f in [x for x in result.findings if x.kind is Kind.IGNORED_LINKS]:
|
|
99
|
+
print(f" [link-ignored] {f.file}: {f.detail}")
|
|
100
|
+
for p in result.invalid:
|
|
101
|
+
print(f" [invalid-frontmatter] {p}: not valid YAML; left untouched (fix the file)")
|
|
102
|
+
if write:
|
|
103
|
+
print(f" WROTE {wrote} file(s).")
|
|
104
|
+
elif upgrades:
|
|
105
|
+
print(" (dry-run — nothing written. Re-run with --write to apply.)")
|
|
106
|
+
|
|
107
|
+
return 1 if result.invalid or (upgrades and not write) else 0
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _run_check(root: Path, excludes: set, as_json: bool, block_markers: tuple) -> int:
|
|
111
|
+
"""Feature 007: report-only gate. Run BOTH checks (integrity + strict) in one invocation and
|
|
112
|
+
return a distinguishable exit code. Never writes. `--robustify` alone does not catch a broken
|
|
113
|
+
robust link, and plain `darnlink .` does not catch an un-anchored plain link — a gate that runs
|
|
114
|
+
only one is blind to the other; `check` runs both so a consumer cannot forget a half.
|
|
115
|
+
|
|
116
|
+
Exit: 0 clean · 2 integrity failure (broken/unresolvable robust links or invalid frontmatter) ·
|
|
117
|
+
3 strict-only failure (anchorable plain links un-anchored). Integrity takes precedence over
|
|
118
|
+
strict when both fail (a broken link is more urgent than an un-anchored one).
|
|
119
|
+
"""
|
|
120
|
+
# Integrity axis (repair, dry-run): robust links whose path is stale/unresolvable, plus invalid YAML.
|
|
121
|
+
index = build_index(root, excludes)
|
|
122
|
+
rep = plan_repairs(root, index, excludes, block_markers)
|
|
123
|
+
repairs = [f for f in rep.findings if f.kind is Kind.REPAIR]
|
|
124
|
+
conflicts = [f for f in rep.findings if f.kind is Kind.CONFLICT]
|
|
125
|
+
unresolved = [f for f in rep.findings if f.kind in (Kind.UNRESOLVABLE, Kind.AMBIGUOUS)]
|
|
126
|
+
integrity_fail = bool(repairs or conflicts or unresolved or index.invalid)
|
|
127
|
+
|
|
128
|
+
# Strict axis (robustify, dry-run): plain links to an anchorable target left un-anchored.
|
|
129
|
+
rob = plan_robustify(root, create_frontmatter=False, excludes=excludes, block_markers=block_markers)
|
|
130
|
+
upgrades = [f for f in rob.findings if f.kind is Kind.ROBUSTIFY]
|
|
131
|
+
strict_fail = bool(upgrades or rob.invalid)
|
|
132
|
+
|
|
133
|
+
code = 2 if integrity_fail else (3 if strict_fail else 0)
|
|
134
|
+
|
|
135
|
+
if as_json:
|
|
136
|
+
print(json.dumps({
|
|
137
|
+
"check": True,
|
|
138
|
+
"exit_code": code,
|
|
139
|
+
"integrity": {
|
|
140
|
+
"failed": integrity_fail,
|
|
141
|
+
"repairs": len(repairs), "conflicts": len(conflicts),
|
|
142
|
+
"unresolved": len(unresolved), "invalid_frontmatter": len(index.invalid),
|
|
143
|
+
"invalid_frontmatter_files": [str(p) for p in index.invalid],
|
|
144
|
+
"findings": [{"kind": f.kind.value, "file": str(f.file), "detail": f.detail}
|
|
145
|
+
for f in (repairs + conflicts + unresolved)]
|
|
146
|
+
+ [{"kind": Kind.INVALID_FRONTMATTER.value, "file": str(p),
|
|
147
|
+
"detail": "frontmatter present but not valid YAML; not indexed"}
|
|
148
|
+
for p in index.invalid],
|
|
149
|
+
},
|
|
150
|
+
"strict": {
|
|
151
|
+
"failed": strict_fail,
|
|
152
|
+
"robustify": len(upgrades), "invalid_frontmatter": len(rob.invalid),
|
|
153
|
+
"invalid_frontmatter_files": [str(p) for p in rob.invalid],
|
|
154
|
+
"findings": [{"kind": f.kind.value, "file": str(f.file), "detail": f.detail}
|
|
155
|
+
for f in upgrades]
|
|
156
|
+
+ [{"kind": Kind.INVALID_FRONTMATTER.value, "file": str(p),
|
|
157
|
+
"detail": "frontmatter present but not valid YAML; left untouched (fix the file)"}
|
|
158
|
+
for p in rob.invalid],
|
|
159
|
+
},
|
|
160
|
+
}, indent=2))
|
|
161
|
+
else:
|
|
162
|
+
outcome = {0: "clean", 2: "integrity failure", 3: "strict failure"}[code]
|
|
163
|
+
print(f"darnlink check — root: {root}")
|
|
164
|
+
print(f" [integrity] repair: {len(repairs)} | conflicts: {len(conflicts)} | "
|
|
165
|
+
f"unresolved: {len(unresolved)} | invalid frontmatter: {len(index.invalid)} "
|
|
166
|
+
f"→ {'FAIL' if integrity_fail else 'ok'}")
|
|
167
|
+
print(f" [strict] to robustify: {len(upgrades)} | invalid frontmatter: {len(rob.invalid)} "
|
|
168
|
+
f"→ {'FAIL' if strict_fail else 'ok'}")
|
|
169
|
+
for f in repairs + conflicts + unresolved:
|
|
170
|
+
print(f" [integrity/{f.kind.value}] {f.file}: {f.detail}")
|
|
171
|
+
for p in index.invalid:
|
|
172
|
+
print(f" [integrity/invalid-frontmatter] {p}: not valid YAML; not indexed (fix the file)")
|
|
173
|
+
for f in upgrades:
|
|
174
|
+
print(f" [strict/robustify] {f.file}: {f.detail}")
|
|
175
|
+
for p in rob.invalid:
|
|
176
|
+
print(f" [strict/invalid-frontmatter] {p}: not valid YAML; left untouched (fix the file)")
|
|
177
|
+
print(f" → exit {code} ({outcome})")
|
|
178
|
+
|
|
179
|
+
return code
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
class _CheckArgParser(argparse.ArgumentParser):
|
|
183
|
+
# Exit 1 (usage) on a bad flag/arg, not argparse's default 2 — 2 means "integrity failure" here.
|
|
184
|
+
def error(self, message: str): # noqa: D401
|
|
185
|
+
self.print_usage(sys.stderr)
|
|
186
|
+
self.exit(1, f"{self.prog}: error: {message}\n")
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def _run_check_cli(argv: List[str]) -> int:
|
|
190
|
+
"""Parse `darnlink check [PATH] [--exclude … --ignore-block … --json]` (report-only: no --write)."""
|
|
191
|
+
parser = _CheckArgParser(
|
|
192
|
+
prog="darnlink check",
|
|
193
|
+
description="report-only gate: run BOTH the repair (integrity) and robustify (strict) checks "
|
|
194
|
+
"over PATH and exit 0 (clean) / 2 (integrity) / 3 (strict). Never writes.",
|
|
195
|
+
)
|
|
196
|
+
parser.add_argument("path", nargs="?", default=".", help="root directory to scan (default: .)")
|
|
197
|
+
parser.add_argument("--exclude", action="append", default=[], metavar="PATTERN", help="directory-name glob to skip (fnmatch, case-sensitive; a plain name matches exactly) (repeatable)")
|
|
198
|
+
parser.add_argument("--ignore-block", action="append", default=[], metavar="NAME",
|
|
199
|
+
help="ignore links inside <!-- NAME-start --> … <!-- NAME-end --> blocks (repeatable)")
|
|
200
|
+
parser.add_argument("--json", action="store_true", help="machine-readable output")
|
|
201
|
+
args = parser.parse_args(argv)
|
|
202
|
+
root = Path(args.path).resolve()
|
|
203
|
+
if not root.is_dir():
|
|
204
|
+
print(f"error: not a directory: {root}", file=sys.stderr)
|
|
205
|
+
return 1
|
|
206
|
+
excludes = set(DEFAULT_EXCLUDES) | set(args.exclude)
|
|
207
|
+
return _run_check(root, excludes, args.json, tuple(args.ignore_block))
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def main(argv: Optional[List[str]] = None) -> int:
|
|
211
|
+
raw = list(sys.argv[1:] if argv is None else argv)
|
|
212
|
+
if raw and raw[0] == "check": # feature 007: report-only gate subcommand
|
|
213
|
+
return _run_check_cli(raw[1:])
|
|
214
|
+
|
|
215
|
+
parser = argparse.ArgumentParser(
|
|
216
|
+
prog="darnlink",
|
|
217
|
+
description="auto-healing Markdown links: repair links whose target moved, "
|
|
218
|
+
"or robustify plain links (anchored by UUID).",
|
|
219
|
+
)
|
|
220
|
+
parser.add_argument("path", nargs="?", default=".", help="root directory to scan (default: .)")
|
|
221
|
+
parser.add_argument("--write", action="store_true", help="apply changes (default: dry-run report)")
|
|
222
|
+
parser.add_argument("--robustify", action="store_true", help="upgrade plain links to robust (default op: repair)")
|
|
223
|
+
parser.add_argument("--create-frontmatter", action="store_true", help="(robustify) allow creating frontmatter where missing")
|
|
224
|
+
parser.add_argument(
|
|
225
|
+
"--no-create-frontmatter-for",
|
|
226
|
+
action="append",
|
|
227
|
+
default=[],
|
|
228
|
+
metavar="GLOB",
|
|
229
|
+
help="(robustify) basename glob whose targets are never given a uuid — no frontmatter block "
|
|
230
|
+
"created and no uuid line inserted into existing frontmatter — regardless of "
|
|
231
|
+
"--create-frontmatter (repeatable; e.g. --no-create-frontmatter-for content.md). For files a "
|
|
232
|
+
"pipeline regenerates. Reusing a uuid the target already has is unaffected.",
|
|
233
|
+
)
|
|
234
|
+
parser.add_argument("--exclude", action="append", default=[], metavar="PATTERN", help="directory-name glob to skip (fnmatch, case-sensitive; a plain name matches exactly) (repeatable)")
|
|
235
|
+
parser.add_argument(
|
|
236
|
+
"--ignore-block",
|
|
237
|
+
action="append",
|
|
238
|
+
default=[],
|
|
239
|
+
metavar="NAME",
|
|
240
|
+
help="ignore links inside generated blocks <!-- NAME-start --> ... <!-- NAME-end --> "
|
|
241
|
+
"(repeatable; e.g. --ignore-block autogrid)",
|
|
242
|
+
)
|
|
243
|
+
parser.add_argument("--json", action="store_true", help="machine-readable output")
|
|
244
|
+
args = parser.parse_args(argv)
|
|
245
|
+
|
|
246
|
+
root = Path(args.path).resolve()
|
|
247
|
+
if not root.is_dir():
|
|
248
|
+
print(f"error: not a directory: {root}", file=sys.stderr)
|
|
249
|
+
return 2
|
|
250
|
+
|
|
251
|
+
excludes = set(DEFAULT_EXCLUDES) | set(args.exclude)
|
|
252
|
+
block_markers = tuple(args.ignore_block)
|
|
253
|
+
if args.robustify:
|
|
254
|
+
return _run_robustify(
|
|
255
|
+
root, args.write, args.create_frontmatter, excludes, args.json, block_markers,
|
|
256
|
+
tuple(args.no_create_frontmatter_for),
|
|
257
|
+
)
|
|
258
|
+
return _run_repair(root, args.write, excludes, args.json, block_markers)
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
if __name__ == "__main__":
|
|
262
|
+
sys.exit(main())
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"""Surgical frontmatter edits: read/insert a `uuid` without reformatting the rest of the file.
|
|
2
|
+
|
|
3
|
+
We deliberately avoid re-dumping YAML (which would reorder keys and create noisy diffs and risks
|
|
4
|
+
the truncation bug seen in the predecessor). Instead we textually insert a `uuid:` line.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import re
|
|
9
|
+
import uuid as _uuid
|
|
10
|
+
from typing import Optional, Tuple
|
|
11
|
+
|
|
12
|
+
# A leading YAML frontmatter block: ---\n <body> \n---\n <rest>
|
|
13
|
+
_FM_BLOCK_RE = re.compile(r"\A(---\s*\n)(.*?\n?)(---\s*\n)(.*)\Z", re.DOTALL)
|
|
14
|
+
_UUID_LINE_RE = re.compile(r"^uuid:\s*(.+)$", re.MULTILINE)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def new_uuid() -> str:
|
|
18
|
+
return str(_uuid.uuid4())
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def detect_newline(content: str) -> str:
|
|
22
|
+
r"""The file's dominant line ending: '\r\n' if any CRLF is present, else '\n'."""
|
|
23
|
+
return "\r\n" if "\r\n" in content else "\n"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def read_text_keep_newlines(path) -> str:
|
|
27
|
+
"""Read text WITHOUT universal-newline translation, so CRLF/LF are preserved verbatim.
|
|
28
|
+
|
|
29
|
+
Uses utf-8-sig so a leading UTF-8 BOM (common on Windows-authored files) is stripped on read —
|
|
30
|
+
otherwise it would sit before the `---` and break frontmatter detection. Files with no BOM are
|
|
31
|
+
read identically to plain utf-8.
|
|
32
|
+
"""
|
|
33
|
+
with open(path, encoding="utf-8-sig", newline="") as f:
|
|
34
|
+
return f.read()
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def write_text_keep_newlines(path, content: str) -> None:
|
|
38
|
+
r"""Write text verbatim — do NOT translate '\n' to the platform's os.linesep."""
|
|
39
|
+
with open(path, "w", encoding="utf-8", newline="") as f:
|
|
40
|
+
f.write(content)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def read_uuid_from_content(content: str) -> Optional[str]:
|
|
44
|
+
"""Return the lowercased `uuid` from a leading frontmatter block, or None."""
|
|
45
|
+
m = _FM_BLOCK_RE.match(content)
|
|
46
|
+
if not m:
|
|
47
|
+
return None
|
|
48
|
+
um = _UUID_LINE_RE.search(m.group(2))
|
|
49
|
+
if not um:
|
|
50
|
+
return None
|
|
51
|
+
return um.group(1).strip().strip("'\"").lower() or None
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def has_frontmatter(content: str) -> bool:
|
|
55
|
+
return _FM_BLOCK_RE.match(content) is not None
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def add_uuid_to_content(
|
|
59
|
+
content: str, uuid_value: str, create_frontmatter: bool
|
|
60
|
+
) -> Optional[str]:
|
|
61
|
+
"""Return content with `uuid: <uuid_value>` inserted into the frontmatter.
|
|
62
|
+
|
|
63
|
+
- If a frontmatter block exists, insert the line just after the opening `---`.
|
|
64
|
+
- If none exists, create a minimal block only when `create_frontmatter` is True.
|
|
65
|
+
- Returns None when there is no frontmatter and creation is not allowed (caller skips).
|
|
66
|
+
Assumes the file does not already have a uuid (caller checks).
|
|
67
|
+
"""
|
|
68
|
+
nl = detect_newline(content)
|
|
69
|
+
m = _FM_BLOCK_RE.match(content)
|
|
70
|
+
if m:
|
|
71
|
+
head, body, sep, rest = m.groups()
|
|
72
|
+
return f"{head}uuid: {uuid_value}{nl}{body}{sep}{rest}"
|
|
73
|
+
if create_frontmatter:
|
|
74
|
+
return f"---{nl}uuid: {uuid_value}{nl}---{nl}{nl}{content}"
|
|
75
|
+
return None
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
"""Build a `uuid -> file` index by scanning Markdown frontmatter.
|
|
2
|
+
|
|
3
|
+
This plain dictionary replaces the predecessor's heavy entity model
|
|
4
|
+
(`csv_data_manager`/`MarkdownRepoIndex`) — it is the core of the L1 split.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import fnmatch
|
|
9
|
+
import os
|
|
10
|
+
from dataclasses import dataclass, field
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Dict, Iterator, List
|
|
13
|
+
|
|
14
|
+
import frontmatter
|
|
15
|
+
|
|
16
|
+
DEFAULT_EXCLUDES = {".git", "node_modules", ".venv", "__pycache__", "_build", ".tox", "dist", "build"}
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _dir_excluded(name: str, excludes) -> bool:
|
|
20
|
+
"""A directory name is excluded if it matches any pattern by glob (fnmatch, case-sensitive).
|
|
21
|
+
A pattern with no wildcards matches exactly, so plain names (`node_modules`) still work — the
|
|
22
|
+
glob is purely additive. Lets a repo exclude a family with one line, e.g. `old`, `old_*`, `*_old`."""
|
|
23
|
+
return any(fnmatch.fnmatchcase(name, pat) for pat in excludes)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass
|
|
27
|
+
class FrontmatterIndex:
|
|
28
|
+
by_uuid: Dict[str, Path] = field(default_factory=dict)
|
|
29
|
+
duplicates: Dict[str, List[Path]] = field(default_factory=dict)
|
|
30
|
+
invalid: List[Path] = field(default_factory=list) # files whose frontmatter is not valid YAML
|
|
31
|
+
|
|
32
|
+
def get(self, uuid: str) -> Path | None:
|
|
33
|
+
return self.by_uuid.get(uuid.lower())
|
|
34
|
+
|
|
35
|
+
def is_ambiguous(self, uuid: str) -> bool:
|
|
36
|
+
return uuid.lower() in self.duplicates
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def iter_markdown_files(root: Path, excludes: set[str] = DEFAULT_EXCLUDES) -> Iterator[Path]:
|
|
40
|
+
"""Yield all `.md` files under `root`, skipping excluded directory names."""
|
|
41
|
+
root = root.resolve()
|
|
42
|
+
for dirpath, dirnames, filenames in os.walk(root):
|
|
43
|
+
dirnames[:] = [d for d in dirnames if not _dir_excluded(d, excludes)]
|
|
44
|
+
for fn in filenames:
|
|
45
|
+
if fn.lower().endswith(".md"):
|
|
46
|
+
yield Path(dirpath) / fn
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def read_frontmatter_uuid(content: str) -> tuple[str, str | None]:
|
|
50
|
+
"""Canonical uuid reader, used by EVERY operation (index, repair, robustify).
|
|
51
|
+
|
|
52
|
+
Returns `(status, uuid)`:
|
|
53
|
+
- `("none", None)` no leading frontmatter block at all.
|
|
54
|
+
- `("invalid", None)` a frontmatter block that is NOT valid YAML — reported, never read/written.
|
|
55
|
+
- `("valid", uuid)` well-formed YAML; `uuid` is the lowercased value or None if absent.
|
|
56
|
+
|
|
57
|
+
Parsing is delegated to `python-frontmatter` (PyYAML) — the standard for the format (FORMAT.md).
|
|
58
|
+
A tolerant regex MUST NOT be used here: it would accept what YAML rejects (FR-023/FR-024)."""
|
|
59
|
+
from .frontmatter_edit import has_frontmatter # regex presence-check; no cycle (links-free module)
|
|
60
|
+
|
|
61
|
+
if not has_frontmatter(content):
|
|
62
|
+
return ("none", None)
|
|
63
|
+
try:
|
|
64
|
+
meta = frontmatter.loads(content).metadata
|
|
65
|
+
except Exception:
|
|
66
|
+
return ("invalid", None)
|
|
67
|
+
if not isinstance(meta, dict):
|
|
68
|
+
return ("invalid", None)
|
|
69
|
+
u = meta.get("uuid")
|
|
70
|
+
if u is None:
|
|
71
|
+
return ("valid", None)
|
|
72
|
+
if not isinstance(u, str):
|
|
73
|
+
return ("invalid", None) # uuid present but not a string scalar (list/dict/number): malformed
|
|
74
|
+
return ("valid", u.strip().lower() or None)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def build_index(root: Path, excludes: set[str] = DEFAULT_EXCLUDES) -> FrontmatterIndex:
|
|
78
|
+
"""Scan `root` and map each frontmatter `uuid` to its file. Records duplicates separately.
|
|
79
|
+
|
|
80
|
+
Files carrying the `<!-- darnlink-ignore-file -->` marker are skipped: an opted-out file is not
|
|
81
|
+
a resolvable target, so a robust link pointing at its uuid is reported unresolvable (FR-019)."""
|
|
82
|
+
from .links import file_is_ignored # local import: links has no package deps, but keep it lazy
|
|
83
|
+
|
|
84
|
+
index = FrontmatterIndex()
|
|
85
|
+
for path in iter_markdown_files(root, excludes):
|
|
86
|
+
try:
|
|
87
|
+
# utf-8-sig strips a leading UTF-8 BOM (common on Windows-authored files) so it doesn't
|
|
88
|
+
# sit before the `---` and hide the frontmatter from the index. Same as the write path.
|
|
89
|
+
content = path.read_text(encoding="utf-8-sig") # read once: marker + uuid both come from it
|
|
90
|
+
except Exception:
|
|
91
|
+
continue
|
|
92
|
+
if file_is_ignored(content):
|
|
93
|
+
continue
|
|
94
|
+
status, u = read_frontmatter_uuid(content)
|
|
95
|
+
if status == "invalid":
|
|
96
|
+
index.invalid.append(path) # report; an invalid file is never a resolvable target (FR-024)
|
|
97
|
+
continue
|
|
98
|
+
if not u:
|
|
99
|
+
continue
|
|
100
|
+
if u in index.duplicates:
|
|
101
|
+
index.duplicates[u].append(path)
|
|
102
|
+
elif u in index.by_uuid:
|
|
103
|
+
# second sighting: promote to duplicate, drop from the unambiguous map
|
|
104
|
+
first = index.by_uuid.pop(u)
|
|
105
|
+
index.duplicates[u] = [first, path]
|
|
106
|
+
else:
|
|
107
|
+
index.by_uuid[u] = path
|
|
108
|
+
return index
|
darnlink/links.py
ADDED
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
"""Robust-link grammar: detect, parse and emit.
|
|
2
|
+
|
|
3
|
+
Grammar (ported from the predecessor `tx_aiready_mdlink`):
|
|
4
|
+
[text](href) <!-- uuid: <36-char-uuid> -->
|
|
5
|
+
Detection tolerates any whitespace between `)` and the comment; emission uses a single space.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import re
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from typing import List, Sequence, Tuple
|
|
12
|
+
|
|
13
|
+
# A robust link: a Markdown link immediately followed (any whitespace) by a uuid HTML comment.
|
|
14
|
+
ROBUST_LINK_RE = re.compile(
|
|
15
|
+
r"\[(?P<text>[^\]]+)\]\((?P<href>[^)]+)\)\s*<!--\s*uuid:\s*(?P<uuid>[0-9a-fA-F-]{36})\s*-->"
|
|
16
|
+
)
|
|
17
|
+
# Any inline Markdown link.
|
|
18
|
+
MD_LINK_RE = re.compile(r"\[(?P<text>[^\]]+)\]\((?P<href>[^)]+)\)")
|
|
19
|
+
# A uuid comment that immediately follows a link (used to tell plain from robust).
|
|
20
|
+
# No `^`: it is applied with .match(content, pos), which already anchors at pos.
|
|
21
|
+
_TRAILING_UUID_RE = re.compile(r"\s*<!--\s*uuid:\s*[0-9a-fA-F-]{36}\s*-->")
|
|
22
|
+
|
|
23
|
+
Span = Tuple[int, int]
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def ignored_spans(content: str, block_markers: Sequence[str]) -> List[Span]:
|
|
27
|
+
"""Spans of generated blocks to ignore: between `<!-- NAME-start -->` and `<!-- NAME-end -->`.
|
|
28
|
+
|
|
29
|
+
Lets darnlink leave machine-generated regions (e.g. a generator's auto-built tables) untouched
|
|
30
|
+
so it only operates on hand-authored prose links.
|
|
31
|
+
"""
|
|
32
|
+
spans: List[Span] = []
|
|
33
|
+
for name in block_markers:
|
|
34
|
+
pat = re.compile(
|
|
35
|
+
rf"<!--\s*{re.escape(name)}-start\s*-->.*?<!--\s*{re.escape(name)}-end\s*-->",
|
|
36
|
+
re.DOTALL,
|
|
37
|
+
)
|
|
38
|
+
spans.extend((m.start(), m.end()) for m in pat.finditer(content))
|
|
39
|
+
return spans
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _in_spans(pos: int, spans: Sequence[Span]) -> bool:
|
|
43
|
+
return any(s <= pos < e for s, e in spans)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _fenced_code_spans(content: str) -> List[Span]:
|
|
47
|
+
"""Spans of fenced code blocks: ```` ``` ````/`~~~` (indent <=3), closed by the same fence char
|
|
48
|
+
of equal-or-greater length. An info string after the opener is allowed. An unclosed fence
|
|
49
|
+
extends to EOF (over-ignoring is safe; corrupting code is not -- FR-016)."""
|
|
50
|
+
spans: List[Span] = []
|
|
51
|
+
pos = 0
|
|
52
|
+
fence: Tuple[str, int, int] | None = None # (char, length, start offset)
|
|
53
|
+
for line in content.splitlines(keepends=True):
|
|
54
|
+
stripped = line.lstrip(" ")
|
|
55
|
+
indent = len(line) - len(stripped)
|
|
56
|
+
if fence is None:
|
|
57
|
+
if indent <= 3 and stripped[:3] in ("```", "~~~"):
|
|
58
|
+
ch = stripped[0]
|
|
59
|
+
run = len(stripped) - len(stripped.lstrip(ch))
|
|
60
|
+
if run >= 3:
|
|
61
|
+
fence = (ch, run, pos)
|
|
62
|
+
else:
|
|
63
|
+
ch, run, start = fence
|
|
64
|
+
body = stripped.rstrip()
|
|
65
|
+
# a closing fence is only fence chars, of the same kind, length >= the opener
|
|
66
|
+
if indent <= 3 and body and set(body) == {ch} and len(body) >= run:
|
|
67
|
+
spans.append((start, pos + len(line)))
|
|
68
|
+
fence = None
|
|
69
|
+
pos += len(line)
|
|
70
|
+
if fence is not None:
|
|
71
|
+
spans.append((fence[2], len(content))) # unclosed -> to EOF
|
|
72
|
+
return spans
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _inline_code_spans(content: str, skip: Sequence[Span]) -> List[Span]:
|
|
76
|
+
"""Spans of inline code: a run of N backticks closed by the next run of exactly N backticks
|
|
77
|
+
(FR-017). An unterminated run is not code. Positions inside `skip` (fenced blocks) are not
|
|
78
|
+
scanned, so backticks inside a fence never pair with backticks outside it."""
|
|
79
|
+
spans: List[Span] = []
|
|
80
|
+
n = len(content)
|
|
81
|
+
i = 0
|
|
82
|
+
while i < n:
|
|
83
|
+
if content[i] != "`" or _in_spans(i, skip):
|
|
84
|
+
i += 1
|
|
85
|
+
continue
|
|
86
|
+
j = i
|
|
87
|
+
while j < n and content[j] == "`":
|
|
88
|
+
j += 1
|
|
89
|
+
run = j - i
|
|
90
|
+
# look for a closing run of exactly `run` backticks
|
|
91
|
+
k = j
|
|
92
|
+
closed = False
|
|
93
|
+
while k < n:
|
|
94
|
+
if _in_spans(k, skip):
|
|
95
|
+
break # reached a fenced block; an inline span cannot cross it -> unterminated
|
|
96
|
+
if content[k] == "`":
|
|
97
|
+
m = k
|
|
98
|
+
while m < n and content[m] == "`":
|
|
99
|
+
m += 1
|
|
100
|
+
if m - k == run:
|
|
101
|
+
spans.append((i, m))
|
|
102
|
+
i = m
|
|
103
|
+
closed = True
|
|
104
|
+
break
|
|
105
|
+
k = m
|
|
106
|
+
else:
|
|
107
|
+
k += 1
|
|
108
|
+
if not closed:
|
|
109
|
+
i = j # unterminated opener; not a code span
|
|
110
|
+
return spans
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def code_spans(content: str) -> List[Span]:
|
|
114
|
+
"""All spans that are code (fenced blocks + inline code). Links starting inside any of these
|
|
115
|
+
are examples, not navigational links, and must never be rewritten (FR-015). Pure & deterministic."""
|
|
116
|
+
fenced = _fenced_code_spans(content)
|
|
117
|
+
return fenced + _inline_code_spans(content, fenced)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _carries_marker(content: str, keyword: str, marker_re: "re.Pattern[str]") -> bool:
|
|
121
|
+
"""True if `marker_re` matches outside a code span (so a file documenting the marker as an
|
|
122
|
+
example does not opt itself out). Pure & deterministic.
|
|
123
|
+
|
|
124
|
+
The `keyword` substring test is a cheap reject: markers are rare, and `code_spans()` parses the
|
|
125
|
+
whole file, so the common case (no marker at all) must not pay for it. It is safe because every
|
|
126
|
+
marker regex requires that keyword literally — a file that lacks the substring cannot match."""
|
|
127
|
+
if keyword not in content:
|
|
128
|
+
return False
|
|
129
|
+
code = code_spans(content)
|
|
130
|
+
return any(not _in_spans(m.start(), code) for m in marker_re.finditer(content))
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
# A whole-file opt-out: a file carrying this marker is removed from the darnlink graph entirely.
|
|
134
|
+
IGNORE_FILE_MARKER = "<!-- darnlink-ignore-file -->"
|
|
135
|
+
_IGNORE_FILE_RE = re.compile(r"<!--\s*darnlink-ignore-file\s*-->")
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def file_is_ignored(content: str) -> bool:
|
|
139
|
+
"""True if the file opts out of darnlink via a `<!-- darnlink-ignore-file -->` marker that is
|
|
140
|
+
NOT inside a code span (so a file documenting the marker as an example does not self-ignore).
|
|
141
|
+
FR-019..FR-021; composes with code_spans (feature 002). Pure & deterministic."""
|
|
142
|
+
return _carries_marker(content, "darnlink-ignore-file", _IGNORE_FILE_RE)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
# A SOURCE-only opt-out: darnlink never rewrites the links inside this file, but the file stays a
|
|
146
|
+
# first-class target (its uuid is indexed, so inbound robust links resolve and heal). This is the
|
|
147
|
+
# axis `darnlink-ignore-file` fuses: that one also drops the file as a target (FR-019), which the
|
|
148
|
+
# motivating case — a generated, heavily-linked INDEX.md — cannot afford. Feature 006.
|
|
149
|
+
IGNORE_LINKS_MARKER = "<!-- darnlink-ignore-links -->"
|
|
150
|
+
_IGNORE_LINKS_RE = re.compile(r"<!--\s*darnlink-ignore-links\s*-->")
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def file_ignores_links(content: str) -> bool:
|
|
154
|
+
"""True if the file opts its OWN links out via a `<!-- darnlink-ignore-links -->` marker that is
|
|
155
|
+
NOT inside a code span. Says nothing about the target axis: the file keeps its uuid indexed.
|
|
156
|
+
FR-033/FR-036/FR-037; composes with code_spans (feature 002). Pure & deterministic.
|
|
157
|
+
|
|
158
|
+
Note (FR-040): the marker must not precede the frontmatter block — the canonical reader only
|
|
159
|
+
recognises a *leading* `---`, so a marker on line 1 would hide the file's own uuid and silently
|
|
160
|
+
cost it the target axis. Detection itself is position-free; the ordering is a property of the
|
|
161
|
+
frontmatter format, not of this check."""
|
|
162
|
+
return _carries_marker(content, "darnlink-ignore-links", _IGNORE_LINKS_RE)
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
@dataclass(frozen=True)
|
|
166
|
+
class RobustLink:
|
|
167
|
+
text: str
|
|
168
|
+
href: str
|
|
169
|
+
uuid: str
|
|
170
|
+
start: int # span of the whole robust link (link + comment) in the source
|
|
171
|
+
end: int
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
@dataclass(frozen=True)
|
|
175
|
+
class PlainLink:
|
|
176
|
+
text: str
|
|
177
|
+
href: str
|
|
178
|
+
start: int # span of just the [text](href) in the source
|
|
179
|
+
end: int
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def find_robust_links(content: str, ignore: Sequence[Span] = ()) -> List[RobustLink]:
|
|
183
|
+
"""All robust links in the content, in document order, skipping any inside `ignore` spans."""
|
|
184
|
+
return [
|
|
185
|
+
RobustLink(m.group("text"), m.group("href"), m.group("uuid").lower(), m.start(), m.end())
|
|
186
|
+
for m in ROBUST_LINK_RE.finditer(content)
|
|
187
|
+
if not _in_spans(m.start(), ignore)
|
|
188
|
+
]
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def find_plain_links(content: str, ignore: Sequence[Span] = ()) -> List[PlainLink]:
|
|
192
|
+
"""All Markdown links that are NOT already robust, skipping any inside `ignore` spans."""
|
|
193
|
+
out: List[PlainLink] = []
|
|
194
|
+
for m in MD_LINK_RE.finditer(content):
|
|
195
|
+
if _TRAILING_UUID_RE.match(content, m.end()):
|
|
196
|
+
continue # this link is part of a robust link; skip
|
|
197
|
+
if _in_spans(m.start(), ignore):
|
|
198
|
+
continue # inside a generated block (e.g. autogrid); leave it alone
|
|
199
|
+
out.append(PlainLink(m.group("text"), m.group("href"), m.start(), m.end()))
|
|
200
|
+
return out
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def emit_robust_link(text: str, href: str, uuid: str) -> str:
|
|
204
|
+
"""Canonical robust-link rendering: a single space before the comment."""
|
|
205
|
+
return f"[{text}]({href}) <!-- uuid: {uuid} -->"
|