From 62a9f6223745c0df42be1f4226f31dfb10c83ea8 Mon Sep 17 00:00:00 2001
From: ax_rd <ax_rd@aisim.cn>
Date: Thu, 03 Sep 2026 13:53:11 +0800
Subject: [PATCH] fix: e2e-verify.sh 退出码处理(FAILS 计数 + pipefail,CI 可凭退出码判定)

---
 internal/index/reconcile.go |  126 +++++++++++++++++++++++++++++++++++++++--
 1 files changed, 118 insertions(+), 8 deletions(-)

diff --git a/internal/index/reconcile.go b/internal/index/reconcile.go
index 8024eb3..18c25c3 100644
--- a/internal/index/reconcile.go
+++ b/internal/index/reconcile.go
@@ -18,6 +18,7 @@
 	Modified  int
 	Deleted   int
 	Unchanged int
+	Resolved  int // 悬空链接自动补全成功的条数
 }
 
 // Reconcile 增量对账:stat 比对 → 只对变更文件解析和写库
@@ -101,10 +102,12 @@
 		}
 	}
 
-	// 5. 悬空链接重试(新增/修改节点可能让 failed 链接变可解析)
-	if res.Added+res.Modified > 0 {
-		store.RetryUnresolved() // 见 Task 4,此处先以空实现占位编译通过
+	// 5. 悬空链接重试:新节点入库后,历史悬空链接可能变可解析
+	resolved, err := store.RetryUnresolved()
+	if err != nil {
+		return nil, fmt.Errorf("悬空链接重试失败: %w", err)
 	}
+	res.Resolved = resolved
 	return res, nil
 }
 
@@ -156,8 +159,12 @@
 		targetID, prov, ok := s.resolveWikilink(link)
 		if !ok {
 			tail := nameTail(link)
+			// 幂等守卫:同 from_node+link_text 不重复插(unresolved_links 无 UNIQUE 约束,用 WHERE NOT EXISTS)
 			s.db.Exec(`INSERT INTO unresolved_links (from_node, link_text, name_tail, status)
-				VALUES (?, ?, ?, 'pending') ON CONFLICT DO NOTHING`, nodeID, link, tail)
+				SELECT ?, ?, ?, 'pending'
+				WHERE NOT EXISTS (
+					SELECT 1 FROM unresolved_links WHERE from_node = ? AND link_text = ?)`,
+				nodeID, link, tail, nodeID, link)
 			continue
 		}
 		if err := s.InsertEdge(&graph.Edge{
@@ -180,7 +187,7 @@
 		s.db.QueryRow(`SELECT COALESCE(MAX(to_node), 1000000) FROM edges WHERE to_node >= 1000000 AND relation=?`, relation).Scan(&maxID)
 		virtualID = maxID + 1
 	}
-	return s.InsertEdge(&graph.Edge{FromNode: fromNode, ToNode: virtualID, Relation: relation, Label: label})
+	return s.InsertEdge(&graph.Edge{FromNode: fromNode, ToNode: virtualID, Relation: relation, Label: label, Provenance: relation})
 }
 
 // resolveWikilink 解析 wikilink 目标,返回 (nodeID, provenance, ok)
@@ -218,7 +225,8 @@
 		if base == link || nTitle == link {
 			return nid, "exact", true
 		}
-		if fuzzyID == 0 && strings.Contains(nTitle, link) {
+		// 空 link 跳过 fuzzy(Contains 对空串恒真会误建边)
+		if fuzzyID == 0 && link != "" && strings.Contains(nTitle, link) {
 			fuzzyID = nid
 		}
 	}
@@ -236,8 +244,110 @@
 	return link
 }
 
-// RetryUnresolved 重试解析悬空链接(Task 4 补全完整实现)
-func (s *Store) RetryUnresolved() {}
+// RetryUnresolved 重试解析悬空链接:用当前全部节点的标题/文件名去匹配 unresolved_links 的 name_tail。
+// 命中则建边(provenance 按匹配严格度)、删行。返回成功解析条数。
+func (s *Store) RetryUnresolved() (int, error) {
+	rows, err := s.db.Query(`SELECT id, from_node, link_text, name_tail FROM unresolved_links`)
+	if err != nil {
+		return 0, err
+	}
+	type pending struct {
+		id       int64
+		fromNode int64
+		linkText string
+		tail     string
+	}
+	var pendings []pending
+	for rows.Next() {
+		var p pending
+		if err := rows.Scan(&p.id, &p.fromNode, &p.linkText, &p.tail); err != nil {
+			rows.Close()
+			return 0, err
+		}
+		pendings = append(pendings, p)
+	}
+	rows.Close()
+	if len(pendings) == 0 {
+		return 0, nil
+	}
+
+	// 建匹配索引:标题/文件名(去编号) → nodeID,精确匹配优先
+	type matchInfo struct {
+		id   int64
+		prov string
+	}
+	exactMap := make(map[string]matchInfo)
+	var fuzzyRows []struct {
+		id    int64
+		title string
+	}
+	nrows, err := s.db.Query(`SELECT id, title, path FROM nodes`)
+	if err != nil {
+		return 0, err
+	}
+	for nrows.Next() {
+		var id int64
+		var title, path string
+		if err := nrows.Scan(&id, &title, &path); err != nil {
+			nrows.Close()
+			return 0, err
+		}
+		if _, ok := exactMap[title]; !ok {
+			exactMap[title] = matchInfo{id, "exact"}
+		}
+		base := filepath.Base(path)
+		base = strings.TrimSuffix(base, ".md")
+		if dash := strings.Index(base, "-"); dash >= 0 {
+			base = base[dash+1:]
+		}
+		if _, ok := exactMap[base]; !ok {
+			exactMap[base] = matchInfo{id, "exact"}
+		}
+		fuzzyRows = append(fuzzyRows, struct {
+			id    int64
+			title string
+		}{id, title})
+	}
+	nrows.Close()
+
+	resolved := 0
+	for _, p := range pendings {
+		if info, ok := exactMap[p.tail]; ok {
+			// 精确命中:建边 + 删悬空行
+			if err := s.InsertEdge(&graph.Edge{
+				FromNode: p.fromNode, ToNode: info.id,
+				Relation: "wikilink", Label: p.linkText, Provenance: info.prov,
+			}); err != nil {
+				return resolved, err
+			}
+			if _, err := s.db.Exec(`DELETE FROM unresolved_links WHERE id=?`, p.id); err != nil {
+				return resolved, err
+			}
+			resolved++
+			continue
+		}
+		// fuzzy:标题包含(空 tail 跳过,避免 Contains 恒真)
+		if p.tail == "" {
+			continue
+		}
+		for _, fr := range fuzzyRows {
+			if strings.Contains(fr.title, p.tail) {
+				if err := s.InsertEdge(&graph.Edge{
+					FromNode: p.fromNode, ToNode: fr.id,
+					Relation: "wikilink", Label: p.linkText, Provenance: "fuzzy",
+				}); err != nil {
+					return resolved, err
+				}
+				if _, err := s.db.Exec(`DELETE FROM unresolved_links WHERE id=?`, p.id); err != nil {
+					return resolved, err
+				}
+				resolved++
+				break
+			}
+		}
+	}
+	return resolved, nil
+}
 
 // QuickCheck 只 stat 比对(不读内容不哈希),返回是否有差异
 func QuickCheck(store *Store, vaultPath string) (bool, error) {

--
Gitblit v1.10.0