handoff-mcp-server 0.24.6 → 0.25.0

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.
@@ -1,14 +1,15 @@
1
1
  //! Document management: splitting a single authored Markdown body into
2
2
  //! in-memory sections, and persisting documents to `.handoff/docs/` as a
3
- //! 2-file slug-based pair (v5 rearchitecture,
4
- //! wiki/130-document-management.md §3.1).
3
+ //! single frontmatter+body Markdown file per document (frontmatter
4
+ //! migration, t123.1-t123.3 — supersedes the earlier 2-file
5
+ //! `_doc.<slug>.json` + `_doc.<slug>.md` pair, wiki/130-document-management.md
6
+ //! §3.1).
5
7
  //!
6
8
  //! Layout:
7
9
  //!
8
10
  //! ```text
9
11
  //! .handoff/docs/
10
- //! _doc.<slug>.json # document metadata (incl. `sections[]` byte-offset index)
11
- //! _doc.<slug>.md # full document body (pure Markdown, never split into files)
12
+ //! _doc.<slug>.md # YAML frontmatter (metadata) + full document body
12
13
  //! injected/
13
14
  //! <session-id>.json # per-session "already injected" sidecar
14
15
  //! ```
@@ -16,10 +17,26 @@
16
17
  //! `slug` is a human-readable, caller-supplied name (`[a-z0-9-]`, max
17
18
  //! [`model::MAX_SLUG_LEN`] chars) used purely for file naming so `ls
18
19
  //! .handoff/docs/` is self-describing. The stable `id` (timestamp-based)
19
- //! stays inside the metadata JSON for family-tree/task-link references;
20
+ //! stays inside the frontmatter for family-tree/task-link references;
20
21
  //! [`find_doc_by_id`] resolves an `id` back to its document when the slug
21
22
  //! isn't known by the caller.
22
23
  //!
24
+ //! `sections[]` is never persisted — [`read_doc`]/[`read_all_docs`] always
25
+ //! recompute it fresh from the body via [`split::split`] +
26
+ //! [`split::compute_sections`], so a manual edit to the `.md` file can never
27
+ //! leave a stale byte-offset index on disk (t123.2). `content_hash` is
28
+ //! likewise recomputed on every read (not trusted from frontmatter) so drift
29
+ //! detection (`doc_reassemble`, verification staleness) still works after a
30
+ //! manual edit.
31
+ //!
32
+ //! **Migration**: a `_doc.<slug>.json` file next to `_doc.<slug>.md`
33
+ //! indicates the old 2-file format. [`read_doc`]/[`read_all_docs`]
34
+ //! transparently migrate it in place on first access (t123.3): the JSON
35
+ //! metadata is folded into a frontmatter block prepended to the `.md` body,
36
+ //! the `.json` file is deleted, and the migration is logged to stderr (this
37
+ //! is a stdio-based MCP server, so stdout must stay clean JSON-RPC-only).
38
+ //! Callers never need to know whether a document was migrated.
39
+ //!
23
40
  //! See `wiki/130-document-management.md` §3-4 for the full storage
24
41
  //! architecture and data model.
25
42
  //!
@@ -28,6 +45,7 @@
28
45
  //! projects created before this feature shipped are unaffected until they
29
46
  //! first call `doc_save`.
30
47
 
48
+ pub mod frontmatter;
31
49
  pub mod model;
32
50
  pub mod reassemble;
33
51
  pub mod split;
@@ -37,7 +55,8 @@ use std::path::{Path, PathBuf};
37
55
  use anyhow::{bail, Context, Result};
38
56
 
39
57
  pub use model::{
40
- CodeRef, DocMetadata, DocRelation, DocSource, SectionIndex, Verification, VerificationItem,
58
+ CodeRef, DocMetadata, DocRelation, DocSource, SectionIndex, SubItem, Verification,
59
+ VerificationItem,
41
60
  };
42
61
 
43
62
  /// Path to the `docs/` directory inside a `.handoff/` dir.
@@ -76,6 +95,8 @@ pub fn validate_slug(slug: &str) -> Result<()> {
76
95
  Ok(())
77
96
  }
