handoff-mcp-server 0.26.0 → 0.28.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.
Files changed (72) hide show
  1. package/README.md +31 -4
  2. package/bin/handoff-mcp.js +56 -13
  3. package/bin/resolve-binary.js +122 -0
  4. package/package.json +14 -9
  5. package/Cargo.lock +0 -686
  6. package/Cargo.toml +0 -30
  7. package/scripts/cargo-env.sh +0 -29
  8. package/scripts/handoff-memory-hook.py +0 -208
  9. package/scripts/install-local.sh +0 -109
  10. package/scripts/postinstall.js +0 -50
  11. package/scripts/sync-plugin-skills.sh +0 -35
  12. package/scripts/sync-plugin-version.sh +0 -85
  13. package/scripts/sync-workflow-inline.sh +0 -138
  14. package/src/cli.rs +0 -551
  15. package/src/context/injection.rs +0 -276
  16. package/src/context/mod.rs +0 -129
  17. package/src/lib.rs +0 -5
  18. package/src/main.rs +0 -157
  19. package/src/mcp/handlers/assignees.rs +0 -254
  20. package/src/mcp/handlers/auto_schedule.rs +0 -489
  21. package/src/mcp/handlers/bulk_update.rs +0 -155
  22. package/src/mcp/handlers/calendar.rs +0 -196
  23. package/src/mcp/handlers/capacity.rs +0 -318
  24. package/src/mcp/handlers/check_criterion.rs +0 -70
  25. package/src/mcp/handlers/config.rs +0 -402
  26. package/src/mcp/handlers/config_crud.rs +0 -183
  27. package/src/mcp/handlers/dashboard.rs +0 -214
  28. package/src/mcp/handlers/docs.rs +0 -2288
  29. package/src/mcp/handlers/docs_query.rs +0 -1335
  30. package/src/mcp/handlers/fork_session.rs +0 -91
  31. package/src/mcp/handlers/get_session.rs +0 -48
  32. package/src/mcp/handlers/get_task.rs +0 -53
  33. package/src/mcp/handlers/import_context.rs +0 -470
  34. package/src/mcp/handlers/init.rs +0 -28
  35. package/src/mcp/handlers/list_sessions.rs +0 -187
  36. package/src/mcp/handlers/list_tasks.rs +0 -308
  37. package/src/mcp/handlers/load_context.rs +0 -361
  38. package/src/mcp/handlers/log_time.rs +0 -67
  39. package/src/mcp/handlers/memory.rs +0 -961
  40. package/src/mcp/handlers/merge_sessions.rs +0 -103
  41. package/src/mcp/handlers/metrics.rs +0 -196
  42. package/src/mcp/handlers/milestones.rs +0 -102
  43. package/src/mcp/handlers/mod.rs +0 -140
  44. package/src/mcp/handlers/refer.rs +0 -307
  45. package/src/mcp/handlers/referrals.rs +0 -74
  46. package/src/mcp/handlers/save_context.rs +0 -354
  47. package/src/mcp/handlers/task_checklist.rs +0 -507
  48. package/src/mcp/handlers/timer.rs +0 -529
  49. package/src/mcp/handlers/update_session.rs +0 -197
  50. package/src/mcp/handlers/update_task.rs +0 -452
  51. package/src/mcp/mod.rs +0 -6
  52. package/src/mcp/protocol.rs +0 -41
  53. package/src/mcp/resources.rs +0 -57
  54. package/src/mcp/router.rs +0 -154
  55. package/src/mcp/tools.rs +0 -1522
  56. package/src/mcp/types.rs +0 -108
  57. package/src/setup.rs +0 -1212
  58. package/src/storage/config.rs +0 -578
  59. package/src/storage/docs/frontmatter.rs +0 -509
  60. package/src/storage/docs/mod.rs +0 -835
  61. package/src/storage/docs/model.rs +0 -708
  62. package/src/storage/docs/reassemble.rs +0 -167
  63. package/src/storage/docs/split.rs +0 -377
  64. package/src/storage/git.rs +0 -47
  65. package/src/storage/memory/injected.rs +0 -340
  66. package/src/storage/memory/mod.rs +0 -236
  67. package/src/storage/memory/model.rs +0 -127
  68. package/src/storage/mod.rs +0 -96
  69. package/src/storage/referrals.rs +0 -248
  70. package/src/storage/sessions.rs +0 -859
  71. package/src/storage/tasks.rs +0 -957
  72. package/templates/claude-md-section.md +0 -12
