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 +3 -0
- engmem/backfill.py +592 -0
- engmem/cache.py +185 -0
- engmem/cli.py +573 -0
- engmem/gate1.py +248 -0
- engmem/gate1_audit.py +181 -0
- engmem/install.py +581 -0
- engmem/mcp_server.py +1188 -0
- engmem/output.py +426 -0
- engmem/runtime.py +57 -0
- engmem/scoring.py +600 -0
- engmem/sections.py +279 -0
- engmem/spine.py +490 -0
- engmem/staging.py +73 -0
- engmem/telemetry.py +274 -0
- engmem/templates/engmem.save.md +280 -0
- engmem/templates/engmem.save.quick.md +55 -0
- engmem/templates/engmem.start.md +161 -0
- engmem-0.1.1.dist-info/METADATA +282 -0
- engmem-0.1.1.dist-info/RECORD +23 -0
- engmem-0.1.1.dist-info/WHEEL +4 -0
- engmem-0.1.1.dist-info/entry_points.txt +2 -0
- engmem-0.1.1.dist-info/licenses/LICENSE +21 -0
engmem/__init__.py
ADDED
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}"
|