78
97
 
98
+ /// Legacy JSON sidecar path (`_doc.<slug>.json`). Only used by the
99
+ /// migration path (t123.3) — new writes never create this file.
79
100
  fn doc_meta_path(handoff_dir: &Path, slug: &str) -> PathBuf {
80
101
  docs_dir(handoff_dir).join(format!("_doc.{slug}.json"))
81
102
  }
@@ -84,38 +105,66 @@ fn doc_body_path(handoff_dir: &Path, slug: &str) -> PathBuf {
84
105
  docs_dir(handoff_dir).join(format!("_doc.{slug}.md"))
85
106
  }
86
107
 
87
- /// Write a document's metadata to `_doc.<slug>.json` atomically, creating
88
- /// `docs/` lazily.
108
+ /// Write a document's metadata as YAML frontmatter into `_doc.<slug>.md`,
109
+ /// atomically, creating `docs/` lazily. Preserves whatever body currently
110
+ /// exists on disk for this slug (callers that also change the body must
111
+ /// call [`write_doc_body`] first — this is `doc_save`'s existing write
112
+ /// order). A brand new document with no body on disk yet is written with an
113
+ /// empty body.
114
+ ///
115
+ /// `doc.sections` is never persisted (t123.2) regardless of what it holds
116
+ /// in memory when this is called.
89
117
  pub fn write_doc(handoff_dir: &Path, doc: &DocMetadata) -> Result<PathBuf> {
90
118
  ensure_docs_dir(handoff_dir)?;
91
- let path = doc_meta_path(handoff_dir, &doc.slug);
92
- let content = serde_json::to_string_pretty(doc).context("Failed to serialize document")?;
93
- crate::storage::atomic_write(&path, content.as_bytes())
94
- .with_context(|| format!("Failed to write document: {}", path.display()))?;
119
+ let path = doc_body_path(handoff_dir, &doc.slug);
120
+ let body = read_doc_body(handoff_dir, &doc.slug)?.unwrap_or_default();
121
+ frontmatter::write_frontmatter_doc(&path, doc, &body)?;
95
122
  Ok(path)
96
123
  }
97
124
 
98
125
  /// Write a document's full body to `_doc.<slug>.md` atomically, creating
99
126
  /// `docs/` lazily. `body` is written exactly as given — no re-rendering —
100
127
  /// so it can be read back byte-identical via [`read_doc_body`].
128
+ ///
129
+ /// This preserves whatever frontmatter already exists on disk for this
130
+ /// slug (or writes no frontmatter at all for a brand-new file — the
131
+ /// subsequent [`write_doc`] call in `doc_save`'s write order fills it in).
132
+ /// Writing only the body without ever following up with [`write_doc`]
133
+ /// would leave a frontmatter-less `.md` file, which reads back as "no
134
+ /// frontmatter" (migration-signal territory) rather than a valid document —
135
+ /// callers must always pair this with a `write_doc` call.
101
136
  pub fn write_doc_body(handoff_dir: &Path, slug: &str, body: &str) -> Result<PathBuf> {
102
137
  ensure_docs_dir(handoff_dir)?;
103
138
  let path = doc_body_path(handoff_dir, slug);
104
- crate::storage::atomic_write(&path, body.as_bytes())
139
+ let existing_doc = frontmatter::read_frontmatter_doc(&path, slug)?.map(|(doc, _)| doc);
140
+ let content = match existing_doc {
141
+ Some(doc) => {
142
+ let fm_yaml = frontmatter::serialize_frontmatter(&doc)?;
143
+ format!("---\n{fm_yaml}---\n{body}")
144
+ }
145
+ None => body.to_string(),
146
+ };
147
+ crate::storage::atomic_write(&path, content.as_bytes())
105
148
  .with_context(|| format!("Failed to write document body: {}", path.display()))?;
106
149
  Ok(path)
107
150
  }
108
151
 
