@jamesaphoenix/tx-core 0.4.5 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (199) hide show
  1. package/dist/db.d.ts +4 -9
  2. package/dist/db.d.ts.map +1 -1
  3. package/dist/db.js +6 -80
  4. package/dist/db.js.map +1 -1
  5. package/dist/errors.d.ts +67 -10
  6. package/dist/errors.d.ts.map +1 -1
  7. package/dist/errors.js +44 -10
  8. package/dist/errors.js.map +1 -1
  9. package/dist/index.d.ts +12 -7
  10. package/dist/index.d.ts.map +1 -1
  11. package/dist/index.js +22 -28
  12. package/dist/index.js.map +1 -1
  13. package/dist/layer.d.ts +22 -10
  14. package/dist/layer.d.ts.map +1 -1
  15. package/dist/layer.js +97 -39
  16. package/dist/layer.js.map +1 -1
  17. package/dist/mappers/anchor.d.ts +28 -0
  18. package/dist/mappers/anchor.d.ts.map +1 -0
  19. package/dist/mappers/anchor.js +105 -0
  20. package/dist/mappers/anchor.js.map +1 -0
  21. package/dist/mappers/candidate.d.ts +25 -0
  22. package/dist/mappers/candidate.d.ts.map +1 -0
  23. package/dist/mappers/candidate.js +83 -0
  24. package/dist/mappers/candidate.js.map +1 -0
  25. package/dist/mappers/doc.d.ts +2 -4
  26. package/dist/mappers/doc.d.ts.map +1 -1
  27. package/dist/mappers/doc.js +7 -4
  28. package/dist/mappers/doc.js.map +1 -1
  29. package/dist/mappers/edge.d.ts +19 -0
  30. package/dist/mappers/edge.d.ts.map +1 -0
  31. package/dist/mappers/edge.js +81 -0
  32. package/dist/mappers/edge.js.map +1 -0
  33. package/dist/mappers/index.d.ts +7 -3
  34. package/dist/mappers/index.d.ts.map +1 -1
  35. package/dist/mappers/index.js +14 -6
  36. package/dist/mappers/index.js.map +1 -1
  37. package/dist/mappers/message.d.ts +15 -0
  38. package/dist/mappers/message.d.ts.map +1 -0
  39. package/dist/mappers/message.js +58 -0
  40. package/dist/mappers/message.js.map +1 -0
  41. package/dist/repo/anchor-repo.d.ts +52 -0
  42. package/dist/repo/anchor-repo.d.ts.map +1 -0
  43. package/dist/repo/anchor-repo.js +245 -0
  44. package/dist/repo/anchor-repo.js.map +1 -0
  45. package/dist/repo/candidate-repo.d.ts +16 -0
  46. package/dist/repo/candidate-repo.d.ts.map +1 -0
  47. package/dist/repo/candidate-repo.js +164 -0
  48. package/dist/repo/candidate-repo.js.map +1 -0
  49. package/dist/repo/compaction-repo.d.ts +41 -0
  50. package/dist/repo/compaction-repo.d.ts.map +1 -0
  51. package/dist/repo/compaction-repo.js +84 -0
  52. package/dist/repo/compaction-repo.js.map +1 -0
  53. package/dist/repo/doc-repo.d.ts +68 -51
  54. package/dist/repo/doc-repo.d.ts.map +1 -1
  55. package/dist/repo/doc-repo.js +120 -54
  56. package/dist/repo/doc-repo.js.map +1 -1
  57. package/dist/repo/edge-repo.d.ts +26 -0
  58. package/dist/repo/edge-repo.d.ts.map +1 -0
  59. package/dist/repo/edge-repo.js +258 -0
  60. package/dist/repo/edge-repo.js.map +1 -0
  61. package/dist/repo/index.d.ts +8 -3
  62. package/dist/repo/index.d.ts.map +1 -1
  63. package/dist/repo/index.js +7 -2
  64. package/dist/repo/index.js.map +1 -1
  65. package/dist/repo/message-repo.d.ts +55 -0
  66. package/dist/repo/message-repo.d.ts.map +1 -0
  67. package/dist/repo/message-repo.js +132 -0
  68. package/dist/repo/message-repo.js.map +1 -0
  69. package/dist/services/agent-service.d.ts +18 -23
  70. package/dist/services/agent-service.d.ts.map +1 -1
  71. package/dist/services/agent-service.js +9 -0
  72. package/dist/services/agent-service.js.map +1 -1
  73. package/dist/services/anchor-service.d.ts +147 -0
  74. package/dist/services/anchor-service.d.ts.map +1 -0
  75. package/dist/services/anchor-service.js +540 -0
  76. package/dist/services/anchor-service.js.map +1 -0
  77. package/dist/services/anchor-verification.d.ts +102 -0
  78. package/dist/services/anchor-verification.d.ts.map +1 -0
  79. package/dist/services/anchor-verification.js +817 -0
  80. package/dist/services/anchor-verification.js.map +1 -0
  81. package/dist/services/ast-grep-service.d.ts +58 -0
  82. package/dist/services/ast-grep-service.d.ts.map +1 -0
  83. package/dist/services/ast-grep-service.js +427 -0
  84. package/dist/services/ast-grep-service.js.map +1 -0
  85. package/dist/services/attempt-service.d.ts.map +1 -1
  86. package/dist/services/attempt-service.js +4 -1
  87. package/dist/services/attempt-service.js.map +1 -1
  88. package/dist/services/auto-sync-service.d.ts.map +1 -1
  89. package/dist/services/auto-sync-service.js +7 -7
  90. package/dist/services/auto-sync-service.js.map +1 -1
  91. package/dist/services/candidate-extractor-service.d.ts +44 -0
  92. package/dist/services/candidate-extractor-service.d.ts.map +1 -0
  93. package/dist/services/candidate-extractor-service.js +175 -0
  94. package/dist/services/candidate-extractor-service.js.map +1 -0
  95. package/dist/services/claim-service.d.ts.map +1 -1
  96. package/dist/services/claim-service.js +0 -8
  97. package/dist/services/claim-service.js.map +1 -1
  98. package/dist/services/compaction-service.d.ts +105 -0
  99. package/dist/services/compaction-service.d.ts.map +1 -0
  100. package/dist/services/compaction-service.js +281 -0
  101. package/dist/services/compaction-service.js.map +1 -0
  102. package/dist/services/cycle-scan-service.d.ts +1 -5
  103. package/dist/services/cycle-scan-service.d.ts.map +1 -1
  104. package/dist/services/cycle-scan-service.js +49 -19
  105. package/dist/services/cycle-scan-service.js.map +1 -1
  106. package/dist/services/daemon-service.d.ts +2 -8
  107. package/dist/services/daemon-service.d.ts.map +1 -1
  108. package/dist/services/daemon-service.js +21 -35
  109. package/dist/services/daemon-service.js.map +1 -1
  110. package/dist/services/doc-service.d.ts +25 -32
  111. package/dist/services/doc-service.d.ts.map +1 -1
  112. package/dist/services/doc-service.js +206 -190
  113. package/dist/services/doc-service.js.map +1 -1
  114. package/dist/services/edge-service.d.ts +78 -0
  115. package/dist/services/edge-service.d.ts.map +1 -0
  116. package/dist/services/edge-service.js +158 -0
  117. package/dist/services/edge-service.js.map +1 -0
  118. package/dist/services/embedding-service.d.ts +2 -2
  119. package/dist/services/embedding-service.d.ts.map +1 -1
  120. package/dist/services/embedding-service.js +7 -13
  121. package/dist/services/embedding-service.js.map +1 -1
  122. package/dist/services/feedback-tracker.d.ts +64 -0
  123. package/dist/services/feedback-tracker.d.ts.map +1 -0
  124. package/dist/services/feedback-tracker.js +110 -0
  125. package/dist/services/feedback-tracker.js.map +1 -0
  126. package/dist/services/file-watcher-service.d.ts.map +1 -1
  127. package/dist/services/file-watcher-service.js +1 -3
  128. package/dist/services/file-watcher-service.js.map +1 -1
  129. package/dist/services/graph-expansion.d.ts +158 -0
  130. package/dist/services/graph-expansion.d.ts.map +1 -0
  131. package/dist/services/graph-expansion.js +487 -0
  132. package/dist/services/graph-expansion.js.map +1 -0
  133. package/dist/services/index.d.ts +19 -8
  134. package/dist/services/index.d.ts.map +1 -1
  135. package/dist/services/index.js +18 -7
  136. package/dist/services/index.js.map +1 -1
  137. package/dist/services/learning-service.d.ts.map +1 -1
  138. package/dist/services/learning-service.js +22 -14
  139. package/dist/services/learning-service.js.map +1 -1
  140. package/dist/services/llm-service.d.ts +61 -39
  141. package/dist/services/llm-service.d.ts.map +1 -1
  142. package/dist/services/llm-service.js +199 -113
  143. package/dist/services/llm-service.js.map +1 -1
  144. package/dist/services/message-service.d.ts +57 -0
  145. package/dist/services/message-service.d.ts.map +1 -0
  146. package/dist/services/message-service.js +78 -0
  147. package/dist/services/message-service.js.map +1 -0
  148. package/dist/services/orchestrator-service.d.ts.map +1 -1
  149. package/dist/services/orchestrator-service.js +19 -20
  150. package/dist/services/orchestrator-service.js.map +1 -1
  151. package/dist/services/promotion-service.d.ts +67 -0
  152. package/dist/services/promotion-service.d.ts.map +1 -0
  153. package/dist/services/promotion-service.js +151 -0
  154. package/dist/services/promotion-service.js.map +1 -0
  155. package/dist/services/query-expansion-service.d.ts +7 -22
  156. package/dist/services/query-expansion-service.d.ts.map +1 -1
  157. package/dist/services/query-expansion-service.js +41 -75
  158. package/dist/services/query-expansion-service.js.map +1 -1
  159. package/dist/services/retriever-service.d.ts +8 -5
  160. package/dist/services/retriever-service.d.ts.map +1 -1
  161. package/dist/services/retriever-service.js +150 -15
  162. package/dist/services/retriever-service.js.map +1 -1
  163. package/dist/services/swarm-verification.d.ts +104 -0
  164. package/dist/services/swarm-verification.d.ts.map +1 -0
  165. package/dist/services/swarm-verification.js +406 -0
  166. package/dist/services/swarm-verification.js.map +1 -0
  167. package/dist/services/sync-service.d.ts.map +1 -1
  168. package/dist/services/sync-service.js +8 -9
  169. package/dist/services/sync-service.js.map +1 -1
  170. package/dist/services/task-service.d.ts.map +1 -1
  171. package/dist/services/task-service.js +3 -8
  172. package/dist/services/task-service.js.map +1 -1
  173. package/dist/services/tracing-service.js +6 -6
  174. package/dist/services/tracing-service.js.map +1 -1
  175. package/dist/services/transcript-adapter.d.ts.map +1 -1
  176. package/dist/services/transcript-adapter.js +1 -1
  177. package/dist/services/transcript-adapter.js.map +1 -1
  178. package/dist/services/worker-process.d.ts.map +1 -1
  179. package/dist/services/worker-process.js +8 -30
  180. package/dist/services/worker-process.js.map +1 -1
  181. package/dist/utils/doc-hash.d.ts +0 -4
  182. package/dist/utils/doc-hash.d.ts.map +1 -1
  183. package/dist/utils/doc-hash.js.map +1 -1
  184. package/dist/utils/doc-renderer.d.ts +31 -26
  185. package/dist/utils/doc-renderer.d.ts.map +1 -1
  186. package/dist/utils/doc-renderer.js +0 -7
  187. package/dist/utils/doc-renderer.js.map +1 -1
  188. package/dist/utils/llm-json.d.ts +17 -0
  189. package/dist/utils/llm-json.d.ts.map +1 -0
  190. package/dist/utils/llm-json.js +51 -0
  191. package/dist/utils/llm-json.js.map +1 -0
  192. package/dist/utils/toml-config.d.ts +10 -16
  193. package/dist/utils/toml-config.d.ts.map +1 -1
  194. package/dist/utils/toml-config.js +3 -1
  195. package/dist/utils/toml-config.js.map +1 -1
  196. package/dist/worker/run-worker.d.ts.map +1 -1
  197. package/dist/worker/run-worker.js +2 -7
  198. package/dist/worker/run-worker.js.map +1 -1
  199. package/package.json +9 -8
