handoff-mcp-server 0.24.6 → 0.25.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/Cargo.lock +29 -3
- package/Cargo.toml +4 -2
- package/README.md +23 -9
- package/package.json +1 -1
- package/skills/handoff-docs/SKILL.md +181 -9
- package/src/context/injection.rs +55 -18
- package/src/context/mod.rs +1 -1
- package/src/mcp/handlers/auto_schedule.rs +1 -13
- package/src/mcp/handlers/capacity.rs +1 -14
- package/src/mcp/handlers/config.rs +13 -0
- package/src/mcp/handlers/docs.rs +832 -76
- package/src/mcp/handlers/docs_query.rs +4 -4
- package/src/mcp/handlers/memory.rs +88 -13
- package/src/mcp/handlers/mod.rs +1 -0
- package/src/mcp/handlers/task_checklist.rs +17 -5
- package/src/mcp/tools.rs +28 -7
- package/src/storage/config.rs +217 -5
- package/src/storage/docs/frontmatter.rs +509 -0
- package/src/storage/docs/mod.rs +346 -108
- package/src/storage/docs/model.rs +186 -15
- package/src/storage/memory/mod.rs +1 -0
- package/src/storage/memory/model.rs +38 -7
package/src/mcp/handlers/docs.rs
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
//! MCP handlers for document management (save / get / list) — P1-6a (t96.1),
|
|
2
|
-
//!
|
|
2
|
+
//! frontmatter migration (t123.1-t123.3, single-file slug-based storage,
|
|
3
3
|
//! wiki/130-document-management.md §3.1).
|
|
4
4
|
//!
|
|
5
|
-
//! Builds on the storage layer in `crate::storage::docs` (
|
|
6
|
-
//!
|
|
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
7
|
//! task<->doc bidirectional link sync in
|
|
8
8
|
//! `crate::storage::tasks::sync_doc_task_links`. See
|
|
9
9
|
//! `wiki/130-document-management.md` §5.1-§5.3 for the spec.
|
|
@@ -20,7 +20,7 @@ use crate::storage::docs::split::{compute_sections, split};
|
|
|
20
20
|
use crate::storage::docs::{
|
|
21
21
|
delete_doc, delete_doc_body, ensure_docs_dir, find_doc_by_id, read_all_docs, read_doc,
|
|
22
22
|
read_doc_body, validate_slug, write_doc, write_doc_body, CodeRef, DocMetadata, DocRelation,
|
|
23
|
-
Verification, VerificationItem,
|
|
23
|
+
SubItem, Verification, VerificationItem,
|
|
24
24
|
};
|
|
25
25
|
use crate::storage::ensure_handoff_exists;
|
|
26
26
|
use crate::storage::tasks::sync_doc_task_links;
|
|
@@ -85,10 +85,10 @@ pub fn handle_doc_save(arguments: &Value) -> Result<String> {
|
|
|
85
85
|
};
|
|
86
86
|
|
|
87
87
|
// `append_body`: join the appended text onto the existing document's
|
|
88
|
-
// stripped body (read_doc_body — NOT read_full_body, whose
|
|
89
|
-
//
|
|
90
|
-
//
|
|
91
|
-
//
|
|
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
92
|
let joined_body: String;
|
|
93
93
|
let body: &str = if let Some(append_body) = append_body_arg {
|
|
94
94
|
let existing_doc = existing
|
|
@@ -210,8 +210,7 @@ pub fn handle_doc_save(arguments: &Value) -> Result<String> {
|
|
|
210
210
|
|
|
211
211
|
doc.has_bom = split_doc.has_bom;
|
|
212
212
|
doc.line_ending = split_doc.line_ending.to_string();
|
|
213
|
-
doc.
|
|
214
|
-
doc.source.frontmatter_trailing_eol = split_doc.frontmatter_trailing_eol;
|
|
213
|
+
doc.split_level = split_level;
|
|
215
214
|
doc.updated_at = now.clone();
|
|
216
215
|
|
|
217
216
|
// v5: the full body (after BOM/frontmatter stripping) is written verbatim
|
|
@@ -310,33 +309,130 @@ pub fn handle_doc_save(arguments: &Value) -> Result<String> {
|
|
|
310
309
|
})))
|
|
311
310
|
}
|
|
312
311
|
|
|
313
|
-
///
|
|
314
|
-
///
|
|
315
|
-
///
|
|
316
|
-
///
|
|
317
|
-
///
|
|
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.
|
|
318
427
|
fn read_full_body(handoff: &Path, doc: &DocMetadata) -> Result<Option<String>> {
|
|
319
|
-
let Some(
|
|
428
|
+
let Some(body) = read_doc_body(handoff, &doc.slug)? else {
|
|
320
429
|
return Ok(None);
|
|
321
430
|
};
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
"\n"
|
|
328
|
-
};
|
|
329
|
-
let trailing_eol = if doc.source.frontmatter_trailing_eol {
|
|
330
|
-
eol
|
|
331
|
-
} else {
|
|
332
|
-
""
|
|
333
|
-
};
|
|
334
|
-
body = format!("---{eol}{frontmatter}---{trailing_eol}{body}");
|
|
335
|
-
}
|
|
336
|
-
if doc.has_bom {
|
|
337
|
-
body = format!("\u{FEFF}{body}");
|
|
338
|
-
}
|
|
339
|
-
Ok(Some(body))
|
|
431
|
+
Ok(Some(if doc.has_bom {
|
|
432
|
+
format!("\u{FEFF}{body}")
|
|
433
|
+
} else {
|
|
434
|
+
body
|
|
435
|
+
}))
|
|
340
436
|
}
|
|
341
437
|
|
|
342
438
|
/// `handoff_doc_get` — read a document (by `doc_id` or `slug`) as `full`
|
|
@@ -467,11 +563,12 @@ fn rank_docs_by_query(handoff: &Path, docs: &[DocMetadata], query: &str) -> Resu
|
|
|
467
563
|
index_texts.push(text);
|
|
468
564
|
}
|
|
469
565
|
|
|
470
|
-
let corpus = lexsim::Corpus::
|
|
471
|
-
let query_tokens = lexsim::
|
|
566
|
+
let corpus = lexsim::Corpus::build_weighted(&index_texts);
|
|
567
|
+
let query_tokens = lexsim::tokenize_weighted(query);
|
|
472
568
|
let scope_paths: Vec<Vec<String>> = docs.iter().map(|d| d.scope_paths.clone()).collect();
|
|
473
569
|
let config = RankConfig {
|
|
474
570
|
min_score: DOC_QUERY_MIN_SCORE,
|
|
571
|
+
relative_threshold: 0.0,
|
|
475
572
|
scope_path_bonus: SCOPE_PATH_BONUS,
|
|
476
573
|
limit: docs.len(),
|
|
477
574
|
};
|
|
@@ -563,9 +660,13 @@ pub fn handle_doc_reassemble(arguments: &Value) -> Result<String> {
|
|
|
563
660
|
let doc = resolve_doc(&handoff, doc_id)?
|
|
564
661
|
.ok_or_else(|| anyhow::anyhow!("Document not found: {doc_id}"))?;
|
|
565
662
|
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
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());
|
|
569
670
|
|
|
570
671
|
let body = read_full_body(&handoff, &doc)?.unwrap_or_default();
|
|
571
672
|
|
|
@@ -699,15 +800,38 @@ fn doc_tree_node_json(handoff: &Path, doc: &DocMetadata, include_related: bool)
|
|
|
699
800
|
|
|
700
801
|
/// Recomputes `Verification.status` from its items (wiki/140-verification-matrix.md
|
|
701
802
|
/// §3.3): all pending -> "pending"; all verified/skipped -> "verified";
|
|
702
|
-
/// otherwise -> "in_review".
|
|
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`.
|
|
703
806
|
fn recompute_verification_status(items: &[VerificationItem]) -> String {
|
|
704
|
-
|
|
807
|
+
let statuses: Vec<String> = items.iter().map(item_effective_status).collect();
|
|
808
|
+
if statuses.iter().all(|s| s == "pending") {
|
|
705
809
|
"pending".to_string()
|
|
706
|
-
} else if
|
|
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
|
|
707
829
|
.iter()
|
|
708
|
-
.all(|
|
|
830
|
+
.all(|s| s.status == "verified" || s.status == "skipped")
|
|
709
831
|
{
|
|
710
832
|
"verified".to_string()
|
|
833
|
+
} else if item.sub_items.iter().all(|s| s.status == "pending") {
|
|
834
|
+
"pending".to_string()
|
|
711
835
|
} else {
|
|
712
836
|
"in_review".to_string()
|
|
713
837
|
}
|
|
@@ -732,6 +856,264 @@ fn code_refs_from_value(v: &Value) -> Vec<CodeRef> {
|
|
|
732
856
|
.unwrap_or_default()
|
|
733
857
|
}
|
|
734
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
|
+
|
|
735
1117
|
/// Summary counts used by both `doc_verify`'s mutation response and
|
|
736
1118
|
/// `doc_verify_status`'s `progress` block.
|
|
737
1119
|
struct VerificationCounts {
|
|
@@ -742,30 +1124,62 @@ struct VerificationCounts {
|
|
|
742
1124
|
stale: usize,
|
|
743
1125
|
}
|
|
744
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 の合計").
|
|
745
1132
|
fn count_verification(doc: &DocMetadata, v: &Verification) -> VerificationCounts {
|
|
746
|
-
let checked =
|
|
747
|
-
let skipped =
|
|
748
|
-
let pending =
|
|
749
|
-
let stale =
|
|
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
|
+
|
|
750
1159
|
VerificationCounts {
|
|
751
1160
|
checked,
|
|
752
1161
|
skipped,
|
|
753
1162
|
pending,
|
|
754
|
-
total:
|
|
1163
|
+
total: checked + skipped + pending,
|
|
755
1164
|
stale,
|
|
756
1165
|
}
|
|
757
1166
|
}
|
|
758
1167
|
|
|
759
1168
|
/// An item is stale when it was verified at a content_hash that no longer
|
|
760
1169
|
/// matches its section's current content_hash (spec §3.5) — items never
|
|
761
|
-
/// verified (`content_hash_at_verify: None`) are never stale,
|
|
1170
|
+
/// verified (`content_hash_at_verify: None`) are never stale, items whose
|
|
762
1171
|
/// section has been removed (sync should have dropped them, but be
|
|
763
|
-
/// defensive) are treated as stale so drift is never silently hidden
|
|
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.
|
|
764
1175
|
fn item_is_stale(doc: &DocMetadata, item: &VerificationItem) -> bool {
|
|
765
1176
|
let Some(hash_at_verify) = &item.content_hash_at_verify else {
|
|
766
1177
|
return false;
|
|
767
1178
|
};
|
|
768
|
-
|
|
1179
|
+
let Some(fragment_seq) = item.fragment_seq else {
|
|
1180
|
+
return false;
|
|
1181
|
+
};
|
|
1182
|
+
match doc.sections.iter().find(|s| s.seq == fragment_seq) {
|
|
769
1183
|
Some(section) => §ion.content_hash != hash_at_verify,
|
|
770
1184
|
None => true,
|
|
771
1185
|
}
|
|
@@ -789,6 +1203,24 @@ pub fn handle_doc_verify(arguments: &Value) -> Result<String> {
|
|
|
789
1203
|
let mut doc = resolve_doc(&handoff, doc_id)?
|
|
790
1204
|
.ok_or_else(|| anyhow::anyhow!("Document not found: {doc_id}"))?;
|
|
791
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
|
+
|
|
792
1224
|
let now = chrono::Utc::now().to_rfc3339();
|
|
793
1225
|
|
|
794
1226
|
match action {
|
|
@@ -813,7 +1245,7 @@ pub fn handle_doc_verify(arguments: &Value) -> Result<String> {
|
|
|
813
1245
|
.sections
|
|
814
1246
|
.iter()
|
|
815
1247
|
.map(|s| VerificationItem {
|
|
816
|
-
fragment_seq: s.seq,
|
|
1248
|
+
fragment_seq: Some(s.seq),
|
|
817
1249
|
heading: s.heading.clone(),
|
|
818
1250
|
status: if skip_seqs.contains(&s.seq) {
|
|
819
1251
|
"skipped".to_string()
|
|
@@ -826,6 +1258,9 @@ pub fn handle_doc_verify(arguments: &Value) -> Result<String> {
|
|
|
826
1258
|
verified_at: None,
|
|
827
1259
|
notes: String::new(),
|
|
828
1260
|
content_hash_at_verify: None,
|
|
1261
|
+
category: "section".to_string(),
|
|
1262
|
+
sub_items: Vec::new(),
|
|
1263
|
+
label: None,
|
|
829
1264
|
})
|
|
830
1265
|
.collect();
|
|
831
1266
|
|
|
@@ -837,7 +1272,7 @@ pub fn handle_doc_verify(arguments: &Value) -> Result<String> {
|
|
|
837
1272
|
});
|
|
838
1273
|
}
|
|
839
1274
|
"check" => {
|
|
840
|
-
let
|
|
1275
|
+
let fragment_seqs = required_fragment_seqs(arguments)?;
|
|
841
1276
|
let reviewer = arguments
|
|
842
1277
|
.get("reviewer")
|
|
843
1278
|
.and_then(|v| v.as_str())
|
|
@@ -846,31 +1281,172 @@ pub fn handle_doc_verify(arguments: &Value) -> Result<String> {
|
|
|
846
1281
|
.get("notes")
|
|
847
1282
|
.and_then(|v| v.as_str())
|
|
848
1283
|
.map(str::to_string);
|
|
849
|
-
let
|
|
850
|
-
.
|
|
851
|
-
.
|
|
852
|
-
.
|
|
853
|
-
|
|
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();
|
|
854
1334
|
|
|
855
1335
|
let v = verification_mut(&mut doc, doc_id)?;
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
item.
|
|
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
|
+
}
|
|
864
1361
|
}
|
|
865
|
-
item.content_hash_at_verify = section_hash;
|
|
866
1362
|
v.updated_at = now.clone();
|
|
867
1363
|
v.status = recompute_verification_status(&v.items);
|
|
868
1364
|
}
|
|
869
1365
|
"skip" => {
|
|
870
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
|
+
|
|
871
1372
|
let v = verification_mut(&mut doc, doc_id)?;
|
|
872
1373
|
let item = find_item_mut(v, fragment_seq, doc_id)?;
|
|
873
|
-
|
|
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
|
+
}
|
|
874
1450
|
v.updated_at = now.clone();
|
|
875
1451
|
v.status = recompute_verification_status(&v.items);
|
|
876
1452
|
}
|
|
@@ -879,13 +1455,19 @@ pub fn handle_doc_verify(arguments: &Value) -> Result<String> {
|
|
|
879
1455
|
let v = verification_mut(&mut doc, doc_id)?;
|
|
880
1456
|
let current_seqs: std::collections::HashSet<usize> =
|
|
881
1457
|
sections.iter().map(|s| s.seq).collect();
|
|
882
|
-
|
|
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
|
+
});
|
|
883
1465
|
let existing_seqs: std::collections::HashSet<usize> =
|
|
884
|
-
v.items.iter().
|
|
1466
|
+
v.items.iter().filter_map(|i| i.fragment_seq).collect();
|
|
885
1467
|
for s in §ions {
|
|
886
1468
|
if !existing_seqs.contains(&s.seq) {
|
|
887
1469
|
v.items.push(VerificationItem {
|
|
888
|
-
fragment_seq: s.seq,
|
|
1470
|
+
fragment_seq: Some(s.seq),
|
|
889
1471
|
heading: s.heading.clone(),
|
|
890
1472
|
status: "pending".to_string(),
|
|
891
1473
|
impl_refs: Vec::new(),
|
|
@@ -894,10 +1476,15 @@ pub fn handle_doc_verify(arguments: &Value) -> Result<String> {
|
|
|
894
1476
|
verified_at: None,
|
|
895
1477
|
notes: String::new(),
|
|
896
1478
|
content_hash_at_verify: None,
|
|
1479
|
+
category: "section".to_string(),
|
|
1480
|
+
sub_items: Vec::new(),
|
|
1481
|
+
label: None,
|
|
897
1482
|
});
|
|
898
1483
|
}
|
|
899
1484
|
}
|
|
900
|
-
|
|
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));
|
|
901
1488
|
v.updated_at = now.clone();
|
|
902
1489
|
v.status = recompute_verification_status(&v.items);
|
|
903
1490
|
}
|
|
@@ -918,7 +1505,7 @@ pub fn handle_doc_verify(arguments: &Value) -> Result<String> {
|
|
|
918
1505
|
v.status = recompute_verification_status(&v.items);
|
|
919
1506
|
}
|
|
920
1507
|
other => anyhow::bail!(
|
|
921
|
-
"Unknown action '{other}'; expected one of generate, check, skip, sync, set_refs"
|
|
1508
|
+
"Unknown action '{other}'; expected one of generate, check, check_all, skip, sync, set_refs, add_item, suggest_refs"
|
|
922
1509
|
),
|
|
923
1510
|
}
|
|
924
1511
|
|
|
@@ -949,6 +1536,25 @@ fn required_fragment_seq(arguments: &Value) -> Result<usize> {
|
|
|
949
1536
|
.ok_or_else(|| anyhow::anyhow!("'fragment_seq' is required for this action"))
|
|
950
1537
|
}
|
|
951
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
|
+
|
|
952
1558
|
fn verification_mut<'a>(doc: &'a mut DocMetadata, doc_id: &str) -> Result<&'a mut Verification> {
|
|
953
1559
|
doc.verification.as_mut().ok_or_else(|| {
|
|
954
1560
|
anyhow::anyhow!(
|
|
@@ -964,7 +1570,7 @@ fn find_item_mut<'a>(
|
|
|
964
1570
|
) -> Result<&'a mut VerificationItem> {
|
|
965
1571
|
v.items
|
|
966
1572
|
.iter_mut()
|
|
967
|
-
.find(|i| i.fragment_seq == fragment_seq)
|
|
1573
|
+
.find(|i| i.fragment_seq == Some(fragment_seq))
|
|
968
1574
|
.ok_or_else(|| {
|
|
969
1575
|
anyhow::anyhow!(
|
|
970
1576
|
"No verification item at fragment_seq={fragment_seq} for document {doc_id}"
|
|
@@ -972,6 +1578,21 @@ fn find_item_mut<'a>(
|
|
|
972
1578
|
})
|
|
973
1579
|
}
|
|
974
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
|
+
|
|
975
1596
|
/// `handoff_doc_verify_status` — verification matrix summary + optional
|
|
976
1597
|
/// per-item detail with stale detection (wiki/140-verification-matrix.md
|
|
977
1598
|
/// §4.2).
|
|
@@ -987,6 +1608,10 @@ pub fn handle_doc_verify_status(arguments: &Value) -> Result<String> {
|
|
|
987
1608
|
.get("include_items")
|
|
988
1609
|
.and_then(|v| v.as_bool())
|
|
989
1610
|
.unwrap_or(false);
|
|
1611
|
+
let format = arguments
|
|
1612
|
+
.get("format")
|
|
1613
|
+
.and_then(|v| v.as_str())
|
|
1614
|
+
.unwrap_or("json");
|
|
990
1615
|
|
|
991
1616
|
let doc = resolve_doc(&handoff, doc_id)?
|
|
992
1617
|
.ok_or_else(|| anyhow::anyhow!("Document not found: {doc_id}"))?;
|
|
@@ -1004,6 +1629,10 @@ pub fn handle_doc_verify_status(arguments: &Value) -> Result<String> {
|
|
|
1004
1629
|
(counts.checked + counts.skipped) as f64 / counts.total as f64 * 100.0
|
|
1005
1630
|
};
|
|
1006
1631
|
|
|
1632
|
+
if format == "checklist" {
|
|
1633
|
+
return Ok(render_verification_checklist(&doc, v, &counts, percentage));
|
|
1634
|
+
}
|
|
1635
|
+
|
|
1007
1636
|
let mut out = json!({
|
|
1008
1637
|
"doc_id": doc.id,
|
|
1009
1638
|
"title": doc.title,
|
|
@@ -1023,6 +1652,21 @@ pub fn handle_doc_verify_status(arguments: &Value) -> Result<String> {
|
|
|
1023
1652
|
.items
|
|
1024
1653
|
.iter()
|
|
1025
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();
|
|
1026
1670
|
json!({
|
|
1027
1671
|
"fragment_seq": i.fragment_seq,
|
|
1028
1672
|
"heading": i.heading,
|
|
@@ -1033,6 +1677,9 @@ pub fn handle_doc_verify_status(arguments: &Value) -> Result<String> {
|
|
|
1033
1677
|
"reviewer": i.reviewer,
|
|
1034
1678
|
"verified_at": i.verified_at,
|
|
1035
1679
|
"notes": i.notes,
|
|
1680
|
+
"category": i.category,
|
|
1681
|
+
"sub_items": sub_items,
|
|
1682
|
+
"label": i.label,
|
|
1036
1683
|
})
|
|
1037
1684
|
})
|
|
1038
1685
|
.collect();
|
|
@@ -1042,6 +1689,109 @@ pub fn handle_doc_verify_status(arguments: &Value) -> Result<String> {
|
|
|
1042
1689
|
Ok(to_json(&out))
|
|
1043
1690
|
}
|
|
1044
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
|
+
|
|
1045
1795
|
/// `handoff_doc_graph` — build a graph of every document in the project:
|
|
1046
1796
|
/// `nodes[]` (one per document, with optional verification progress),
|
|
1047
1797
|
/// `edges[]` (explicit parent_child/related links, plus implicit
|
|
@@ -1489,7 +2239,7 @@ mod graph_tests {
|
|
|
1489
2239
|
updated_at: "2026-07-12T00:00:00Z".to_string(),
|
|
1490
2240
|
items: vec![
|
|
1491
2241
|
VerificationItem {
|
|
1492
|
-
fragment_seq: 0,
|
|
2242
|
+
fragment_seq: Some(0),
|
|
1493
2243
|
heading: String::new(),
|
|
1494
2244
|
status: "verified".to_string(),
|
|
1495
2245
|
impl_refs: Vec::new(),
|
|
@@ -1498,9 +2248,12 @@ mod graph_tests {
|
|
|
1498
2248
|
verified_at: None,
|
|
1499
2249
|
notes: String::new(),
|
|
1500
2250
|
content_hash_at_verify: None,
|
|
2251
|
+
category: "section".to_string(),
|
|
2252
|
+
sub_items: Vec::new(),
|
|
2253
|
+
label: None,
|
|
1501
2254
|
},
|
|
1502
2255
|
VerificationItem {
|
|
1503
|
-
fragment_seq: 1,
|
|
2256
|
+
fragment_seq: Some(1),
|
|
1504
2257
|
heading: "H".to_string(),
|
|
1505
2258
|
status: "pending".to_string(),
|
|
1506
2259
|
impl_refs: Vec::new(),
|
|
@@ -1509,6 +2262,9 @@ mod graph_tests {
|
|
|
1509
2262
|
verified_at: None,
|
|
1510
2263
|
notes: String::new(),
|
|
1511
2264
|
content_hash_at_verify: None,
|
|
2265
|
+
category: "section".to_string(),
|
|
2266
|
+
sub_items: Vec::new(),
|
|
2267
|
+
label: None,
|
|
1512
2268
|
},
|
|
1513
2269
|
],
|
|
1514
2270
|
});
|