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
package/src/mcp/handlers/docs.rs
DELETED
|
@@ -1,2288 +0,0 @@
|
|
|
1
|
-
//! MCP handlers for document management (save / get / list) — P1-6a (t96.1),
|
|
2
|
-
//! frontmatter migration (t123.1-t123.3, single-file slug-based storage,
|
|
3
|
-
//! wiki/130-document-management.md §3.1).
|
|
4
|
-
//!
|
|
5
|
-
//! Builds on the storage layer in `crate::storage::docs` (on-demand section
|
|
6
|
-
//! computation + slug-named `_doc.<slug>.md` frontmatter+body I/O) and the
|
|
7
|
-
//! task<->doc bidirectional link sync in
|
|
8
|
-
//! `crate::storage::tasks::sync_doc_task_links`. See
|
|
9
|
-
//! `wiki/130-document-management.md` §5.1-§5.3 for the spec.
|
|
10
|
-
|
|
11
|
-
use std::path::Path;
|
|
12
|
-
|
|
13
|
-
use anyhow::{Context, Result};
|
|
14
|
-
use serde_json::{json, Value};
|
|
15
|
-
|
|
16
|
-
use super::resolve_project_dir;
|
|
17
|
-
use crate::context::injection::{rank_by_bm25_and_scope, RankConfig};
|
|
18
|
-
use crate::storage::docs::reassemble::extract_section;
|
|
19
|
-
use crate::storage::docs::split::{compute_sections, split};
|
|
20
|
-
use crate::storage::docs::{
|
|
21
|
-
delete_doc, delete_doc_body, ensure_docs_dir, find_doc_by_id, read_all_docs, read_doc,
|
|
22
|
-
read_doc_body, validate_slug, write_doc, write_doc_body, CodeRef, DocMetadata, DocRelation,
|
|
23
|
-
SubItem, Verification, VerificationItem,
|
|
24
|
-
};
|
|
25
|
-
use crate::storage::ensure_handoff_exists;
|
|
26
|
-
use crate::storage::tasks::sync_doc_task_links;
|
|
27
|
-
|
|
28
|
-
/// Bonus added to a document's BM25 score when one of its `scope_paths` is a
|
|
29
|
-
/// prefix of one of the query's `file_paths`. Mirrors `memory.rs`'s
|
|
30
|
-
/// `SCOPE_PATH_BONUS` — kept as a separate constant since the two features
|
|
31
|
-
/// tune independently even though the value happens to match today.
|
|
32
|
-
const SCOPE_PATH_BONUS: f64 = 2.0;
|
|
33
|
-
|
|
34
|
-
/// Default relevance floor for `doc_list(query=...)`. Kept at 0.0 (no floor)
|
|
35
|
-
/// since `doc_list` is an explicit search the caller controls via `query`
|
|
36
|
-
/// presence/absence, unlike `memory_query`'s hook-driven auto-injection which
|
|
37
|
-
/// needs a floor to avoid noise.
|
|
38
|
-
const DOC_QUERY_MIN_SCORE: f64 = 0.0;
|
|
39
|
-
|
|
40
|
-
fn new_doc_id() -> String {
|
|
41
|
-
format!("doc-{}", chrono::Utc::now().format("%Y%m%d-%H%M%S-%6f"))
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
/// Resolve a document by either its file-naming `slug` or its stable `id`
|
|
45
|
-
/// (spec instructs `doc_get`/`doc_delete`/etc. to accept either). Tries the
|
|
46
|
-
/// direct slug-keyed file lookup first (cheap, no scan), falling back to a
|
|
47
|
-
/// full `id` scan so callers that only recorded a document's `id` (e.g. from
|
|
48
|
-
/// a `related`/`parent_id` reference) can still resolve it.
|
|
49
|
-
fn resolve_doc(handoff: &Path, slug_or_id: &str) -> Result<Option<DocMetadata>> {
|
|
50
|
-
if let Some(doc) = read_doc(handoff, slug_or_id)? {
|
|
51
|
-
return Ok(Some(doc));
|
|
52
|
-
}
|
|
53
|
-
find_doc_by_id(handoff, slug_or_id)
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
/// `handoff_doc_save` — create or update a document from a full Markdown
|
|
57
|
-
/// body: split into in-memory sections, persist the body + metadata as a
|
|
58
|
-
/// slug-named pair, and sync the task<->doc bidirectional link.
|
|
59
|
-
pub fn handle_doc_save(arguments: &Value) -> Result<String> {
|
|
60
|
-
let project_dir = resolve_project_dir(arguments)?;
|
|
61
|
-
let handoff = ensure_handoff_exists(&project_dir)?;
|
|
62
|
-
ensure_docs_dir(&handoff)?;
|
|
63
|
-
|
|
64
|
-
let body_arg = arguments.get("body").and_then(|v| v.as_str());
|
|
65
|
-
let append_body_arg = arguments.get("append_body").and_then(|v| v.as_str());
|
|
66
|
-
if body_arg.is_some() && append_body_arg.is_some() {
|
|
67
|
-
anyhow::bail!("'body' and 'append_body' are mutually exclusive");
|
|
68
|
-
}
|
|
69
|
-
if body_arg.is_none() && append_body_arg.is_none() {
|
|
70
|
-
anyhow::bail!("either 'body' or 'append_body' is required");
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
let doc_id = arguments.get("doc_id").and_then(|v| v.as_str());
|
|
74
|
-
if append_body_arg.is_some() && doc_id.is_none() {
|
|
75
|
-
anyhow::bail!(
|
|
76
|
-
"'append_body' requires 'doc_id' (appending to a new document is not meaningful)"
|
|
77
|
-
);
|
|
78
|
-
}
|
|
79
|
-
let existing = match doc_id {
|
|
80
|
-
Some(id) => Some(
|
|
81
|
-
find_doc_by_id(&handoff, id)?
|
|
82
|
-
.ok_or_else(|| anyhow::anyhow!("Document not found: {id}"))?,
|
|
83
|
-
),
|
|
84
|
-
None => None,
|
|
85
|
-
};
|
|
86
|
-
|
|
87
|
-
// `append_body`: join the appended text onto the existing document's
|
|
88
|
-
// stripped body (read_doc_body — NOT read_full_body, whose BOM
|
|
89
|
-
// restoration would otherwise get re-detected and double-persisted by
|
|
90
|
-
// `split()` below). No separator is inserted when the existing body is
|
|
91
|
-
// empty/missing (spec §3.1 edge case).
|
|
92
|
-
let joined_body: String;
|
|
93
|
-
let body: &str = if let Some(append_body) = append_body_arg {
|
|
94
|
-
let existing_doc = existing
|
|
95
|
-
.as_ref()
|
|
96
|
-
.expect("append_body requires doc_id, checked above, so existing is Some");
|
|
97
|
-
let existing_body = read_doc_body(&handoff, &existing_doc.slug)?.unwrap_or_default();
|
|
98
|
-
let separator = arguments
|
|
99
|
-
.get("separator")
|
|
100
|
-
.and_then(|v| v.as_str())
|
|
101
|
-
.unwrap_or("\n\n");
|
|
102
|
-
joined_body = if existing_body.is_empty() {
|
|
103
|
-
append_body.to_string()
|
|
104
|
-
} else {
|
|
105
|
-
format!("{existing_body}{separator}{append_body}")
|
|
106
|
-
};
|
|
107
|
-
&joined_body
|
|
108
|
-
} else {
|
|
109
|
-
body_arg.expect("body_arg is Some in this branch, checked above")
|
|
110
|
-
};
|
|
111
|
-
|
|
112
|
-
// slug: required for new documents, taken from the existing document on
|
|
113
|
-
// update (the `slug` argument is ignored on update — renaming a
|
|
114
|
-
// document's file-naming slug is out of scope for `doc_save`).
|
|
115
|
-
let slug = match &existing {
|
|
116
|
-
Some(d) => d.slug.clone(),
|
|
117
|
-
None => {
|
|
118
|
-
let slug = arguments
|
|
119
|
-
.get("slug")
|
|
120
|
-
.and_then(|v| v.as_str())
|
|
121
|
-
.ok_or_else(|| anyhow::anyhow!("'slug' is required for new documents"))?
|
|
122
|
-
.to_string();
|
|
123
|
-
validate_slug(&slug)?;
|
|
124
|
-
if read_doc(&handoff, &slug)?.is_some() {
|
|
125
|
-
anyhow::bail!("slug '{slug}' is already in use by another document");
|
|
126
|
-
}
|
|
127
|
-
slug
|
|
128
|
-
}
|
|
129
|
-
};
|
|
130
|
-
|
|
131
|
-
let title = arguments
|
|
132
|
-
.get("title")
|
|
133
|
-
.and_then(|v| v.as_str())
|
|
134
|
-
.or(existing.as_ref().map(|d| d.title.as_str()))
|
|
135
|
-
.ok_or_else(|| anyhow::anyhow!("'title' is required for new documents"))?
|
|
136
|
-
.to_string();
|
|
137
|
-
|
|
138
|
-
let split_level = arguments
|
|
139
|
-
.get("split_level")
|
|
140
|
-
.and_then(|v| v.as_u64())
|
|
141
|
-
.map(|n| n as u8)
|
|
142
|
-
.unwrap_or(crate::storage::docs::split::DEFAULT_SPLIT_LEVEL);
|
|
143
|
-
|
|
144
|
-
let split_doc = split(body, split_level)?;
|
|
145
|
-
|
|
146
|
-
let now = chrono::Utc::now().to_rfc3339();
|
|
147
|
-
let id = doc_id.map(str::to_string).unwrap_or_else(new_doc_id);
|
|
148
|
-
|
|
149
|
-
let mut doc = match existing {
|
|
150
|
-
Some(mut d) => {
|
|
151
|
-
d.title = title.clone();
|
|
152
|
-
d
|
|
153
|
-
}
|
|
154
|
-
None => {
|
|
155
|
-
let mut d = DocMetadata::new(
|
|
156
|
-
id.clone(),
|
|
157
|
-
slug.clone(),
|
|
158
|
-
title.clone(),
|
|
159
|
-
"note".to_string(),
|
|
160
|
-
now.clone(),
|
|
161
|
-
);
|
|
162
|
-
d.source.origin = "authored".to_string();
|
|
163
|
-
d
|
|
164
|
-
}
|
|
165
|
-
};
|
|
166
|
-
|
|
167
|
-
if let Some(doc_type) = arguments.get("doc_type").and_then(|v| v.as_str()) {
|
|
168
|
-
doc.doc_type = doc_type.to_string();
|
|
169
|
-
}
|
|
170
|
-
if let Some(tags) = arguments.get("tags") {
|
|
171
|
-
doc.tags = string_array_value(tags);
|
|
172
|
-
}
|
|
173
|
-
if let Some(scope_paths) = arguments.get("scope_paths") {
|
|
174
|
-
doc.scope_paths = string_array_value(scope_paths);
|
|
175
|
-
}
|
|
176
|
-
let previous_parent_id = doc.parent_id.clone();
|
|
177
|
-
if let Some(parent_id) = arguments.get("parent_id") {
|
|
178
|
-
doc.parent_id = parent_id.as_str().map(str::to_string);
|
|
179
|
-
}
|
|
180
|
-
let mut warnings: Vec<String> = Vec::new();
|
|
181
|
-
if let Some(related) = arguments.get("related").and_then(|v| v.as_array()) {
|
|
182
|
-
let mut malformed_count = 0usize;
|
|
183
|
-
doc.related = related
|
|
184
|
-
.iter()
|
|
185
|
-
.filter_map(|r| {
|
|
186
|
-
let rid = r.get("id").and_then(|v| v.as_str());
|
|
187
|
-
let rel = r.get("rel").and_then(|v| v.as_str());
|
|
188
|
-
match (rid, rel) {
|
|
189
|
-
(Some(rid), Some(rel)) => Some(DocRelation {
|
|
190
|
-
id: rid.to_string(),
|
|
191
|
-
rel: rel.to_string(),
|
|
192
|
-
}),
|
|
193
|
-
_ => {
|
|
194
|
-
malformed_count += 1;
|
|
195
|
-
None
|
|
196
|
-
}
|
|
197
|
-
}
|
|
198
|
-
})
|
|
199
|
-
.collect();
|
|
200
|
-
if malformed_count > 0 {
|
|
201
|
-
warnings.push(format!(
|
|
202
|
-
"Ignored {malformed_count} malformed 'related' entr{} (each entry requires string 'id' and 'rel')",
|
|
203
|
-
if malformed_count == 1 { "y" } else { "ies" }
|
|
204
|
-
));
|
|
205
|
-
}
|
|
206
|
-
}
|
|
207
|
-
if let Some(auto_inject) = arguments.get("auto_inject").and_then(|v| v.as_str()) {
|
|
208
|
-
doc.auto_inject = auto_inject.to_string();
|
|
209
|
-
}
|
|
210
|
-
|
|
211
|
-
doc.has_bom = split_doc.has_bom;
|
|
212
|
-
doc.line_ending = split_doc.line_ending.to_string();
|
|
213
|
-
doc.split_level = split_level;
|
|
214
|
-
doc.updated_at = now.clone();
|
|
215
|
-
|
|
216
|
-
// v5: the full body (after BOM/frontmatter stripping) is written verbatim
|
|
217
|
-
// to `_doc.<slug>.md`; sections are an in-memory byte-offset index into
|
|
218
|
-
// it, computed fresh on every save (no stale-fragment cleanup needed —
|
|
219
|
-
// there is nothing left on disk to clean up per section).
|
|
220
|
-
let body_after_strip: String = split_doc.fragments.iter().map(|f| f.body).collect();
|
|
221
|
-
if !body_after_strip.starts_with("# ") {
|
|
222
|
-
warnings.push(
|
|
223
|
-
"body does not start with a level-1 heading — consider adding one for readability"
|
|
224
|
-
.to_string(),
|
|
225
|
-
);
|
|
226
|
-
}
|
|
227
|
-
write_doc_body(&handoff, &slug, &body_after_strip)?;
|
|
228
|
-
doc.sections = compute_sections(&split_doc);
|
|
229
|
-
|
|
230
|
-
let content_hash = lexsim::content_hash(&body_after_strip);
|
|
231
|
-
doc.content_hash = content_hash.clone();
|
|
232
|
-
doc.source.canonical_hash = Some(content_hash);
|
|
233
|
-
|
|
234
|
-
let new_task_ids = arguments
|
|
235
|
-
.get("task_ids")
|
|
236
|
-
.map(string_array_value)
|
|
237
|
-
.unwrap_or_else(|| doc.task_ids.clone());
|
|
238
|
-
|
|
239
|
-
if arguments.get("task_ids").is_some() {
|
|
240
|
-
let (link_ids, unlink_ids) = if doc_id.is_some() {
|
|
241
|
-
let previous: Vec<String> = doc.task_ids.clone();
|
|
242
|
-
let link: Vec<String> = new_task_ids
|
|
243
|
-
.iter()
|
|
244
|
-
.filter(|t| !previous.contains(t))
|
|
245
|
-
.cloned()
|
|
246
|
-
.collect();
|
|
247
|
-
let unlink: Vec<String> = previous
|
|
248
|
-
.iter()
|
|
249
|
-
.filter(|t| !new_task_ids.contains(t))
|
|
250
|
-
.cloned()
|
|
251
|
-
.collect();
|
|
252
|
-
(link, unlink)
|
|
253
|
-
} else {
|
|
254
|
-
(new_task_ids.clone(), Vec::new())
|
|
255
|
-
};
|
|
256
|
-
|
|
257
|
-
let tasks_dir = handoff.join("tasks");
|
|
258
|
-
let report = sync_doc_task_links(&tasks_dir, &id, &title, &link_ids, &unlink_ids)?;
|
|
259
|
-
if !report.unresolved.is_empty() {
|
|
260
|
-
warnings.push(format!(
|
|
261
|
-
"Could not resolve task id(s) for linking: {}",
|
|
262
|
-
report.unresolved.join(", ")
|
|
263
|
-
));
|
|
264
|
-
}
|
|
265
|
-
doc.task_ids = new_task_ids;
|
|
266
|
-
}
|
|
267
|
-
|
|
268
|
-
write_doc(&handoff, &doc)?;
|
|
269
|
-
|
|
270
|
-
// Keep the family tree's `children` list in sync with `parent_id`: if the
|
|
271
|
-
// parent changed (including unset -> set on first save), push this doc's
|
|
272
|
-
// id into the new parent's `children` and drop it from the old parent's,
|
|
273
|
-
// mirroring the same "sync the other side" pattern as
|
|
274
|
-
// sync_doc_task_links. A parent id that doesn't resolve is a non-fatal
|
|
275
|
-
// warning, not a rollback — same policy as unresolved task_ids above.
|
|
276
|
-
// `parent_id` references a document's stable `id`, not its `slug`, so
|
|
277
|
-
// resolution goes through `find_doc_by_id`.
|
|
278
|
-
if doc.parent_id != previous_parent_id {
|
|
279
|
-
if let Some(old_parent_id) = &previous_parent_id {
|
|
280
|
-
if let Some(mut old_parent) = find_doc_by_id(&handoff, old_parent_id)? {
|
|
281
|
-
let before = old_parent.children.len();
|
|
282
|
-
old_parent.children.retain(|c| c != &id);
|
|
283
|
-
if old_parent.children.len() != before {
|
|
284
|
-
write_doc(&handoff, &old_parent)?;
|
|
285
|
-
}
|
|
286
|
-
}
|
|
287
|
-
}
|
|
288
|
-
if let Some(new_parent_id) = &doc.parent_id {
|
|
289
|
-
match find_doc_by_id(&handoff, new_parent_id)? {
|
|
290
|
-
Some(mut new_parent) => {
|
|
291
|
-
if !new_parent.children.iter().any(|c| c == &id) {
|
|
292
|
-
new_parent.children.push(id.clone());
|
|
293
|
-
write_doc(&handoff, &new_parent)?;
|
|
294
|
-
}
|
|
295
|
-
}
|
|
296
|
-
None => warnings.push(format!("Parent document not found: {new_parent_id}")),
|
|
297
|
-
}
|
|
298
|
-
}
|
|
299
|
-
}
|
|
300
|
-
|
|
301
|
-
Ok(to_json(&json!({
|
|
302
|
-
"doc_id": id,
|
|
303
|
-
"slug": doc.slug,
|
|
304
|
-
"title": doc.title,
|
|
305
|
-
"doc_type": doc.doc_type,
|
|
306
|
-
"section_count": doc.sections.len(),
|
|
307
|
-
"content_hash": doc.content_hash,
|
|
308
|
-
"warnings": warnings,
|
|
309
|
-
})))
|
|
310
|
-
}
|
|
311
|
-
|
|
312
|
-
/// `handoff_doc_update_section` — replace a single section's body by `seq`
|
|
313
|
-
/// without requiring the caller to re-send the whole document (partial
|
|
314
|
-
/// update API, t123.4). Computes sections on-demand from the current body
|
|
315
|
-
/// (mirrors `read_doc`'s recompute — sections are never trusted from
|
|
316
|
-
/// frontmatter), byte-slices out the target section's range, splices in
|
|
317
|
-
/// `new_content`, and writes the result back. `expected_hash` is an optional
|
|
318
|
-
/// optimistic lock against the section's current `content_hash`.
|
|
319
|
-
pub fn handle_doc_update_section(arguments: &Value) -> Result<String> {
|
|
320
|
-
let project_dir = resolve_project_dir(arguments)?;
|
|
321
|
-
let handoff = ensure_handoff_exists(&project_dir)?;
|
|
322
|
-
|
|
323
|
-
let doc_id = arguments
|
|
324
|
-
.get("doc_id")
|
|
325
|
-
.and_then(|v| v.as_str())
|
|
326
|
-
.ok_or_else(|| anyhow::anyhow!("'doc_id' is required"))?;
|
|
327
|
-
let seq = arguments
|
|
328
|
-
.get("seq")
|
|
329
|
-
.and_then(|v| v.as_u64())
|
|
330
|
-
.ok_or_else(|| anyhow::anyhow!("'seq' is required"))? as usize;
|
|
331
|
-
let new_content = arguments
|
|
332
|
-
.get("new_content")
|
|
333
|
-
.and_then(|v| v.as_str())
|
|
334
|
-
.ok_or_else(|| anyhow::anyhow!("'new_content' is required"))?;
|
|
335
|
-
let expected_hash = arguments.get("expected_hash").and_then(|v| v.as_str());
|
|
336
|
-
|
|
337
|
-
let mut doc = resolve_doc(&handoff, doc_id)?
|
|
338
|
-
.ok_or_else(|| anyhow::anyhow!("Document not found: {doc_id}"))?;
|
|
339
|
-
|
|
340
|
-
let body = read_doc_body(&handoff, &doc.slug)?
|
|
341
|
-
.ok_or_else(|| anyhow::anyhow!("Document body file missing for slug '{}'", doc.slug))?;
|
|
342
|
-
let split_doc = split(&body, doc.split_level)?;
|
|
343
|
-
let sections = compute_sections(&split_doc);
|
|
344
|
-
|
|
345
|
-
let section = sections
|
|
346
|
-
.iter()
|
|
347
|
-
.find(|s| s.seq == seq)
|
|
348
|
-
.ok_or_else(|| anyhow::anyhow!("Section not found: doc_id={doc_id} seq={seq}"))?;
|
|
349
|
-
|
|
350
|
-
if let Some(expected) = expected_hash {
|
|
351
|
-
if expected != section.content_hash {
|
|
352
|
-
anyhow::bail!(
|
|
353
|
-
"expected_hash mismatch for doc_id={doc_id} seq={seq}: expected {expected}, \
|
|
354
|
-
current content_hash is {} — retry with the current hash if this overwrite is \
|
|
355
|
-
still intended",
|
|
356
|
-
section.content_hash
|
|
357
|
-
);
|
|
358
|
-
}
|
|
359
|
-
}
|
|
360
|
-
|
|
361
|
-
// Splice `new_content` into the section's byte range. `extract_section`
|
|
362
|
-
// is not used here (it would re-validate the just-computed hash, which
|
|
363
|
-
// is redundant since `section` was computed from this exact `body`
|
|
364
|
-
// moments ago) — the byte range is sliced directly instead.
|
|
365
|
-
let start = section.byte_offset;
|
|
366
|
-
let end = section.byte_offset + section.byte_length;
|
|
367
|
-
let mut new_body = String::with_capacity(body.len() - (end - start) + new_content.len());
|
|
368
|
-
new_body.push_str(&body[..start]);
|
|
369
|
-
new_body.push_str(new_content);
|
|
370
|
-
new_body.push_str(&body[end..]);
|
|
371
|
-
|
|
372
|
-
write_doc_body(&handoff, &doc.slug, &new_body)?;
|
|
373
|
-
|
|
374
|
-
let new_split_doc = split(&new_body, doc.split_level)?;
|
|
375
|
-
let new_sections = compute_sections(&new_split_doc);
|
|
376
|
-
doc.sections = new_sections.clone();
|
|
377
|
-
|
|
378
|
-
let now = chrono::Utc::now().to_rfc3339();
|
|
379
|
-
doc.updated_at = now;
|
|
380
|
-
|
|
381
|
-
let content_hash = lexsim::content_hash(&new_body);
|
|
382
|
-
doc.content_hash = content_hash.clone();
|
|
383
|
-
doc.source.canonical_hash = Some(content_hash);
|
|
384
|
-
|
|
385
|
-
write_doc(&handoff, &doc)?;
|
|
386
|
-
|
|
387
|
-
crate::context::doc_corpus_cache()
|
|
388
|
-
.lock()
|
|
389
|
-
.expect("cache")
|
|
390
|
-
.increment_generation();
|
|
391
|
-
|
|
392
|
-
let updated_section = new_sections.iter().find(|s| s.seq == seq);
|
|
393
|
-
let verification_stale = doc.verification.as_ref().is_some_and(|v| {
|
|
394
|
-
v.items
|
|
395
|
-
.iter()
|
|
396
|
-
.any(|i| i.fragment_seq == Some(seq) && item_is_stale(&doc, i))
|
|
397
|
-
});
|
|
398
|
-
|
|
399
|
-
let mut out = json!({
|
|
400
|
-
"doc_id": doc.id,
|
|
401
|
-
"seq": seq,
|
|
402
|
-
"heading": updated_section.map(|s| s.heading.clone()),
|
|
403
|
-
"content_hash": updated_section.map(|s| s.content_hash.clone()),
|
|
404
|
-
"updated_at": doc.updated_at,
|
|
405
|
-
"section_count": doc.sections.len(),
|
|
406
|
-
});
|
|
407
|
-
if verification_stale {
|
|
408
|
-
out["warnings"] = json!([format!(
|
|
409
|
-
"Verification item at fragment_seq={seq} is now stale (content changed since it was verified)"
|
|
410
|
-
)]);
|
|
411
|
-
}
|
|
412
|
-
|
|
413
|
-
Ok(to_json(&out))
|
|
414
|
-
}
|
|
415
|
-
|
|
416
|
-
/// Reads a document's authored content body: the part of `_doc.<slug>.md`
|
|
417
|
-
/// *after* handoff's own YAML frontmatter block, with the original UTF-8 BOM
|
|
418
|
-
/// (if any) restored in front of it. Returns `Ok(None)` when the `.md` file
|
|
419
|
-
/// is missing.
|
|
420
|
-
///
|
|
421
|
-
/// Frontmatter migration (t123.1): the `.md` file's frontmatter now *is* the
|
|
422
|
-
/// document's metadata (handoff-owned), not a user-authored block being
|
|
423
|
-
/// losslessly stashed — so unlike the pre-migration 2-file format, a leading
|
|
424
|
-
/// YAML block the caller originally passed into `doc_save`'s `body` argument
|
|
425
|
-
/// is absorbed into (and superseded by) handoff's own frontmatter, not
|
|
426
|
-
/// preserved verbatim. Only the BOM is still restored losslessly.
|
|
427
|
-
fn read_full_body(handoff: &Path, doc: &DocMetadata) -> Result<Option<String>> {
|
|
428
|
-
let Some(body) = read_doc_body(handoff, &doc.slug)? else {
|
|
429
|
-
return Ok(None);
|
|
430
|
-
};
|
|
431
|
-
Ok(Some(if doc.has_bom {
|
|
432
|
-
format!("\u{FEFF}{body}")
|
|
433
|
-
} else {
|
|
434
|
-
body
|
|
435
|
-
}))
|
|
436
|
-
}
|
|
437
|
-
|
|
438
|
-
/// `handoff_doc_get` — read a document (by `doc_id` or `slug`) as `full`
|
|
439
|
-
/// (the original Markdown body + metadata), `meta` (metadata only), or
|
|
440
|
-
/// `section` (one section's body, byte-sliced from `_doc.<slug>.md`).
|
|
441
|
-
pub fn handle_doc_get(arguments: &Value) -> Result<String> {
|
|
442
|
-
let project_dir = resolve_project_dir(arguments)?;
|
|
443
|
-
let handoff = ensure_handoff_exists(&project_dir)?;
|
|
444
|
-
|
|
445
|
-
let doc_id = arguments
|
|
446
|
-
.get("doc_id")
|
|
447
|
-
.and_then(|v| v.as_str())
|
|
448
|
-
.ok_or_else(|| anyhow::anyhow!("'doc_id' is required"))?;
|
|
449
|
-
|
|
450
|
-
let format = arguments
|
|
451
|
-
.get("format")
|
|
452
|
-
.and_then(|v| v.as_str())
|
|
453
|
-
.unwrap_or("full");
|
|
454
|
-
|
|
455
|
-
match format {
|
|
456
|
-
"meta" => {
|
|
457
|
-
let doc = resolve_doc(&handoff, doc_id)?
|
|
458
|
-
.ok_or_else(|| anyhow::anyhow!("Document not found: {doc_id}"))?;
|
|
459
|
-
Ok(to_json(&doc_metadata_json(&doc)))
|
|
460
|
-
}
|
|
461
|
-
"section" | "fragment" => {
|
|
462
|
-
let seq = arguments
|
|
463
|
-
.get("seq")
|
|
464
|
-
.and_then(|v| v.as_u64())
|
|
465
|
-
.ok_or_else(|| anyhow::anyhow!("'seq' is required when format='section'"))?
|
|
466
|
-
as usize;
|
|
467
|
-
let doc = resolve_doc(&handoff, doc_id)?
|
|
468
|
-
.ok_or_else(|| anyhow::anyhow!("Document not found: {doc_id}"))?;
|
|
469
|
-
let section =
|
|
470
|
-
doc.sections.iter().find(|s| s.seq == seq).ok_or_else(|| {
|
|
471
|
-
anyhow::anyhow!("Section not found: doc_id={doc_id} seq={seq}")
|
|
472
|
-
})?;
|
|
473
|
-
let body = read_doc_body(&handoff, &doc.slug)?.ok_or_else(|| {
|
|
474
|
-
anyhow::anyhow!("Document body file missing for slug '{}'", doc.slug)
|
|
475
|
-
})?;
|
|
476
|
-
let section_body = extract_section(&body, section)?;
|
|
477
|
-
Ok(to_json(&json!({
|
|
478
|
-
"doc_id": doc.id,
|
|
479
|
-
"seq": section.seq,
|
|
480
|
-
"heading": section.heading,
|
|
481
|
-
"level": section.level,
|
|
482
|
-
"content_hash": section.content_hash,
|
|
483
|
-
"body": section_body,
|
|
484
|
-
})))
|
|
485
|
-
}
|
|
486
|
-
_ => {
|
|
487
|
-
let doc = resolve_doc(&handoff, doc_id)?
|
|
488
|
-
.ok_or_else(|| anyhow::anyhow!("Document not found: {doc_id}"))?;
|
|
489
|
-
let body = read_full_body(&handoff, &doc)?.unwrap_or_default();
|
|
490
|
-
let mut out = doc_metadata_json(&doc);
|
|
491
|
-
out["body"] = json!(body);
|
|
492
|
-
Ok(to_json(&out))
|
|
493
|
-
}
|
|
494
|
-
}
|
|
495
|
-
}
|
|
496
|
-
|
|
497
|
-
/// `handoff_doc_list` — list/search documents with optional `doc_type` /
|
|
498
|
-
/// `tags` (AND) / `task_id` filters, BM25 `query` ranking, and optional
|
|
499
|
-
/// reassembled `body` inclusion.
|
|
500
|
-
pub fn handle_doc_list(arguments: &Value) -> Result<String> {
|
|
501
|
-
let project_dir = resolve_project_dir(arguments)?;
|
|
502
|
-
let handoff = ensure_handoff_exists(&project_dir)?;
|
|
503
|
-
|
|
504
|
-
let doc_type = arguments.get("doc_type").and_then(|v| v.as_str());
|
|
505
|
-
let tags = arguments.get("tags").map(string_array_value);
|
|
506
|
-
let task_id = arguments.get("task_id").and_then(|v| v.as_str());
|
|
507
|
-
let include_body = arguments
|
|
508
|
-
.get("include_body")
|
|
509
|
-
.and_then(|v| v.as_bool())
|
|
510
|
-
.unwrap_or(false);
|
|
511
|
-
let query = arguments
|
|
512
|
-
.get("query")
|
|
513
|
-
.and_then(|v| v.as_str())
|
|
514
|
-
.map(str::trim)
|
|
515
|
-
.filter(|s| !s.is_empty());
|
|
516
|
-
|
|
517
|
-
let mut docs = read_all_docs(&handoff)?;
|
|
518
|
-
if let Some(dt) = doc_type {
|
|
519
|
-
docs.retain(|d| d.doc_type == dt);
|
|
520
|
-
}
|
|
521
|
-
if let Some(tags) = &tags {
|
|
522
|
-
if !tags.is_empty() {
|
|
523
|
-
docs.retain(|d| tags.iter().all(|t| d.tags.contains(t)));
|
|
524
|
-
}
|
|
525
|
-
}
|
|
526
|
-
if let Some(tid) = task_id {
|
|
527
|
-
docs.retain(|d| d.task_ids.iter().any(|t| t == tid));
|
|
528
|
-
}
|
|
529
|
-
|
|
530
|
-
let ordered_indices: Vec<usize> = if let Some(q) = query {
|
|
531
|
-
rank_docs_by_query(&handoff, &docs, q)?
|
|
532
|
-
} else {
|
|
533
|
-
(0..docs.len()).collect()
|
|
534
|
-
};
|
|
535
|
-
|
|
536
|
-
let mut out_docs = Vec::with_capacity(ordered_indices.len());
|
|
537
|
-
for idx in ordered_indices {
|
|
538
|
-
let d = &docs[idx];
|
|
539
|
-
let mut entry = doc_metadata_json(d);
|
|
540
|
-
if include_body {
|
|
541
|
-
let body = read_full_body(&handoff, d)?.unwrap_or_default();
|
|
542
|
-
entry["body"] = json!(body);
|
|
543
|
-
}
|
|
544
|
-
out_docs.push(entry);
|
|
545
|
-
}
|
|
546
|
-
|
|
547
|
-
Ok(to_json(&json!({ "documents": out_docs })))
|
|
548
|
-
}
|
|
549
|
-
|
|
550
|
-
/// Ranks `docs` against `query` via BM25 over each document's index text
|
|
551
|
-
/// (title + tags + body), returning original-order indices sorted by
|
|
552
|
-
/// descending relevance. Corpus is built fresh every call (no cache — the
|
|
553
|
-
/// cache is reserved for `doc_query`, t96.3, per the task's own note).
|
|
554
|
-
fn rank_docs_by_query(handoff: &Path, docs: &[DocMetadata], query: &str) -> Result<Vec<usize>> {
|
|
555
|
-
let mut index_texts = Vec::with_capacity(docs.len());
|
|
556
|
-
for d in docs {
|
|
557
|
-
let body = read_doc_body(handoff, &d.slug)?.unwrap_or_default();
|
|
558
|
-
let mut text = d.title.clone();
|
|
559
|
-
text.push(' ');
|
|
560
|
-
text.push_str(&d.tags.join(" "));
|
|
561
|
-
text.push(' ');
|
|
562
|
-
text.push_str(&body);
|
|
563
|
-
index_texts.push(text);
|
|
564
|
-
}
|
|
565
|
-
|
|
566
|
-
let corpus = lexsim::Corpus::build_weighted(&index_texts);
|
|
567
|
-
let query_tokens = lexsim::tokenize_weighted(query);
|
|
568
|
-
let scope_paths: Vec<Vec<String>> = docs.iter().map(|d| d.scope_paths.clone()).collect();
|
|
569
|
-
let config = RankConfig {
|
|
570
|
-
min_score: DOC_QUERY_MIN_SCORE,
|
|
571
|
-
relative_threshold: 0.0,
|
|
572
|
-
scope_path_bonus: SCOPE_PATH_BONUS,
|
|
573
|
-
limit: docs.len(),
|
|
574
|
-
};
|
|
575
|
-
let ranked = rank_by_bm25_and_scope(&corpus, &query_tokens, &scope_paths, &[], &config);
|
|
576
|
-
Ok(ranked.into_iter().map(|item| item.index).collect())
|
|
577
|
-
}
|
|
578
|
-
|
|
579
|
-
/// `handoff_doc_delete` — delete a document (by `doc_id` or `slug`) and its
|
|
580
|
-
/// body file, unlink it from any linked tasks, remove it from its parent's
|
|
581
|
-
/// `children`, and orphan (clear `parent_id` on) any of its own children.
|
|
582
|
-
/// See `wiki/130-document-management.md` §5.4.
|
|
583
|
-
pub fn handle_doc_delete(arguments: &Value) -> Result<String> {
|
|
584
|
-
let project_dir = resolve_project_dir(arguments)?;
|
|
585
|
-
let handoff = ensure_handoff_exists(&project_dir)?;
|
|
586
|
-
|
|
587
|
-
let doc_id = arguments
|
|
588
|
-
.get("doc_id")
|
|
589
|
-
.and_then(|v| v.as_str())
|
|
590
|
-
.ok_or_else(|| anyhow::anyhow!("'doc_id' is required"))?;
|
|
591
|
-
|
|
592
|
-
let doc = resolve_doc(&handoff, doc_id)?
|
|
593
|
-
.ok_or_else(|| anyhow::anyhow!("Document not found: {doc_id}"))?;
|
|
594
|
-
|
|
595
|
-
let mut warnings: Vec<String> = Vec::new();
|
|
596
|
-
|
|
597
|
-
delete_doc_body(&handoff, &doc.slug)?;
|
|
598
|
-
delete_doc(&handoff, &doc.slug)?;
|
|
599
|
-
|
|
600
|
-
if !doc.task_ids.is_empty() {
|
|
601
|
-
let tasks_dir = handoff.join("tasks");
|
|
602
|
-
let report = sync_doc_task_links(&tasks_dir, &doc.id, &doc.title, &[], &doc.task_ids)?;
|
|
603
|
-
if !report.unresolved.is_empty() {
|
|
604
|
-
warnings.push(format!(
|
|
605
|
-
"Could not resolve task id(s) for unlinking: {}",
|
|
606
|
-
report.unresolved.join(", ")
|
|
607
|
-
));
|
|
608
|
-
}
|
|
609
|
-
}
|
|
610
|
-
|
|
611
|
-
if let Some(parent_id) = &doc.parent_id {
|
|
612
|
-
if let Some(mut parent) = find_doc_by_id(&handoff, parent_id)? {
|
|
613
|
-
let before = parent.children.len();
|
|
614
|
-
parent.children.retain(|c| c != &doc.id);
|
|
615
|
-
if parent.children.len() != before {
|
|
616
|
-
write_doc(&handoff, &parent)?;
|
|
617
|
-
}
|
|
618
|
-
} else {
|
|
619
|
-
warnings.push(format!("Parent document not found: {parent_id}"));
|
|
620
|
-
}
|
|
621
|
-
}
|
|
622
|
-
|
|
623
|
-
for child_id in &doc.children {
|
|
624
|
-
if let Some(mut child) = find_doc_by_id(&handoff, child_id)? {
|
|
625
|
-
child.parent_id = None;
|
|
626
|
-
write_doc(&handoff, &child)?;
|
|
627
|
-
} else {
|
|
628
|
-
warnings.push(format!("Child document not found: {child_id}"));
|
|
629
|
-
}
|
|
630
|
-
}
|
|
631
|
-
|
|
632
|
-
crate::context::doc_corpus_cache()
|
|
633
|
-
.lock()
|
|
634
|
-
.expect("cache")
|
|
635
|
-
.increment_generation();
|
|
636
|
-
|
|
637
|
-
Ok(to_json(&json!({
|
|
638
|
-
"deleted": true,
|
|
639
|
-
"doc_id": doc.id,
|
|
640
|
-
"section_count": doc.sections.len(),
|
|
641
|
-
"warnings": warnings,
|
|
642
|
-
})))
|
|
643
|
-
}
|
|
644
|
-
|
|
645
|
-
/// `handoff_doc_reassemble` — read a document's (by `doc_id` or `slug`)
|
|
646
|
-
/// original Markdown body directly from `_doc.<slug>.md` (v5: the `.md` file
|
|
647
|
-
/// already *is* the original document, restoring BOM/frontmatter is the only
|
|
648
|
-
/// reassembly step left), and detect drift (the body's current content hash
|
|
649
|
-
/// no longer matches the recorded `content_hash` — e.g. edited directly
|
|
650
|
-
/// outside `doc_save`). See `wiki/130-document-management.md` §5.5.
|
|
651
|
-
pub fn handle_doc_reassemble(arguments: &Value) -> Result<String> {
|
|
652
|
-
let project_dir = resolve_project_dir(arguments)?;
|
|
653
|
-
let handoff = ensure_handoff_exists(&project_dir)?;
|
|
654
|
-
|
|
655
|
-
let doc_id = arguments
|
|
656
|
-
.get("doc_id")
|
|
657
|
-
.and_then(|v| v.as_str())
|
|
658
|
-
.ok_or_else(|| anyhow::anyhow!("'doc_id' is required"))?;
|
|
659
|
-
|
|
660
|
-
let doc = resolve_doc(&handoff, doc_id)?
|
|
661
|
-
.ok_or_else(|| anyhow::anyhow!("Document not found: {doc_id}"))?;
|
|
662
|
-
|
|
663
|
-
// `doc.content_hash` is recomputed fresh from the body on every read
|
|
664
|
-
// (t123.2), so comparing it to itself would never detect drift.
|
|
665
|
-
// `doc.source.canonical_hash` is the hash persisted at the *last
|
|
666
|
-
// `doc_save`* (untouched by the on-read recompute — see
|
|
667
|
-
// `storage::docs::read_doc`), so that's the correct "was this edited
|
|
668
|
-
// out-of-band since the last save" baseline.
|
|
669
|
-
let drifted = doc.source.canonical_hash.as_deref() != Some(doc.content_hash.as_str());
|
|
670
|
-
|
|
671
|
-
let body = read_full_body(&handoff, &doc)?.unwrap_or_default();
|
|
672
|
-
|
|
673
|
-
let output_path = arguments.get("output_path").and_then(|v| v.as_str());
|
|
674
|
-
let mut out = json!({
|
|
675
|
-
"doc_id": doc.id,
|
|
676
|
-
"body": body,
|
|
677
|
-
"drifted": drifted,
|
|
678
|
-
});
|
|
679
|
-
if let Some(path) = output_path {
|
|
680
|
-
std::fs::write(path, &body)
|
|
681
|
-
.with_context(|| format!("Failed to write reassembled document to {path}"))?;
|
|
682
|
-
out["output_path"] = json!(path);
|
|
683
|
-
}
|
|
684
|
-
|
|
685
|
-
Ok(to_json(&out))
|
|
686
|
-
}
|
|
687
|
-
|
|
688
|
-
/// `handoff_doc_tree` — traverse a document's family tree starting from
|
|
689
|
-
/// `doc_id`: its immediate parent (if any) plus `depth` levels of children,
|
|
690
|
-
/// optionally including its `related` (semantic) links. See
|
|
691
|
-
/// `wiki/130-document-management.md` §5.6.
|
|
692
|
-
pub fn handle_doc_tree(arguments: &Value) -> Result<String> {
|
|
693
|
-
let project_dir = resolve_project_dir(arguments)?;
|
|
694
|
-
let handoff = ensure_handoff_exists(&project_dir)?;
|
|
695
|
-
|
|
696
|
-
let doc_id = arguments
|
|
697
|
-
.get("doc_id")
|
|
698
|
-
.and_then(|v| v.as_str())
|
|
699
|
-
.ok_or_else(|| anyhow::anyhow!("'doc_id' is required"))?;
|
|
700
|
-
|
|
701
|
-
let depth = arguments
|
|
702
|
-
.get("depth")
|
|
703
|
-
.and_then(|v| v.as_u64())
|
|
704
|
-
.unwrap_or(DEFAULT_TREE_DEPTH);
|
|
705
|
-
|
|
706
|
-
let include_related = arguments
|
|
707
|
-
.get("include_related")
|
|
708
|
-
.and_then(|v| v.as_bool())
|
|
709
|
-
.unwrap_or(false);
|
|
710
|
-
|
|
711
|
-
let doc = resolve_doc(&handoff, doc_id)?
|
|
712
|
-
.ok_or_else(|| anyhow::anyhow!("Document not found: {doc_id}"))?;
|
|
713
|
-
|
|
714
|
-
let mut tree = doc_tree_node_json(&handoff, &doc, include_related)?;
|
|
715
|
-
|
|
716
|
-
let parent = match &doc.parent_id {
|
|
717
|
-
Some(parent_id) => find_doc_by_id(&handoff, parent_id)?.map(|p| doc_tree_summary_json(&p)),
|
|
718
|
-
None => None,
|
|
719
|
-
};
|
|
720
|
-
tree["parent"] = parent.unwrap_or(Value::Null);
|
|
721
|
-
|
|
722
|
-
tree["children"] = json!(doc_tree_children(
|
|
723
|
-
&handoff,
|
|
724
|
-
&doc.children,
|
|
725
|
-
depth,
|
|
726
|
-
include_related
|
|
727
|
-
)?);
|
|
728
|
-
|
|
729
|
-
Ok(to_json(&tree))
|
|
730
|
-
}
|
|
731
|
-
|
|
732
|
-
/// Default depth for `handoff_doc_tree` when `depth` is omitted (spec §5.6).
|
|
733
|
-
const DEFAULT_TREE_DEPTH: u64 = 2;
|
|
734
|
-
|
|
735
|
-
/// Recursively builds the `children` array for [`handle_doc_tree`], descending
|
|
736
|
-
/// up to `depth` levels. Missing child documents (broken link) are skipped
|
|
737
|
-
/// rather than erroring the whole traversal.
|
|
738
|
-
fn doc_tree_children(
|
|
739
|
-
handoff: &Path,
|
|
740
|
-
child_ids: &[String],
|
|
741
|
-
depth: u64,
|
|
742
|
-
include_related: bool,
|
|
743
|
-
) -> Result<Vec<Value>> {
|
|
744
|
-
if depth == 0 {
|
|
745
|
-
return Ok(Vec::new());
|
|
746
|
-
}
|
|
747
|
-
let mut out = Vec::with_capacity(child_ids.len());
|
|
748
|
-
for child_id in child_ids {
|
|
749
|
-
let Some(child) = find_doc_by_id(handoff, child_id)? else {
|
|
750
|
-
continue;
|
|
751
|
-
};
|
|
752
|
-
let mut node = doc_tree_node_json(handoff, &child, include_related)?;
|
|
753
|
-
node["children"] = json!(doc_tree_children(
|
|
754
|
-
handoff,
|
|
755
|
-
&child.children,
|
|
756
|
-
depth - 1,
|
|
757
|
-
include_related
|
|
758
|
-
)?);
|
|
759
|
-
out.push(node);
|
|
760
|
-
}
|
|
761
|
-
Ok(out)
|
|
762
|
-
}
|
|
763
|
-
|
|
764
|
-
/// Compact `{id, title, doc_type}` summary used for `parent` and family-tree
|
|
765
|
-
/// list entries (`related`) in `doc_tree`'s output.
|
|
766
|
-
fn doc_tree_summary_json(doc: &DocMetadata) -> Value {
|
|
767
|
-
json!({
|
|
768
|
-
"id": doc.id,
|
|
769
|
-
"title": doc.title,
|
|
770
|
-
"doc_type": doc.doc_type,
|
|
771
|
-
})
|
|
772
|
-
}
|
|
773
|
-
|
|
774
|
-
/// One node in the `doc_tree` output: id/title/doc_type plus (optionally)
|
|
775
|
-
/// `related` summaries (each resolved to `{id, rel, title}`). `children` is
|
|
776
|
-
/// populated by the caller afterward.
|
|
777
|
-
fn doc_tree_node_json(handoff: &Path, doc: &DocMetadata, include_related: bool) -> Result<Value> {
|
|
778
|
-
let mut related: Vec<Value> = Vec::new();
|
|
779
|
-
if include_related {
|
|
780
|
-
for r in &doc.related {
|
|
781
|
-
// related entries may point cross-tree/cross-project ids that
|
|
782
|
-
// don't resolve locally; that lookup is deferred to a future
|
|
783
|
-
// resolver (spec §10.3) — for now a related id that can't be
|
|
784
|
-
// read from this project's docs/ is a no-op skip, matching the
|
|
785
|
-
// same lenient policy as read_all_docs.
|
|
786
|
-
let Some(target) = find_doc_by_id(handoff, &r.id)? else {
|
|
787
|
-
continue;
|
|
788
|
-
};
|
|
789
|
-
related.push(json!({ "id": r.id, "rel": r.rel, "title": target.title }));
|
|
790
|
-
}
|
|
791
|
-
}
|
|
792
|
-
Ok(json!({
|
|
793
|
-
"id": doc.id,
|
|
794
|
-
"title": doc.title,
|
|
795
|
-
"doc_type": doc.doc_type,
|
|
796
|
-
"children": [],
|
|
797
|
-
"related": related,
|
|
798
|
-
}))
|
|
799
|
-
}
|
|
800
|
-
|
|
801
|
-
/// Recomputes `Verification.status` from its items (wiki/140-verification-matrix.md
|
|
802
|
-
/// §3.3): all pending -> "pending"; all verified/skipped -> "verified";
|
|
803
|
-
/// otherwise -> "in_review". v2 (§7.4): an item with `sub_items` is judged by
|
|
804
|
-
/// its sub_items' aggregate effective status, not its own `status` field —
|
|
805
|
-
/// see `item_effective_status`.
|
|
806
|
-
fn recompute_verification_status(items: &[VerificationItem]) -> String {
|
|
807
|
-
let statuses: Vec<String> = items.iter().map(item_effective_status).collect();
|
|
808
|
-
if statuses.iter().all(|s| s == "pending") {
|
|
809
|
-
"pending".to_string()
|
|
810
|
-
} else if statuses.iter().all(|s| s == "verified" || s == "skipped") {
|
|
811
|
-
"verified".to_string()
|
|
812
|
-
} else {
|
|
813
|
-
"in_review".to_string()
|
|
814
|
-
}
|
|
815
|
-
}
|
|
816
|
-
|
|
817
|
-
/// v2 (§7.4): the effective status of a `VerificationItem` for the purposes
|
|
818
|
-
/// of the parent `Verification.status` rollup. An item with no `sub_items`
|
|
819
|
-
/// uses its own `status` unchanged (v1 behavior). An item with `sub_items`
|
|
820
|
-
/// is judged by their aggregate: all verified/skipped -> "verified", all
|
|
821
|
-
/// pending -> "pending", otherwise -> "in_review" (a partial mix, distinct
|
|
822
|
-
/// from "pending").
|
|
823
|
-
fn item_effective_status(item: &VerificationItem) -> String {
|
|
824
|
-
if item.sub_items.is_empty() {
|
|
825
|
-
return item.status.clone();
|
|
826
|
-
}
|
|
827
|
-
if item
|
|
828
|
-
.sub_items
|
|
829
|
-
.iter()
|
|
830
|
-
.all(|s| s.status == "verified" || s.status == "skipped")
|
|
831
|
-
{
|
|
832
|
-
"verified".to_string()
|
|
833
|
-
} else if item.sub_items.iter().all(|s| s.status == "pending") {
|
|
834
|
-
"pending".to_string()
|
|
835
|
-
} else {
|
|
836
|
-
"in_review".to_string()
|
|
837
|
-
}
|
|
838
|
-
}
|
|
839
|
-
|
|
840
|
-
/// Parses a JSON array of `{path, lines?, label?}` objects into `CodeRef`s.
|
|
841
|
-
/// Entries missing the required `path` are skipped.
|
|
842
|
-
fn code_refs_from_value(v: &Value) -> Vec<CodeRef> {
|
|
843
|
-
v.as_array()
|
|
844
|
-
.map(|arr| {
|
|
845
|
-
arr.iter()
|
|
846
|
-
.filter_map(|r| {
|
|
847
|
-
let path = r.get("path").and_then(|v| v.as_str())?.to_string();
|
|
848
|
-
Some(CodeRef {
|
|
849
|
-
path,
|
|
850
|
-
lines: r.get("lines").and_then(|v| v.as_str()).map(str::to_string),
|
|
851
|
-
label: r.get("label").and_then(|v| v.as_str()).map(str::to_string),
|
|
852
|
-
})
|
|
853
|
-
})
|
|
854
|
-
.collect()
|
|
855
|
-
})
|
|
856
|
-
.unwrap_or_default()
|
|
857
|
-
}
|
|
858
|
-
|
|
859
|
-
/// Source file extensions `suggest_refs` scans for impl/test definitions
|
|
860
|
-
/// (t124.6). Kept as a small fixed list rather than "every file" so a scan
|
|
861
|
-
/// stays fast and doesn't surface binary/asset noise; extend here if a
|
|
862
|
-
/// project needs another language.
|
|
863
|
-
const SUGGEST_REFS_EXTENSIONS: &[&str] = &["rs", "ts", "tsx", "py", "go", "js", "jsx"];
|
|
864
|
-
|
|
865
|
-
/// Maximum number of impl_ref/test_ref candidates returned per verification
|
|
866
|
-
/// item by `suggest_refs` (spec: "Return at most ~20 suggestions per item to
|
|
867
|
-
/// avoid overwhelming output"), applied independently to each of the two
|
|
868
|
-
/// lists.
|
|
869
|
-
const SUGGEST_REFS_MAX_PER_ITEM: usize = 20;
|
|
870
|
-
|
|
871
|
-
/// A single scanned definition (function/struct/impl/mod, or a test
|
|
872
|
-
/// function) found while walking `scope_paths` — the raw material
|
|
873
|
-
/// `suggest_refs` matches against verification item headings.
|
|
874
|
-
struct ScannedDefinition {
|
|
875
|
-
/// Path to the source file, relative to the project root (matches the
|
|
876
|
-
/// `path` shape already used by `CodeRef`/`set_refs`).
|
|
877
|
-
rel_path: String,
|
|
878
|
-
/// The identifier name found after the defining keyword (e.g. the `foo`
|
|
879
|
-
/// in `fn foo(...)`), used for the heading fuzzy-match.
|
|
880
|
-
name: String,
|
|
881
|
-
/// 1-based line number the definition starts on, used to build the
|
|
882
|
-
/// `lines` hint on the suggested `CodeRef`.
|
|
883
|
-
line: usize,
|
|
884
|
-
}
|
|
885
|
-
|
|
886
|
-
/// Walks `doc.scope_paths` under `project_dir` and, for every verification
|
|
887
|
-
/// item, returns impl/test ref candidates whose definition name fuzzy-
|
|
888
|
-
/// matches the item's heading (t124.6). Read-only — never touches the
|
|
889
|
-
/// document or the filesystem beyond reading source files.
|
|
890
|
-
fn suggest_refs(project_dir: &Path, doc: &DocMetadata, v: &Verification) -> Vec<Value> {
|
|
891
|
-
let files = scan_scope_files(project_dir, &doc.scope_paths);
|
|
892
|
-
let (impl_defs, test_defs) = scan_definitions(project_dir, &files);
|
|
893
|
-
|
|
894
|
-
v.items
|
|
895
|
-
.iter()
|
|
896
|
-
.map(|item| {
|
|
897
|
-
let heading = item.label.clone().unwrap_or_else(|| item.heading.clone());
|
|
898
|
-
let keywords = heading_keywords(&heading);
|
|
899
|
-
|
|
900
|
-
let suggested_impl_refs = match_definitions(&impl_defs, &keywords);
|
|
901
|
-
let suggested_test_refs = match_definitions(&test_defs, &keywords);
|
|
902
|
-
|
|
903
|
-
json!({
|
|
904
|
-
"fragment_seq": item.fragment_seq,
|
|
905
|
-
"heading": item.heading,
|
|
906
|
-
"suggested_impl_refs": suggested_impl_refs,
|
|
907
|
-
"suggested_test_refs": suggested_test_refs,
|
|
908
|
-
})
|
|
909
|
-
})
|
|
910
|
-
.collect()
|
|
911
|
-
}
|
|
912
|
-
|
|
913
|
-
/// Recursively collects every file under `project_dir` whose relative path
|
|
914
|
-
/// starts with one of `scope_paths` (prefix match, spec: "Look for files
|
|
915
|
-
/// matching scope_paths patterns") and whose extension is in
|
|
916
|
-
/// [`SUGGEST_REFS_EXTENSIONS`]. `scope_paths` entries are relative to
|
|
917
|
-
/// `project_dir` (e.g. `src/mcp/handlers/`), matching how `scope_paths` is
|
|
918
|
-
/// documented and used elsewhere (BM25 scope bonus, shared-scope graph
|
|
919
|
-
/// edges).
|
|
920
|
-
fn scan_scope_files(project_dir: &Path, scope_paths: &[String]) -> Vec<std::path::PathBuf> {
|
|
921
|
-
let mut out = Vec::new();
|
|
922
|
-
for scope in scope_paths {
|
|
923
|
-
let root = project_dir.join(scope);
|
|
924
|
-
walk_dir(&root, &mut out);
|
|
925
|
-
}
|
|
926
|
-
out
|
|
927
|
-
}
|
|
928
|
-
|
|
929
|
-
fn walk_dir(path: &Path, out: &mut Vec<std::path::PathBuf>) {
|
|
930
|
-
if path.is_file() {
|
|
931
|
-
if has_suggest_refs_extension(path) {
|
|
932
|
-
out.push(path.to_path_buf());
|
|
933
|
-
}
|
|
934
|
-
return;
|
|
935
|
-
}
|
|
936
|
-
let Ok(entries) = std::fs::read_dir(path) else {
|
|
937
|
-
return;
|
|
938
|
-
};
|
|
939
|
-
for entry in entries.flatten() {
|
|
940
|
-
let p = entry.path();
|
|
941
|
-
if p.is_dir() {
|
|
942
|
-
walk_dir(&p, out);
|
|
943
|
-
} else if has_suggest_refs_extension(&p) {
|
|
944
|
-
out.push(p);
|
|
945
|
-
}
|
|
946
|
-
}
|
|
947
|
-
}
|
|
948
|
-
|
|
949
|
-
fn has_suggest_refs_extension(path: &Path) -> bool {
|
|
950
|
-
path.extension()
|
|
951
|
-
.and_then(|e| e.to_str())
|
|
952
|
-
.is_some_and(|ext| SUGGEST_REFS_EXTENSIONS.contains(&ext))
|
|
953
|
-
}
|
|
954
|
-
|
|
955
|
-
/// Splits `files` into impl definitions (fn/struct/impl/mod) and test
|
|
956
|
-
/// definitions (files under a `tests/` dir, named `*_test.*`/`test_*`, or
|
|
957
|
-
/// containing `#[test]`/`#[cfg(test)]`-marked functions), per-file, by
|
|
958
|
-
/// scanning each line with the heuristics from the task spec.
|
|
959
|
-
fn scan_definitions(
|
|
960
|
-
project_dir: &Path,
|
|
961
|
-
files: &[std::path::PathBuf],
|
|
962
|
-
) -> (Vec<ScannedDefinition>, Vec<ScannedDefinition>) {
|
|
963
|
-
let mut impl_defs = Vec::new();
|
|
964
|
-
let mut test_defs = Vec::new();
|
|
965
|
-
|
|
966
|
-
for file in files {
|
|
967
|
-
let Ok(content) = std::fs::read_to_string(file) else {
|
|
968
|
-
continue;
|
|
969
|
-
};
|
|
970
|
-
let rel_path = file
|
|
971
|
-
.strip_prefix(project_dir)
|
|
972
|
-
.unwrap_or(file)
|
|
973
|
-
.to_string_lossy()
|
|
974
|
-
.replace('\\', "/");
|
|
975
|
-
let is_test_file = is_test_path(&rel_path);
|
|
976
|
-
|
|
977
|
-
let mut next_is_test_fn = false;
|
|
978
|
-
for (idx, line) in content.lines().enumerate() {
|
|
979
|
-
let trimmed = line.trim_start();
|
|
980
|
-
let line_no = idx + 1;
|
|
981
|
-
|
|
982
|
-
if trimmed.starts_with("#[test]") || trimmed.starts_with("#[cfg(test)]") {
|
|
983
|
-
next_is_test_fn = true;
|
|
984
|
-
continue;
|
|
985
|
-
}
|
|
986
|
-
|
|
987
|
-
if let Some(name) = extract_test_fn_name(trimmed) {
|
|
988
|
-
if is_test_file || next_is_test_fn {
|
|
989
|
-
test_defs.push(ScannedDefinition {
|
|
990
|
-
rel_path: rel_path.clone(),
|
|
991
|
-
name,
|
|
992
|
-
line: line_no,
|
|
993
|
-
});
|
|
994
|
-
}
|
|
995
|
-
next_is_test_fn = false;
|
|
996
|
-
continue;
|
|
997
|
-
}
|
|
998
|
-
next_is_test_fn = false;
|
|
999
|
-
|
|
1000
|
-
if let Some(name) = extract_impl_def_name(trimmed) {
|
|
1001
|
-
let bucket = if is_test_file {
|
|
1002
|
-
&mut test_defs
|
|
1003
|
-
} else {
|
|
1004
|
-
&mut impl_defs
|
|
1005
|
-
};
|
|
1006
|
-
bucket.push(ScannedDefinition {
|
|
1007
|
-
rel_path: rel_path.clone(),
|
|
1008
|
-
name,
|
|
1009
|
-
line: line_no,
|
|
1010
|
-
});
|
|
1011
|
-
}
|
|
1012
|
-
}
|
|
1013
|
-
}
|
|
1014
|
-
|
|
1015
|
-
(impl_defs, test_defs)
|
|
1016
|
-
}
|
|
1017
|
-
|
|
1018
|
-
fn is_test_path(rel_path: &str) -> bool {
|
|
1019
|
-
let lower = rel_path.to_ascii_lowercase();
|
|
1020
|
-
lower.split('/').any(|seg| seg == "tests" || seg == "test")
|
|
1021
|
-
|| lower.contains("_test.")
|
|
1022
|
-
|| lower.contains("/test_")
|
|
1023
|
-
|| lower.starts_with("test_")
|
|
1024
|
-
}
|
|
1025
|
-
|
|
1026
|
-
/// Recognizes `fn test_*` / `def test_*` test-function definitions
|
|
1027
|
-
/// (spec: "`fn test_`") regardless of visibility/async modifiers.
|
|
1028
|
-
fn extract_test_fn_name(trimmed: &str) -> Option<String> {
|
|
1029
|
-
for prefix in ["pub async fn ", "pub fn ", "async fn ", "fn ", "def "] {
|
|
1030
|
-
if let Some(rest) = trimmed.strip_prefix(prefix) {
|
|
1031
|
-
if rest.trim_start().starts_with("test_") {
|
|
1032
|
-
return extract_identifier(rest);
|
|
1033
|
-
}
|
|
1034
|
-
}
|
|
1035
|
-
}
|
|
1036
|
-
None
|
|
1037
|
-
}
|
|
1038
|
-
|
|
1039
|
-
/// Recognizes impl-style definitions the spec calls out: `fn `, `pub fn `,
|
|
1040
|
-
/// `struct `, `impl `, `mod `.
|
|
1041
|
-
fn extract_impl_def_name(trimmed: &str) -> Option<String> {
|
|
1042
|
-
for prefix in [
|
|
1043
|
-
"pub async fn ",
|
|
1044
|
-
"pub fn ",
|
|
1045
|
-
"async fn ",
|
|
1046
|
-
"fn ",
|
|
1047
|
-
"pub struct ",
|
|
1048
|
-
"struct ",
|
|
1049
|
-
"pub mod ",
|
|
1050
|
-
"mod ",
|
|
1051
|
-
"impl ",
|
|
1052
|
-
] {
|
|
1053
|
-
if let Some(rest) = trimmed.strip_prefix(prefix) {
|
|
1054
|
-
return extract_identifier(rest);
|
|
1055
|
-
}
|
|
1056
|
-
}
|
|
1057
|
-
None
|
|
1058
|
-
}
|
|
1059
|
-
|
|
1060
|
-
/// Pulls the leading identifier (`[A-Za-z0-9_]+`) off the start of `rest`,
|
|
1061
|
-
/// e.g. `"foo(bar: &str) {"` -> `"foo"`, `"Foo<T> for Bar"` -> `"Foo"`.
|
|
1062
|
-
fn extract_identifier(rest: &str) -> Option<String> {
|
|
1063
|
-
let ident: String = rest
|
|
1064
|
-
.chars()
|
|
1065
|
-
.take_while(|c| c.is_alphanumeric() || *c == '_')
|
|
1066
|
-
.collect();
|
|
1067
|
-
if ident.is_empty() {
|
|
1068
|
-
None
|
|
1069
|
-
} else {
|
|
1070
|
-
Some(ident)
|
|
1071
|
-
}
|
|
1072
|
-
}
|
|
1073
|
-
|
|
1074
|
-
/// Splits a heading into lowercase keyword tokens for the fuzzy match
|
|
1075
|
-
/// (spec: "case-insensitive substring match"), dropping short/common words
|
|
1076
|
-
/// that would otherwise match almost every identifier.
|
|
1077
|
-
fn heading_keywords(heading: &str) -> Vec<String> {
|
|
1078
|
-
const STOPWORDS: &[&str] = &["the", "a", "an", "of", "to", "and", "or", "for", "in", "on"];
|
|
1079
|
-
heading
|
|
1080
|
-
.split(|c: char| !c.is_alphanumeric())
|
|
1081
|
-
.map(|w| w.to_ascii_lowercase())
|
|
1082
|
-
.filter(|w| w.len() > 2 && !STOPWORDS.contains(&w.as_str()))
|
|
1083
|
-
.collect()
|
|
1084
|
-
}
|
|
1085
|
-
|
|
1086
|
-
/// Matches `defs` whose `name` (case-insensitive) contains any of
|
|
1087
|
-
/// `keywords` as a substring, deduplicates by `(path, name)`, and caps the
|
|
1088
|
-
/// result at [`SUGGEST_REFS_MAX_PER_ITEM`].
|
|
1089
|
-
fn match_definitions(defs: &[ScannedDefinition], keywords: &[String]) -> Vec<Value> {
|
|
1090
|
-
if keywords.is_empty() {
|
|
1091
|
-
return Vec::new();
|
|
1092
|
-
}
|
|
1093
|
-
let mut seen = std::collections::HashSet::new();
|
|
1094
|
-
let mut out = Vec::new();
|
|
1095
|
-
for def in defs {
|
|
1096
|
-
let name_lower = def.name.to_ascii_lowercase();
|
|
1097
|
-
let matches = keywords.iter().any(|kw| name_lower.contains(kw.as_str()));
|
|
1098
|
-
if !matches {
|
|
1099
|
-
continue;
|
|
1100
|
-
}
|
|
1101
|
-
let key = (def.rel_path.clone(), def.name.clone());
|
|
1102
|
-
if !seen.insert(key) {
|
|
1103
|
-
continue;
|
|
1104
|
-
}
|
|
1105
|
-
out.push(json!({
|
|
1106
|
-
"path": def.rel_path,
|
|
1107
|
-
"lines": def.line.to_string(),
|
|
1108
|
-
"label": def.name,
|
|
1109
|
-
}));
|
|
1110
|
-
if out.len() >= SUGGEST_REFS_MAX_PER_ITEM {
|
|
1111
|
-
break;
|
|
1112
|
-
}
|
|
1113
|
-
}
|
|
1114
|
-
out
|
|
1115
|
-
}
|
|
1116
|
-
|
|
1117
|
-
/// Summary counts used by both `doc_verify`'s mutation response and
|
|
1118
|
-
/// `doc_verify_status`'s `progress` block.
|
|
1119
|
-
struct VerificationCounts {
|
|
1120
|
-
checked: usize,
|
|
1121
|
-
skipped: usize,
|
|
1122
|
-
pending: usize,
|
|
1123
|
-
total: usize,
|
|
1124
|
-
stale: usize,
|
|
1125
|
-
}
|
|
1126
|
-
|
|
1127
|
-
/// v2 (§7.4): counts every leaf verification unit — a top-level item that
|
|
1128
|
-
/// has no `sub_items` counts itself directly (v1 behavior, includes freeform
|
|
1129
|
-
/// items), while an item that *does* have `sub_items` counts each sub_item
|
|
1130
|
-
/// instead of itself (so `total` is "top-level item count (leaf-only) + all
|
|
1131
|
-
/// sub_items count", matching the spec's "トップレベル + 全 sub_items の合計").
|
|
1132
|
-
fn count_verification(doc: &DocMetadata, v: &Verification) -> VerificationCounts {
|
|
1133
|
-
let mut checked = 0;
|
|
1134
|
-
let mut skipped = 0;
|
|
1135
|
-
let mut pending = 0;
|
|
1136
|
-
let mut stale = 0;
|
|
1137
|
-
|
|
1138
|
-
for item in &v.items {
|
|
1139
|
-
if item.sub_items.is_empty() {
|
|
1140
|
-
match item.status.as_str() {
|
|
1141
|
-
"verified" => checked += 1,
|
|
1142
|
-
"skipped" => skipped += 1,
|
|
1143
|
-
_ => pending += 1,
|
|
1144
|
-
}
|
|
1145
|
-
} else {
|
|
1146
|
-
for sub in &item.sub_items {
|
|
1147
|
-
match sub.status.as_str() {
|
|
1148
|
-
"verified" => checked += 1,
|
|
1149
|
-
"skipped" => skipped += 1,
|
|
1150
|
-
_ => pending += 1,
|
|
1151
|
-
}
|
|
1152
|
-
}
|
|
1153
|
-
}
|
|
1154
|
-
if item_is_stale(doc, item) {
|
|
1155
|
-
stale += 1;
|
|
1156
|
-
}
|
|
1157
|
-
}
|
|
1158
|
-
|
|
1159
|
-
VerificationCounts {
|
|
1160
|
-
checked,
|
|
1161
|
-
skipped,
|
|
1162
|
-
pending,
|
|
1163
|
-
total: checked + skipped + pending,
|
|
1164
|
-
stale,
|
|
1165
|
-
}
|
|
1166
|
-
}
|
|
1167
|
-
|
|
1168
|
-
/// An item is stale when it was verified at a content_hash that no longer
|
|
1169
|
-
/// matches its section's current content_hash (spec §3.5) — items never
|
|
1170
|
-
/// verified (`content_hash_at_verify: None`) are never stale, items whose
|
|
1171
|
-
/// section has been removed (sync should have dropped them, but be
|
|
1172
|
-
/// defensive) are treated as stale so drift is never silently hidden, and
|
|
1173
|
-
/// freeform items (v2, `fragment_seq: None`) are never stale since they are
|
|
1174
|
-
/// not tied to any section's content_hash.
|
|
1175
|
-
fn item_is_stale(doc: &DocMetadata, item: &VerificationItem) -> bool {
|
|
1176
|
-
let Some(hash_at_verify) = &item.content_hash_at_verify else {
|
|
1177
|
-
return false;
|
|
1178
|
-
};
|
|
1179
|
-
let Some(fragment_seq) = item.fragment_seq else {
|
|
1180
|
-
return false;
|
|
1181
|
-
};
|
|
1182
|
-
match doc.sections.iter().find(|s| s.seq == fragment_seq) {
|
|
1183
|
-
Some(section) => §ion.content_hash != hash_at_verify,
|
|
1184
|
-
None => true,
|
|
1185
|
-
}
|
|
1186
|
-
}
|
|
1187
|
-
|
|
1188
|
-
/// `handoff_doc_verify` — generate/check/skip/sync/set_refs a document's
|
|
1189
|
-
/// verification matrix (wiki/140-verification-matrix.md §4.1).
|
|
1190
|
-
pub fn handle_doc_verify(arguments: &Value) -> Result<String> {
|
|
1191
|
-
let project_dir = resolve_project_dir(arguments)?;
|
|
1192
|
-
let handoff = ensure_handoff_exists(&project_dir)?;
|
|
1193
|
-
|
|
1194
|
-
let doc_id = arguments
|
|
1195
|
-
.get("doc_id")
|
|
1196
|
-
.and_then(|v| v.as_str())
|
|
1197
|
-
.ok_or_else(|| anyhow::anyhow!("'doc_id' is required"))?;
|
|
1198
|
-
let action = arguments
|
|
1199
|
-
.get("action")
|
|
1200
|
-
.and_then(|v| v.as_str())
|
|
1201
|
-
.ok_or_else(|| anyhow::anyhow!("'action' is required"))?;
|
|
1202
|
-
|
|
1203
|
-
let mut doc = resolve_doc(&handoff, doc_id)?
|
|
1204
|
-
.ok_or_else(|| anyhow::anyhow!("Document not found: {doc_id}"))?;
|
|
1205
|
-
|
|
1206
|
-
// `suggest_refs` is read-only (it never mutates the verification matrix,
|
|
1207
|
-
// only proposes candidates for the caller to feed into `set_refs`), and
|
|
1208
|
-
// its response shape (a `suggestions` list) differs from every other
|
|
1209
|
-
// action's mutation-count summary — handled separately, before the
|
|
1210
|
-
// shared mutate-then-write flow below.
|
|
1211
|
-
if action == "suggest_refs" {
|
|
1212
|
-
let v = doc.verification.as_ref().ok_or_else(|| {
|
|
1213
|
-
anyhow::anyhow!(
|
|
1214
|
-
"No verification matrix exists for document {doc_id}; use action='generate' first"
|
|
1215
|
-
)
|
|
1216
|
-
})?;
|
|
1217
|
-
let suggestions = suggest_refs(&project_dir, &doc, v);
|
|
1218
|
-
return Ok(to_json(&json!({
|
|
1219
|
-
"doc_id": doc.id,
|
|
1220
|
-
"suggestions": suggestions,
|
|
1221
|
-
})));
|
|
1222
|
-
}
|
|
1223
|
-
|
|
1224
|
-
let now = chrono::Utc::now().to_rfc3339();
|
|
1225
|
-
|
|
1226
|
-
match action {
|
|
1227
|
-
"generate" => {
|
|
1228
|
-
if doc.verification.is_some() {
|
|
1229
|
-
anyhow::bail!(
|
|
1230
|
-
"Verification matrix already exists for document {doc_id}; use action='sync' to re-sync it instead"
|
|
1231
|
-
);
|
|
1232
|
-
}
|
|
1233
|
-
let skip_seqs: Vec<usize> = arguments
|
|
1234
|
-
.get("skip_seqs")
|
|
1235
|
-
.and_then(|v| v.as_array())
|
|
1236
|
-
.map(|arr| {
|
|
1237
|
-
arr.iter()
|
|
1238
|
-
.filter_map(|v| v.as_u64())
|
|
1239
|
-
.map(|n| n as usize)
|
|
1240
|
-
.collect()
|
|
1241
|
-
})
|
|
1242
|
-
.unwrap_or_default();
|
|
1243
|
-
|
|
1244
|
-
let items: Vec<VerificationItem> = doc
|
|
1245
|
-
.sections
|
|
1246
|
-
.iter()
|
|
1247
|
-
.map(|s| VerificationItem {
|
|
1248
|
-
fragment_seq: Some(s.seq),
|
|
1249
|
-
heading: s.heading.clone(),
|
|
1250
|
-
status: if skip_seqs.contains(&s.seq) {
|
|
1251
|
-
"skipped".to_string()
|
|
1252
|
-
} else {
|
|
1253
|
-
"pending".to_string()
|
|
1254
|
-
},
|
|
1255
|
-
impl_refs: Vec::new(),
|
|
1256
|
-
test_refs: Vec::new(),
|
|
1257
|
-
reviewer: None,
|
|
1258
|
-
verified_at: None,
|
|
1259
|
-
notes: String::new(),
|
|
1260
|
-
content_hash_at_verify: None,
|
|
1261
|
-
category: "section".to_string(),
|
|
1262
|
-
sub_items: Vec::new(),
|
|
1263
|
-
label: None,
|
|
1264
|
-
})
|
|
1265
|
-
.collect();
|
|
1266
|
-
|
|
1267
|
-
doc.verification = Some(Verification {
|
|
1268
|
-
status: recompute_verification_status(&items),
|
|
1269
|
-
created_at: now.clone(),
|
|
1270
|
-
updated_at: now,
|
|
1271
|
-
items,
|
|
1272
|
-
});
|
|
1273
|
-
}
|
|
1274
|
-
"check" => {
|
|
1275
|
-
let fragment_seqs = required_fragment_seqs(arguments)?;
|
|
1276
|
-
let reviewer = arguments
|
|
1277
|
-
.get("reviewer")
|
|
1278
|
-
.and_then(|v| v.as_str())
|
|
1279
|
-
.map(str::to_string);
|
|
1280
|
-
let notes = arguments
|
|
1281
|
-
.get("notes")
|
|
1282
|
-
.and_then(|v| v.as_str())
|
|
1283
|
-
.map(str::to_string);
|
|
1284
|
-
let sub_item_index = arguments
|
|
1285
|
-
.get("sub_item_index")
|
|
1286
|
-
.and_then(|v| v.as_u64())
|
|
1287
|
-
.map(|n| n as usize);
|
|
1288
|
-
|
|
1289
|
-
for fragment_seq in fragment_seqs {
|
|
1290
|
-
let section_hash = doc
|
|
1291
|
-
.sections
|
|
1292
|
-
.iter()
|
|
1293
|
-
.find(|s| s.seq == fragment_seq)
|
|
1294
|
-
.map(|s| s.content_hash.clone());
|
|
1295
|
-
|
|
1296
|
-
let v = verification_mut(&mut doc, doc_id)?;
|
|
1297
|
-
let item = find_item_mut(v, fragment_seq, doc_id)?;
|
|
1298
|
-
|
|
1299
|
-
if let Some(sub_index) = sub_item_index {
|
|
1300
|
-
let sub = find_sub_item_mut(item, sub_index, fragment_seq, doc_id)?;
|
|
1301
|
-
sub.status = "verified".to_string();
|
|
1302
|
-
sub.verified_at = Some(now.clone());
|
|
1303
|
-
if reviewer.is_some() {
|
|
1304
|
-
sub.reviewer = reviewer.clone();
|
|
1305
|
-
}
|
|
1306
|
-
if let Some(notes) = ¬es {
|
|
1307
|
-
sub.notes = notes.clone();
|
|
1308
|
-
}
|
|
1309
|
-
} else {
|
|
1310
|
-
item.status = "verified".to_string();
|
|
1311
|
-
item.verified_at = Some(now.clone());
|
|
1312
|
-
if reviewer.is_some() {
|
|
1313
|
-
item.reviewer = reviewer.clone();
|
|
1314
|
-
}
|
|
1315
|
-
if let Some(notes) = ¬es {
|
|
1316
|
-
item.notes = notes.clone();
|
|
1317
|
-
}
|
|
1318
|
-
item.content_hash_at_verify = section_hash;
|
|
1319
|
-
}
|
|
1320
|
-
v.updated_at = now.clone();
|
|
1321
|
-
v.status = recompute_verification_status(&v.items);
|
|
1322
|
-
}
|
|
1323
|
-
}
|
|
1324
|
-
"check_all" => {
|
|
1325
|
-
let reviewer = arguments
|
|
1326
|
-
.get("reviewer")
|
|
1327
|
-
.and_then(|v| v.as_str())
|
|
1328
|
-
.map(str::to_string);
|
|
1329
|
-
let notes = arguments
|
|
1330
|
-
.get("notes")
|
|
1331
|
-
.and_then(|v| v.as_str())
|
|
1332
|
-
.map(str::to_string);
|
|
1333
|
-
let sections = doc.sections.clone();
|
|
1334
|
-
|
|
1335
|
-
let v = verification_mut(&mut doc, doc_id)?;
|
|
1336
|
-
for item in v.items.iter_mut() {
|
|
1337
|
-
let section_hash = item.fragment_seq.and_then(|seq| {
|
|
1338
|
-
sections
|
|
1339
|
-
.iter()
|
|
1340
|
-
.find(|s| s.seq == seq)
|
|
1341
|
-
.map(|s| s.content_hash.clone())
|
|
1342
|
-
});
|
|
1343
|
-
item.status = "verified".to_string();
|
|
1344
|
-
item.verified_at = Some(now.clone());
|
|
1345
|
-
if reviewer.is_some() {
|
|
1346
|
-
item.reviewer = reviewer.clone();
|
|
1347
|
-
}
|
|
1348
|
-
if let Some(notes) = ¬es {
|
|
1349
|
-
item.notes = notes.clone();
|
|
1350
|
-
}
|
|
1351
|
-
item.content_hash_at_verify = section_hash;
|
|
1352
|
-
|
|
1353
|
-
// v2: check_all also verifies every sub_item (spec §7.2).
|
|
1354
|
-
for sub in item.sub_items.iter_mut() {
|
|
1355
|
-
sub.status = "verified".to_string();
|
|
1356
|
-
sub.verified_at = Some(now.clone());
|
|
1357
|
-
if reviewer.is_some() {
|
|
1358
|
-
sub.reviewer = reviewer.clone();
|
|
1359
|
-
}
|
|
1360
|
-
}
|
|
1361
|
-
}
|
|
1362
|
-
v.updated_at = now.clone();
|
|
1363
|
-
v.status = recompute_verification_status(&v.items);
|
|
1364
|
-
}
|
|
1365
|
-
"skip" => {
|
|
1366
|
-
let fragment_seq = required_fragment_seq(arguments)?;
|
|
1367
|
-
let sub_item_index = arguments
|
|
1368
|
-
.get("sub_item_index")
|
|
1369
|
-
.and_then(|v| v.as_u64())
|
|
1370
|
-
.map(|n| n as usize);
|
|
1371
|
-
|
|
1372
|
-
let v = verification_mut(&mut doc, doc_id)?;
|
|
1373
|
-
let item = find_item_mut(v, fragment_seq, doc_id)?;
|
|
1374
|
-
if let Some(sub_index) = sub_item_index {
|
|
1375
|
-
let sub = find_sub_item_mut(item, sub_index, fragment_seq, doc_id)?;
|
|
1376
|
-
sub.status = "skipped".to_string();
|
|
1377
|
-
} else {
|
|
1378
|
-
item.status = "skipped".to_string();
|
|
1379
|
-
}
|
|
1380
|
-
v.updated_at = now.clone();
|
|
1381
|
-
v.status = recompute_verification_status(&v.items);
|
|
1382
|
-
}
|
|
1383
|
-
"add_item" => {
|
|
1384
|
-
let v = verification_mut(&mut doc, doc_id)?;
|
|
1385
|
-
match arguments.get("fragment_seq").and_then(|v| v.as_u64()) {
|
|
1386
|
-
None => {
|
|
1387
|
-
// Freeform top-level item (spec §7.2): fragment_seq
|
|
1388
|
-
// omitted/null, label required.
|
|
1389
|
-
let label = arguments
|
|
1390
|
-
.get("label")
|
|
1391
|
-
.and_then(|v| v.as_str())
|
|
1392
|
-
.ok_or_else(|| {
|
|
1393
|
-
anyhow::anyhow!(
|
|
1394
|
-
"'label' is required for add_item when 'fragment_seq' is omitted"
|
|
1395
|
-
)
|
|
1396
|
-
})?
|
|
1397
|
-
.to_string();
|
|
1398
|
-
let category = arguments
|
|
1399
|
-
.get("category")
|
|
1400
|
-
.and_then(|v| v.as_str())
|
|
1401
|
-
.unwrap_or("visual")
|
|
1402
|
-
.to_string();
|
|
1403
|
-
v.items.push(VerificationItem {
|
|
1404
|
-
fragment_seq: None,
|
|
1405
|
-
heading: label.clone(),
|
|
1406
|
-
status: "pending".to_string(),
|
|
1407
|
-
impl_refs: Vec::new(),
|
|
1408
|
-
test_refs: Vec::new(),
|
|
1409
|
-
reviewer: None,
|
|
1410
|
-
verified_at: None,
|
|
1411
|
-
notes: String::new(),
|
|
1412
|
-
content_hash_at_verify: None,
|
|
1413
|
-
category,
|
|
1414
|
-
sub_items: Vec::new(),
|
|
1415
|
-
label: Some(label),
|
|
1416
|
-
});
|
|
1417
|
-
}
|
|
1418
|
-
Some(seq) => {
|
|
1419
|
-
// Sub-item on an existing section item (spec §7.2):
|
|
1420
|
-
// fragment_seq given, description required.
|
|
1421
|
-
let fragment_seq = seq as usize;
|
|
1422
|
-
let description = arguments
|
|
1423
|
-
.get("description")
|
|
1424
|
-
.and_then(|v| v.as_str())
|
|
1425
|
-
.ok_or_else(|| {
|
|
1426
|
-
anyhow::anyhow!(
|
|
1427
|
-
"'description' is required for add_item when 'fragment_seq' is given"
|
|
1428
|
-
)
|
|
1429
|
-
})?
|
|
1430
|
-
.to_string();
|
|
1431
|
-
let category = arguments
|
|
1432
|
-
.get("category")
|
|
1433
|
-
.and_then(|v| v.as_str())
|
|
1434
|
-
.unwrap_or("requirement")
|
|
1435
|
-
.to_string();
|
|
1436
|
-
|
|
1437
|
-
let item = find_item_mut(v, fragment_seq, doc_id)?;
|
|
1438
|
-
let index = item.sub_items.len();
|
|
1439
|
-
item.sub_items.push(SubItem {
|
|
1440
|
-
index,
|
|
1441
|
-
description,
|
|
1442
|
-
status: "pending".to_string(),
|
|
1443
|
-
reviewer: None,
|
|
1444
|
-
verified_at: None,
|
|
1445
|
-
notes: String::new(),
|
|
1446
|
-
category,
|
|
1447
|
-
});
|
|
1448
|
-
}
|
|
1449
|
-
}
|
|
1450
|
-
v.updated_at = now.clone();
|
|
1451
|
-
v.status = recompute_verification_status(&v.items);
|
|
1452
|
-
}
|
|
1453
|
-
"sync" => {
|
|
1454
|
-
let sections = doc.sections.clone();
|
|
1455
|
-
let v = verification_mut(&mut doc, doc_id)?;
|
|
1456
|
-
let current_seqs: std::collections::HashSet<usize> =
|
|
1457
|
-
sections.iter().map(|s| s.seq).collect();
|
|
1458
|
-
// Freeform items (fragment_seq=None, v2) are never section-tied,
|
|
1459
|
-
// so `sync` always keeps them — only section-tied items whose
|
|
1460
|
-
// seq no longer exists are dropped.
|
|
1461
|
-
v.items.retain(|i| match i.fragment_seq {
|
|
1462
|
-
Some(seq) => current_seqs.contains(&seq),
|
|
1463
|
-
None => true,
|
|
1464
|
-
});
|
|
1465
|
-
let existing_seqs: std::collections::HashSet<usize> =
|
|
1466
|
-
v.items.iter().filter_map(|i| i.fragment_seq).collect();
|
|
1467
|
-
for s in §ions {
|
|
1468
|
-
if !existing_seqs.contains(&s.seq) {
|
|
1469
|
-
v.items.push(VerificationItem {
|
|
1470
|
-
fragment_seq: Some(s.seq),
|
|
1471
|
-
heading: s.heading.clone(),
|
|
1472
|
-
status: "pending".to_string(),
|
|
1473
|
-
impl_refs: Vec::new(),
|
|
1474
|
-
test_refs: Vec::new(),
|
|
1475
|
-
reviewer: None,
|
|
1476
|
-
verified_at: None,
|
|
1477
|
-
notes: String::new(),
|
|
1478
|
-
content_hash_at_verify: None,
|
|
1479
|
-
category: "section".to_string(),
|
|
1480
|
-
sub_items: Vec::new(),
|
|
1481
|
-
label: None,
|
|
1482
|
-
});
|
|
1483
|
-
}
|
|
1484
|
-
}
|
|
1485
|
-
// Sort section-tied items by seq; freeform items (None) sort
|
|
1486
|
-
// last, in their prior relative order (stable sort).
|
|
1487
|
-
v.items.sort_by_key(|i| (i.fragment_seq.is_none(), i.fragment_seq));
|
|
1488
|
-
v.updated_at = now.clone();
|
|
1489
|
-
v.status = recompute_verification_status(&v.items);
|
|
1490
|
-
}
|
|
1491
|
-
"set_refs" => {
|
|
1492
|
-
let fragment_seq = required_fragment_seq(arguments)?;
|
|
1493
|
-
let impl_refs = arguments.get("impl_refs").map(code_refs_from_value);
|
|
1494
|
-
let test_refs = arguments.get("test_refs").map(code_refs_from_value);
|
|
1495
|
-
|
|
1496
|
-
let v = verification_mut(&mut doc, doc_id)?;
|
|
1497
|
-
let item = find_item_mut(v, fragment_seq, doc_id)?;
|
|
1498
|
-
if let Some(impl_refs) = impl_refs {
|
|
1499
|
-
item.impl_refs = impl_refs;
|
|
1500
|
-
}
|
|
1501
|
-
if let Some(test_refs) = test_refs {
|
|
1502
|
-
item.test_refs = test_refs;
|
|
1503
|
-
}
|
|
1504
|
-
v.updated_at = now.clone();
|
|
1505
|
-
v.status = recompute_verification_status(&v.items);
|
|
1506
|
-
}
|
|
1507
|
-
other => anyhow::bail!(
|
|
1508
|
-
"Unknown action '{other}'; expected one of generate, check, check_all, skip, sync, set_refs, add_item, suggest_refs"
|
|
1509
|
-
),
|
|
1510
|
-
}
|
|
1511
|
-
|
|
1512
|
-
write_doc(&handoff, &doc)?;
|
|
1513
|
-
|
|
1514
|
-
let v = doc
|
|
1515
|
-
.verification
|
|
1516
|
-
.as_ref()
|
|
1517
|
-
.expect("verification was just set/mutated above");
|
|
1518
|
-
let counts = count_verification(&doc, v);
|
|
1519
|
-
|
|
1520
|
-
Ok(to_json(&json!({
|
|
1521
|
-
"doc_id": doc.id,
|
|
1522
|
-
"verification_status": v.status,
|
|
1523
|
-
"checked": counts.checked,
|
|
1524
|
-
"skipped": counts.skipped,
|
|
1525
|
-
"pending": counts.pending,
|
|
1526
|
-
"total": counts.total,
|
|
1527
|
-
"stale": counts.stale,
|
|
1528
|
-
})))
|
|
1529
|
-
}
|
|
1530
|
-
|
|
1531
|
-
fn required_fragment_seq(arguments: &Value) -> Result<usize> {
|
|
1532
|
-
arguments
|
|
1533
|
-
.get("fragment_seq")
|
|
1534
|
-
.and_then(|v| v.as_u64())
|
|
1535
|
-
.map(|n| n as usize)
|
|
1536
|
-
.ok_or_else(|| anyhow::anyhow!("'fragment_seq' is required for this action"))
|
|
1537
|
-
}
|
|
1538
|
-
|
|
1539
|
-
/// Like `required_fragment_seq`, but accepts `fragment_seq` as either a
|
|
1540
|
-
/// single number (backward compat) or an array of numbers (batch `check`).
|
|
1541
|
-
fn required_fragment_seqs(arguments: &Value) -> Result<Vec<usize>> {
|
|
1542
|
-
match arguments.get("fragment_seq") {
|
|
1543
|
-
Some(Value::Array(arr)) => {
|
|
1544
|
-
let seqs: Vec<usize> = arr
|
|
1545
|
-
.iter()
|
|
1546
|
-
.filter_map(|v| v.as_u64())
|
|
1547
|
-
.map(|n| n as usize)
|
|
1548
|
-
.collect();
|
|
1549
|
-
if seqs.is_empty() {
|
|
1550
|
-
anyhow::bail!("'fragment_seq' array must contain at least one section seq");
|
|
1551
|
-
}
|
|
1552
|
-
Ok(seqs)
|
|
1553
|
-
}
|
|
1554
|
-
_ => required_fragment_seq(arguments).map(|seq| vec![seq]),
|
|
1555
|
-
}
|
|
1556
|
-
}
|
|
1557
|
-
|
|
1558
|
-
fn verification_mut<'a>(doc: &'a mut DocMetadata, doc_id: &str) -> Result<&'a mut Verification> {
|
|
1559
|
-
doc.verification.as_mut().ok_or_else(|| {
|
|
1560
|
-
anyhow::anyhow!(
|
|
1561
|
-
"No verification matrix exists for document {doc_id}; use action='generate' first"
|
|
1562
|
-
)
|
|
1563
|
-
})
|
|
1564
|
-
}
|
|
1565
|
-
|
|
1566
|
-
fn find_item_mut<'a>(
|
|
1567
|
-
v: &'a mut Verification,
|
|
1568
|
-
fragment_seq: usize,
|
|
1569
|
-
doc_id: &str,
|
|
1570
|
-
) -> Result<&'a mut VerificationItem> {
|
|
1571
|
-
v.items
|
|
1572
|
-
.iter_mut()
|
|
1573
|
-
.find(|i| i.fragment_seq == Some(fragment_seq))
|
|
1574
|
-
.ok_or_else(|| {
|
|
1575
|
-
anyhow::anyhow!(
|
|
1576
|
-
"No verification item at fragment_seq={fragment_seq} for document {doc_id}"
|
|
1577
|
-
)
|
|
1578
|
-
})
|
|
1579
|
-
}
|
|
1580
|
-
|
|
1581
|
-
/// v2: finds a `SubItem` by `index` within `item.sub_items` (used by
|
|
1582
|
-
/// `check`/`skip` when `sub_item_index` is given).
|
|
1583
|
-
fn find_sub_item_mut<'a>(
|
|
1584
|
-
item: &'a mut VerificationItem,
|
|
1585
|
-
sub_index: usize,
|
|
1586
|
-
fragment_seq: usize,
|
|
1587
|
-
doc_id: &str,
|
|
1588
|
-
) -> Result<&'a mut SubItem> {
|
|
1589
|
-
item.sub_items.get_mut(sub_index).ok_or_else(|| {
|
|
1590
|
-
anyhow::anyhow!(
|
|
1591
|
-
"No sub_item at index={sub_index} for fragment_seq={fragment_seq} on document {doc_id}"
|
|
1592
|
-
)
|
|
1593
|
-
})
|
|
1594
|
-
}
|
|
1595
|
-
|
|
1596
|
-
/// `handoff_doc_verify_status` — verification matrix summary + optional
|
|
1597
|
-
/// per-item detail with stale detection (wiki/140-verification-matrix.md
|
|
1598
|
-
/// §4.2).
|
|
1599
|
-
pub fn handle_doc_verify_status(arguments: &Value) -> Result<String> {
|
|
1600
|
-
let project_dir = resolve_project_dir(arguments)?;
|
|
1601
|
-
let handoff = ensure_handoff_exists(&project_dir)?;
|
|
1602
|
-
|
|
1603
|
-
let doc_id = arguments
|
|
1604
|
-
.get("doc_id")
|
|
1605
|
-
.and_then(|v| v.as_str())
|
|
1606
|
-
.ok_or_else(|| anyhow::anyhow!("'doc_id' is required"))?;
|
|
1607
|
-
let include_items = arguments
|
|
1608
|
-
.get("include_items")
|
|
1609
|
-
.and_then(|v| v.as_bool())
|
|
1610
|
-
.unwrap_or(false);
|
|
1611
|
-
let format = arguments
|
|
1612
|
-
.get("format")
|
|
1613
|
-
.and_then(|v| v.as_str())
|
|
1614
|
-
.unwrap_or("json");
|
|
1615
|
-
|
|
1616
|
-
let doc = resolve_doc(&handoff, doc_id)?
|
|
1617
|
-
.ok_or_else(|| anyhow::anyhow!("Document not found: {doc_id}"))?;
|
|
1618
|
-
|
|
1619
|
-
let v = doc.verification.as_ref().ok_or_else(|| {
|
|
1620
|
-
anyhow::anyhow!(
|
|
1621
|
-
"No verification matrix exists for document {doc_id}; use handoff_doc_verify(action='generate') first"
|
|
1622
|
-
)
|
|
1623
|
-
})?;
|
|
1624
|
-
|
|
1625
|
-
let counts = count_verification(&doc, v);
|
|
1626
|
-
let percentage = if counts.total == 0 {
|
|
1627
|
-
0.0
|
|
1628
|
-
} else {
|
|
1629
|
-
(counts.checked + counts.skipped) as f64 / counts.total as f64 * 100.0
|
|
1630
|
-
};
|
|
1631
|
-
|
|
1632
|
-
if format == "checklist" {
|
|
1633
|
-
return Ok(render_verification_checklist(&doc, v, &counts, percentage));
|
|
1634
|
-
}
|
|
1635
|
-
|
|
1636
|
-
let mut out = json!({
|
|
1637
|
-
"doc_id": doc.id,
|
|
1638
|
-
"title": doc.title,
|
|
1639
|
-
"verification_status": v.status,
|
|
1640
|
-
"progress": {
|
|
1641
|
-
"checked": counts.checked,
|
|
1642
|
-
"skipped": counts.skipped,
|
|
1643
|
-
"pending": counts.pending,
|
|
1644
|
-
"total": counts.total,
|
|
1645
|
-
"stale": counts.stale,
|
|
1646
|
-
"percentage": percentage,
|
|
1647
|
-
},
|
|
1648
|
-
});
|
|
1649
|
-
|
|
1650
|
-
if include_items {
|
|
1651
|
-
let items: Vec<Value> = v
|
|
1652
|
-
.items
|
|
1653
|
-
.iter()
|
|
1654
|
-
.map(|i| {
|
|
1655
|
-
let sub_items: Vec<Value> = i
|
|
1656
|
-
.sub_items
|
|
1657
|
-
.iter()
|
|
1658
|
-
.map(|s| {
|
|
1659
|
-
json!({
|
|
1660
|
-
"index": s.index,
|
|
1661
|
-
"description": s.description,
|
|
1662
|
-
"status": s.status,
|
|
1663
|
-
"reviewer": s.reviewer,
|
|
1664
|
-
"verified_at": s.verified_at,
|
|
1665
|
-
"notes": s.notes,
|
|
1666
|
-
"category": s.category,
|
|
1667
|
-
})
|
|
1668
|
-
})
|
|
1669
|
-
.collect();
|
|
1670
|
-
json!({
|
|
1671
|
-
"fragment_seq": i.fragment_seq,
|
|
1672
|
-
"heading": i.heading,
|
|
1673
|
-
"status": i.status,
|
|
1674
|
-
"stale": item_is_stale(&doc, i),
|
|
1675
|
-
"impl_refs": i.impl_refs,
|
|
1676
|
-
"test_refs": i.test_refs,
|
|
1677
|
-
"reviewer": i.reviewer,
|
|
1678
|
-
"verified_at": i.verified_at,
|
|
1679
|
-
"notes": i.notes,
|
|
1680
|
-
"category": i.category,
|
|
1681
|
-
"sub_items": sub_items,
|
|
1682
|
-
"label": i.label,
|
|
1683
|
-
})
|
|
1684
|
-
})
|
|
1685
|
-
.collect();
|
|
1686
|
-
out["items"] = json!(items);
|
|
1687
|
-
}
|
|
1688
|
-
|
|
1689
|
-
Ok(to_json(&out))
|
|
1690
|
-
}
|
|
1691
|
-
|
|
1692
|
-
/// Status icon + label used by the `format="checklist"` Markdown rendering
|
|
1693
|
-
/// (spec §7.3): `✓ verified`, `⊘ skipped`, `○ pending`.
|
|
1694
|
-
fn status_icon(status: &str) -> String {
|
|
1695
|
-
match status {
|
|
1696
|
-
"verified" => "✓ verified".to_string(),
|
|
1697
|
-
"skipped" => "⊘ skipped".to_string(),
|
|
1698
|
-
other => format!("○ {other}"),
|
|
1699
|
-
}
|
|
1700
|
-
}
|
|
1701
|
-
|
|
1702
|
-
/// Renders a document's verification matrix as a Markdown checklist (v2,
|
|
1703
|
-
/// wiki/140-verification-matrix.md §7.3): one `##` block per top-level item
|
|
1704
|
-
/// (`§{seq} {heading}` for section-tied items, `— {label}` for freeform
|
|
1705
|
-
/// items), with impl/test refs and a `- [x]`/`- [ ]` checkbox line per
|
|
1706
|
-
/// sub_item.
|
|
1707
|
-
fn render_verification_checklist(
|
|
1708
|
-
doc: &DocMetadata,
|
|
1709
|
-
v: &Verification,
|
|
1710
|
-
counts: &VerificationCounts,
|
|
1711
|
-
percentage: f64,
|
|
1712
|
-
) -> String {
|
|
1713
|
-
use std::fmt::Write;
|
|
1714
|
-
|
|
1715
|
-
let mut out = String::new();
|
|
1716
|
-
let _ = writeln!(out, "# Verification: {}", doc.title);
|
|
1717
|
-
let _ = writeln!(
|
|
1718
|
-
out,
|
|
1719
|
-
"Status: {} ({}/{}, {:.0}%)",
|
|
1720
|
-
v.status,
|
|
1721
|
-
counts.checked + counts.skipped,
|
|
1722
|
-
counts.total,
|
|
1723
|
-
percentage
|
|
1724
|
-
);
|
|
1725
|
-
out.push('\n');
|
|
1726
|
-
|
|
1727
|
-
for item in &v.items {
|
|
1728
|
-
let icon = status_icon(&item.status);
|
|
1729
|
-
let stale_warning = if item_is_stale(doc, item) {
|
|
1730
|
-
" ⚠ stale"
|
|
1731
|
-
} else {
|
|
1732
|
-
""
|
|
1733
|
-
};
|
|
1734
|
-
|
|
1735
|
-
match item.fragment_seq {
|
|
1736
|
-
Some(seq) => {
|
|
1737
|
-
let _ = writeln!(out, "## §{seq} {} {icon}{stale_warning}", item.heading);
|
|
1738
|
-
if !item.impl_refs.is_empty() {
|
|
1739
|
-
let refs: Vec<String> = item.impl_refs.iter().map(code_ref_display).collect();
|
|
1740
|
-
let _ = writeln!(out, "- impl: {}", refs.join(", "));
|
|
1741
|
-
}
|
|
1742
|
-
if !item.test_refs.is_empty() {
|
|
1743
|
-
let refs: Vec<String> = item.test_refs.iter().map(code_ref_display).collect();
|
|
1744
|
-
let _ = writeln!(out, "- test: {}", refs.join(", "));
|
|
1745
|
-
}
|
|
1746
|
-
}
|
|
1747
|
-
None => {
|
|
1748
|
-
let label = item.label.as_deref().unwrap_or(&item.heading);
|
|
1749
|
-
let _ = writeln!(
|
|
1750
|
-
out,
|
|
1751
|
-
"## — {label} {icon}{stale_warning} [{}]",
|
|
1752
|
-
item.category
|
|
1753
|
-
);
|
|
1754
|
-
}
|
|
1755
|
-
}
|
|
1756
|
-
|
|
1757
|
-
for sub in &item.sub_items {
|
|
1758
|
-
let checkbox = if sub.status == "verified" { "x" } else { " " };
|
|
1759
|
-
match (&sub.reviewer, &sub.verified_at) {
|
|
1760
|
-
(Some(reviewer), Some(verified_at)) => {
|
|
1761
|
-
let date = verified_at.split('T').next().unwrap_or(verified_at);
|
|
1762
|
-
let _ = writeln!(
|
|
1763
|
-
out,
|
|
1764
|
-
"- [{checkbox}] {} (@{reviewer}, {date}) [{}]",
|
|
1765
|
-
sub.description, sub.category
|
|
1766
|
-
);
|
|
1767
|
-
}
|
|
1768
|
-
_ => {
|
|
1769
|
-
let _ = writeln!(out, "- [{checkbox}] {} [{}]", sub.description, sub.category);
|
|
1770
|
-
}
|
|
1771
|
-
}
|
|
1772
|
-
}
|
|
1773
|
-
out.push('\n');
|
|
1774
|
-
}
|
|
1775
|
-
|
|
1776
|
-
out
|
|
1777
|
-
}
|
|
1778
|
-
|
|
1779
|
-
/// Renders a `CodeRef` for the checklist format: `path` optionally suffixed
|
|
1780
|
-
/// with `:lines` and/or ` (label)`.
|
|
1781
|
-
fn code_ref_display(r: &CodeRef) -> String {
|
|
1782
|
-
let mut s = r.path.clone();
|
|
1783
|
-
if let Some(lines) = &r.lines {
|
|
1784
|
-
s.push(':');
|
|
1785
|
-
s.push_str(lines);
|
|
1786
|
-
}
|
|
1787
|
-
if let Some(label) = &r.label {
|
|
1788
|
-
s.push_str(" (");
|
|
1789
|
-
s.push_str(label);
|
|
1790
|
-
s.push(')');
|
|
1791
|
-
}
|
|
1792
|
-
s
|
|
1793
|
-
}
|
|
1794
|
-
|
|
1795
|
-
/// `handoff_doc_graph` — build a graph of every document in the project:
|
|
1796
|
-
/// `nodes[]` (one per document, with optional verification progress),
|
|
1797
|
-
/// `edges[]` (explicit parent_child/related links, plus implicit
|
|
1798
|
-
/// shared_task/shared_scope links when `include_implicit=true`), and
|
|
1799
|
-
/// `layers` (doc ids grouped by `doc_type`).
|
|
1800
|
-
pub fn handle_doc_graph(arguments: &Value) -> Result<String> {
|
|
1801
|
-
let project_dir = resolve_project_dir(arguments)?;
|
|
1802
|
-
let handoff = ensure_handoff_exists(&project_dir)?;
|
|
1803
|
-
|
|
1804
|
-
let include_implicit = arguments
|
|
1805
|
-
.get("include_implicit")
|
|
1806
|
-
.and_then(|v| v.as_bool())
|
|
1807
|
-
.unwrap_or(true);
|
|
1808
|
-
let include_verification = arguments
|
|
1809
|
-
.get("include_verification")
|
|
1810
|
-
.and_then(|v| v.as_bool())
|
|
1811
|
-
.unwrap_or(false);
|
|
1812
|
-
|
|
1813
|
-
let docs = read_all_docs(&handoff)?;
|
|
1814
|
-
|
|
1815
|
-
let nodes: Vec<Value> = docs
|
|
1816
|
-
.iter()
|
|
1817
|
-
.map(|d| doc_graph_node_json(d, include_verification))
|
|
1818
|
-
.collect();
|
|
1819
|
-
|
|
1820
|
-
let mut edges = doc_graph_explicit_edges(&docs);
|
|
1821
|
-
if include_implicit {
|
|
1822
|
-
edges.extend(doc_graph_implicit_edges(&docs));
|
|
1823
|
-
}
|
|
1824
|
-
|
|
1825
|
-
let mut layers: std::collections::BTreeMap<String, Vec<String>> =
|
|
1826
|
-
std::collections::BTreeMap::new();
|
|
1827
|
-
for d in &docs {
|
|
1828
|
-
layers
|
|
1829
|
-
.entry(d.doc_type.clone())
|
|
1830
|
-
.or_default()
|
|
1831
|
-
.push(d.id.clone());
|
|
1832
|
-
}
|
|
1833
|
-
|
|
1834
|
-
Ok(to_json(&json!({
|
|
1835
|
-
"nodes": nodes,
|
|
1836
|
-
"edges": edges,
|
|
1837
|
-
"layers": layers,
|
|
1838
|
-
})))
|
|
1839
|
-
}
|
|
1840
|
-
|
|
1841
|
-
/// Builds one `handoff_doc_graph` node: id/slug/title/doc_type/tags/task_ids
|
|
1842
|
-
/// /section_count/updated_at, plus `verification_progress` when requested
|
|
1843
|
-
/// (and the document has a verification matrix).
|
|
1844
|
-
fn doc_graph_node_json(doc: &DocMetadata, include_verification: bool) -> Value {
|
|
1845
|
-
let mut node = json!({
|
|
1846
|
-
"id": doc.id,
|
|
1847
|
-
"slug": doc.slug,
|
|
1848
|
-
"title": doc.title,
|
|
1849
|
-
"doc_type": doc.doc_type,
|
|
1850
|
-
"tags": doc.tags,
|
|
1851
|
-
"task_ids": doc.task_ids,
|
|
1852
|
-
"section_count": doc.sections.len(),
|
|
1853
|
-
"updated_at": doc.updated_at,
|
|
1854
|
-
});
|
|
1855
|
-
if include_verification {
|
|
1856
|
-
if let Some(v) = &doc.verification {
|
|
1857
|
-
let total = v.items.len();
|
|
1858
|
-
let verified = v.items.iter().filter(|i| i.status == "verified").count();
|
|
1859
|
-
node["verification_progress"] = json!({ "total": total, "verified": verified });
|
|
1860
|
-
}
|
|
1861
|
-
}
|
|
1862
|
-
node
|
|
1863
|
-
}
|
|
1864
|
-
|
|
1865
|
-
/// Explicit edges: `parent_id` (`type="parent_child"`, `direction="down"`,
|
|
1866
|
-
/// from=parent to=child) and `related[]` (`type=<rel>`,
|
|
1867
|
-
/// `direction="forward"`, from=this doc to=related target). Related entries
|
|
1868
|
-
/// pointing at an id not present in `docs` are still emitted — the graph
|
|
1869
|
-
/// consumer is expected to render dangling links, not silently drop them.
|
|
1870
|
-
fn doc_graph_explicit_edges(docs: &[DocMetadata]) -> Vec<Value> {
|
|
1871
|
-
let mut edges = Vec::new();
|
|
1872
|
-
for d in docs {
|
|
1873
|
-
if let Some(parent_id) = &d.parent_id {
|
|
1874
|
-
edges.push(json!({
|
|
1875
|
-
"from": parent_id,
|
|
1876
|
-
"to": d.id,
|
|
1877
|
-
"type": "parent_child",
|
|
1878
|
-
"direction": "down",
|
|
1879
|
-
}));
|
|
1880
|
-
}
|
|
1881
|
-
for r in &d.related {
|
|
1882
|
-
edges.push(json!({
|
|
1883
|
-
"from": d.id,
|
|
1884
|
-
"to": r.id,
|
|
1885
|
-
"type": r.rel,
|
|
1886
|
-
"direction": "forward",
|
|
1887
|
-
}));
|
|
1888
|
-
}
|
|
1889
|
-
}
|
|
1890
|
-
edges
|
|
1891
|
-
}
|
|
1892
|
-
|
|
1893
|
-
/// Implicit edges: `shared_task` (two documents sharing at least one
|
|
1894
|
-
/// `task_ids` entry — `task_ids` on the edge lists every id shared, not just
|
|
1895
|
-
/// the first) and `shared_scope` (two documents sharing at least one
|
|
1896
|
-
/// `scope_paths` entry). Both are unordered/undirected pairs, emitted once
|
|
1897
|
-
/// per pair (i<j) to avoid duplicating the same relationship in both
|
|
1898
|
-
/// directions.
|
|
1899
|
-
fn doc_graph_implicit_edges(docs: &[DocMetadata]) -> Vec<Value> {
|
|
1900
|
-
let mut edges = Vec::new();
|
|
1901
|
-
for i in 0..docs.len() {
|
|
1902
|
-
for j in (i + 1)..docs.len() {
|
|
1903
|
-
let a = &docs[i];
|
|
1904
|
-
let b = &docs[j];
|
|
1905
|
-
|
|
1906
|
-
let shared_tasks: Vec<String> = a
|
|
1907
|
-
.task_ids
|
|
1908
|
-
.iter()
|
|
1909
|
-
.filter(|t| b.task_ids.contains(t))
|
|
1910
|
-
.cloned()
|
|
1911
|
-
.collect();
|
|
1912
|
-
if !shared_tasks.is_empty() {
|
|
1913
|
-
edges.push(json!({
|
|
1914
|
-
"from": a.id,
|
|
1915
|
-
"to": b.id,
|
|
1916
|
-
"type": "shared_task",
|
|
1917
|
-
"task_ids": shared_tasks,
|
|
1918
|
-
}));
|
|
1919
|
-
}
|
|
1920
|
-
|
|
1921
|
-
let shares_scope = a.scope_paths.iter().any(|p| b.scope_paths.contains(p));
|
|
1922
|
-
if shares_scope {
|
|
1923
|
-
edges.push(json!({
|
|
1924
|
-
"from": a.id,
|
|
1925
|
-
"to": b.id,
|
|
1926
|
-
"type": "shared_scope",
|
|
1927
|
-
}));
|
|
1928
|
-
}
|
|
1929
|
-
}
|
|
1930
|
-
}
|
|
1931
|
-
edges
|
|
1932
|
-
}
|
|
1933
|
-
|
|
1934
|
-
/// One entry in a `handoff_doc_trace` `chain[]`/`branches[].docs[]`:
|
|
1935
|
-
/// `{id, title, doc_type, rel}`. `rel` describes how this doc relates to the
|
|
1936
|
-
/// previous entry in the chain ("parent", "child", or the `related[].rel`
|
|
1937
|
-
/// value for a related-doc detour); `None` for the trace's starting doc.
|
|
1938
|
-
fn doc_trace_item_json(doc: &DocMetadata, rel: Option<&str>) -> Value {
|
|
1939
|
-
json!({
|
|
1940
|
-
"id": doc.id,
|
|
1941
|
-
"title": doc.title,
|
|
1942
|
-
"doc_type": doc.doc_type,
|
|
1943
|
-
"rel": rel,
|
|
1944
|
-
})
|
|
1945
|
-
}
|
|
1946
|
-
|
|
1947
|
-
/// Walks the child->parent chain starting at `doc` (exclusive — `doc` itself
|
|
1948
|
-
/// is not included), ordered from the immediate parent up to the root.
|
|
1949
|
-
/// `visited` prevents infinite loops on a cyclic `parent_id` graph; a doc
|
|
1950
|
-
/// already visited (including `doc` itself) stops the walk rather than
|
|
1951
|
-
/// erroring.
|
|
1952
|
-
fn doc_trace_walk_up(
|
|
1953
|
-
handoff: &Path,
|
|
1954
|
-
doc: &DocMetadata,
|
|
1955
|
-
visited: &mut std::collections::HashSet<String>,
|
|
1956
|
-
) -> Result<Vec<Value>> {
|
|
1957
|
-
let mut out = Vec::new();
|
|
1958
|
-
let mut current = doc.clone();
|
|
1959
|
-
while let Some(parent_id) = current.parent_id.clone() {
|
|
1960
|
-
if visited.contains(&parent_id) {
|
|
1961
|
-
break;
|
|
1962
|
-
}
|
|
1963
|
-
let Some(parent) = find_doc_by_id(handoff, &parent_id)? else {
|
|
1964
|
-
break;
|
|
1965
|
-
};
|
|
1966
|
-
visited.insert(parent.id.clone());
|
|
1967
|
-
out.push(doc_trace_item_json(&parent, Some("parent")));
|
|
1968
|
-
current = parent;
|
|
1969
|
-
}
|
|
1970
|
-
out.reverse();
|
|
1971
|
-
Ok(out)
|
|
1972
|
-
}
|
|
1973
|
-
|
|
1974
|
-
/// Recursively walks parent->children (DFS) starting at `doc` (exclusive).
|
|
1975
|
-
/// Returns the primary descendant chain (first child at each level) plus any
|
|
1976
|
-
/// `branches` recorded for multi-child forks. `visited` prevents infinite
|
|
1977
|
-
/// loops on a cyclic `children` graph.
|
|
1978
|
-
fn doc_trace_walk_down(
|
|
1979
|
-
handoff: &Path,
|
|
1980
|
-
doc: &DocMetadata,
|
|
1981
|
-
visited: &mut std::collections::HashSet<String>,
|
|
1982
|
-
branches: &mut Vec<Value>,
|
|
1983
|
-
) -> Result<Vec<Value>> {
|
|
1984
|
-
let mut children = Vec::new();
|
|
1985
|
-
for child_id in &doc.children {
|
|
1986
|
-
if visited.contains(child_id) {
|
|
1987
|
-
continue;
|
|
1988
|
-
}
|
|
1989
|
-
if let Some(child) = find_doc_by_id(handoff, child_id)? {
|
|
1990
|
-
children.push(child);
|
|
1991
|
-
}
|
|
1992
|
-
}
|
|
1993
|
-
|
|
1994
|
-
if children.is_empty() {
|
|
1995
|
-
return Ok(Vec::new());
|
|
1996
|
-
}
|
|
1997
|
-
|
|
1998
|
-
// Fork detection: more than one live (non-visited, resolvable) child at
|
|
1999
|
-
// this level. Every child's own sub-chain is recorded under `branches`;
|
|
2000
|
-
// the first child's sub-chain also becomes the primary continuation of
|
|
2001
|
-
// the returned chain, so a single-child level still reads as a plain
|
|
2002
|
-
// linear chain.
|
|
2003
|
-
let is_fork = children.len() > 1;
|
|
2004
|
-
let mut primary_chain = Vec::new();
|
|
2005
|
-
|
|
2006
|
-
for (idx, child) in children.iter().enumerate() {
|
|
2007
|
-
if visited.contains(&child.id) {
|
|
2008
|
-
continue;
|
|
2009
|
-
}
|
|
2010
|
-
visited.insert(child.id.clone());
|
|
2011
|
-
let mut sub_chain = vec![doc_trace_item_json(child, Some("child"))];
|
|
2012
|
-
sub_chain.extend(doc_trace_walk_down(handoff, child, visited, branches)?);
|
|
2013
|
-
|
|
2014
|
-
if is_fork {
|
|
2015
|
-
branches.push(json!({
|
|
2016
|
-
"fork_from": doc.id,
|
|
2017
|
-
"docs": sub_chain,
|
|
2018
|
-
}));
|
|
2019
|
-
}
|
|
2020
|
-
if idx == 0 {
|
|
2021
|
-
primary_chain = sub_chain;
|
|
2022
|
-
}
|
|
2023
|
-
}
|
|
2024
|
-
|
|
2025
|
-
Ok(primary_chain)
|
|
2026
|
-
}
|
|
2027
|
-
|
|
2028
|
-
/// Appends `related` (implements/references/etc.) detours for every document
|
|
2029
|
-
/// already present in `chain` (by id), skipping any related id already
|
|
2030
|
-
/// visited. Related docs are appended once, immediately, as a flat list — a
|
|
2031
|
-
/// "detour" from the main chain rather than a further recursive expansion.
|
|
2032
|
-
fn doc_trace_related_detours(
|
|
2033
|
-
handoff: &Path,
|
|
2034
|
-
chain_doc_ids: &[String],
|
|
2035
|
-
visited: &mut std::collections::HashSet<String>,
|
|
2036
|
-
) -> Result<Vec<Value>> {
|
|
2037
|
-
let mut out = Vec::new();
|
|
2038
|
-
for doc_id in chain_doc_ids {
|
|
2039
|
-
let Some(doc) = find_doc_by_id(handoff, doc_id)? else {
|
|
2040
|
-
continue;
|
|
2041
|
-
};
|
|
2042
|
-
for r in &doc.related {
|
|
2043
|
-
if visited.contains(&r.id) {
|
|
2044
|
-
continue;
|
|
2045
|
-
}
|
|
2046
|
-
let Some(target) = find_doc_by_id(handoff, &r.id)? else {
|
|
2047
|
-
continue;
|
|
2048
|
-
};
|
|
2049
|
-
visited.insert(target.id.clone());
|
|
2050
|
-
out.push(doc_trace_item_json(&target, Some(&r.rel)));
|
|
2051
|
-
}
|
|
2052
|
-
}
|
|
2053
|
-
Ok(out)
|
|
2054
|
-
}
|
|
2055
|
-
|
|
2056
|
-
/// `handoff_doc_trace` — trace a document's family-tree lineage: `up` (walk
|
|
2057
|
-
/// child->parent), `down` (walk parent->children, DFS), or `both` (merge the
|
|
2058
|
-
/// up chain + the target + the down chain). `related` docs encountered along
|
|
2059
|
-
/// the primary chain are appended as detour entries. Multi-child forks in the
|
|
2060
|
-
/// `down` direction are additionally reported in `branches[]`.
|
|
2061
|
-
pub fn handle_doc_trace(arguments: &Value) -> Result<String> {
|
|
2062
|
-
let project_dir = resolve_project_dir(arguments)?;
|
|
2063
|
-
let handoff = ensure_handoff_exists(&project_dir)?;
|
|
2064
|
-
|
|
2065
|
-
let doc_id = arguments
|
|
2066
|
-
.get("doc_id")
|
|
2067
|
-
.and_then(|v| v.as_str())
|
|
2068
|
-
.ok_or_else(|| anyhow::anyhow!("'doc_id' is required"))?;
|
|
2069
|
-
let direction = arguments
|
|
2070
|
-
.get("direction")
|
|
2071
|
-
.and_then(|v| v.as_str())
|
|
2072
|
-
.unwrap_or("both");
|
|
2073
|
-
|
|
2074
|
-
let doc = resolve_doc(&handoff, doc_id)?
|
|
2075
|
-
.ok_or_else(|| anyhow::anyhow!("Document not found: {doc_id}"))?;
|
|
2076
|
-
|
|
2077
|
-
let mut visited: std::collections::HashSet<String> = std::collections::HashSet::new();
|
|
2078
|
-
visited.insert(doc.id.clone());
|
|
2079
|
-
|
|
2080
|
-
let mut branches: Vec<Value> = Vec::new();
|
|
2081
|
-
let mut chain: Vec<Value> = Vec::new();
|
|
2082
|
-
|
|
2083
|
-
if direction == "up" || direction == "both" {
|
|
2084
|
-
chain.extend(doc_trace_walk_up(&handoff, &doc, &mut visited)?);
|
|
2085
|
-
}
|
|
2086
|
-
chain.push(doc_trace_item_json(&doc, None));
|
|
2087
|
-
if direction == "down" || direction == "both" {
|
|
2088
|
-
chain.extend(doc_trace_walk_down(
|
|
2089
|
-
&handoff,
|
|
2090
|
-
&doc,
|
|
2091
|
-
&mut visited,
|
|
2092
|
-
&mut branches,
|
|
2093
|
-
)?);
|
|
2094
|
-
}
|
|
2095
|
-
|
|
2096
|
-
let chain_doc_ids: Vec<String> = chain
|
|
2097
|
-
.iter()
|
|
2098
|
-
.filter_map(|v| v["id"].as_str().map(str::to_string))
|
|
2099
|
-
.collect();
|
|
2100
|
-
chain.extend(doc_trace_related_detours(
|
|
2101
|
-
&handoff,
|
|
2102
|
-
&chain_doc_ids,
|
|
2103
|
-
&mut visited,
|
|
2104
|
-
)?);
|
|
2105
|
-
|
|
2106
|
-
Ok(to_json(&json!({
|
|
2107
|
-
"chain": chain,
|
|
2108
|
-
"branches": branches,
|
|
2109
|
-
})))
|
|
2110
|
-
}
|
|
2111
|
-
|
|
2112
|
-
fn doc_metadata_json(doc: &DocMetadata) -> Value {
|
|
2113
|
-
json!({
|
|
2114
|
-
"id": doc.id,
|
|
2115
|
-
"slug": doc.slug,
|
|
2116
|
-
"title": doc.title,
|
|
2117
|
-
"doc_type": doc.doc_type,
|
|
2118
|
-
"tags": doc.tags,
|
|
2119
|
-
"scope_paths": doc.scope_paths,
|
|
2120
|
-
"parent_id": doc.parent_id,
|
|
2121
|
-
"children": doc.children,
|
|
2122
|
-
"related": doc.related,
|
|
2123
|
-
"auto_inject": doc.auto_inject,
|
|
2124
|
-
"task_ids": doc.task_ids,
|
|
2125
|
-
"has_bom": doc.has_bom,
|
|
2126
|
-
"line_ending": doc.line_ending,
|
|
2127
|
-
"sections": doc.sections,
|
|
2128
|
-
"section_count": doc.sections.len(),
|
|
2129
|
-
"created_at": doc.created_at,
|
|
2130
|
-
"updated_at": doc.updated_at,
|
|
2131
|
-
"content_hash": doc.content_hash,
|
|
2132
|
-
})
|
|
2133
|
-
}
|
|
2134
|
-
|
|
2135
|
-
/// Read a `&[String]` from a JSON string-array value (missing/non-array →
|
|
2136
|
-
/// empty).
|
|
2137
|
-
fn string_array_value(v: &Value) -> Vec<String> {
|
|
2138
|
-
v.as_array()
|
|
2139
|
-
.map(|arr| {
|
|
2140
|
-
arr.iter()
|
|
2141
|
-
.filter_map(|v| v.as_str())
|
|
2142
|
-
.map(str::to_string)
|
|
2143
|
-
.collect()
|
|
2144
|
-
})
|
|
2145
|
-
.unwrap_or_default()
|
|
2146
|
-
}
|
|
2147
|
-
|
|
2148
|
-
fn to_json(v: &Value) -> String {
|
|
2149
|
-
serde_json::to_string_pretty(v).unwrap_or_else(|_| v.to_string())
|
|
2150
|
-
}
|
|
2151
|
-
|
|
2152
|
-
#[cfg(test)]
|
|
2153
|
-
mod graph_tests {
|
|
2154
|
-
use super::*;
|
|
2155
|
-
|
|
2156
|
-
fn doc(id: &str, slug: &str, doc_type: &str) -> DocMetadata {
|
|
2157
|
-
DocMetadata::new(
|
|
2158
|
-
id.to_string(),
|
|
2159
|
-
slug.to_string(),
|
|
2160
|
-
format!("Title {id}"),
|
|
2161
|
-
doc_type.to_string(),
|
|
2162
|
-
"2026-07-12T00:00:00Z".to_string(),
|
|
2163
|
-
)
|
|
2164
|
-
}
|
|
2165
|
-
|
|
2166
|
-
#[test]
|
|
2167
|
-
fn explicit_edges_include_parent_child_and_related() {
|
|
2168
|
-
let mut parent = doc("doc-1", "parent", "spec");
|
|
2169
|
-
let mut child = doc("doc-2", "child", "design");
|
|
2170
|
-
child.parent_id = Some("doc-1".to_string());
|
|
2171
|
-
parent.children = vec!["doc-2".to_string()];
|
|
2172
|
-
child.related.push(DocRelation {
|
|
2173
|
-
id: "doc-3".to_string(),
|
|
2174
|
-
rel: "implements".to_string(),
|
|
2175
|
-
});
|
|
2176
|
-
let other = doc("doc-3", "other", "note");
|
|
2177
|
-
|
|
2178
|
-
let docs = vec![parent, child, other];
|
|
2179
|
-
let edges = doc_graph_explicit_edges(&docs);
|
|
2180
|
-
|
|
2181
|
-
assert!(edges.iter().any(|e| e["type"] == "parent_child"
|
|
2182
|
-
&& e["from"] == "doc-1"
|
|
2183
|
-
&& e["to"] == "doc-2"
|
|
2184
|
-
&& e["direction"] == "down"));
|
|
2185
|
-
assert!(edges.iter().any(|e| e["type"] == "implements"
|
|
2186
|
-
&& e["from"] == "doc-2"
|
|
2187
|
-
&& e["to"] == "doc-3"
|
|
2188
|
-
&& e["direction"] == "forward"));
|
|
2189
|
-
}
|
|
2190
|
-
|
|
2191
|
-
#[test]
|
|
2192
|
-
fn implicit_edges_detect_shared_task_ids() {
|
|
2193
|
-
let mut a = doc("doc-1", "a", "spec");
|
|
2194
|
-
let mut b = doc("doc-2", "b", "spec");
|
|
2195
|
-
a.task_ids = vec!["t-1".to_string(), "t-2".to_string()];
|
|
2196
|
-
b.task_ids = vec!["t-2".to_string(), "t-3".to_string()];
|
|
2197
|
-
let docs = vec![a, b];
|
|
2198
|
-
|
|
2199
|
-
let edges = doc_graph_implicit_edges(&docs);
|
|
2200
|
-
let shared_task_edge = edges
|
|
2201
|
-
.iter()
|
|
2202
|
-
.find(|e| e["type"] == "shared_task")
|
|
2203
|
-
.expect("shared_task edge must be generated");
|
|
2204
|
-
assert_eq!(shared_task_edge["from"], "doc-1");
|
|
2205
|
-
assert_eq!(shared_task_edge["to"], "doc-2");
|
|
2206
|
-
assert_eq!(shared_task_edge["task_ids"], json!(["t-2"]));
|
|
2207
|
-
}
|
|
2208
|
-
|
|
2209
|
-
#[test]
|
|
2210
|
-
fn implicit_edges_detect_shared_scope_paths() {
|
|
2211
|
-
let mut a = doc("doc-1", "a", "spec");
|
|
2212
|
-
let mut b = doc("doc-2", "b", "spec");
|
|
2213
|
-
a.scope_paths = vec!["src/mcp/".to_string()];
|
|
2214
|
-
b.scope_paths = vec!["src/mcp/".to_string(), "src/storage/".to_string()];
|
|
2215
|
-
let docs = vec![a, b];
|
|
2216
|
-
|
|
2217
|
-
let edges = doc_graph_implicit_edges(&docs);
|
|
2218
|
-
assert!(edges
|
|
2219
|
-
.iter()
|
|
2220
|
-
.any(|e| e["type"] == "shared_scope" && e["from"] == "doc-1" && e["to"] == "doc-2"));
|
|
2221
|
-
}
|
|
2222
|
-
|
|
2223
|
-
#[test]
|
|
2224
|
-
fn implicit_edges_absent_when_nothing_shared() {
|
|
2225
|
-
let a = doc("doc-1", "a", "spec");
|
|
2226
|
-
let b = doc("doc-2", "b", "spec");
|
|
2227
|
-
let docs = vec![a, b];
|
|
2228
|
-
|
|
2229
|
-
let edges = doc_graph_implicit_edges(&docs);
|
|
2230
|
-
assert!(edges.is_empty());
|
|
2231
|
-
}
|
|
2232
|
-
|
|
2233
|
-
#[test]
|
|
2234
|
-
fn graph_node_json_includes_verification_progress_when_requested() {
|
|
2235
|
-
let mut d = doc("doc-1", "a", "spec");
|
|
2236
|
-
d.verification = Some(Verification {
|
|
2237
|
-
status: "in_review".to_string(),
|
|
2238
|
-
created_at: "2026-07-12T00:00:00Z".to_string(),
|
|
2239
|
-
updated_at: "2026-07-12T00:00:00Z".to_string(),
|
|
2240
|
-
items: vec![
|
|
2241
|
-
VerificationItem {
|
|
2242
|
-
fragment_seq: Some(0),
|
|
2243
|
-
heading: String::new(),
|
|
2244
|
-
status: "verified".to_string(),
|
|
2245
|
-
impl_refs: Vec::new(),
|
|
2246
|
-
test_refs: Vec::new(),
|
|
2247
|
-
reviewer: None,
|
|
2248
|
-
verified_at: None,
|
|
2249
|
-
notes: String::new(),
|
|
2250
|
-
content_hash_at_verify: None,
|
|
2251
|
-
category: "section".to_string(),
|
|
2252
|
-
sub_items: Vec::new(),
|
|
2253
|
-
label: None,
|
|
2254
|
-
},
|
|
2255
|
-
VerificationItem {
|
|
2256
|
-
fragment_seq: Some(1),
|
|
2257
|
-
heading: "H".to_string(),
|
|
2258
|
-
status: "pending".to_string(),
|
|
2259
|
-
impl_refs: Vec::new(),
|
|
2260
|
-
test_refs: Vec::new(),
|
|
2261
|
-
reviewer: None,
|
|
2262
|
-
verified_at: None,
|
|
2263
|
-
notes: String::new(),
|
|
2264
|
-
content_hash_at_verify: None,
|
|
2265
|
-
category: "section".to_string(),
|
|
2266
|
-
sub_items: Vec::new(),
|
|
2267
|
-
label: None,
|
|
2268
|
-
},
|
|
2269
|
-
],
|
|
2270
|
-
});
|
|
2271
|
-
|
|
2272
|
-
let with_verification = doc_graph_node_json(&d, true);
|
|
2273
|
-
assert_eq!(
|
|
2274
|
-
with_verification["verification_progress"],
|
|
2275
|
-
json!({ "total": 2, "verified": 1 })
|
|
2276
|
-
);
|
|
2277
|
-
|
|
2278
|
-
let without_verification = doc_graph_node_json(&d, false);
|
|
2279
|
-
assert!(without_verification.get("verification_progress").is_none());
|
|
2280
|
-
}
|
|
2281
|
-
|
|
2282
|
-
#[test]
|
|
2283
|
-
fn graph_node_json_omits_verification_progress_when_no_matrix() {
|
|
2284
|
-
let d = doc("doc-1", "a", "spec");
|
|
2285
|
-
let node = doc_graph_node_json(&d, true);
|
|
2286
|
-
assert!(node.get("verification_progress").is_none());
|
|
2287
|
-
}
|
|
2288
|
-
}
|