@@ -0,0 +1,817 @@
1
+ /**
2
+ * AnchorVerificationService - Periodic anchor verification for PRD-017
3
+ *
4
+ * Verifies anchors by checking actual file system state:
5
+ * - glob: Check if glob pattern still matches files
6
+ * - hash: Compute content hash and compare
7
+ * - symbol: Grep-based check for symbol presence
8
+ * - line_range: Verify file exists and has enough lines
9
+ *
10
+ * Updates anchor status (valid/drifted/invalid) and logs all changes
11
+ * to invalidation_log via AnchorRepository.
12
+ *
13
+ * @see docs/prd/PRD-017-invalidation-maintenance.md
14
+ * @see docs/design/DD-017-invalidation-maintenance.md
15
+ */
16
+ import { Config, Context, Effect, Layer } from "effect";
17
+ import * as fs from "node:fs/promises";
18
+ import * as crypto from "node:crypto";
19
+ import * as path from "node:path";
20
+ import { AnchorRepository } from "../repo/anchor-repo.js";
21
+ import { matchesGlob } from "../utils/glob.js";
22
+ // =============================================================================
23
+ // Service Definition
24
+ // =============================================================================
25
+ export class AnchorVerificationService extends Context.Tag("AnchorVerificationService")() {
26
+ }
27
+ // =============================================================================
28
+ // Configuration
29
+ // =============================================================================
30
+ /** Default anchor cache TTL in seconds (1 hour) */
31
+ export const DEFAULT_ANCHOR_CACHE_TTL = 3600;
32
+ /**
33
+ * Get the anchor cache TTL from TX_ANCHOR_CACHE_TTL env var.
34
+ * Used by lazy verification to determine staleness.
35
+ *
36
+ * @returns Effect yielding TTL in seconds (default: 3600)
37
+ */
38
+ export const getAnchorTTL = () => Config.number("TX_ANCHOR_CACHE_TTL").pipe(Config.withDefault(DEFAULT_ANCHOR_CACHE_TTL), Effect.catchAll(() => Effect.succeed(DEFAULT_ANCHOR_CACHE_TTL)));
39
+ /**
40
+ * Check if an anchor is stale based on its verified_at timestamp and TTL.
41
+ * An anchor is stale if:
42
+ * - verified_at is null (never verified), or
43
+ * - verified_at is older than (now - TTL)
44
+ *
45
+ * @param anchor - The anchor to check
46
+ * @param ttlSeconds - TTL in seconds (from getAnchorTTL)
47
+ * @returns true if anchor is stale and needs verification
48
+ */
49
+ export const isStale = (anchor, ttlSeconds) => {
50
+ if (!anchor.verifiedAt) {
51
+ return true; // Never verified = stale
52
+ }
53
+ const verifiedAtMs = anchor.verifiedAt.getTime();
54
+ const ttlMs = ttlSeconds * 1000;
55
+ const cutoffMs = Date.now() - ttlMs;
56
+ return verifiedAtMs < cutoffMs;
57
+ };
58
+ // =============================================================================
59
+ // Utility Functions
60
+ // =============================================================================
61
+ /** Compute SHA256 hash of content */
62
+ const computeContentHash = (content) => crypto.createHash("sha256").update(content).digest("hex");
63
+ /** Check if file exists */
64
+ const fileExists = (filePath) => Effect.tryPromise({
65
+ try: async () => {
66
+ await fs.access(filePath);
67
+ return true;
68
+ },
69
+ catch: () => false
70
+ }).pipe(Effect.catchAll(() => Effect.succeed(false)));
71
+ /** Read file content */
72
+ const readFile = (filePath) => Effect.tryPromise({
73
+ try: async () => {
74
+ const content = await fs.readFile(filePath, "utf-8");
75
+ return content;
76
+ },
77
+ catch: () => null
78
+ }).pipe(Effect.catchAll(() => Effect.succeed(null)));
79
+ /** Read specific line range from file */
80
+ const readLineRange = (filePath, lineStart, lineEnd) => Effect.gen(function* () {
81
+ // Validate line numbers are positive (1-indexed)
82
+ if (lineStart < 1 || lineEnd < 1)
83
+ return null;
84
+ // Validate range is valid (end >= start)
85
+ if (lineEnd < lineStart)
86
+ return null;
87
+ const content = yield* readFile(filePath);
88
+ if (!content)
89
+ return null;
90
+ const lines = content.split("\n");
91
+ if (lineStart > lines.length)
92
+ return null;
93
+ const end = Math.min(lineEnd, lines.length);
94
+ return lines.slice(lineStart - 1, end).join("\n");
95
+ });
96
+ /** Count lines in file */
97
+ const countLines = (filePath) => Effect.gen(function* () {
98
+ const content = yield* readFile(filePath);
99
+ if (!content)
100
+ return 0;
101
+ return content.split("\n").length;
102
+ });
103
+ /** Escape regex special characters in a string */
104
+ const escapeRegex = (str) => str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
105
+ /** Check if a symbol exists in file (grep-based) */
106
+ const symbolExistsInFile = (filePath, symbolName) => Effect.gen(function* () {
107
+ const content = yield* readFile(filePath);
108
+ if (!content)
109
+ return false;
110
+ // Extract just the symbol name from FQName (e.g., "file.ts::ClassName" -> "ClassName")
111
+ // Uses lastIndexOf+slice instead of split+pop to avoid:
112
+ // - Array allocation for a single value extraction
113
+ // - The ?? fallback not catching empty strings (pop() returns "" for "file.ts::", and "" ?? x === "")
114
+ const lastSep = symbolName.lastIndexOf("::");
115
+ const simpleName = lastSep >= 0
116
+ ? symbolName.slice(lastSep + 2) || symbolName
117
+ : symbolName;
118
+ // If symbol name is empty or whitespace-only, symbol is not found
119
+ // This prevents false positives where regex would match any declaration
120
+ if (!simpleName || simpleName.trim().length === 0) {
121
+ return false;
122
+ }
123
+ // Escape regex special characters to prevent injection and false matches
124
+ const escapedName = escapeRegex(simpleName.trim());
125
+ // Check for common patterns: function, class, const, export, interface, type
126
+ // Use explicit boundaries (?<![...]) and (?![...]) instead of \b because
127
+ // \b doesn't work with $ (not a word char), causing symbols like $store to fail
128
+ const idStart = `(?<![a-zA-Z0-9_$])`; // negative lookbehind for identifier chars
129
+ const idEnd = `(?![a-zA-Z0-9_$])`; // negative lookahead for identifier chars
130
+ const patterns = [
131
+ new RegExp(`\\b(function|const|let|var)\\s+${escapedName}${idEnd}`),
132
+ new RegExp(`\\bclass\\s+${escapedName}${idEnd}`),
133
+ new RegExp(`\\binterface\\s+${escapedName}${idEnd}`),
134
+ new RegExp(`\\btype\\s+${escapedName}${idEnd}`),
135
+ new RegExp(`\\bexport\\s+(default\\s+)?(function|class|const|let|var|interface|type)\\s+${escapedName}${idEnd}`),
136
+ new RegExp(`\\bexport\\s+\\{[^}]*${idStart}${escapedName}${idEnd}[^}]*\\}`) // export { Symbol }
137
+ ];
138
+ return patterns.some(p => p.test(content));
139
+ });
140
+ // =============================================================================
141
+ // Self-Healing Utilities (PRD-017)
142
+ // =============================================================================
143
+ /** Default threshold for self-healing (80% similarity) */
144
+ const SELF_HEAL_THRESHOLD = 0.8;
145
+ /** Maximum content preview length */
146
+ const MAX_PREVIEW_LENGTH = 500;
147
+ /**
148
+ * Tokenize text for Jaccard similarity computation.
149
+ * Extracts words/identifiers, lowercased, filtering short tokens.
150
+ */
151
+ const tokenize = (text) => {
152
+ return new Set(text
153
+ .toLowerCase()
154
+ .replace(/[^\w\s]/g, " ")
155
+ .split(/\s+/)
156
+ .filter(t => t.length > 2));
157
+ };
158
+ /**
159
+ * Compute Jaccard similarity between two sets of tokens.
160
+ * Returns value between 0 (completely different) and 1 (identical).
161
+ */
162
+ const jaccardSimilarity = (a, b) => {
163
+ if (a.size === 0 && b.size === 0)
164
+ return 1;
165
+ if (a.size === 0 || b.size === 0)
166
+ return 0;
167
+ const intersection = new Set([...a].filter(x => b.has(x)));
168
+ const union = new Set([...a, ...b]);
169
+ return union.size === 0 ? 0 : intersection.size / union.size;
170
+ };
171
+ /**
172
+ * Compute Jaccard similarity between two text strings.
173
+ */
174
+ const computeSimilarity = (oldContent, newContent) => {
175
+ const oldTokens = tokenize(oldContent);
176
+ const newTokens = tokenize(newContent);
177
+ return jaccardSimilarity(oldTokens, newTokens);
178
+ };
179
+ /**
180
+ * Create a content preview (truncated content for storage/comparison).
181
+ */
182
+ const createContentPreview = (content) => {
183
+ if (content.length <= MAX_PREVIEW_LENGTH)
184
+ return content;
185
+ return content.slice(0, MAX_PREVIEW_LENGTH);
186
+ };
187
+ /**
188
+ * Try to self-heal a drifted anchor by comparing content similarity.
189
+ * If the new content is similar enough (>= 0.8 Jaccard), update the anchor
190
+ * with the new hash and preview.
191
+ */
192
+ const trySelfHeal = (anchor, newContent, newHash, anchorRepo) => Effect.gen(function* () {
193
+ // If no content preview stored, we can't compare - can't self-heal
194
+ if (!anchor.contentPreview) {
195
+ return { healed: false, similarity: 0 };
196
+ }
197
+ // Compute similarity between old preview and new content
198
+ const similarity = computeSimilarity(anchor.contentPreview, newContent);
199
+ // If similarity is above threshold, self-heal
200
+ if (similarity >= SELF_HEAL_THRESHOLD) {
201
+ const newPreview = createContentPreview(newContent);
202
+ // Update anchor with new hash and preview
203
+ yield* anchorRepo.update(anchor.id, {
204
+ contentHash: newHash,
205
+ contentPreview: newPreview,
206
+ verifiedAt: new Date()
207
+ });
208
+ return {
209
+ healed: true,
210
+ similarity,
211
+ newHash,
212
+ newPreview
213
+ };
214
+ }
215
+ // Similarity too low - can't self-heal
216
+ return { healed: false, similarity };
217
+ });
218
+ // =============================================================================
219
+ // Service Implementation
220
+ // =============================================================================
221
+ export const AnchorVerificationServiceLive = Layer.effect(AnchorVerificationService, Effect.gen(function* () {
222
+ const anchorRepo = yield* AnchorRepository;
223
+ /**
224
+ * Verify a single anchor against the actual file system.
225
+ */
226
+ const verifyAnchor = (anchor, detectedBy, baseDir) => Effect.gen(function* () {
227
+ const oldStatus = anchor.status;
228
+ // Skip pinned anchors
229
+ if (anchor.pinned) {
230
+ return {
231
+ anchorId: anchor.id,
232
+ previousStatus: oldStatus,
233
+ newStatus: oldStatus,
234
+ action: "unchanged"
235
+ };
236
+ }
237
+ // Resolve full file path and validate it stays within baseDir
238
+ const resolvedBase = path.resolve(baseDir);
239
+ const fullPath = path.resolve(resolvedBase, anchor.filePath);
240
+ if (!fullPath.startsWith(resolvedBase + path.sep) && fullPath !== resolvedBase) {
241
+ yield* anchorRepo.updateStatus(anchor.id, "invalid");
242
+ yield* anchorRepo.logInvalidation({
243
+ anchorId: anchor.id,
244
+ oldStatus,
245
+ newStatus: "invalid",
246
+ reason: "path_traversal_rejected",
247
+ detectedBy
248
+ });
249
+ return {
250
+ anchorId: anchor.id,
251
+ previousStatus: oldStatus,
252
+ newStatus: "invalid",
253
+ action: "invalidated",
254
+ reason: "path_traversal_rejected"
255
+ };
256
+ }
257
+ // Step 1: Check if file exists
258
+ const exists = yield* fileExists(fullPath);
259
+ if (!exists) {
260
+ // File deleted - mark as invalid
261
+ yield* anchorRepo.updateStatus(anchor.id, "invalid");
262
+ yield* anchorRepo.logInvalidation({
263
+ anchorId: anchor.id,
264
+ oldStatus,
265
+ newStatus: "invalid",
266
+ reason: "file_deleted",
267
+ detectedBy
268
+ });
269
+ return {
270
+ anchorId: anchor.id,
271
+ previousStatus: oldStatus,
272
+ newStatus: "invalid",
273
+ action: "invalidated",
274
+ reason: "file_deleted"
275
+ };
276
+ }
277
+ // Step 2: Type-specific verification
278
+ switch (anchor.anchorType) {
279
+ case "glob": {
280
+ // For glob anchors, the file exists and matches - should be valid
281
+ // If previously drifted/invalid, recover to valid
282
+ if (oldStatus !== "valid") {
283
+ yield* anchorRepo.updateStatus(anchor.id, "valid");
284
+ yield* anchorRepo.logInvalidation({
285
+ anchorId: anchor.id,
286
+ oldStatus,
287
+ newStatus: "valid",
288
+ reason: "recovered",
289
+ detectedBy
290
+ });
291
+ return {
292
+ anchorId: anchor.id,
293
+ previousStatus: oldStatus,
294
+ newStatus: "valid",
295
+ action: "self_healed",
296
+ reason: "file_restored"
297
+ };
298
+ }
299
+ yield* anchorRepo.updateVerifiedAt(anchor.id);
300
+ return {
301
+ anchorId: anchor.id,
302
+ previousStatus: oldStatus,
303
+ newStatus: oldStatus,
304
+ action: "unchanged"
305
+ };
306
+ }
307
+ case "hash": {
308
+ // Check content hash
309
+ // Use explicit null checks: 0 is an invalid line number, not "no line range"
310
+ if (anchor.lineStart == null || anchor.lineEnd == null) {
311
+ // No line range specified, read whole file
312
+ const content = yield* readFile(fullPath);
313
+ if (!content) {
314
+ yield* anchorRepo.updateStatus(anchor.id, "invalid");
315
+ yield* anchorRepo.logInvalidation({
316
+ anchorId: anchor.id,
317
+ oldStatus,
318
+ newStatus: "invalid",
319
+ reason: "content_read_failed",
320
+ detectedBy
321
+ });
322
+ return {
323
+ anchorId: anchor.id,
324
+ previousStatus: oldStatus,
325
+ newStatus: "invalid",
326
+ action: "invalidated",
327
+ reason: "content_read_failed"
328
+ };
329
+ }
330
+ const newHash = computeContentHash(content);
331
+ // If no stored hash, initialize it with current content (can't verify without baseline)
332
+ if (!anchor.contentHash) {
333
+ const newPreview = createContentPreview(content);
334
+ yield* anchorRepo.update(anchor.id, {
335
+ contentHash: newHash,
336
+ contentPreview: newPreview,
337
+ verifiedAt: new Date()
338
+ });
339
+ return {
340
+ anchorId: anchor.id,
341
+ previousStatus: oldStatus,
342
+ newStatus: oldStatus,
343
+ action: "unchanged"
344
+ };
345
+ }
346
+ if (newHash === anchor.contentHash) {
347
+ // Hash matches - if previously drifted/invalid, recover to valid
348
+ if (oldStatus !== "valid") {
349
+ yield* anchorRepo.updateStatus(anchor.id, "valid");
350
+ yield* anchorRepo.logInvalidation({
351
+ anchorId: anchor.id,
352
+ oldStatus,
353
+ newStatus: "valid",
354
+ reason: "recovered",
355
+ detectedBy
356
+ });
357
+ return {
358
+ anchorId: anchor.id,
359
+ previousStatus: oldStatus,
360
+ newStatus: "valid",
361
+ action: "self_healed",
362
+ reason: "content_restored"
363
+ };
364
+ }
365
+ yield* anchorRepo.updateVerifiedAt(anchor.id);
366
+ return {
367
+ anchorId: anchor.id,
368
+ previousStatus: oldStatus,
369
+ newStatus: oldStatus,
370
+ action: "unchanged"
371
+ };
372
+ }
373
+ // Hash mismatch - try self-healing if we have content preview
374
+ const healResult = yield* trySelfHeal(anchor, content, newHash, anchorRepo);
375
+ if (healResult.healed) {
376
+ // Successfully self-healed - update status to valid
377
+ yield* anchorRepo.updateStatus(anchor.id, "valid");
378
+ yield* anchorRepo.logInvalidation({
379
+ anchorId: anchor.id,
380
+ oldStatus,
381
+ newStatus: "valid",
382
+ reason: "self_healed",
383
+ detectedBy,
384
+ oldContentHash: anchor.contentHash,
385
+ newContentHash: newHash,
386
+ similarityScore: healResult.similarity
387
+ });
388
+ return {
389
+ anchorId: anchor.id,
390
+ previousStatus: oldStatus,
391
+ newStatus: "valid",
392
+ action: "self_healed",
393
+ reason: "content_similar",
394
+ similarity: healResult.similarity,
395
+ oldContentHash: anchor.contentHash,
396
+ newContentHash: newHash
397
+ };
398
+ }
399
+ // Could not self-heal - mark as drifted
400
+ yield* anchorRepo.updateStatus(anchor.id, "drifted");
401
+ yield* anchorRepo.logInvalidation({
402
+ anchorId: anchor.id,
403
+ oldStatus,
404
+ newStatus: "drifted",
405
+ reason: "hash_mismatch",
406
+ detectedBy,
407
+ oldContentHash: anchor.contentHash,
408
+ newContentHash: newHash,
409
+ similarityScore: healResult.similarity
410
+ });
411
+ return {
412
+ anchorId: anchor.id,
413
+ previousStatus: oldStatus,
414
+ newStatus: "drifted",
415
+ action: "drifted",
416
+ reason: "hash_mismatch",
417
+ similarity: healResult.similarity,
418
+ oldContentHash: anchor.contentHash,
419
+ newContentHash: newHash
420
+ };
421
+ }
422
+ // Read line range
423
+ const content = yield* readLineRange(fullPath, anchor.lineStart, anchor.lineEnd);
424
+ if (!content) {
425
+ yield* anchorRepo.updateStatus(anchor.id, "invalid");
426
+ yield* anchorRepo.logInvalidation({
427
+ anchorId: anchor.id,
428
+ oldStatus,
429
+ newStatus: "invalid",
430
+ reason: "line_range_invalid",
431
+ detectedBy
432
+ });
433
+ return {
434
+ anchorId: anchor.id,
435
+ previousStatus: oldStatus,
436
+ newStatus: "invalid",
437
+ action: "invalidated",
438
+ reason: "line_range_invalid"
439
+ };
440
+ }
441
+ const newHash = computeContentHash(content);
442
+ // If no stored hash, initialize it with current content (can't verify without baseline)
443
+ if (!anchor.contentHash) {
444
+ const newPreview = createContentPreview(content);
445
+ yield* anchorRepo.update(anchor.id, {
446
+ contentHash: newHash,
447
+ contentPreview: newPreview,
448
+ verifiedAt: new Date()
449
+ });
450
+ return {
451
+ anchorId: anchor.id,
452
+ previousStatus: oldStatus,
453
+ newStatus: oldStatus,
454
+ action: "unchanged"
455
+ };
456
+ }
457
+ if (newHash === anchor.contentHash) {
458
+ // Hash matches - if previously drifted/invalid, recover to valid
459
+ if (oldStatus !== "valid") {
460
+ yield* anchorRepo.updateStatus(anchor.id, "valid");
461
+ yield* anchorRepo.logInvalidation({
462
+ anchorId: anchor.id,
463
+ oldStatus,
464
+ newStatus: "valid",
465
+ reason: "recovered",
466
+ detectedBy
467
+ });
468
+ return {
469
+ anchorId: anchor.id,
470
+ previousStatus: oldStatus,
471
+ newStatus: "valid",
472
+ action: "self_healed",
473
+ reason: "content_restored"
474
+ };
475
+ }
476
+ yield* anchorRepo.updateVerifiedAt(anchor.id);
477
+ return {
478
+ anchorId: anchor.id,
479
+ previousStatus: oldStatus,
480
+ newStatus: oldStatus,
481
+ action: "unchanged"
482
+ };
483
+ }
484
+ // Hash mismatch - try self-healing if we have content preview
485
+ const healResult = yield* trySelfHeal(anchor, content, newHash, anchorRepo);
486
+ if (healResult.healed) {
487
+ // Successfully self-healed - update status to valid
488
+ yield* anchorRepo.updateStatus(anchor.id, "valid");
489
+ yield* anchorRepo.logInvalidation({
490
+ anchorId: anchor.id,
491
+ oldStatus,
492
+ newStatus: "valid",
493
+ reason: "self_healed",
494
+ detectedBy,
495
+ oldContentHash: anchor.contentHash,
496
+ newContentHash: newHash,
497
+ similarityScore: healResult.similarity
498
+ });
499
+ return {
500
+ anchorId: anchor.id,
501
+ previousStatus: oldStatus,
502
+ newStatus: "valid",
503
+ action: "self_healed",
504
+ reason: "content_similar",
505
+ similarity: healResult.similarity,
506
+ oldContentHash: anchor.contentHash,
507
+ newContentHash: newHash
508
+ };
509
+ }
510
+ // Could not self-heal - mark as drifted
511
+ yield* anchorRepo.updateStatus(anchor.id, "drifted");
512
+ yield* anchorRepo.logInvalidation({
513
+ anchorId: anchor.id,
514
+ oldStatus,
515
+ newStatus: "drifted",
516
+ reason: "hash_mismatch",
517
+ detectedBy,
518
+ oldContentHash: anchor.contentHash,
519
+ newContentHash: newHash,
520
+ similarityScore: healResult.similarity
521
+ });
522
+ return {
523
+ anchorId: anchor.id,
524
+ previousStatus: oldStatus,
525
+ newStatus: "drifted",
526
+ action: "drifted",
527
+ reason: "hash_mismatch",
528
+ similarity: healResult.similarity,
529
+ oldContentHash: anchor.contentHash,
530
+ newContentHash: newHash
531
+ };
532
+ }
533
+ case "symbol": {
534
+ // Check if symbol exists in file
535
+ const symbolName = anchor.symbolFqname ?? anchor.anchorValue;
536
+ // If symbol name is empty or invalid, mark as invalid immediately
537
+ // This prevents false positives from regex matching any declaration
538
+ if (!symbolName || symbolName.trim().length === 0) {
539
+ yield* anchorRepo.updateStatus(anchor.id, "invalid");
540
+ yield* anchorRepo.logInvalidation({
541
+ anchorId: anchor.id,
542
+ oldStatus,
543
+ newStatus: "invalid",
544
+ reason: "symbol_name_invalid",
545
+ detectedBy
546
+ });
547
+ return {
548
+ anchorId: anchor.id,
549
+ previousStatus: oldStatus,
550
+ newStatus: "invalid",
551
+ action: "invalidated",
552
+ reason: "symbol_name_invalid"
553
+ };
554
+ }
555
+ const symbolExists = yield* symbolExistsInFile(fullPath, symbolName);
556
+ if (symbolExists) {
557
+ // Symbol found - if previously drifted/invalid, recover to valid
558
+ if (oldStatus !== "valid") {
559
+ yield* anchorRepo.updateStatus(anchor.id, "valid");
560
+ yield* anchorRepo.logInvalidation({
561
+ anchorId: anchor.id,
562
+ oldStatus,
563
+ newStatus: "valid",
564
+ reason: "recovered",
565
+ detectedBy
566
+ });
567
+ return {
568
+ anchorId: anchor.id,
569
+ previousStatus: oldStatus,
570
+ newStatus: "valid",
571
+ action: "self_healed",
572
+ reason: "symbol_restored"
573
+ };
574
+ }
575
+ yield* anchorRepo.updateVerifiedAt(anchor.id);
576
+ return {
577
+ anchorId: anchor.id,
578
+ previousStatus: oldStatus,
579
+ newStatus: oldStatus,
580
+ action: "unchanged"
581
+ };
582
+ }
583
+ // Symbol not found - mark as invalid
584
+ yield* anchorRepo.updateStatus(anchor.id, "invalid");
585
+ yield* anchorRepo.logInvalidation({
586
+ anchorId: anchor.id,
587
+ oldStatus,
588
+ newStatus: "invalid",
589
+ reason: "symbol_missing",
590
+ detectedBy
591
+ });
592
+ return {
593
+ anchorId: anchor.id,
594
+ previousStatus: oldStatus,
595
+ newStatus: "invalid",
596
+ action: "invalidated",
597
+ reason: "symbol_missing"
598
+ };
599
+ }
600
+ case "line_range": {
601
+ // Check if file has enough lines
602
+ const lineCount = yield* countLines(fullPath);
603
+ const requiredLines = anchor.lineEnd ?? anchor.lineStart ?? 1;
604
+ if (lineCount >= requiredLines) {
605
+ // Line count sufficient - if previously drifted/invalid, recover to valid
606
+ if (oldStatus !== "valid") {
607
+ yield* anchorRepo.updateStatus(anchor.id, "valid");
608
+ yield* anchorRepo.logInvalidation({
609
+ anchorId: anchor.id,
610
+ oldStatus,
611
+ newStatus: "valid",
612
+ reason: "recovered",
613
+ detectedBy
614
+ });
615
+ return {
616
+ anchorId: anchor.id,
617
+ previousStatus: oldStatus,
618
+ newStatus: "valid",
619
+ action: "self_healed",
620
+ reason: "line_count_restored"
621
+ };
622
+ }
623
+ yield* anchorRepo.updateVerifiedAt(anchor.id);
624
+ return {
625
+ anchorId: anchor.id,
626
+ previousStatus: oldStatus,
627
+ newStatus: oldStatus,
628
+ action: "unchanged"
629
+ };
630
+ }
631
+ // Not enough lines - mark as drifted (file changed but still exists)
632
+ yield* anchorRepo.updateStatus(anchor.id, "drifted");
633
+ yield* anchorRepo.logInvalidation({
634
+ anchorId: anchor.id,
635
+ oldStatus,
636
+ newStatus: "drifted",
637
+ reason: `line_count_insufficient (have ${lineCount}, need ${requiredLines})`,
638
+ detectedBy
639
+ });
640
+ return {
641
+ anchorId: anchor.id,
642
+ previousStatus: oldStatus,
643
+ newStatus: "drifted",
644
+ action: "drifted",
645
+ reason: `line_count_insufficient`
646
+ };
647
+ }
648
+ default:
649
+ // Unknown anchor type - keep as is
650
+ yield* anchorRepo.updateVerifiedAt(anchor.id);
651
+ return {
652
+ anchorId: anchor.id,
653
+ previousStatus: oldStatus,
654
+ newStatus: oldStatus,
655
+ action: "unchanged"
656
+ };
657
+ }
658
+ });
659
+ /**
660
+ * Aggregate verification results into summary
661
+ */
662
+ const aggregateResults = (results, failedAnchors, startTime) => {
663
+ let unchanged = 0;
664
+ let selfHealed = 0;
665
+ let drifted = 0;
666
+ let invalid = 0;
667
+ for (const result of results) {
668
+ switch (result.action) {
669
+ case "unchanged":
670
+ unchanged++;
671
+ break;
672
+ case "self_healed":
673
+ selfHealed++;
674
+ break;
675
+ case "drifted":
676
+ drifted++;
677
+ break;
678
+ case "invalidated":
679
+ invalid++;
680
+ break;
681
+ }
682
+ }
683
+ return {
684
+ total: results.length + failedAnchors.length,
685
+ unchanged,
686
+ selfHealed,
687
+ drifted,
688
+ invalid,
689
+ errors: failedAnchors.length,
690
+ duration: Date.now() - startTime,
691
+ failedAnchors
692
+ };
693
+ };
694
+ return {
695
+ verify: (anchorId, options = {}) => Effect.gen(function* () {
696
+ const anchor = yield* anchorRepo.findById(anchorId);
697
+ if (!anchor) {
698
+ // Return unchanged result for non-existent anchor
699
+ return {
700
+ anchorId,
701
+ previousStatus: "valid",
702
+ newStatus: "valid",
703
+ action: "unchanged",
704
+ reason: "anchor_not_found"
705
+ };
706
+ }
707
+ const detectedBy = options.detectedBy ?? "lazy";
708
+ const baseDir = options.baseDir ?? process.cwd();
709
+ return yield* verifyAnchor(anchor, detectedBy, baseDir);
710
+ }),
711
+ verifyAll: (options = {}) => Effect.gen(function* () {
712
+ const startTime = Date.now();
713
+ const detectedBy = options.detectedBy ?? "periodic";
714
+ const baseDir = options.baseDir ?? process.cwd();
715
+ const skipPinned = options.skipPinned ?? true;
716
+ const anchors = yield* anchorRepo.findAll(100_000);
717
+ const results = [];
718
+ const failedAnchors = [];
719
+ for (const anchor of anchors) {
720
+ if (skipPinned && anchor.pinned) {
721
+ results.push({
722
+ anchorId: anchor.id,
723
+ previousStatus: anchor.status,
724
+ newStatus: anchor.status,
725
+ action: "unchanged"
726
+ });
727
+ continue;
728
+ }
729
+ const result = yield* verifyAnchor(anchor, detectedBy, baseDir).pipe(Effect.catchAll((error) => {
730
+ const errorMessage = error instanceof Error ? error.message : String(error);
731
+ console.error(`[AnchorVerification] Failed to verify anchor ${anchor.id} (${anchor.filePath}): ${errorMessage}`);
732
+ failedAnchors.push({
733
+ anchorId: anchor.id,
734
+ filePath: anchor.filePath,
735
+ error: errorMessage
736
+ });
737
+ return Effect.succeed(null);
738
+ }));
739
+ if (result) {
740
+ results.push(result);
741
+ }
742
+ }
743
+ return aggregateResults(results, failedAnchors, startTime);
744
+ }),
745
+ verifyFile: (filePath, options = {}) => Effect.gen(function* () {
746
+ const startTime = Date.now();
747
+ const detectedBy = options.detectedBy ?? "manual";
748
+ const baseDir = options.baseDir ?? process.cwd();
749
+ const skipPinned = options.skipPinned ?? true;
750
+ const anchors = yield* anchorRepo.findByFilePath(filePath);
751
+ const results = [];
752
+ const failedAnchors = [];
753
+ for (const anchor of anchors) {
754
+ if (skipPinned && anchor.pinned) {
755
+ results.push({
756
+ anchorId: anchor.id,
757
+ previousStatus: anchor.status,
758
+ newStatus: anchor.status,
759
+ action: "unchanged"
760
+ });
761
+ continue;
762
+ }
763
+ const result = yield* verifyAnchor(anchor, detectedBy, baseDir).pipe(Effect.catchAll((error) => {
764
+ const errorMessage = error instanceof Error ? error.message : String(error);
765
+ console.error(`[AnchorVerification] Failed to verify anchor ${anchor.id} (${anchor.filePath}): ${errorMessage}`);
766
+ failedAnchors.push({
767
+ anchorId: anchor.id,
768
+ filePath: anchor.filePath,
769
+ error: errorMessage
770
+ });
771
+ return Effect.succeed(null);
772
+ }));
773
+ if (result) {
774
+ results.push(result);
775
+ }
776
+ }
777
+ return aggregateResults(results, failedAnchors, startTime);
778
+ }),
779
+ verifyGlob: (globPattern, options = {}) => Effect.gen(function* () {
780
+ const startTime = Date.now();
781
+ const detectedBy = options.detectedBy ?? "manual";
782
+ const baseDir = options.baseDir ?? process.cwd();
783
+ const skipPinned = options.skipPinned ?? true;
784
+ // Get all anchors and filter by glob pattern (explicit high limit for batch verification)
785
+ const allAnchors = yield* anchorRepo.findAll(100_000);
786
+ const matchingAnchors = allAnchors.filter(a => matchesGlob(a.filePath, globPattern));
787
+ const results = [];
788
+ const failedAnchors = [];
789
+ for (const anchor of matchingAnchors) {
790
+ if (skipPinned && anchor.pinned) {
791
+ results.push({
792
+ anchorId: anchor.id,
793
+ previousStatus: anchor.status,
794
+ newStatus: anchor.status,
795
+ action: "unchanged"
796
+ });
797
+ continue;
798
+ }
799
+ const result = yield* verifyAnchor(anchor, detectedBy, baseDir).pipe(Effect.catchAll((error) => {
800
+ const errorMessage = error instanceof Error ? error.message : String(error);
801
+ console.error(`[AnchorVerification] Failed to verify anchor ${anchor.id} (${anchor.filePath}): ${errorMessage}`);
802
+ failedAnchors.push({
803
+ anchorId: anchor.id,
804
+ filePath: anchor.filePath,
805
+ error: errorMessage
806
+ });
807
+ return Effect.succeed(null);
808
+ }));
809
+ if (result) {
810
+ results.push(result);
811
+ }
812
+ }
813
+ return aggregateResults(results, failedAnchors, startTime);
814
+ })
815
+ };
816
+ }));
817
+ //# sourceMappingURL=anchor-verification.js.map