109
- /// Read a document's full body from `_doc.<slug>.md`. Returns `Ok(None)`
110
- /// when the file does not exist.
152
+ /// Read a document's full body from `_doc.<slug>.md` the part *after* the
153
+ /// YAML frontmatter block. Returns `Ok(None)` when the file does not exist.
154
+ /// A file with no frontmatter (old-format body-only file, or a plain `.md`
155
+ /// dropped in by hand) returns its entire content as the body.
111
156
  pub fn read_doc_body(handoff_dir: &Path, slug: &str) -> Result<Option<String>> {
112
157
  let path = doc_body_path(handoff_dir, slug);
113
- match std::fs::read_to_string(&path) {
114
- Ok(content) => Ok(Some(content)),
115
- Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
116
- Err(e) => {
117
- Err(e).with_context(|| format!("Failed to read document body: {}", path.display()))
118
- }
158
+ match frontmatter::read_frontmatter_doc(&path, slug) {
159
+ Ok(Some((_, body))) => Ok(Some(body)),
160
+ Ok(None) => match std::fs::read_to_string(&path) {
161
+ Ok(content) => Ok(Some(content)),
162
+ Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
163
+ Err(e) => {
164
+ Err(e).with_context(|| format!("Failed to read document body: {}", path.display()))
165
+ }
166
+ },
167
+ Err(e) => Err(e),
119
168
  }
120
169
  }
121
170
 
@@ -131,38 +180,125 @@ pub fn delete_doc_body(handoff_dir: &Path, slug: &str) -> Result<bool> {
131
180
  Ok(true)
132
181
  }
133
182
 
134
- /// Read one document's metadata by exact slug. Returns `Ok(None)` when the
135
- /// file does not exist or fails to parse (lenient read, same policy as
136
- /// memory).
183
+ /// Migrates an old-format document (`_doc.<slug>.json` + `_doc.<slug>.md`
184
+ /// body-only file) to the new single-file frontmatter format, in place
185
+ /// (t123.3): reads the JSON metadata, reads the existing (frontmatter-less)
186
+ /// body, writes a new `_doc.<slug>.md` with the metadata folded into a
187
+ /// YAML frontmatter block prepended to that body, then deletes the `.json`
188
+ /// sidecar. Logs the migration to stderr. Returns the migrated
189
+ /// [`DocMetadata`] (with `sections` still empty — the caller computes those
190
+ /// fresh, same as any other read).
191
+ fn migrate_legacy_doc(handoff_dir: &Path, slug: &str) -> Result<DocMetadata> {
192
+ let json_path = doc_meta_path(handoff_dir, slug);
193
+ let json_content = std::fs::read_to_string(&json_path).with_context(|| {
194
+ format!(
195
+ "Failed to read legacy document metadata: {}",
196
+ json_path.display()
197
+ )
198
+ })?;
199
+ let mut doc: DocMetadata = serde_json::from_str(&json_content).with_context(|| {
200
+ format!(
201
+ "Failed to parse legacy document metadata: {}",
202
+ json_path.display()
203
+ )
204
+ })?;
205
+ // sections/version are storage-layer bookkeeping the frontmatter format
206
+ // no longer persists (t123.2) — clear here so the migrated file starts
207
+ // clean, matching what any other `write_doc` call would produce.
208
+ doc.sections = Vec::new();
209
+
210
+ let body_path = doc_body_path(handoff_dir, slug);
211
+ let body = std::fs::read_to_string(&body_path).with_context(|| {
212
+ format!(
213
+ "Failed to read legacy document body: {}",
214
+ body_path.display()
215
+ )
216
+ })?;
217
+
218
+ frontmatter::write_frontmatter_doc(&body_path, &doc, &body)?;
219
+ std::fs::remove_file(&json_path).with_context(|| {
220
+ format!(
221
+ "Failed to delete legacy document metadata after migration: {}",
222
+ json_path.display()
223
+ )
224
+ })?;
225
+
226
+ eprintln!(
227
+ "handoff-mcp: migrated document '{slug}' (id={}) from JSON+MD sidecar format to \
228
+ frontmatter MD",
229
+ doc.id
230
+ );
231
+
232
+ Ok(doc)
233
+ }
234
+
235
+ /// Read one document by exact slug: parses YAML frontmatter from
236
+ /// `_doc.<slug>.md`, transparently migrating an old-format
237
+ /// `_doc.<slug>.json` + `_doc.<slug>.md` pair in place first if that's what
238
+ /// is on disk (t123.3). Always recomputes `sections[]` fresh from the body
239
+ /// (t123.2) and `content_hash` from the body's current bytes (drift
240
+ /// detection stays correct after a manual edit) before returning.
241
+ ///
242
+ /// Returns `Ok(None)` when:
243
+ /// - neither `_doc.<slug>.md` nor `_doc.<slug>.json` exists, or
244
+ /// - `_doc.<slug>.md` exists with no frontmatter and no `.json` sidecar
245
+ /// (a body-only leftover from a partially-completed migration, or a
246
+ /// plain `.md` file dropped in by hand — logged as a warning, not
247
+ /// silently ignored, since it's ambiguous whether this was ever meant to
248
+ /// be a handoff document).
137
249
  ///
138
- /// Caution: `slug` is a required (non-`#[serde(default)]`) field on
139
- /// [`DocMetadata`], so this is **not** a backward-compat path for real v4
140
- /// documents (which had no `slug` field and used per-fragment physical
141
- /// files). A genuine v4 `_doc.*.json` fails to deserialize under the v5
142
- /// schema and is silently treated as `Ok(None)` here — i.e. it would
143
- /// disappear from `doc_get`/`doc_list`/`doc_query` with no warning, not be
144
- /// gracefully migrated. This repo's own migration plan
145
- /// (wiki/130-document-management.md, "移行" section) deliberately scoped v4
146
- /// migration out because no real v4 documents exist outside dev test data;
147
- /// if that assumption ever changes, a real migration path is needed before
148
- /// pointing this binary at a directory with genuine v4 documents.
250
+ /// Returns `Err` when a `.md` file has a `---` fence but the enclosed YAML
251
+ /// fails to parse (corrupt frontmatter a genuine error, not a migration
252
+ /// signal), or when a legacy JSON sidecar exists but fails to parse/migrate.
149
253
  pub fn read_doc(handoff_dir: &Path, slug: &str) -> Result<Option<DocMetadata>> {
150
- let path = doc_meta_path(handoff_dir, slug);
151
- match std::fs::read_to_string(&path) {
152
- Ok(content) => Ok(serde_json::from_str::<DocMetadata>(&content).ok()),
153
- Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
154
- Err(e) => Err(e).with_context(|| format!("Failed to read document: {}", path.display())),
254
+ let body_path = doc_body_path(handoff_dir, slug);
255
+ let json_path = doc_meta_path(handoff_dir, slug);
256
+
257
+ let parsed = frontmatter::read_frontmatter_doc(&body_path, slug)?;
258
+ let (mut doc, body) = match parsed {
259
+ Some((doc, body)) => (doc, body),
260
+ None => {
261
+ if !body_path.exists() {
262
+ return Ok(None);
263
+ }
264
+ if !json_path.exists() {
265
+ eprintln!(
266
+ "handoff-mcp: document body file '{}' has no YAML frontmatter and no \
267
+ legacy JSON sidecar to migrate from — skipping",
268
+ body_path.display()
269
+ );
270
+ return Ok(None);
271
+ }
272
+ let migrated = migrate_legacy_doc(handoff_dir, slug)?;
273
+ let body = read_doc_body(handoff_dir, slug)?.unwrap_or_default();
274
+ (migrated, body)
275
+ }
276
+ };
277
+
278
+ recompute_sections_and_hash(&mut doc, &body);
279
+ Ok(Some(doc))
280
+ }
281
+
282
+ /// Recomputes `doc.sections` and `doc.content_hash` from `body` (t123.2):
283
+ /// sections are never trusted from frontmatter (always empty there), and
284
+ /// content_hash is recomputed rather than trusted so drift detection
285
+ /// (`doc_reassemble`, verification staleness) reflects the body's actual
286
+ /// current bytes even after a manual out-of-band edit.
287
+ fn recompute_sections_and_hash(doc: &mut DocMetadata, body: &str) {
288
+ if let Ok(split_doc) = split::split(body, doc.split_level) {
289
+ doc.sections = split::compute_sections(&split_doc);
155
290
  }
291
+ doc.content_hash = lexsim::content_hash(body);
156
292
  }
157
293
 
158
- /// Read every document in `docs/`, skipping any `_doc.*.json` file that
159
- /// fails to parse. `.md` body files and the `injected/` subdirectory are
160
- /// ignored. Returns an empty vec when `docs/` does not exist (uninitialized
161
- /// / feature-untouched projects).
162
- ///
163
- /// See the caution on [`read_doc`]: a real v4 document (no `slug` field)
164
- /// fails to parse under the v5 schema and is skipped here silently, same as
165
- /// any other corrupt file — it is not migrated or surfaced as a warning.
294
+ /// Read every document in `docs/`: every `_doc.*.md` file (parsed via
295
+ /// [`read_doc`], which transparently migrates any paired legacy `.json`
296
+ /// sidecar first t123.3). The `injected/` subdirectory is ignored. A
297
+ /// `.md` file that fails to parse under [`read_doc`] (corrupt frontmatter,
298
+ /// or a body-only leftover with no `.json` to migrate from) is skipped
299
+ /// silently/with a warning respectively, same policy as [`read_doc`] itself
300
+ /// applies per-file. Returns an empty vec when `docs/` does not exist
301
+ /// (uninitialized / feature-untouched projects).
166
302
  pub fn read_all_docs(handoff_dir: &Path) -> Result<Vec<DocMetadata>> {
167
303
  let dir = docs_dir(handoff_dir);
168
304
  if !dir.exists() {
@@ -182,17 +318,23 @@ pub fn read_all_docs(handoff_dir: &Path) -> Result<Vec<DocMetadata>> {
182
318
  continue;
183
319
  }
184
320
  let name = entry.file_name().to_string_lossy().to_string();
185
- if !name.starts_with("_doc.") || !name.ends_with(".json") {
321
+ if !name.starts_with("_doc.") || !name.ends_with(".md") {
186
322
  continue;
187
323
  }
188
- let content = match std::fs::read_to_string(&path) {
189
- Ok(c) => c,
190
- Err(_) => continue,
324
+ let Some(slug) = name
325
+ .strip_prefix("_doc.")
326
+ .and_then(|s| s.strip_suffix(".md"))
327
+ else {
328
+ continue;
191
329
  };
192
- if let Ok(doc) = serde_json::from_str::<DocMetadata>(&content) {
193
- docs.push(doc);
330
+ match read_doc(handoff_dir, slug) {
331
+ Ok(Some(doc)) => docs.push(doc),
332
+ Ok(None) => {}
333
+ // Corrupt frontmatter / failed migration: skip silently
334
+ // (lenient read, mirrors memory) rather than failing the whole
335
+ // listing over one bad file.
336
+ Err(_) => {}
194
337
  }
195
- // Unparseable file: skip silently (lenient read, mirrors memory).
196
338
  }
197
339
  Ok(docs)
198
340
  }
@@ -235,17 +377,35 @@ pub fn batch_resolve_docs(
235
377
  .collect())
236
378
  }
237
379
 
238
- /// Delete a document's metadata file by exact slug. Returns `Ok(false)` when
239
- /// the file does not exist. Does not touch the document's body file —
240
- /// callers that want a full delete should also call [`delete_doc_body`].
380
+ /// Delete a document's metadata by exact slug. In the single-file
381
+ /// frontmatter format, metadata and body live in the same
382
+ /// `_doc.<slug>.md` file, so this is equivalent to [`delete_doc_body`]
383
+ /// kept as a separate function (rather than folding callers onto one) so
384
+ /// existing call sites that call both (`doc_delete`'s "delete body, then
385
+ /// delete metadata" order) keep working unchanged: the second call is a
386
+ /// no-op `Ok(false)` once the first has removed the file. Also removes a
387
+ /// leftover legacy `_doc.<slug>.json` sidecar, if one still exists
388
+ /// (e.g. a document deleted mid-migration). Returns `Ok(false)` when
389
+ /// neither file existed.
241
390
  pub fn delete_doc(handoff_dir: &Path, slug: &str) -> Result<bool> {
242
- let path = doc_meta_path(handoff_dir, slug);
243
- if !path.exists() {
244
- return Ok(false);
245
- }
246
- std::fs::remove_file(&path)
247
- .with_context(|| format!("Failed to delete document: {}", path.display()))?;
248
- Ok(true)
391
+ let md_path = doc_body_path(handoff_dir, slug);
392
+ let json_path = doc_meta_path(handoff_dir, slug);
393
+ let mut deleted = false;
394
+ if md_path.exists() {
395
+ std::fs::remove_file(&md_path)
396
+ .with_context(|| format!("Failed to delete document: {}", md_path.display()))?;
397
+ deleted = true;
398
+ }
399
+ if json_path.exists() {
400
+ std::fs::remove_file(&json_path).with_context(|| {
401
+ format!(
402
+ "Failed to delete legacy document metadata: {}",
403
+ json_path.display()
404
+ )
405
+ })?;
406
+ deleted = true;
407
+ }
408
+ Ok(deleted)
249
409
  }
250
410
 
251
411
  #[cfg(test)]
@@ -306,26 +466,13 @@ mod tests {
306
466
  doc.task_ids = vec!["T-79".to_string()];
307
467
  doc.has_bom = true;
308
468
  doc.line_ending = "crlf".to_string();
309
- doc.source.frontmatter = Some("title: Foo\n".to_string());
310
- doc.sections = vec![
311
- SectionIndex {
312
- seq: 0,
313
- heading: String::new(),
314
- level: 0,
315
- byte_offset: 0,
316
- byte_length: 10,
317
- content_hash: "hash0".to_string(),
318
- },
319
- SectionIndex {
320
- seq: 1,
321
- heading: "アーキテクチャ".to_string(),
322
- level: 1,
323
- byte_offset: 10,
324
- byte_length: 20,
325
- content_hash: "hash1".to_string(),
326
- },
327
- ];
328
469
 
470
+ // v6 (frontmatter migration): sections are never persisted — they
471
+ // are recomputed on every read from the body written via
472
+ // `write_doc_body`, so a real body with a heading is needed here to
473
+ // exercise that recomputation instead of hand-setting `sections`.
474
+ let body = "Preamble.\r\n\r\n## アーキテクチャ\r\nSection body.\r\n";
475
+ write_doc_body(&h, "session-loop-verification", body).unwrap();
329
476
  write_doc(&h, &doc).unwrap();
330
477
 
331
478
  let back = read_doc(&h, "session-loop-verification")
@@ -338,21 +485,24 @@ mod tests {
338
485
  assert_eq!(back.tags, doc.tags);
339
486
  assert_eq!(back.scope_paths, doc.scope_paths);
340
487
  assert_eq!(back.task_ids, doc.task_ids);
341
- assert_eq!(back.sections.len(), 2);
488
+ assert_eq!(
489
+ back.sections.len(),
490
+ 2,
491
+ "sections recomputed on read: {:?}",
492
+ back.sections
493
+ );
342
494
  assert_eq!(back.sections[1].heading, "アーキテクチャ");
343
- assert_eq!(back.sections[1].byte_offset, 10);
344
495
  assert_eq!(back.auto_inject, "auto");
345
496
  assert!(back.parent_id.is_none());
346
497
  assert!(back.has_bom, "has_bom must round-trip through write/read");
347
498
  assert_eq!(back.line_ending, "crlf");
348
- assert_eq!(
349
- back.source.frontmatter.as_deref(),
350
- Some("title: Foo\n"),
351
- "source.frontmatter must round-trip through write/read"
352
- );
353
499
 
354
- // File is named by slug, not by id.
500
+ // Exactly one file on disk for this document (single-file
501
+ // frontmatter format — no JSON sidecar).
355
502
  assert!(docs_dir(&h)
503
+ .join("_doc.session-loop-verification.md")
504
+ .exists());
505
+ assert!(!docs_dir(&h)
356
506
  .join("_doc.session-loop-verification.json")
357
507
  .exists());
358
508
  }
@@ -377,27 +527,24 @@ mod tests {
377
527
  let tmp = TempDir::new().unwrap();
378
528
  let h = handoff(&tmp);
379
529
  write_doc(&h, &sample_doc("doc-good", "doc-good")).unwrap();
530
+ // A lone legacy `.json` sidecar with no paired `.md` body is not a
531
+ // migratable document (read_all_docs only iterates `.md` files) —
532
+ // it must simply be ignored, not crash the scan.
380
533
  std::fs::write(docs_dir(&h).join("_doc.doc-bad.json"), b"{not json").unwrap();
381
- // A body file sitting alongside must never be mistaken for metadata.
382
- write_doc_body(&h, "doc-good", "body text").unwrap();
383
534
 
384
535
  let all = read_all_docs(&h).unwrap();
385
- assert_eq!(all.len(), 1, "corrupt doc skipped, body file ignored");
536
+ assert_eq!(all.len(), 1, "lone json-only file ignored");
386
537
  assert_eq!(all[0].id, "doc-good");
387
538
  }
388
539
 
389
- /// Caution (found in review): a genuine v4 `_doc.*.json` file (no
390
- /// `slug` field `slug` is new, required, in v5; `fragments` entries
391
- /// with no `byte_offset`/`byte_length`/`content_hash`) fails to
392
- /// deserialize as `DocMetadata` and is treated exactly like a corrupt
393
- /// file: skipped silently, with no warning. This is a deliberate,
394
- /// documented trade-off (wiki/130-document-management.md's migration
395
- /// section states no real v4 documents exist outside dev test data), not
396
- /// a graceful migration path — asserting it here so a regression toward
397
- /// "v4 docs silently vanish" is caught, and so the behavior stays
398
- /// documented in code rather than only in review notes.
540
+ /// A lone legacy `_doc.*.json` file with no paired `_doc.*.md` body
541
+ /// cannot be migrated (t123.3's migration reads both halves) it is
542
+ /// simply invisible to `read_all_docs`/`read_doc`, same as any other
543
+ /// non-`.md` file in `docs/`. This is distinct from the "real" migration
544
+ /// path exercised by [`read_doc_migrates_legacy_json_md_pair_in_place`],
545
+ /// which requires both files to be present.
399
546
  #[test]
400
- fn read_all_docs_silently_skips_real_v4_file_missing_slug() {
547
+ fn read_all_docs_ignores_lone_legacy_json_with_no_paired_md() {
401
548
  let tmp = TempDir::new().unwrap();
402
549
  let h = handoff(&tmp);
403
550
  write_doc(&h, &sample_doc("doc-good", "doc-good")).unwrap();
@@ -421,17 +568,108 @@ mod tests {
421
568
 
422
569
  assert!(
423
570
  read_doc(&h, "old-spec").unwrap().is_none(),
424
- "a real v4 doc (no slug field) must not be readable under the v5 schema"
571
+ "a lone json sidecar with no paired .md body is not readable/migratable"
425
572
  );
426
573
  let all = read_all_docs(&h).unwrap();
427
574
  assert_eq!(
428
575
  all.len(),
429
576
  1,
430
- "the v4 doc must be silently skipped, not surfaced as an error or a warning"
577
+ "the lone json file must be silently skipped, not surfaced as an error or a warning"
431
578
  );
432
579
  assert_eq!(all[0].id, "doc-good");
433
580
  }
434
581
 
582
+ /// t123.3: a genuine old-format `_doc.<slug>.json` + `_doc.<slug>.md`
583
+ /// pair is transparently migrated in place on first `read_doc` access —
584
+ /// the JSON metadata is folded into a YAML frontmatter block prepended
585
+ /// to the existing body, and the `.json` sidecar is deleted.
586
+ #[test]
587
+ fn read_doc_migrates_legacy_json_md_pair_in_place() {
588
+ let tmp = TempDir::new().unwrap();
589
+ let h = handoff(&tmp);
590
+ ensure_docs_dir(&h).unwrap();
591
+ let slug = "legacy-doc";
592
+ let legacy_json = serde_json::json!({
593
+ "version": 2,
594
+ "id": "doc-legacy-1",
595
+ "slug": slug,
596
+ "title": "Legacy Doc",
597
+ "doc_type": "spec",
598
+ "tags": ["old-format"],
599
+ "created_at": "2026-01-01T00:00:00Z",
600
+ "updated_at": "2026-01-02T00:00:00Z",
601
+ "content_hash": "stale-hash-will-be-recomputed",
602
+ });
603
+ std::fs::write(
604
+ docs_dir(&h).join(format!("_doc.{slug}.json")),
605
+ serde_json::to_vec(&legacy_json).unwrap(),
606
+ )
607
+ .unwrap();
608
+ let body = "# Legacy Doc\n\n## Old Section\n\nBody text.\n";
609
+ std::fs::write(docs_dir(&h).join(format!("_doc.{slug}.md")), body).unwrap();
610
+
611
+ let migrated = read_doc(&h, slug).unwrap().expect("must migrate and read");
612
+ assert_eq!(migrated.id, "doc-legacy-1");
613
+ assert_eq!(migrated.title, "Legacy Doc");
614
+ assert_eq!(migrated.tags, vec!["old-format".to_string()]);
615
+ assert_eq!(
616
+ migrated.sections.len(),
617
+ 3,
618
+ "sections recomputed fresh from body post-migration (seq0 preamble + H1 + H2): {:?}",
619
+ migrated.sections
620
+ );
621
+ assert_eq!(migrated.content_hash, lexsim::content_hash(body));
622
+
623
+ // The .json sidecar must be gone; the .md file must now carry
624
+ // frontmatter (starts with "---\n").
625
+ assert!(!docs_dir(&h).join(format!("_doc.{slug}.json")).exists());
626
+ let new_content =
627
+ std::fs::read_to_string(docs_dir(&h).join(format!("_doc.{slug}.md"))).unwrap();
628
+ assert!(new_content.starts_with("---\n"));
629
+
630
+ // Re-reading must be stable (idempotent) and not re-migrate.
631
+ let reread = read_doc(&h, slug).unwrap().expect("must still read");
632
+ assert_eq!(reread.id, migrated.id);
633
+ assert_eq!(reread.sections.len(), 3);
634
+ }
635
+
636
+ /// The migration path is also exercised transparently through
637
+ /// `read_all_docs`, so a directory with a mix of already-migrated and
638
+ /// legacy documents surfaces every document once, in the new format.
639
+ #[test]
640
+ fn read_all_docs_migrates_legacy_pairs_transparently() {
641
+ let tmp = TempDir::new().unwrap();
642
+ let h = handoff(&tmp);
643
+ write_doc(&h, &sample_doc("doc-new", "already-new")).unwrap();
644
+ write_doc_body(&h, "already-new", "# New\n\nBody.\n").unwrap();
645
+
646
+ let legacy_json = serde_json::json!({
647
+ "version": 2,
648
+ "id": "doc-legacy-2",
649
+ "slug": "legacy-two",
650
+ "title": "Legacy Two",
651
+ "doc_type": "note",
652
+ "created_at": "2026-01-01T00:00:00Z",
653
+ "updated_at": "2026-01-01T00:00:00Z",
654
+ });
655
+ std::fs::write(
656
+ docs_dir(&h).join("_doc.legacy-two.json"),
657
+ serde_json::to_vec(&legacy_json).unwrap(),
658
+ )
659
+ .unwrap();
660
+ std::fs::write(
661
+ docs_dir(&h).join("_doc.legacy-two.md"),
662
+ "# Legacy Two\n\nBody.\n",
663
+ )
664
+ .unwrap();
665
+
666
+ let all = read_all_docs(&h).unwrap();
667
+ assert_eq!(all.len(), 2);
668
+ assert!(all.iter().any(|d| d.id == "doc-new"));
669
+ assert!(all.iter().any(|d| d.id == "doc-legacy-2"));
670
+ assert!(!docs_dir(&h).join("_doc.legacy-two.json").exists());
671
+ }
672
+
435
673
  #[test]
436
674
  fn find_doc_by_id_scans_all_docs() {
437
675
  let tmp = TempDir::new().unwrap();