@@ -1,708 +0,0 @@
1
- //! Data model for documents (wiki/130-document-management.md v5 rearchitecture).
2
- //! One `DocMetadata` per `_doc.<slug>.json`, paired with its full body at
3
- //! `_doc.<slug>.md` (see `super` module docs). Sections are computed
4
- //! in-memory (byte offsets into the body) rather than split into physical
5
- //! fragment files.
6
-
7
- use std::collections::HashMap;
8
-
9
- use serde::{Deserialize, Serialize};
10
- use serde_json::Value;
11
-
12
- /// Current document schema version. Bump when `DocMetadata` changes shape in
13
- /// a way that needs migration handling on read.
14
- pub const DOC_SCHEMA_VERSION: u32 = 2;
15
-
16
- /// Valid `doc_type` values (spec §4.1, extensible via `config.toml`
17
- /// `settings.doc_types.types` — this list is the storage-layer default set,
18
- /// not an enforced enum, so a project-configured custom type still
19
- /// round-trips through `serde` even if it is not in this list).
20
- pub const VALID_DOC_TYPES: &[&str] = &["spec", "design", "adr", "guide", "note"];
21
-
22
- /// Valid `auto_inject` values (spec §7.2.1).
23
- pub const VALID_AUTO_INJECT: &[&str] = &["auto", "full", "outline", "none"];
24
-
25
- /// Valid `related[].rel` relationship kinds (spec §4.3).
26
- pub const VALID_RELATIONS: &[&str] = &[
27
- "supersedes",
28
- "references",
29
- "implements",
30
- "extends",
31
- "conflicts",
32
- ];
33
-
34
- /// A document: the family-tree node and section manifest persisted at
35
- /// `_doc.<slug>.json` (spec §4.1, v5). The full Markdown body lives
36
- /// unsplit at `_doc.<slug>.md`.
37
- #[derive(Debug, Clone, Serialize, Deserialize)]
38
- pub struct DocMetadata {
39
- /// Schema version (= [`DOC_SCHEMA_VERSION`]).
40
- pub version: u32,
41
- /// Stable id: `doc-YYYYMMDD-HHMMSS-NNNNNN`. Kept internally for
42
- /// family-tree/task-link references; file naming uses `slug` instead.
43
- pub id: String,
44
- /// Human-readable file-naming slug (`[a-z0-9-]`, max 60 chars). Required
45
- /// on creation; used to build `_doc.<slug>.json` / `_doc.<slug>.md`.
46
- pub slug: String,
47
- pub title: String,
48
- /// One of [`VALID_DOC_TYPES`] by convention (spec: `spec | design | adr |
49
- /// guide | note`), not enforced here — validation belongs to the
50
- /// `doc_save` handler (t96) so a project-configured custom type can still
51
- /// be persisted.
52
- pub doc_type: String,
53
- #[serde(default)]
54
- pub tags: Vec<String>,
55
- #[serde(default)]
56
- pub scope_paths: Vec<String>,
57
-
58
- /// Family tree: `None` = root document.
59
- #[serde(default)]
60
- pub parent_id: Option<String>,
61
- /// Ordered child document ids.
62
- #[serde(default)]
63
- pub children: Vec<String>,
64
- /// Sibling/relative relationships (semantic, not structural).
65
- #[serde(default)]
66
- pub related: Vec<DocRelation>,
67
-
68
- /// Auto-injection control (spec §7.2.1): `"auto"` | `"full"` |
69
- /// `"outline"` | `"none"`. Defaults to `"auto"`.
70
- #[serde(default = "default_auto_inject")]
71
- pub auto_inject: String,
72
-
73
- /// Task ids this document is linked to (bidirectional — the task side
74
- /// mirrors this via `TaskLink { link_type: "doc" }`, synced by
75
- /// `crate::storage::tasks::sync_doc_task_links`).
76
- #[serde(default)]
77
- pub task_ids: Vec<String>,
78
-
79
- /// Source tracking for reversibility (spec §4.1 / §8).
80
- #[serde(default)]
81
- pub source: DocSource,
82
-
83
- /// `true` when the authored body started with a UTF-8 BOM (spec §5.1
84
- /// scope rule 6). Computed by [`super::split::split`] and persisted so
85
- /// callers can restore it losslessly. Defaults to `false` for
86
- /// documents written before this field existed.
87
- #[serde(default)]
88
- pub has_bom: bool,
89
- /// `"lf"` or `"crlf"` (spec §5.1 scope rule 6), detected by
90
- /// [`super::split::split`]. Defaults to `"lf"` for backward compat with
91
- /// documents written before this field existed.
92
- #[serde(default = "default_line_ending")]
93
- pub line_ending: String,
94
-
95
- /// ATX heading level (1-6) at which this document is split into
96
- /// sections (frontmatter migration, t123.1/t123.2). Persisted per-doc so
97
- /// a manually-edited `.md` file recomputes the same section boundaries
98
- /// on every read. Defaults to
99
- /// [`super::split::DEFAULT_SPLIT_LEVEL`] for documents saved before this
100
- /// field existed.
101
- #[serde(default = "default_split_level")]
102
- pub split_level: u8,
103
-
104
- /// Section manifest, in `seq` order (v5: replaces the old `fragments`
105
- /// physical-file manifest — `sections` are in-memory byte-offset
106
- /// indexes into `_doc.<slug>.md`, not separate files). Old on-disk
107
- /// documents that still have a `fragments` key deserialize via the
108
- /// `alias` below for backward compat.
109
- #[serde(default, alias = "fragments")]
110
- pub sections: Vec<SectionIndex>,
111
-
112
- pub created_at: String,
113
- pub updated_at: String,
114
-
115
- /// FNV-1a hash of the full document body. Used to detect drift after
116
- /// direct `.md` edits (spec §8.2).
117
- #[serde(default)]
118
- pub content_hash: String,
119
-
120
- /// Verification matrix (wiki/140-verification-matrix.md §3.1). `None` =
121
- /// matrix not yet generated. Managed exclusively through the
122
- /// `handoff_doc_verify` tool — `doc_save` never touches this field, so
123
- /// existing on-disk documents without it deserialize to `None` via
124
- /// `#[serde(default)]`.
125
- #[serde(default, skip_serializing_if = "Option::is_none")]
126
- pub verification: Option<Verification>,
127
-
128
- /// Unknown/unrecognized frontmatter keys, preserved for round-trip
129
- /// fidelity (frontmatter migration spec: "extra fields"). Never written
130
- /// by handoff-mcp itself; only ever populated by parsing a document
131
- /// whose frontmatter has keys outside the known schema (e.g. hand-edited
132
- /// or authored by another tool). Not present in the JSON-era on-disk
133
- /// format, so `#[serde(default)]` keeps old fixtures deserializing.
134
- #[serde(default, skip_serializing_if = "HashMap::is_empty")]
135
- pub extra: HashMap<String, Value>,
136
- }
137
-
138
- fn default_auto_inject() -> String {
139
- "auto".to_string()
140
- }
141
-
142
- fn default_line_ending() -> String {
143
- "lf".to_string()
144
- }
145
-
146
- fn default_split_level() -> u8 {
147
- super::split::DEFAULT_SPLIT_LEVEL
148
- }
149
-
150
- /// Maximum allowed length of a `slug` (spec §3.1 v5 proposal).
151
- pub const MAX_SLUG_LEN: usize = 60;
152
-
153
- impl DocMetadata {
154
- /// Build a fresh document with empty family-tree/section fields and
155
- /// `auto_inject: "auto"`. `now` is an RFC3339 timestamp supplied by the
156
- /// caller (keeps this module clock-free and testable, mirroring
157
- /// `MemoryEntry::new`).
158
- pub fn new(id: String, slug: String, title: String, doc_type: String, now: String) -> Self {
159
- DocMetadata {
160
- version: DOC_SCHEMA_VERSION,
161
- id,
162
- slug,
163
- title,
164
- doc_type,
165
- tags: Vec::new(),
166
- scope_paths: Vec::new(),
167
- parent_id: None,
168
- children: Vec::new(),
169
- related: Vec::new(),
170
- auto_inject: default_auto_inject(),
171
- task_ids: Vec::new(),
172
- source: DocSource::default(),
173
- has_bom: false,
174
- line_ending: default_line_ending(),
175
- split_level: default_split_level(),
176
- sections: Vec::new(),
177
- created_at: now.clone(),
178
- updated_at: now,
179
- content_hash: String::new(),
180
- verification: None,
181
- extra: HashMap::new(),
182
- }
183
- }
184
- }
185
-
186
- /// A sibling/relative relationship to another document (spec §4.3).
187
- #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
188
- pub struct DocRelation {
189
- pub id: String,
190
- /// One of [`VALID_RELATIONS`].
191
- pub rel: String,
192
- }
193
-
194
- /// Source tracking for a document, used to support the reversibility
195
- /// guarantee (spec §4.1 / §8).
196
- #[derive(Debug, Clone, Serialize, Deserialize)]
197
- pub struct DocSource {
198
- /// `"authored"` | `"imported"` | `"split"`. Empty string when unset
199
- /// (fresh documents created directly via `doc_save` default to
200
- /// `"authored"` at the handler level).
201
- #[serde(default)]
202
- pub origin: String,
203
- /// Original file path when imported from `wiki/` or `tmp/`.
204
- #[serde(default, skip_serializing_if = "Option::is_none")]
205
- pub original_path: Option<String>,
206
- /// Canonical-form hash used to detect drift on reassembly. In the
207
- /// frontmatter format (t123.1+), this is the value persisted at the
208
- /// *last save* (`source.canonical_hash` in frontmatter, untouched by
209
- /// `read_doc`'s on-read `content_hash` recompute — t123.2), so comparing
210
- /// it against the freshly-recomputed top-level `content_hash` is the
211
- /// drift signal `doc_reassemble` uses.
212
- #[serde(default, skip_serializing_if = "Option::is_none")]
213
- pub canonical_hash: Option<String>,
214
- /// Legacy field (pre-frontmatter-migration, t96): raw YAML frontmatter
215
- /// block stashed by the old 2-file format when a caller's authored
216
- /// `body` started with its own `---`-fenced block, so it could be
217
- /// restored losslessly on `doc_get`/`doc_reassemble`. **Dead in the
218
- /// frontmatter format** — kept only so a legacy `_doc.<slug>.json`
219
- /// sidecar still deserializes during migration
220
- /// (`storage::docs::migrate_legacy_doc`); a document's own frontmatter
221
- /// is now handoff-owned metadata, so a caller's leading frontmatter
222
- /// block in `body` is absorbed rather than round-tripped (see
223
- /// `handle_doc_get`'s `read_full_body`).
224
- #[serde(default, skip_serializing_if = "Option::is_none")]
225
- pub frontmatter: Option<String>,
226
- /// Legacy field, paired with [`Self::frontmatter`] — see its doc comment.
227
- #[serde(default = "default_frontmatter_trailing_eol")]
228
- pub frontmatter_trailing_eol: bool,
229
- }
230
-
231
- fn default_frontmatter_trailing_eol() -> bool {
232
- true
233
- }
234
-
235
- impl Default for DocSource {
236
- /// Matches the per-field `#[serde(default = ...)]` values above, so a
237
- /// document missing the whole `source` key (oldest on-disk schema) and
238
- /// one missing only `frontmatter_trailing_eol` (this field's own
239
- /// addition) deserialize identically — both keep the pre-fix reassembly
240
- /// behavior of always re-adding the eol after the frontmatter fence.
241
- fn default() -> Self {
242
- DocSource {
243
- origin: String::new(),
244
- original_path: None,
245
- canonical_hash: None,
246
- frontmatter: None,
247
- frontmatter_trailing_eol: default_frontmatter_trailing_eol(),
248
- }
249
- }
250
- }
251
-
252
- /// One entry in a document's section manifest (`DocMetadata::sections`),
253
- /// v5 (spec §3.1): an in-memory byte-offset index into `_doc.<slug>.md`,
254
- /// replacing the v4 `FragmentSummary` (which paired with physical
255
- /// `_frag.*` files) and the old `FragmentMetadata` sidecar entirely.
256
- #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
257
- pub struct SectionIndex {
258
- /// 0-based position in the document. seq 0 is always the preamble.
259
- pub seq: usize,
260
- /// Heading text (without `#` markers), empty string for the seq-0
261
- /// preamble when the document has no heading before it.
262
- pub heading: String,
263
- /// ATX heading level (1-6), or 0 for the seq-0 preamble.
264
- pub level: u8,
265
- /// Byte offset of this section within the document body (the file at
266
- /// `_doc.<slug>.md`, after BOM/frontmatter stripping).
267
- pub byte_offset: usize,
268
- /// Byte length of this section's body.
269
- pub byte_length: usize,
270
- /// FNV-1a hash of this section's body slice.
271
- pub content_hash: String,
272
- }
273
-
274
- /// Verification matrix for a document (wiki/140-verification-matrix.md §3.1).
275
- /// Persisted inside `DocMetadata::verification`.
276
- #[derive(Debug, Clone, Serialize, Deserialize)]
277
- pub struct Verification {
278
- /// Overall status: "pending" | "in_review" | "verified".
279
- pub status: String,
280
- pub created_at: String,
281
- pub updated_at: String,
282
- /// One item per tracked fragment.
283
- pub items: Vec<VerificationItem>,
284
- }
285
-
286
- /// One row in the verification matrix — tracks review state of a single
287
- /// spec fragment (v1) or, since v2 (wiki/140-verification-matrix.md §7), a
288
- /// freeform top-level item not tied to any section (`fragment_seq: None`).
289
- #[derive(Debug, Clone, Serialize, Deserialize)]
290
- pub struct VerificationItem {
291
- /// The section this item tracks. `None` (v2) = a freeform item, not
292
- /// tied to any document section — see `label`.
293
- #[serde(default)]
294
- pub fragment_seq: Option<usize>,
295
- pub heading: String,
296
- /// "pending" | "skipped" | "verified".
297
- pub status: String,
298
- #[serde(default)]
299
- pub impl_refs: Vec<CodeRef>,
300
- #[serde(default)]
301
- pub test_refs: Vec<CodeRef>,
302
- #[serde(default, skip_serializing_if = "Option::is_none")]
303
- pub reviewer: Option<String>,
304
- #[serde(default, skip_serializing_if = "Option::is_none")]
305
- pub verified_at: Option<String>,
306
- #[serde(default)]
307
- pub notes: String,
308
- /// Fragment content_hash at the time of verification. If the fragment's
309
- /// current hash differs, this item's review is stale and should be
310
- /// flagged (`doc_verify_status`).
311
- #[serde(default, skip_serializing_if = "Option::is_none")]
312
- pub content_hash_at_verify: Option<String>,
313
-
314
- /// v2: item category — `"section"` (default, existing heading-level
315
- /// items), `"requirement"`, `"visual"`, `"regression"`, `"manual"`
316
- /// (free-extensible, not an enforced enum).
317
- #[serde(default = "default_category")]
318
- pub category: String,
319
- /// v2: individual requirements tracked within a `category="section"`
320
- /// item.
321
- #[serde(default, skip_serializing_if = "Vec::is_empty")]
322
- pub sub_items: Vec<SubItem>,
323
- /// v2: label for a freeform item (`fragment_seq: None`).
324
- #[serde(default, skip_serializing_if = "Option::is_none")]
325
- pub label: Option<String>,
326
- }
327
-
328
- fn default_category() -> String {
329
- "section".to_string()
330
- }
331
-
332
- /// v2 (wiki/140-verification-matrix.md §7.1): one individual requirement
333
- /// tracked within a section-level `VerificationItem::sub_items`.
334
- #[derive(Debug, Clone, Serialize, Deserialize)]
335
- pub struct SubItem {
336
- /// 0-based position within the parent item's `sub_items`.
337
- pub index: usize,
338
- pub description: String,
339
- /// "pending" | "skipped" | "verified".
340
- pub status: String,
341
- #[serde(default, skip_serializing_if = "Option::is_none")]
342
- pub reviewer: Option<String>,
343
- #[serde(default, skip_serializing_if = "Option::is_none")]
344
- pub verified_at: Option<String>,
345
- #[serde(default)]
346
- pub notes: String,
347
- /// "requirement" (default) | "visual" | "manual" | ... (free-extensible).
348
- #[serde(default = "default_sub_category")]
349
- pub category: String,
350
- }
351
-
352
- fn default_sub_category() -> String {
353
- "requirement".to_string()
354
- }
355
-
356
- /// A reference to a source code location.
357
- #[derive(Debug, Clone, Serialize, Deserialize)]
358
- pub struct CodeRef {
359
- pub path: String,
360
- #[serde(default, skip_serializing_if = "Option::is_none")]
361
- pub lines: Option<String>,
362
- #[serde(default, skip_serializing_if = "Option::is_none")]
363
- pub label: Option<String>,
364
- }
365
-
366
- #[cfg(test)]
367
- mod tests {
368
- use super::*;
369
-
370
- fn new_doc() -> DocMetadata {
371
- DocMetadata::new(
372
- "doc-1".to_string(),
373
- "my-slug".to_string(),
374
- "Title".to_string(),
375
- "spec".to_string(),
376
- "2026-07-11T00:00:00Z".to_string(),
377
- )
378
- }
379
-
380
- #[test]
381
- fn doc_metadata_new_defaults() {
382
- let doc = new_doc();
383
- assert_eq!(doc.version, DOC_SCHEMA_VERSION);
384
- assert_eq!(doc.slug, "my-slug");
385
- assert_eq!(doc.auto_inject, "auto");
386
- assert!(doc.parent_id.is_none());
387
- assert!(doc.children.is_empty());
388
- assert!(doc.sections.is_empty());
389
- }
390
-
391
- #[test]
392
- fn section_index_holds_byte_offset_length_and_hash() {
393
- let body = "## Heading\n\nBody\n";
394
- let section = SectionIndex {
395
- seq: 1,
396
- heading: "Heading".to_string(),
397
- level: 2,
398
- byte_offset: 5,
399
- byte_length: body.len(),
400
- content_hash: lexsim::content_hash(body),
401
- };
402
- assert_eq!(section.byte_length, body.len());
403
- assert_eq!(section.content_hash, lexsim::content_hash(body));
404
- assert_eq!(section.byte_offset, 5);
405
- }
406
-
407
- #[test]
408
- fn serde_roundtrip_doc_metadata() {
409
- let mut doc = new_doc();
410
- doc.related.push(DocRelation {
411
- id: "doc-2".to_string(),
412
- rel: "references".to_string(),
413
- });
414
- doc.source = DocSource {
415
- origin: "authored".to_string(),
416
- original_path: None,
417
- canonical_hash: Some("abc123".to_string()),
418
- frontmatter: None,
419
- frontmatter_trailing_eol: true,
420
- };
421
-
422
- let json = serde_json::to_string(&doc).unwrap();
423
- let back: DocMetadata = serde_json::from_str(&json).unwrap();
424
- assert_eq!(back.related.len(), 1);
425
- assert_eq!(back.related[0].rel, "references");
426
- assert_eq!(back.source.canonical_hash.as_deref(), Some("abc123"));
427
- assert_eq!(back.slug, "my-slug");
428
- }
429
-
430
- /// wiki/130-document-management.md §5.1: `split()` computes `has_bom`,
431
- /// `line_ending`, and `frontmatter` for every authored document — these
432
- /// must round-trip through storage so a BOM/CRLF/frontmatter document is
433
- /// never silently corrupted by `doc_save` (t96).
434
- #[test]
435
- fn doc_metadata_persists_bom_line_ending_and_frontmatter() {
436
- let mut doc = new_doc();
437
- doc.has_bom = true;
438
- doc.line_ending = "crlf".to_string();
439
- doc.source.frontmatter = Some("title: Foo\n".to_string());
440
-
441
- let json = serde_json::to_string(&doc).unwrap();
442
- let back: DocMetadata = serde_json::from_str(&json).unwrap();
443
- assert!(back.has_bom);
444
- assert_eq!(back.line_ending, "crlf");
445
- assert_eq!(back.source.frontmatter.as_deref(), Some("title: Foo\n"));
446
- }
447
-
448
- #[test]
449
- fn doc_metadata_new_defaults_bom_and_line_ending() {
450
- let doc = new_doc();
451
- assert!(!doc.has_bom);
452
- assert_eq!(doc.line_ending, "lf");
453
- assert!(doc.source.frontmatter.is_none());
454
- }
455
-
456
- /// Old on-disk documents written before this field existed must still
457
- /// deserialize (backward compat via `#[serde(default)]`).
458
- #[test]
459
- fn doc_metadata_deserializes_without_bom_line_ending_fields() {
460
- let old_json = serde_json::json!({
461
- "version": 1,
462
- "id": "doc-1",
463
- "slug": "doc-1",
464
- "title": "Title",
465
- "doc_type": "spec",
466
- "created_at": "2026-07-11T00:00:00Z",
467
- "updated_at": "2026-07-11T00:00:00Z",
468
- });
469
- let back: DocMetadata = serde_json::from_value(old_json).unwrap();
470
- assert!(!back.has_bom);
471
- assert_eq!(back.line_ending, "lf");
472
- assert!(back.source.frontmatter.is_none());
473
- assert!(
474
- back.source.frontmatter_trailing_eol,
475
- "pre-fix on-disk documents always had the eol re-added on reassembly; \
476
- the default must preserve that behavior rather than silently drop a byte"
477
- );
478
- }
479
-
480
- /// `#[serde(alias = "fragments")]` on `DocMetadata::sections` lets a
481
- /// *new-shaped* payload (full `SectionIndex` fields: byte_offset,
482
- /// byte_length, content_hash) round-trip whether it's keyed `sections`
483
- /// or (legacy key name) `fragments`.
484
- #[test]
485
- fn doc_metadata_sections_field_accepts_fragments_alias_key() {
486
- let via_alias_key = serde_json::json!({
487
- "version": 2,
488
- "id": "doc-1",
489
- "slug": "doc-1",
490
- "title": "Title",
491
- "doc_type": "spec",
492
- "created_at": "2026-07-11T00:00:00Z",
493
- "updated_at": "2026-07-11T00:00:00Z",
494
- "fragments": [
495
- { "seq": 0, "heading": "", "level": 0, "byte_offset": 0, "byte_length": 10, "content_hash": "abc" }
496
- ],
497
- });
498
- let back: DocMetadata = serde_json::from_value(via_alias_key).unwrap();
499
- assert_eq!(back.sections.len(), 1);
500
- assert_eq!(back.sections[0].byte_length, 10);
501
- }
502
-
503
- /// Caution (found in review): a **real** v4 on-disk document has no
504
- /// `byte_offset`/`byte_length`/`content_hash` in its `fragments` entries
505
- /// (v4's `FragmentSummary` shape was just `{seq, heading, level}`) *and*
506
- /// has no `slug` field at all (`slug` is new in v5, required, with no
507
- /// `#[serde(default)]`). Both gaps make a genuine v4 file fail to
508
- /// deserialize as `DocMetadata` — the `alias = "fragments"` above only
509
- /// helps if the *rest* of the v5 shape (crucially `slug` and full
510
- /// `SectionIndex` fields) is already present. This is **not** a
511
- /// migration path: `storage::docs::read_doc`/`read_all_docs` treat a
512
- /// failed parse as "skip silently" (same policy as any corrupt file),
513
- /// so a real v4 document would vanish from `doc_get`/`doc_list`/
514
- /// `doc_query` with no warning. Deliberately out of scope per
515
- /// wiki/130-document-management.md's migration section (no real v4
516
- /// documents exist outside dev test data) — documented here so the gap
517
- /// isn't mistaken for a safety net if that assumption ever changes.
518
- #[test]
519
- fn doc_metadata_rejects_real_v4_shape_missing_slug_and_byte_fields() {
520
- let real_v4_shape = serde_json::json!({
521
- "version": 1,
522
- "id": "doc-1",
523
- "title": "Title",
524
- "doc_type": "spec",
525
- "created_at": "2026-07-11T00:00:00Z",
526
- "updated_at": "2026-07-11T00:00:00Z",
527
- "fragments": [
528
- { "seq": 0, "heading": "", "level": 0 }
529
- ],
530
- });
531
- let result: Result<DocMetadata, _> = serde_json::from_value(real_v4_shape);
532
- assert!(
533
- result.is_err(),
534
- "a real v4 document (no slug, no byte_offset/byte_length/content_hash) \
535
- must fail to deserialize under the v5 schema, not silently succeed \
536
- with data loss (empty sections) — verifying this fails loudly here so \
537
- read_doc's lenient Ok(None) fallback is a deliberate, documented \
538
- trade-off rather than an invisible one"
539
- );
540
- }
541
-
542
- /// wiki/140-verification-matrix.md §3.4: existing on-disk documents
543
- /// without a `verification` key must deserialize with `verification:
544
- /// None` — `doc_save` and the pre-verification-matrix on-disk schema
545
- /// must be unaffected by this addition.
546
- #[test]
547
- fn doc_metadata_new_defaults_verification_to_none() {
548
- let doc = new_doc();
549
- assert!(doc.verification.is_none());
550
- }
551
-
552
- #[test]
553
- fn doc_metadata_deserializes_without_verification_field() {
554
- let old_json = serde_json::json!({
555
- "version": 2,
556
- "id": "doc-1",
557
- "slug": "doc-1",
558
- "title": "Title",
559
- "doc_type": "spec",
560
- "created_at": "2026-07-11T00:00:00Z",
561
- "updated_at": "2026-07-11T00:00:00Z",
562
- });
563
- let back: DocMetadata = serde_json::from_value(old_json).unwrap();
564
- assert!(back.verification.is_none());
565
- }
566
-
567
- #[test]
568
- fn verification_round_trips_through_doc_metadata() {
569
- let mut doc = new_doc();
570
- doc.verification = Some(Verification {
571
- status: "in_review".to_string(),
572
- created_at: "2026-07-11T10:00:00Z".to_string(),
573
- updated_at: "2026-07-11T14:30:00Z".to_string(),
574
- items: vec![VerificationItem {
575
- fragment_seq: Some(2),
576
- heading: "1. 課題".to_string(),
577
- status: "verified".to_string(),
578
- impl_refs: vec![CodeRef {
579
- path: "src/storage/docs/mod.rs".to_string(),
580
- lines: Some("42-180".to_string()),
581
- label: Some("DocStore".to_string()),
582
- }],
583
- test_refs: vec![CodeRef {
584
- path: "tests/doc_save.rs".to_string(),
585
- lines: None,
586
- label: Some("doc_save roundtrip".to_string()),
587
- }],
588
- reviewer: Some("ai".to_string()),
589
- verified_at: Some("2026-07-11T14:30:00Z".to_string()),
590
- notes: String::new(),
591
- content_hash_at_verify: Some("abc123".to_string()),
592
- category: "section".to_string(),
593
- sub_items: Vec::new(),
594
- label: None,
595
- }],
596
- });
597
-
598
- let json = serde_json::to_string(&doc).unwrap();
599
- let back: DocMetadata = serde_json::from_str(&json).unwrap();
600
- let v = back.verification.expect("verification must round-trip");
601
- assert_eq!(v.status, "in_review");
602
- assert_eq!(v.items.len(), 1);
603
- assert_eq!(v.items[0].fragment_seq, Some(2));
604
- assert_eq!(v.items[0].impl_refs[0].path, "src/storage/docs/mod.rs");
605
- assert_eq!(
606
- v.items[0].test_refs[0].label.as_deref(),
607
- Some("doc_save roundtrip")
608
- );
609
- }
610
-
611
- /// wiki/140-verification-matrix.md §7.1 (v2 extension): a v1
612
- /// `VerificationItem` (plain-number `fragment_seq`, no `category` /
613
- /// `sub_items` / `label`) must still deserialize, defaulting
614
- /// `category` to `"section"`, `sub_items` to empty, and `label` to
615
- /// `None` — v1 behavior is fully preserved.
616
- #[test]
617
- fn verification_item_v1_json_deserializes_with_v2_defaults() {
618
- let v1_item = serde_json::json!({
619
- "fragment_seq": 2,
620
- "heading": "1. 課題",
621
- "status": "verified",
622
- "reviewer": "ai",
623
- "verified_at": "2026-07-11T14:30:00Z",
624
- });
625
- let item: VerificationItem = serde_json::from_value(v1_item).unwrap();
626
- assert_eq!(item.fragment_seq, Some(2));
627
- assert_eq!(item.category, "section");
628
- assert!(item.sub_items.is_empty());
629
- assert!(item.label.is_none());
630
- }
631
-
632
- #[test]
633
- fn sub_item_defaults_category_to_requirement() {
634
- let json = serde_json::json!({
635
- "index": 0,
636
- "description": "形状=八面体であること",
637
- "status": "pending",
638
- });
639
- let sub: SubItem = serde_json::from_value(json).unwrap();
640
- assert_eq!(sub.category, "requirement");
641
- assert!(sub.notes.is_empty());
642
- assert!(sub.reviewer.is_none());
643
- }
644
-
645
- #[test]
646
- fn verification_item_supports_freeform_fragment_seq_none() {
647
- let item = VerificationItem {
648
- fragment_seq: None,
649
- heading: "ドラッグ操作の目視確認".to_string(),
650
- status: "pending".to_string(),
651
- impl_refs: Vec::new(),
652
- test_refs: Vec::new(),
653
- reviewer: None,
654
- verified_at: None,
655
- notes: String::new(),
656
- content_hash_at_verify: None,
657
- category: "visual".to_string(),
658
- sub_items: Vec::new(),
659
- label: Some("ドラッグ操作の目視確認".to_string()),
660
- };
661
- let json = serde_json::to_string(&item).unwrap();
662
- let back: VerificationItem = serde_json::from_str(&json).unwrap();
663
- assert!(back.fragment_seq.is_none());
664
- assert_eq!(back.label.as_deref(), Some("ドラッグ操作の目視確認"));
665
- assert_eq!(back.category, "visual");
666
- }
667
-
668
- #[test]
669
- fn verification_item_sub_items_round_trip() {
670
- let mut item = VerificationItem {
671
- fragment_seq: Some(2),
672
- heading: "1. 課題".to_string(),
673
- status: "in_review".to_string(),
674
- impl_refs: Vec::new(),
675
- test_refs: Vec::new(),
676
- reviewer: None,
677
- verified_at: None,
678
- notes: String::new(),
679
- content_hash_at_verify: None,
680
- category: "section".to_string(),
681
- sub_items: Vec::new(),
682
- label: None,
683
- };
684
- item.sub_items.push(SubItem {
685
- index: 0,
686
- description: "形状=八面体であること".to_string(),
687
- status: "verified".to_string(),
688
- reviewer: Some("ai".to_string()),
689
- verified_at: Some("2026-07-11T14:30:00Z".to_string()),
690
- notes: String::new(),
691
- category: "requirement".to_string(),
692
- });
693
-
694
- let json = serde_json::to_string(&item).unwrap();
695
- let back: VerificationItem = serde_json::from_str(&json).unwrap();
696
- assert_eq!(back.sub_items.len(), 1);
697
- assert_eq!(back.sub_items[0].description, "形状=八面体であること");
698
- assert_eq!(back.sub_items[0].status, "verified");
699
- }
700
-
701
- #[test]
702
- fn valid_constants_contain_spec_values() {
703
- assert!(VALID_DOC_TYPES.contains(&"spec"));
704
- assert!(VALID_DOC_TYPES.contains(&"note"));
705
- assert!(VALID_AUTO_INJECT.contains(&"outline"));
706
- assert!(VALID_RELATIONS.contains(&"supersedes"));
707
- }
708
- }