engmem 0.1.1__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.
engmem/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ from importlib.metadata import version as _installed_version
2
+
3
+ __version__ = _installed_version("engmem")
engmem/backfill.py ADDED
@@ -0,0 +1,592 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ import posixpath
5
+ import re
6
+ from dataclasses import dataclass, field
7
+ from pathlib import Path
8
+ from urllib.parse import unquote, urlsplit
9
+
10
+ import yaml
11
+ from markdown_it import MarkdownIt
12
+
13
+ from engmem.cache import identity_for
14
+ from engmem.sections import sections_for_role, split_sections
15
+ from engmem.spine import (
16
+ Doc,
17
+ parse_document,
18
+ preamble_label_value,
19
+ split_front_matter,
20
+ stated,
21
+ )
22
+ from engmem.staging import commit, discard, newline_of, read_document, stage
23
+
24
+
25
+ class _NoAliasDumper(yaml.SafeDumper):
26
+ """Stops PyYAML rendering the shared `date`/`task_date` object as a YAML anchor."""
27
+
28
+ def ignore_aliases(self, data):
29
+ return True
30
+
31
+
32
+ # under `default_flow_style=None` a mapping with no nested collection dumps flow —
33
+ # `{status: active, backfilled: true}` — and a flow mapping appended into a block front matter
34
+ # is a syntax error, so every document missing only scalar spine fields failed to write at all.
35
+ # Sequences keep the heuristic, so `tags: [widget-cache]` renders exactly as before.
36
+ _NoAliasDumper.add_representer(
37
+ dict,
38
+ lambda dumper, data: dumper.represent_mapping(
39
+ "tag:yaml.org,2002:map", data, flow_style=False
40
+ ),
41
+ )
42
+
43
+
44
+ def _dump_front_matter(data: dict) -> str:
45
+ return yaml.dump(
46
+ data, Dumper=_NoAliasDumper, default_flow_style=None, sort_keys=False,
47
+ allow_unicode=True,
48
+ )
49
+
50
+
51
+ def _front_matter_mapping(front_matter_text: str) -> dict:
52
+ """A document's own front matter; anything that is not a mapping reads as empty."""
53
+ raw = yaml.safe_load(front_matter_text) if front_matter_text else None
54
+ return raw if isinstance(raw, dict) else {}
55
+
56
+
57
+ # the label only; the rest of the line is its value. Every gap is `[^\S\r\n]`, the same
58
+ # "whitespace, but not the line break" class `spine._PREAMBLE_DATE_RE` uses — one rule for
59
+ # every preamble label. See contracts/backfill.md
60
+ _PREAMBLE_STATUS_RE = re.compile(
61
+ r"^[-*][^\S\r\n]*\*{0,2}Status\*{0,2}[^\S\r\n]*:(.*)$",
62
+ re.MULTILINE,
63
+ )
64
+ _PREAMBLE_REPOS_RE = re.compile(
65
+ r"^[-*][^\S\r\n]*\*{0,2}Repos?\*{0,2}[^\S\r\n]*:(.*)$",
66
+ re.MULTILINE,
67
+ )
68
+
69
+ # `*` is a list marker only when it is not doubled: `**Classes:**` is a bold label,
70
+ # and eating its first asterisk hid the label and sent `*Classes:**` to `entities`
71
+ _BULLET_RE = re.compile(r"^\s*(?:[-+•]\s*|\*(?!\*)\s*)")
72
+ _BOLD_LABEL_RE = re.compile(r"^\*\*([^*\n]+?)\*\*\s*:?\s*")
73
+ _PLAIN_LABEL_RE = re.compile(r"^([A-Z][A-Za-z0-9 /&\-]{0,40}?)\s*:\s+")
74
+ _TERM_SEPARATORS = frozenset(",;·|")
75
+ _TRAILING_PAREN_RE = re.compile(r"\s*\([^()]*\)\s*$")
76
+ _BACKTICKED_RE = re.compile(r"^`([^`]+)`$")
77
+
78
+ # terms that satisfy _looks_like_entity's shape test but are never real entities
79
+ # (e.g. "N/A" would otherwise pass on its "/") — see contracts/backfill.md
80
+ _ENTITY_DENYLIST = frozenset({
81
+ "n/a", "na", "tbd", "todo", "etc", "etc.", "none", "n / a",
82
+ "and more", "and others", "and so on",
83
+ })
84
+
85
+
86
+ @dataclass
87
+ class FieldProposal:
88
+ """One field to add, and the evidence it was derived from."""
89
+
90
+ name: str
91
+ value: object
92
+ source: str
93
+
94
+
95
+ @dataclass
96
+ class BackfillProposal:
97
+ doc_id: str
98
+ path: Path
99
+ already_complete: bool
100
+ fields: list[FieldProposal] = field(default_factory=list)
101
+ notes: list[str] = field(default_factory=list)
102
+ # the file as it was when `load_store` read the body every value below was derived from
103
+ # — not as of `propose_backfill`, which runs later. `apply_backfill` refuses to write
104
+ # these values into anything else
105
+ source_identity: tuple[int, int] | None = None
106
+
107
+
108
+ def _split_terms(text: str) -> list[str]:
109
+ """Splits on `,` `;` `·` `|`, except inside a matched pair of parentheses — a parenthetical
110
+ is an aside about the term before it, not a term boundary."""
111
+ protected = [False] * len(text)
112
+ opened: list[int] = []
113
+ for i, char in enumerate(text):
114
+ if char == "(":
115
+ opened.append(i)
116
+ elif char == ")" and opened:
117
+ start = opened.pop()
118
+ protected[start:i + 1] = [True] * (i + 1 - start)
119
+
120
+ parts: list[str] = []
121
+ current: list[str] = []
122
+ for i, char in enumerate(text):
123
+ if char in _TERM_SEPARATORS and not protected[i]:
124
+ parts.append("".join(current))
125
+ current = []
126
+ continue
127
+ current.append(char)
128
+ parts.append("".join(current))
129
+ return parts
130
+
131
+
132
+ def _strip_bullet(line: str) -> str:
133
+ return _BULLET_RE.sub("", line, count=1)
134
+
135
+
136
+ def _extract_label_and_rest(line: str) -> tuple[str | None, str]:
137
+ m = _BOLD_LABEL_RE.match(line)
138
+ if m:
139
+ return m.group(1).rstrip(":").strip(), line[m.end():]
140
+ m = _PLAIN_LABEL_RE.match(line)
141
+ if m:
142
+ return m.group(1).strip(), line[m.end():]
143
+ return None, line
144
+
145
+
146
+ def _clean_candidate(raw_token: str) -> tuple[str, bool] | None:
147
+ """`(text, was_backtick_quoted)`, or None once nothing usable remains."""
148
+ text = _TRAILING_PAREN_RE.sub("", raw_token.strip()).strip()
149
+ m = _BACKTICKED_RE.match(text)
150
+ if m:
151
+ text = m.group(1).strip()
152
+ else:
153
+ text = text.strip("\"").strip("'").strip()
154
+ text = text.rstrip(".,;:").strip()
155
+ if not text:
156
+ return None
157
+ return text, bool(m)
158
+
159
+
160
+ def _looks_like_entity(text: str) -> bool:
161
+ """Shape test only: a digit, one of `/_.-`, or an uppercase letter past the first
162
+ character. Lowercase prose is rejected on purpose — contracts/backfill.md."""
163
+ if not text or len(text) > 60:
164
+ return False
165
+ if not any(ch.isalnum() for ch in text):
166
+ return False # a bare separator like "--" would pass the punctuation check below
167
+ if len(text.split()) > 4:
168
+ return False
169
+ if any(ch.isdigit() for ch in text):
170
+ return True
171
+ if any(ch in "/_.-" for ch in text):
172
+ return True
173
+ if any(ch.isupper() for ch in text[1:]):
174
+ return True # CamelCase, ALLCAPS, or Title Case beyond the first word
175
+ return False
176
+
177
+
178
+ def _extract_entities(section_body: str) -> list[str]:
179
+ """Entity terms from a `Search Keywords` body; group labels are scaffolding, never tags."""
180
+ entities: list[str] = []
181
+ seen_entities: set[str] = set()
182
+
183
+ for raw_line in section_body.splitlines():
184
+ line = _strip_bullet(raw_line)
185
+ if not line.strip():
186
+ continue
187
+ # the label names the group's shape ("Endpoints"), not the document's
188
+ # subject, so it is discarded rather than promoted to a tag
189
+ _label, rest = _extract_label_and_rest(line)
190
+ for raw_token in _split_terms(rest):
191
+ cleaned = _clean_candidate(raw_token)
192
+ if cleaned is None:
193
+ continue
194
+ text, backticked = cleaned
195
+ if text.casefold() in _ENTITY_DENYLIST:
196
+ continue
197
+ if not (backticked or _looks_like_entity(text)):
198
+ continue
199
+ if text not in seen_entities:
200
+ seen_entities.add(text)
201
+ entities.append(text)
202
+
203
+ return entities
204
+
205
+
206
+ def _derive_status(body: str) -> tuple[str, str | None]:
207
+ """`(status, raw_source)` — only a `- Status:` line that says so maps to `superseded`."""
208
+ raw = preamble_label_value(_PREAMBLE_STATUS_RE, body)
209
+ if raw is None:
210
+ return "active", None
211
+ # the past participle only: a stem test ("supersed") also fired on "supersedes" and
212
+ # "superseding", which state the opposite relationship — see contracts/backfill.md
213
+ return ("superseded" if "superseded" in raw.casefold() else "active"), raw
214
+
215
+
216
+ # a repo name is a lowercase-hyphenated slug; anything after it on the line
217
+ # (parenthetical, em-dash aside, prose) is commentary, not part of the name
218
+ _REPO_NAME_RE = re.compile(r"^([a-z0-9]+(?:-[a-z0-9]+)*)")
219
+
220
+
221
+ def _derive_repo_tags(body: str) -> list[str]:
222
+ line_value = preamble_label_value(_PREAMBLE_REPOS_RE, body)
223
+ if line_value is None:
224
+ return []
225
+ tags: list[str] = []
226
+ seen: set[str] = set()
227
+ # an em-dash opens a trailing aside about the entry before it; its own
228
+ # commas would otherwise split into phantom entries, so cut before splitting
229
+ value = re.split(r"\s[—–]\s", line_value)[0]
230
+ for part in re.split(r"[,;·]", value):
231
+ # `*` and `_` alongside the backticks — CommonMark's code span and both of its
232
+ # emphasis delimiters. A repo name contains none of the three, and emphasis around the
233
+ # entry (`**widget-cache**`, `_widget-cache_`) otherwise left the marker in the
234
+ # candidate, where the slug test rejected it and the entry contributed no tag at all
235
+ candidate = part.strip().strip("`*_").strip().casefold()
236
+ name = _REPO_NAME_RE.match(candidate)
237
+ if not name:
238
+ continue
239
+ # the slug must BE the entry, not merely start it (rejects prose like
240
+ # "shared apis (3rd-party)"); a parenthetical suffix is allowed to follow
241
+ remainder = candidate[name.end():].strip()
242
+ if remainder and not remainder.startswith("("):
243
+ continue
244
+ tag = name.group(1)
245
+ if tag not in seen:
246
+ seen.add(tag)
247
+ tags.append(tag)
248
+ return tags
249
+
250
+
251
+ _MD = MarkdownIt("commonmark")
252
+
253
+
254
+ def _link_destinations(body: str) -> list[str]:
255
+ """Every destination CommonMark recognises as a link, outside block quotes."""
256
+ destinations: list[str] = []
257
+ quote_depth = 0
258
+ for token in _MD.parse(body):
259
+ # a block quote holds another document's text, so its links are that document's
260
+ # edges, not this one's. `sections.py` lands on the same policy for a quoted
261
+ # heading — see test_blockquoted_heading_diverges_on_purpose for the reasoning
262
+ if token.type == "blockquote_open":
263
+ quote_depth += 1
264
+ elif token.type == "blockquote_close":
265
+ quote_depth -= 1
266
+ elif quote_depth == 0:
267
+ destinations.extend(
268
+ child.attrGet("href") or ""
269
+ for child in token.children or []
270
+ if child.type == "link_open"
271
+ )
272
+ return destinations
273
+
274
+
275
+ def _sibling_filename(href: str) -> str | None:
276
+ """The filename `href` names in this document's own directory, else None — a link out of
277
+ `sessions/` has no document id to contribute."""
278
+ split = urlsplit(href)
279
+ # a scheme means this is not a path at all — `https://…`, and also `mailto:a@b.md`, which
280
+ # ends in `.md` and would otherwise contribute the "id" `mailto:a@b`
281
+ if split.scheme or split.netloc:
282
+ return None
283
+ # urlsplit cut the fragment before decoding, never after: an anchor is written literally,
284
+ # so a `#` that survived percent-encoding belongs to the filename. `related` holds stems,
285
+ # so what the parser normalised (` ` -> `%20`) is read back as written
286
+ target = unquote(split.path).strip()
287
+ if not target or "\\" in target:
288
+ # a Windows-style path is not a sibling either, and `\` is not a separator here
289
+ return None
290
+ # normalised first, so `./sibling.md` and `a/../sibling.md` are recognised as siblings;
291
+ # anything still carrying a separator afterwards genuinely points out of the directory
292
+ target = posixpath.normpath(target)
293
+ if "/" in target or not target.endswith(".md"):
294
+ return None
295
+ return target
296
+
297
+
298
+ def _extract_related_ids(body: str, self_id: str) -> list[str]:
299
+ """Ids of siblings linked as `[text](sibling.md)`; URLs, anchors and self-links are dropped."""
300
+ ids: list[str] = []
301
+ seen: set[str] = set()
302
+ for href in _link_destinations(body):
303
+ target = _sibling_filename(href)
304
+ if target is None:
305
+ continue
306
+ stem = Path(target).stem
307
+ # the shape half of `spine.validate_doc_id` is deliberately not applied (see the
308
+ # contract), but its safety half still is: `.md`, `..md` and `...md` yield the stems
309
+ # `.md`, `.` and `..`, which name no document and cannot be filenames either
310
+ if not stem or stem.startswith(".") or "\x00" in stem:
311
+ continue
312
+ if stem == self_id or stem in seen:
313
+ continue
314
+ seen.add(stem)
315
+ ids.append(stem)
316
+ return ids
317
+
318
+
319
+ # the human's way to close this out: `[]` written by hand is the answer "checked, there
320
+ # are none", which backfill cannot make on their behalf but must not hide from them
321
+ _ENTITIES_CLOSE_HINT = (
322
+ "If this document really names no identifiers, write `entities: []` yourself — "
323
+ "that is the answer engmem will not guess."
324
+ )
325
+
326
+
327
+ # "engmem found no", not "there is no": what the code knows is that no section resolved to the
328
+ # `keywords` role, which is still a weaker claim than the author reading their own document
329
+ # would make — a `### Search Keywords` under a `##` section smaller than MAX_SECTION_BYTES is
330
+ # never split out, so it is not seen
331
+ _ENTITIES_MISSING_NOTE = (
332
+ "engmem found no 'Search Keywords' section in this document — entities left unset "
333
+ "rather than guessed from free-form body prose. Add a section by hand, or run "
334
+ "/engmem.save to have an agent draft one, then re-run `engmem backfill`. "
335
+ + _ENTITIES_CLOSE_HINT
336
+ )
337
+
338
+
339
+ def _entities_empty_note(locator: str) -> str:
340
+ # names backticks explicitly: the population reaching this note is dominated by the
341
+ # documented false negative (lowercase, punctuation-free prose terms), whose author
342
+ # believes they already named the identifiers — the shape rule is what disagrees
343
+ return (
344
+ f"the 'Search Keywords' section ({locator}) yielded no term that reads as an "
345
+ "entity — entities left unset rather than guessed from its prose. Name the "
346
+ "identifiers there (a term in backticks is always accepted), then re-run "
347
+ "`engmem backfill`. " + _ENTITIES_CLOSE_HINT
348
+ )
349
+
350
+
351
+ def propose_backfill(doc: Doc) -> BackfillProposal:
352
+ """What `engmem backfill` would write for `doc`, without writing it."""
353
+ if doc.spine_complete:
354
+ return BackfillProposal(doc.id, doc.path, True, [], [])
355
+
356
+ text, _ = read_document(doc.path)
357
+ front_matter_text, _ = split_front_matter(text)
358
+ raw = _front_matter_mapping(front_matter_text)
359
+
360
+ body = doc.body
361
+
362
+ candidates: list[FieldProposal] = [
363
+ FieldProposal("id", doc.id, "filename stem"),
364
+ FieldProposal(
365
+ "title", doc.title,
366
+ "first '# H1' heading ('Knowledge Base — ' prefix stripped), else filename stem",
367
+ ),
368
+ FieldProposal(
369
+ # a real datetime.date, not its isoformat() string, so PyYAML
370
+ # renders it unquoted like every hand-written document's own `date:`
371
+ "date", doc.date,
372
+ "stated '- Date:'/'- Updated:' preamble line, else file mtime",
373
+ ),
374
+ FieldProposal("task_date", doc.date, "= date"),
375
+ ]
376
+
377
+ status, status_source = _derive_status(body)
378
+ candidates.append(FieldProposal(
379
+ "status", status,
380
+ f"preamble line '- Status: {status_source}'" if status_source
381
+ else "no '- Status:' preamble line with a value found — defaulted to active",
382
+ ))
383
+ # a superseded document with no successor is a dead end: `output.py` prints a bare
384
+ # "(superseded)" and `scoring.py` has nothing to redirect the reader to. The line that
385
+ # says so almost always names the successor — "Superseded by [v2](widget-cache-v2.md)".
386
+ # The word test above decides direction; the author's own `status:` gates it — both are
387
+ # required, and neither substitutes for the other. See contracts/backfill.md
388
+ stated_status = doc.status if stated(raw, "status") else None
389
+ if status == "superseded" and status_source and stated_status in (None, "superseded"):
390
+ successors = _extract_related_ids(status_source, doc.id)
391
+ if successors:
392
+ candidates.append(FieldProposal(
393
+ "superseded_by", successors[0],
394
+ f"sibling link on the '- Status:' preamble line ({status_source})",
395
+ ))
396
+ candidates.append(FieldProposal(
397
+ "backfilled", True,
398
+ "always true for a document engmem did not itself author",
399
+ ))
400
+
401
+ # every piece, not the first: a `Search Keywords` section over MAX_SECTION_BYTES is split
402
+ # across its `###` subsections, and its terms live in all of them
403
+ keywords_sections = sections_for_role(split_sections(body), "keywords")
404
+ keywords_locators = ", ".join(s.locator for s in keywords_sections)
405
+
406
+ notes: list[str] = []
407
+ if not keywords_sections:
408
+ entities = []
409
+ note = _ENTITIES_MISSING_NOTE
410
+ else:
411
+ entities = _extract_entities("\n".join(s.body for s in keywords_sections))
412
+ note = _entities_empty_note(keywords_locators) if not entities else None
413
+ # a note claims this run left `entities` unset; when the author already stated it, the
414
+ # document is degraded on some other field and the claim would be false
415
+ if note is not None and not stated(raw, "entities"):
416
+ notes.append(note)
417
+
418
+ repo_tags = _derive_repo_tags(body)
419
+ if repo_tags:
420
+ tags_source = f"'- Repos:' preamble line ({', '.join(repo_tags)})"
421
+ elif preamble_label_value(_PREAMBLE_REPOS_RE, body):
422
+ # a line the author wrote and the parser rejected is not the same evidence as no
423
+ # line at all, and "none found" read as the latter
424
+ tags_source = "'- Repos:' preamble line found, but no entry parsed as a repo name"
425
+ else:
426
+ tags_source = "no '- Repos:' preamble line with a value found"
427
+
428
+ # a copy, not `repo_tags` itself: `tags` and `repos` must stay separate objects
429
+ candidates.append(FieldProposal("tags", list(repo_tags), tags_source))
430
+ # repos also ride in `tags`, which is scored; this field is the declared answer to
431
+ # "which code is this about", and stays blank on every backfilled document otherwise
432
+ if repo_tags:
433
+ candidates.append(
434
+ FieldProposal("repos", repo_tags, "'- Repos:' preamble line")
435
+ )
436
+ # only when something was actually derived. `entities: []` is indistinguishable from a
437
+ # human's own "checked, found none", and `stated` counts it as answered — the document
438
+ # would go spine-complete and never be offered to `backfill` again, so the note above
439
+ # ("add a section, then re-run") would be advice that cannot work. Leaving the field
440
+ # unset keeps the document degraded, which is the truth, and keeps the re-run honest.
441
+ if entities:
442
+ candidates.append(FieldProposal(
443
+ "entities", entities,
444
+ f"'Search Keywords' section ({keywords_locators}), "
445
+ f"{len(entities)} term(s) extracted",
446
+ ))
447
+
448
+ related = _extract_related_ids(body, doc.id)
449
+ candidates.append(FieldProposal(
450
+ "related", related,
451
+ "markdown link(s) to sibling .md file(s) in the body" if related
452
+ else "no markdown links to sibling .md files found",
453
+ ))
454
+
455
+ fields = [f for f in candidates if not stated(raw, f.name)]
456
+ return BackfillProposal(doc.id, doc.path, False, fields, notes, doc.source_identity)
457
+
458
+
459
+ _YAML_NULL_TAG = "tag:yaml.org,2002:null"
460
+
461
+
462
+ def _drop_declared_keys(front_matter_text: str, names: set[str]) -> str:
463
+ """Removes the author's own empty `name:` lines for the fields about to be appended, so the
464
+ document does not end up declaring a key twice. Rules and residuals: contracts/backfill.md.
465
+ """
466
+ # asked of the parser, not a regex: column 0 is not a declaration test (an unindented
467
+ # continuation inside a flow collection sits there too), and emptiness has more spellings
468
+ # than are safe to enumerate. Composing is safe — `_front_matter_mapping` already parsed it
469
+ node = yaml.compose(front_matter_text, Loader=yaml.SafeLoader)
470
+ # a whole line is removed per declaration, which needs one declaration per line. A flow
471
+ # mapping (`{title: T, tags: null}`) puts every pair on one line, so removing it would take
472
+ # the author's other keys with it; leaving it alone means the append fails the staged parse
473
+ # instead, which refuses the write rather than losing anything
474
+ if not isinstance(node, yaml.MappingNode) or node.flow_style:
475
+ return front_matter_text
476
+
477
+ dropped = {
478
+ key.start_mark.line
479
+ for key, value in node.value
480
+ if key.value in names
481
+ and value.tag == _YAML_NULL_TAG
482
+ # the whole line goes, so nothing on it may belong to anything else
483
+ and value.end_mark.line == key.start_mark.line
484
+ }
485
+ return "".join(
486
+ line for i, line in enumerate(front_matter_text.splitlines(keepends=True))
487
+ if i not in dropped
488
+ )
489
+
490
+
491
+ class BackfillWriteError(Exception):
492
+ """Raised when a staged write cannot be safely committed; `doc.path` is never touched."""
493
+
494
+
495
+ def apply_backfill(doc: Doc, proposal: BackfillProposal) -> str:
496
+ """Writes `proposal`'s fields into the front matter, preserving the body byte-for-byte."""
497
+ if proposal.already_complete or not proposal.fields:
498
+ return "nothing to backfill"
499
+
500
+ if proposal.doc_id != doc.id or proposal.path != doc.path:
501
+ # the caller paired the wrong two objects; writing one document's derived values into
502
+ # another would put a foreign `id` in its front matter and duplicate an id in the store
503
+ raise BackfillWriteError(
504
+ f"{doc.path.name}: proposal is for {proposal.doc_id} ({proposal.path.name}) "
505
+ "— refusing to write"
506
+ )
507
+
508
+ original_text, bom = read_document(doc.path)
509
+ # everything below `proposal.fields` was derived from the document as it was when
510
+ # `propose_backfill` read it, and the CLI puts a human's confirmation prompt in between.
511
+ # The body is re-read above, so an edit landing in that window is preserved and the
512
+ # byte-for-byte check still passes — the stale part is silent, and it is the values.
513
+ # Stamped *after* that read, so one comparison covers both reads: taken before it, an edit
514
+ # landing in between was certified by a check that had already run. An identity that cannot
515
+ # be taken at all is not a match either — `None != None` is False, so "no evidence" read as
516
+ # "unchanged"
517
+ identity = identity_for(doc.path)
518
+ if identity is None or proposal.source_identity != identity:
519
+ raise BackfillWriteError(
520
+ f"{doc.path.name}: changed on disk since the proposal was made "
521
+ "— refusing to write. Re-run `engmem backfill` to see a current one"
522
+ )
523
+
524
+ front_matter_text, expected_body = split_front_matter(original_text)
525
+ raw = _front_matter_mapping(front_matter_text)
526
+
527
+ missing = {f.name: f.value for f in proposal.fields if not stated(raw, f.name)}
528
+ if not missing:
529
+ return "nothing to backfill (fields already present)"
530
+
531
+ # the lines being added, and the two delimiters, take the document's own line ending. The
532
+ # body is preserved byte-for-byte either way, but emitting LF into a CRLF document left it
533
+ # with both — a whole-file diff under `core.autocrlf` or a `.gitattributes` eol, on exactly
534
+ # the legacy documents this command exists for. The front matter's own lines answer first;
535
+ # a document that has none is having its block created above the body, so the body answers
536
+ newline = newline_of(front_matter_text) or newline_of(expected_body) or "\n"
537
+ addition = _dump_front_matter(missing).replace("\n", newline)
538
+ # no trailing-newline fixup: `split_front_matter` joins `splitlines(keepends=True)` lines
539
+ # that are all followed by the closing `---`, so every line keeps its terminator. Every
540
+ # terminator `splitlines` recognises is either a YAML line break too (CR, NEL, LS) or a
541
+ # character PyYAML refuses to read at all (VT, FF, FS), so such a document never loads
542
+ prefix = _drop_declared_keys(front_matter_text, set(missing))
543
+ new_content = f"---{newline}" + prefix + addition + f"---{newline}" + expected_body
544
+
545
+ # a symlinked document is refused, not written — the same rule `mcp_server` states for
546
+ # its write tools. `os.replace` over the link silently detached the document from whatever
547
+ # it pointed at; writing *through* it is worse in both directions. Out of the directory, it
548
+ # makes this the only path here that writes outside the store, and a store is relocatable
549
+ # with --store, so a link committed to a shared one aims the write anywhere. Inside it, the
550
+ # two names share an inode, so the alias's `id` lands in the real document too and
551
+ # `load_store` then reports a duplicate id across the pair
552
+ if doc.path.is_symlink():
553
+ raise BackfillWriteError(
554
+ f"{doc.path.name}: is a symlink to {os.readlink(doc.path)} — refusing to write. "
555
+ "Replace it with the document itself, or back the target up and copy it in"
556
+ )
557
+
558
+ tmp_path = stage(doc.path, bom + new_content.encode("utf-8"))
559
+ # BaseException, not Exception: a Ctrl-C landing between the stage and the commit must
560
+ # still take the temp file with it
561
+ try:
562
+ staged_text, _ = read_document(tmp_path)
563
+ staged_front_matter, staged_body = split_front_matter(staged_text)
564
+ if staged_body != expected_body:
565
+ raise BackfillWriteError(
566
+ f"{doc.path.name}: staged body did not match the original "
567
+ "byte-for-byte — refusing to write"
568
+ )
569
+ try:
570
+ parse_document(tmp_path)
571
+ except (yaml.YAMLError, ValueError, OSError) as exc:
572
+ raise BackfillWriteError(
573
+ f"{doc.path.name}: staged content does not parse ({exc}) — "
574
+ "refusing to write"
575
+ ) from exc
576
+ # the returned message is a claim about the file; a staged document that parses but
577
+ # does not state what was appended would make it a false one — and `_drop_declared_keys`
578
+ # has already removed the author's own declarations by then
579
+ staged_raw = _front_matter_mapping(staged_front_matter)
580
+ unwritten = [name for name in missing if not stated(staged_raw, name)]
581
+ if unwritten:
582
+ raise BackfillWriteError(
583
+ f"{doc.path.name}: staged front matter does not state "
584
+ f"{', '.join(unwritten)} — refusing to write"
585
+ )
586
+ commit(tmp_path, doc.path)
587
+ except BaseException:
588
+ discard(tmp_path)
589
+ raise
590
+
591
+ field_names = ", ".join(missing)
592
+ return f"backfilled {len(missing)} field(s): {field_names}"