handoff-mcp-server 0.26.0 → 0.27.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.
- package/README.md +31 -4
- package/bin/handoff-mcp.js +56 -13
- package/bin/resolve-binary.js +122 -0
- package/package.json +14 -9
- package/Cargo.lock +0 -686
- package/Cargo.toml +0 -30
- package/scripts/cargo-env.sh +0 -29
- package/scripts/handoff-memory-hook.py +0 -208
- package/scripts/install-local.sh +0 -109
- package/scripts/postinstall.js +0 -50
- package/scripts/sync-plugin-skills.sh +0 -35
- package/scripts/sync-plugin-version.sh +0 -85
- package/scripts/sync-workflow-inline.sh +0 -138
- package/src/cli.rs +0 -551
- package/src/context/injection.rs +0 -276
- package/src/context/mod.rs +0 -129
- package/src/lib.rs +0 -5
- package/src/main.rs +0 -157
- package/src/mcp/handlers/assignees.rs +0 -254
- package/src/mcp/handlers/auto_schedule.rs +0 -489
- package/src/mcp/handlers/bulk_update.rs +0 -155
- package/src/mcp/handlers/calendar.rs +0 -196
- package/src/mcp/handlers/capacity.rs +0 -318
- package/src/mcp/handlers/check_criterion.rs +0 -70
- package/src/mcp/handlers/config.rs +0 -402
- package/src/mcp/handlers/config_crud.rs +0 -183
- package/src/mcp/handlers/dashboard.rs +0 -214
- package/src/mcp/handlers/docs.rs +0 -2288
- package/src/mcp/handlers/docs_query.rs +0 -1335
- package/src/mcp/handlers/fork_session.rs +0 -91
- package/src/mcp/handlers/get_session.rs +0 -48
- package/src/mcp/handlers/get_task.rs +0 -53
- package/src/mcp/handlers/import_context.rs +0 -470
- package/src/mcp/handlers/init.rs +0 -28
- package/src/mcp/handlers/list_sessions.rs +0 -187
- package/src/mcp/handlers/list_tasks.rs +0 -308
- package/src/mcp/handlers/load_context.rs +0 -361
- package/src/mcp/handlers/log_time.rs +0 -67
- package/src/mcp/handlers/memory.rs +0 -961
- package/src/mcp/handlers/merge_sessions.rs +0 -103
- package/src/mcp/handlers/metrics.rs +0 -196
- package/src/mcp/handlers/milestones.rs +0 -102
- package/src/mcp/handlers/mod.rs +0 -140
- package/src/mcp/handlers/refer.rs +0 -307
- package/src/mcp/handlers/referrals.rs +0 -74
- package/src/mcp/handlers/save_context.rs +0 -354
- package/src/mcp/handlers/task_checklist.rs +0 -507
- package/src/mcp/handlers/timer.rs +0 -529
- package/src/mcp/handlers/update_session.rs +0 -197
- package/src/mcp/handlers/update_task.rs +0 -452
- package/src/mcp/mod.rs +0 -6
- package/src/mcp/protocol.rs +0 -41
- package/src/mcp/resources.rs +0 -57
- package/src/mcp/router.rs +0 -154
- package/src/mcp/tools.rs +0 -1522
- package/src/mcp/types.rs +0 -108
- package/src/setup.rs +0 -1212
- package/src/storage/config.rs +0 -578
- package/src/storage/docs/frontmatter.rs +0 -509
- package/src/storage/docs/mod.rs +0 -835
- package/src/storage/docs/model.rs +0 -708
- package/src/storage/docs/reassemble.rs +0 -167
- package/src/storage/docs/split.rs +0 -377
- package/src/storage/git.rs +0 -47
- package/src/storage/memory/injected.rs +0 -340
- package/src/storage/memory/mod.rs +0 -236
- package/src/storage/memory/model.rs +0 -127
- package/src/storage/mod.rs +0 -96
- package/src/storage/referrals.rs +0 -248
- package/src/storage/sessions.rs +0 -859
- package/src/storage/tasks.rs +0 -957
- 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
|
-
}
|