mdcontext 0.0.1 → 0.1.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/.changeset/README.md +28 -0
- package/.changeset/config.json +11 -0
- package/.github/workflows/ci.yml +83 -0
- package/.github/workflows/release.yml +113 -0
- package/.tldrignore +112 -0
- package/AGENTS.md +46 -0
- package/BACKLOG.md +338 -0
- package/README.md +231 -11
- package/biome.json +36 -0
- package/cspell.config.yaml +14 -0
- package/dist/chunk-KRYIFLQR.js +92 -0
- package/dist/chunk-S7E6TFX6.js +742 -0
- package/dist/chunk-VVTGZNBT.js +1519 -0
- package/dist/cli/main.d.ts +1 -0
- package/dist/cli/main.js +2015 -0
- package/dist/index.d.ts +266 -0
- package/dist/index.js +86 -0
- package/dist/mcp/server.d.ts +1 -0
- package/dist/mcp/server.js +376 -0
- package/docs/019-USAGE.md +586 -0
- package/docs/020-current-implementation.md +364 -0
- package/docs/021-DOGFOODING-FINDINGS.md +175 -0
- package/docs/BACKLOG.md +80 -0
- package/docs/DESIGN.md +439 -0
- package/docs/PROJECT.md +88 -0
- package/docs/ROADMAP.md +407 -0
- package/docs/test-links.md +9 -0
- package/package.json +69 -10
- package/pnpm-workspace.yaml +5 -0
- package/research/config-analysis/01-current-implementation.md +470 -0
- package/research/config-analysis/02-strategy-recommendation.md +428 -0
- package/research/config-analysis/03-task-candidates.md +715 -0
- package/research/config-analysis/033-research-configuration-management.md +828 -0
- package/research/config-analysis/034-research-effect-cli-config.md +1504 -0
- package/research/config-analysis/04-consolidated-task-candidates.md +277 -0
- package/research/dogfood/consolidated-tool-evaluation.md +373 -0
- package/research/dogfood/strategy-a/a-synthesis.md +184 -0
- package/research/dogfood/strategy-a/a1-docs.md +226 -0
- package/research/dogfood/strategy-a/a2-amorphic.md +156 -0
- package/research/dogfood/strategy-a/a3-llm.md +164 -0
- package/research/dogfood/strategy-b/b-synthesis.md +228 -0
- package/research/dogfood/strategy-b/b1-architecture.md +207 -0
- package/research/dogfood/strategy-b/b2-gaps.md +258 -0
- package/research/dogfood/strategy-b/b3-workflows.md +250 -0
- package/research/dogfood/strategy-c/c-synthesis.md +451 -0
- package/research/dogfood/strategy-c/c1-explorer.md +192 -0
- package/research/dogfood/strategy-c/c2-diver-memory.md +145 -0
- package/research/dogfood/strategy-c/c3-diver-control.md +148 -0
- package/research/dogfood/strategy-c/c4-diver-failure.md +151 -0
- package/research/dogfood/strategy-c/c5-diver-execution.md +221 -0
- package/research/dogfood/strategy-c/c6-diver-org.md +221 -0
- package/research/effect-cli-error-handling.md +845 -0
- package/research/effect-errors-as-values.md +943 -0
- package/research/errors-task-analysis/00-consolidated-tasks.md +207 -0
- package/research/errors-task-analysis/cli-commands-analysis.md +909 -0
- package/research/errors-task-analysis/embeddings-analysis.md +709 -0
- package/research/errors-task-analysis/index-search-analysis.md +812 -0
- package/research/mdcontext-error-analysis.md +521 -0
- package/research/npm_publish/011-npm-workflow-research-agent2.md +792 -0
- package/research/npm_publish/012-npm-workflow-research-agent1.md +530 -0
- package/research/npm_publish/013-npm-workflow-research-agent3.md +722 -0
- package/research/npm_publish/014-npm-workflow-synthesis.md +556 -0
- package/research/npm_publish/031-npm-workflow-task-analysis.md +134 -0
- package/research/semantic-search/002-research-embedding-models.md +490 -0
- package/research/semantic-search/003-research-rag-alternatives.md +523 -0
- package/research/semantic-search/004-research-vector-search.md +841 -0
- package/research/semantic-search/032-research-semantic-search.md +427 -0
- package/research/task-management-2026/00-synthesis-recommendations.md +295 -0
- package/research/task-management-2026/01-ai-workflow-tools.md +416 -0
- package/research/task-management-2026/02-agent-framework-patterns.md +476 -0
- package/research/task-management-2026/03-lightweight-file-based.md +567 -0
- package/research/task-management-2026/04-established-tools-ai-features.md +541 -0
- package/research/task-management-2026/linear/01-core-features-workflow.md +771 -0
- package/research/task-management-2026/linear/02-api-integrations.md +930 -0
- package/research/task-management-2026/linear/03-ai-features.md +368 -0
- package/research/task-management-2026/linear/04-pricing-setup.md +205 -0
- package/research/task-management-2026/linear/05-usage-patterns-best-practices.md +605 -0
- package/scripts/rebuild-hnswlib.js +63 -0
- package/src/cli/argv-preprocessor.test.ts +210 -0
- package/src/cli/argv-preprocessor.ts +202 -0
- package/src/cli/cli.test.ts +430 -0
- package/src/cli/commands/backlinks.ts +54 -0
- package/src/cli/commands/context.ts +197 -0
- package/src/cli/commands/index-cmd.ts +300 -0
- package/src/cli/commands/index.ts +13 -0
- package/src/cli/commands/links.ts +52 -0
- package/src/cli/commands/search.ts +451 -0
- package/src/cli/commands/stats.ts +146 -0
- package/src/cli/commands/tree.ts +107 -0
- package/src/cli/flag-schemas.ts +275 -0
- package/src/cli/help.ts +386 -0
- package/src/cli/index.ts +9 -0
- package/src/cli/main.ts +145 -0
- package/src/cli/options.ts +31 -0
- package/src/cli/typo-suggester.test.ts +105 -0
- package/src/cli/typo-suggester.ts +130 -0
- package/src/cli/utils.ts +126 -0
- package/src/core/index.ts +1 -0
- package/src/core/types.ts +140 -0
- package/src/embeddings/index.ts +8 -0
- package/src/embeddings/openai-provider.ts +165 -0
- package/src/embeddings/semantic-search.ts +583 -0
- package/src/embeddings/types.ts +82 -0
- package/src/embeddings/vector-store.ts +299 -0
- package/src/index/index.ts +4 -0
- package/src/index/indexer.ts +446 -0
- package/src/index/storage.ts +196 -0
- package/src/index/types.ts +109 -0
- package/src/index/watcher.ts +131 -0
- package/src/index.ts +8 -0
- package/src/mcp/server.ts +483 -0
- package/src/parser/index.ts +1 -0
- package/src/parser/parser.test.ts +291 -0
- package/src/parser/parser.ts +395 -0
- package/src/parser/section-filter.ts +270 -0
- package/src/search/query-parser.test.ts +260 -0
- package/src/search/query-parser.ts +319 -0
- package/src/search/searcher.test.ts +182 -0
- package/src/search/searcher.ts +602 -0
- package/src/summarize/budget-bugs.test.ts +620 -0
- package/src/summarize/formatters.ts +419 -0
- package/src/summarize/index.ts +20 -0
- package/src/summarize/summarizer.test.ts +275 -0
- package/src/summarize/summarizer.ts +528 -0
- package/src/summarize/verify-bugs.test.ts +238 -0
- package/src/utils/index.ts +1 -0
- package/src/utils/tokens.test.ts +142 -0
- package/src/utils/tokens.ts +186 -0
- package/tests/fixtures/cli/.mdcontext/config.json +8 -0
- package/tests/fixtures/cli/.mdcontext/indexes/documents.json +33 -0
- package/tests/fixtures/cli/.mdcontext/indexes/links.json +12 -0
- package/tests/fixtures/cli/.mdcontext/indexes/sections.json +233 -0
- package/tests/fixtures/cli/.mdcontext/vectors.bin +0 -0
- package/tests/fixtures/cli/.mdcontext/vectors.meta.json +1264 -0
- package/tests/fixtures/cli/README.md +9 -0
- package/tests/fixtures/cli/api-reference.md +11 -0
- package/tests/fixtures/cli/getting-started.md +11 -0
- package/tsconfig.json +26 -0
- package/vitest.config.ts +21 -0
- package/vitest.setup.ts +12 -0
|
@@ -0,0 +1,446 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Indexer service for building and updating indexes
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import * as fs from 'node:fs/promises'
|
|
6
|
+
import * as path from 'node:path'
|
|
7
|
+
import { Effect } from 'effect'
|
|
8
|
+
import type { MdSection } from '../core/types.js'
|
|
9
|
+
import { parse } from '../parser/parser.js'
|
|
10
|
+
import {
|
|
11
|
+
computeHash,
|
|
12
|
+
createEmptyDocumentIndex,
|
|
13
|
+
createEmptyLinkIndex,
|
|
14
|
+
createEmptySectionIndex,
|
|
15
|
+
createStorage,
|
|
16
|
+
initializeIndex,
|
|
17
|
+
loadDocumentIndex,
|
|
18
|
+
loadLinkIndex,
|
|
19
|
+
loadSectionIndex,
|
|
20
|
+
saveDocumentIndex,
|
|
21
|
+
saveLinkIndex,
|
|
22
|
+
saveSectionIndex,
|
|
23
|
+
} from './storage.js'
|
|
24
|
+
import type {
|
|
25
|
+
DocumentEntry,
|
|
26
|
+
DocumentIndex,
|
|
27
|
+
IndexBuildError,
|
|
28
|
+
IndexResult,
|
|
29
|
+
SectionEntry,
|
|
30
|
+
} from './types.js'
|
|
31
|
+
|
|
32
|
+
// ============================================================================
|
|
33
|
+
// File Discovery
|
|
34
|
+
// ============================================================================
|
|
35
|
+
|
|
36
|
+
const isMarkdownFile = (filename: string): boolean =>
|
|
37
|
+
filename.endsWith('.md') || filename.endsWith('.mdx')
|
|
38
|
+
|
|
39
|
+
const shouldExclude = (
|
|
40
|
+
filePath: string,
|
|
41
|
+
exclude: readonly string[],
|
|
42
|
+
): boolean => {
|
|
43
|
+
const normalized = filePath.toLowerCase()
|
|
44
|
+
for (const pattern of exclude) {
|
|
45
|
+
if (
|
|
46
|
+
pattern.includes('node_modules') &&
|
|
47
|
+
normalized.includes('node_modules')
|
|
48
|
+
) {
|
|
49
|
+
return true
|
|
50
|
+
}
|
|
51
|
+
if (pattern.startsWith('**/.*') && normalized.includes('/.')) {
|
|
52
|
+
return true
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
return false
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const walkDirectory = async (
|
|
59
|
+
dir: string,
|
|
60
|
+
exclude: readonly string[],
|
|
61
|
+
): Promise<string[]> => {
|
|
62
|
+
const files: string[] = []
|
|
63
|
+
const entries = await fs.readdir(dir, { withFileTypes: true })
|
|
64
|
+
|
|
65
|
+
for (const entry of entries) {
|
|
66
|
+
const fullPath = path.join(dir, entry.name)
|
|
67
|
+
|
|
68
|
+
if (entry.name.startsWith('.') || entry.name === 'node_modules') {
|
|
69
|
+
continue
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
if (shouldExclude(fullPath, exclude)) {
|
|
73
|
+
continue
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
if (entry.isDirectory()) {
|
|
77
|
+
const subFiles = await walkDirectory(fullPath, exclude)
|
|
78
|
+
files.push(...subFiles)
|
|
79
|
+
} else if (entry.isFile() && isMarkdownFile(entry.name)) {
|
|
80
|
+
files.push(fullPath)
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return files
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// ============================================================================
|
|
88
|
+
// Section Flattening
|
|
89
|
+
// ============================================================================
|
|
90
|
+
|
|
91
|
+
const flattenSections = (
|
|
92
|
+
sections: readonly MdSection[],
|
|
93
|
+
docId: string,
|
|
94
|
+
docPath: string,
|
|
95
|
+
): SectionEntry[] => {
|
|
96
|
+
const result: SectionEntry[] = []
|
|
97
|
+
|
|
98
|
+
const traverse = (section: MdSection): void => {
|
|
99
|
+
result.push({
|
|
100
|
+
id: section.id,
|
|
101
|
+
documentId: docId,
|
|
102
|
+
documentPath: docPath,
|
|
103
|
+
heading: section.heading,
|
|
104
|
+
level: section.level,
|
|
105
|
+
startLine: section.startLine,
|
|
106
|
+
endLine: section.endLine,
|
|
107
|
+
tokenCount: section.metadata.tokenCount,
|
|
108
|
+
hasCode: section.metadata.hasCode,
|
|
109
|
+
hasList: section.metadata.hasList,
|
|
110
|
+
hasTable: section.metadata.hasTable,
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
for (const child of section.children) {
|
|
114
|
+
traverse(child)
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
for (const section of sections) {
|
|
119
|
+
traverse(section)
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
return result
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// ============================================================================
|
|
126
|
+
// Link Resolution
|
|
127
|
+
// ============================================================================
|
|
128
|
+
|
|
129
|
+
const resolveInternalLink = (
|
|
130
|
+
href: string,
|
|
131
|
+
fromPath: string,
|
|
132
|
+
rootPath: string,
|
|
133
|
+
): string | null => {
|
|
134
|
+
if (href.startsWith('#')) {
|
|
135
|
+
return fromPath
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
if (href.startsWith('http://') || href.startsWith('https://')) {
|
|
139
|
+
return null
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const linkPath = href.split('#')[0] ?? ''
|
|
143
|
+
if (!linkPath) return null
|
|
144
|
+
|
|
145
|
+
const fromDir = path.dirname(fromPath)
|
|
146
|
+
const resolved = path.resolve(fromDir, linkPath)
|
|
147
|
+
|
|
148
|
+
if (!resolved.startsWith(rootPath)) {
|
|
149
|
+
return null
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
return path.relative(rootPath, resolved)
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// ============================================================================
|
|
156
|
+
// Index Building
|
|
157
|
+
// ============================================================================
|
|
158
|
+
|
|
159
|
+
export interface IndexOptions {
|
|
160
|
+
readonly force?: boolean
|
|
161
|
+
readonly exclude?: readonly string[]
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
export const buildIndex = (
|
|
165
|
+
rootPath: string,
|
|
166
|
+
options: IndexOptions = {},
|
|
167
|
+
): Effect.Effect<IndexResult, Error> =>
|
|
168
|
+
Effect.gen(function* () {
|
|
169
|
+
const startTime = Date.now()
|
|
170
|
+
const storage = createStorage(rootPath)
|
|
171
|
+
const errors: IndexBuildError[] = []
|
|
172
|
+
|
|
173
|
+
// Initialize storage
|
|
174
|
+
yield* initializeIndex(storage)
|
|
175
|
+
|
|
176
|
+
// Load existing indexes or create empty ones
|
|
177
|
+
const existingDocIndex = yield* loadDocumentIndex(storage)
|
|
178
|
+
const docIndex: DocumentIndex =
|
|
179
|
+
options.force || !existingDocIndex
|
|
180
|
+
? createEmptyDocumentIndex(storage.rootPath)
|
|
181
|
+
: existingDocIndex
|
|
182
|
+
|
|
183
|
+
// Load existing section and link indexes to preserve data for unchanged files
|
|
184
|
+
const existingSectionIndex = yield* loadSectionIndex(storage)
|
|
185
|
+
const existingLinkIndex = yield* loadLinkIndex(storage)
|
|
186
|
+
const sectionIndex = existingSectionIndex ?? createEmptySectionIndex()
|
|
187
|
+
const linkIndex = existingLinkIndex ?? createEmptyLinkIndex()
|
|
188
|
+
|
|
189
|
+
// Discover files
|
|
190
|
+
const exclude = options.exclude ?? ['**/node_modules/**', '**/.*/**']
|
|
191
|
+
const files = yield* Effect.tryPromise({
|
|
192
|
+
try: () => walkDirectory(storage.rootPath, exclude),
|
|
193
|
+
catch: (e) => new Error(`Failed to walk directory: ${e}`),
|
|
194
|
+
})
|
|
195
|
+
|
|
196
|
+
// Process each file
|
|
197
|
+
let documentsIndexed = 0
|
|
198
|
+
let sectionsIndexed = 0
|
|
199
|
+
let linksIndexed = 0
|
|
200
|
+
|
|
201
|
+
const mutableDocuments: Record<string, DocumentEntry> = {
|
|
202
|
+
...docIndex.documents,
|
|
203
|
+
}
|
|
204
|
+
// Initialize with existing data to preserve sections/links for unchanged files
|
|
205
|
+
const mutableSections: Record<string, SectionEntry> = {
|
|
206
|
+
...sectionIndex.sections,
|
|
207
|
+
}
|
|
208
|
+
const mutableByHeading: Record<string, string[]> = Object.fromEntries(
|
|
209
|
+
Object.entries(sectionIndex.byHeading).map(([k, v]) => [k, [...v]]),
|
|
210
|
+
)
|
|
211
|
+
const mutableByDocument: Record<string, string[]> = Object.fromEntries(
|
|
212
|
+
Object.entries(sectionIndex.byDocument).map(([k, v]) => [k, [...v]]),
|
|
213
|
+
)
|
|
214
|
+
const mutableForward: Record<string, string[]> = Object.fromEntries(
|
|
215
|
+
Object.entries(linkIndex.forward).map(([k, v]) => [k, [...v]]),
|
|
216
|
+
)
|
|
217
|
+
const mutableBackward: Record<string, string[]> = Object.fromEntries(
|
|
218
|
+
Object.entries(linkIndex.backward).map(([k, v]) => [k, [...v]]),
|
|
219
|
+
)
|
|
220
|
+
const brokenLinks: string[] = [...linkIndex.broken]
|
|
221
|
+
|
|
222
|
+
for (const filePath of files) {
|
|
223
|
+
const relativePath = path.relative(storage.rootPath, filePath)
|
|
224
|
+
|
|
225
|
+
// Process each file, collecting errors instead of failing
|
|
226
|
+
const processFile = Effect.gen(function* () {
|
|
227
|
+
// Read file content and stats
|
|
228
|
+
const [content, stats] = yield* Effect.promise(() =>
|
|
229
|
+
Promise.all([fs.readFile(filePath, 'utf-8'), fs.stat(filePath)]),
|
|
230
|
+
)
|
|
231
|
+
|
|
232
|
+
const hash = computeHash(content)
|
|
233
|
+
const existingEntry = mutableDocuments[relativePath]
|
|
234
|
+
|
|
235
|
+
// Skip if unchanged
|
|
236
|
+
if (
|
|
237
|
+
!options.force &&
|
|
238
|
+
existingEntry &&
|
|
239
|
+
existingEntry.hash === hash &&
|
|
240
|
+
existingEntry.mtime === stats.mtime.getTime()
|
|
241
|
+
) {
|
|
242
|
+
return // File unchanged, skip processing
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// Parse document
|
|
246
|
+
const doc = yield* parse(content, {
|
|
247
|
+
path: relativePath,
|
|
248
|
+
lastModified: stats.mtime,
|
|
249
|
+
}).pipe(
|
|
250
|
+
Effect.mapError(
|
|
251
|
+
(e) => new Error(`Parse error in ${relativePath}: ${e.message}`),
|
|
252
|
+
),
|
|
253
|
+
)
|
|
254
|
+
|
|
255
|
+
// Clean up old sections for this document before adding new ones
|
|
256
|
+
if (existingEntry) {
|
|
257
|
+
const oldSectionIds = mutableByDocument[existingEntry.id] ?? []
|
|
258
|
+
for (const sectionId of oldSectionIds) {
|
|
259
|
+
const oldSection = mutableSections[sectionId]
|
|
260
|
+
if (oldSection) {
|
|
261
|
+
// Remove from byHeading
|
|
262
|
+
const headingKey = oldSection.heading.toLowerCase()
|
|
263
|
+
const headingList = mutableByHeading[headingKey]
|
|
264
|
+
if (headingList) {
|
|
265
|
+
const idx = headingList.indexOf(sectionId)
|
|
266
|
+
if (idx !== -1) headingList.splice(idx, 1)
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
delete mutableSections[sectionId]
|
|
270
|
+
}
|
|
271
|
+
delete mutableByDocument[existingEntry.id]
|
|
272
|
+
|
|
273
|
+
// Clean up old links
|
|
274
|
+
delete mutableForward[relativePath]
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
// Update document index
|
|
278
|
+
mutableDocuments[relativePath] = {
|
|
279
|
+
id: doc.id,
|
|
280
|
+
path: relativePath,
|
|
281
|
+
title: doc.title,
|
|
282
|
+
mtime: stats.mtime.getTime(),
|
|
283
|
+
hash,
|
|
284
|
+
tokenCount: doc.metadata.tokenCount,
|
|
285
|
+
sectionCount: doc.metadata.headingCount,
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
documentsIndexed++
|
|
289
|
+
|
|
290
|
+
// Update section index
|
|
291
|
+
const sections = flattenSections(doc.sections, doc.id, relativePath)
|
|
292
|
+
mutableByDocument[doc.id] = []
|
|
293
|
+
|
|
294
|
+
for (const section of sections) {
|
|
295
|
+
mutableSections[section.id] = section
|
|
296
|
+
mutableByDocument[doc.id]?.push(section.id)
|
|
297
|
+
|
|
298
|
+
// Index by heading
|
|
299
|
+
const headingKey = section.heading.toLowerCase()
|
|
300
|
+
if (!mutableByHeading[headingKey]) {
|
|
301
|
+
mutableByHeading[headingKey] = []
|
|
302
|
+
}
|
|
303
|
+
mutableByHeading[headingKey]?.push(section.id)
|
|
304
|
+
|
|
305
|
+
sectionsIndexed++
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
// Update link index
|
|
309
|
+
const internalLinks = doc.links.filter((l) => l.type === 'internal')
|
|
310
|
+
const outgoingLinks: string[] = []
|
|
311
|
+
|
|
312
|
+
for (const link of internalLinks) {
|
|
313
|
+
const target = resolveInternalLink(
|
|
314
|
+
link.href,
|
|
315
|
+
filePath,
|
|
316
|
+
storage.rootPath,
|
|
317
|
+
)
|
|
318
|
+
|
|
319
|
+
if (target) {
|
|
320
|
+
outgoingLinks.push(target)
|
|
321
|
+
|
|
322
|
+
// Add to backward links
|
|
323
|
+
if (!mutableBackward[target]) {
|
|
324
|
+
mutableBackward[target] = []
|
|
325
|
+
}
|
|
326
|
+
if (!mutableBackward[target]?.includes(relativePath)) {
|
|
327
|
+
mutableBackward[target]?.push(relativePath)
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
linksIndexed++
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
mutableForward[relativePath] = outgoingLinks
|
|
335
|
+
}).pipe(
|
|
336
|
+
Effect.catchAll((error) => {
|
|
337
|
+
errors.push({
|
|
338
|
+
path: relativePath,
|
|
339
|
+
message: error instanceof Error ? error.message : String(error),
|
|
340
|
+
})
|
|
341
|
+
return Effect.void
|
|
342
|
+
}),
|
|
343
|
+
)
|
|
344
|
+
|
|
345
|
+
yield* processFile
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
// Check for broken links
|
|
349
|
+
for (const [_from, targets] of Object.entries(mutableForward)) {
|
|
350
|
+
for (const target of targets) {
|
|
351
|
+
if (!mutableDocuments[target] && !brokenLinks.includes(target)) {
|
|
352
|
+
brokenLinks.push(target)
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
// Save indexes
|
|
358
|
+
yield* saveDocumentIndex(storage, {
|
|
359
|
+
version: docIndex.version,
|
|
360
|
+
rootPath: storage.rootPath,
|
|
361
|
+
documents: mutableDocuments,
|
|
362
|
+
})
|
|
363
|
+
|
|
364
|
+
yield* saveSectionIndex(storage, {
|
|
365
|
+
version: sectionIndex.version,
|
|
366
|
+
sections: mutableSections,
|
|
367
|
+
byHeading: mutableByHeading,
|
|
368
|
+
byDocument: mutableByDocument,
|
|
369
|
+
})
|
|
370
|
+
|
|
371
|
+
yield* saveLinkIndex(storage, {
|
|
372
|
+
version: linkIndex.version,
|
|
373
|
+
forward: mutableForward,
|
|
374
|
+
backward: mutableBackward,
|
|
375
|
+
broken: brokenLinks,
|
|
376
|
+
})
|
|
377
|
+
|
|
378
|
+
const duration = Date.now() - startTime
|
|
379
|
+
|
|
380
|
+
// Calculate totals for all links across all forward entries
|
|
381
|
+
const totalLinks = Object.values(mutableForward).reduce(
|
|
382
|
+
(sum, links) => sum + links.length,
|
|
383
|
+
0,
|
|
384
|
+
)
|
|
385
|
+
|
|
386
|
+
return {
|
|
387
|
+
documentsIndexed,
|
|
388
|
+
sectionsIndexed,
|
|
389
|
+
linksIndexed,
|
|
390
|
+
totalDocuments: Object.keys(mutableDocuments).length,
|
|
391
|
+
totalSections: Object.keys(mutableSections).length,
|
|
392
|
+
totalLinks,
|
|
393
|
+
duration,
|
|
394
|
+
errors,
|
|
395
|
+
}
|
|
396
|
+
})
|
|
397
|
+
|
|
398
|
+
// ============================================================================
|
|
399
|
+
// Link Queries
|
|
400
|
+
// ============================================================================
|
|
401
|
+
|
|
402
|
+
export const getOutgoingLinks = (
|
|
403
|
+
rootPath: string,
|
|
404
|
+
filePath: string,
|
|
405
|
+
): Effect.Effect<readonly string[], Error> =>
|
|
406
|
+
Effect.gen(function* () {
|
|
407
|
+
const storage = createStorage(rootPath)
|
|
408
|
+
const linkIndex = yield* loadLinkIndex(storage)
|
|
409
|
+
|
|
410
|
+
if (!linkIndex) {
|
|
411
|
+
return []
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
const relativePath = path.relative(storage.rootPath, path.resolve(filePath))
|
|
415
|
+
return linkIndex.forward[relativePath] ?? []
|
|
416
|
+
})
|
|
417
|
+
|
|
418
|
+
export const getIncomingLinks = (
|
|
419
|
+
rootPath: string,
|
|
420
|
+
filePath: string,
|
|
421
|
+
): Effect.Effect<readonly string[], Error> =>
|
|
422
|
+
Effect.gen(function* () {
|
|
423
|
+
const storage = createStorage(rootPath)
|
|
424
|
+
const linkIndex = yield* loadLinkIndex(storage)
|
|
425
|
+
|
|
426
|
+
if (!linkIndex) {
|
|
427
|
+
return []
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
const relativePath = path.relative(storage.rootPath, path.resolve(filePath))
|
|
431
|
+
return linkIndex.backward[relativePath] ?? []
|
|
432
|
+
})
|
|
433
|
+
|
|
434
|
+
export const getBrokenLinks = (
|
|
435
|
+
rootPath: string,
|
|
436
|
+
): Effect.Effect<readonly string[], Error> =>
|
|
437
|
+
Effect.gen(function* () {
|
|
438
|
+
const storage = createStorage(rootPath)
|
|
439
|
+
const linkIndex = yield* loadLinkIndex(storage)
|
|
440
|
+
|
|
441
|
+
if (!linkIndex) {
|
|
442
|
+
return []
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
return linkIndex.broken
|
|
446
|
+
})
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Index storage operations
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import * as crypto from 'node:crypto'
|
|
6
|
+
import * as fs from 'node:fs/promises'
|
|
7
|
+
import * as path from 'node:path'
|
|
8
|
+
import { Effect } from 'effect'
|
|
9
|
+
|
|
10
|
+
import type {
|
|
11
|
+
DocumentIndex,
|
|
12
|
+
IndexConfig,
|
|
13
|
+
LinkIndex,
|
|
14
|
+
SectionIndex,
|
|
15
|
+
} from './types.js'
|
|
16
|
+
import { getIndexPaths, INDEX_VERSION } from './types.js'
|
|
17
|
+
|
|
18
|
+
// ============================================================================
|
|
19
|
+
// File System Helpers
|
|
20
|
+
// ============================================================================
|
|
21
|
+
|
|
22
|
+
const ensureDir = (dirPath: string): Effect.Effect<void, Error> =>
|
|
23
|
+
Effect.tryPromise({
|
|
24
|
+
try: () => fs.mkdir(dirPath, { recursive: true }),
|
|
25
|
+
catch: (e) => new Error(`Failed to create directory ${dirPath}: ${e}`),
|
|
26
|
+
}).pipe(Effect.map(() => undefined))
|
|
27
|
+
|
|
28
|
+
const readJsonFile = <T>(filePath: string): Effect.Effect<T | null, Error> =>
|
|
29
|
+
Effect.tryPromise({
|
|
30
|
+
try: async () => {
|
|
31
|
+
try {
|
|
32
|
+
const content = await fs.readFile(filePath, 'utf-8')
|
|
33
|
+
return JSON.parse(content) as T
|
|
34
|
+
} catch {
|
|
35
|
+
return null
|
|
36
|
+
}
|
|
37
|
+
},
|
|
38
|
+
catch: (e) => new Error(`Failed to read ${filePath}: ${e}`),
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
const writeJsonFile = <T>(
|
|
42
|
+
filePath: string,
|
|
43
|
+
data: T,
|
|
44
|
+
): Effect.Effect<void, Error> =>
|
|
45
|
+
Effect.gen(function* () {
|
|
46
|
+
const dir = path.dirname(filePath)
|
|
47
|
+
yield* ensureDir(dir)
|
|
48
|
+
yield* Effect.tryPromise({
|
|
49
|
+
try: () => fs.writeFile(filePath, JSON.stringify(data, null, 2)),
|
|
50
|
+
catch: (e) => new Error(`Failed to write ${filePath}: ${e}`),
|
|
51
|
+
})
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
// ============================================================================
|
|
55
|
+
// Hash Computation
|
|
56
|
+
// ============================================================================
|
|
57
|
+
|
|
58
|
+
export const computeHash = (content: string): string => {
|
|
59
|
+
return crypto.createHash('sha256').update(content).digest('hex').slice(0, 16)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// ============================================================================
|
|
63
|
+
// Index Storage Operations
|
|
64
|
+
// ============================================================================
|
|
65
|
+
|
|
66
|
+
export interface IndexStorage {
|
|
67
|
+
readonly rootPath: string
|
|
68
|
+
readonly paths: ReturnType<typeof getIndexPaths>
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export const createStorage = (rootPath: string): IndexStorage => ({
|
|
72
|
+
rootPath: path.resolve(rootPath),
|
|
73
|
+
paths: getIndexPaths(path.resolve(rootPath)),
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
export const initializeIndex = (
|
|
77
|
+
storage: IndexStorage,
|
|
78
|
+
): Effect.Effect<void, Error> =>
|
|
79
|
+
Effect.gen(function* () {
|
|
80
|
+
yield* ensureDir(storage.paths.root)
|
|
81
|
+
yield* ensureDir(storage.paths.parsed)
|
|
82
|
+
yield* ensureDir(path.dirname(storage.paths.documents))
|
|
83
|
+
|
|
84
|
+
// Create default config if it doesn't exist
|
|
85
|
+
const existingConfig = yield* loadConfig(storage)
|
|
86
|
+
if (!existingConfig) {
|
|
87
|
+
const config: IndexConfig = {
|
|
88
|
+
version: INDEX_VERSION,
|
|
89
|
+
rootPath: storage.rootPath,
|
|
90
|
+
include: ['**/*.md', '**/*.mdx'],
|
|
91
|
+
exclude: ['**/node_modules/**', '**/.*/**'],
|
|
92
|
+
createdAt: new Date().toISOString(),
|
|
93
|
+
updatedAt: new Date().toISOString(),
|
|
94
|
+
}
|
|
95
|
+
yield* saveConfig(storage, config)
|
|
96
|
+
}
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
// ============================================================================
|
|
100
|
+
// Config Operations
|
|
101
|
+
// ============================================================================
|
|
102
|
+
|
|
103
|
+
export const loadConfig = (
|
|
104
|
+
storage: IndexStorage,
|
|
105
|
+
): Effect.Effect<IndexConfig | null, Error> =>
|
|
106
|
+
readJsonFile<IndexConfig>(storage.paths.config)
|
|
107
|
+
|
|
108
|
+
export const saveConfig = (
|
|
109
|
+
storage: IndexStorage,
|
|
110
|
+
config: IndexConfig,
|
|
111
|
+
): Effect.Effect<void, Error> =>
|
|
112
|
+
writeJsonFile(storage.paths.config, {
|
|
113
|
+
...config,
|
|
114
|
+
updatedAt: new Date().toISOString(),
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
// ============================================================================
|
|
118
|
+
// Document Index Operations
|
|
119
|
+
// ============================================================================
|
|
120
|
+
|
|
121
|
+
export const loadDocumentIndex = (
|
|
122
|
+
storage: IndexStorage,
|
|
123
|
+
): Effect.Effect<DocumentIndex | null, Error> =>
|
|
124
|
+
readJsonFile<DocumentIndex>(storage.paths.documents)
|
|
125
|
+
|
|
126
|
+
export const saveDocumentIndex = (
|
|
127
|
+
storage: IndexStorage,
|
|
128
|
+
index: DocumentIndex,
|
|
129
|
+
): Effect.Effect<void, Error> => writeJsonFile(storage.paths.documents, index)
|
|
130
|
+
|
|
131
|
+
export const createEmptyDocumentIndex = (rootPath: string): DocumentIndex => ({
|
|
132
|
+
version: INDEX_VERSION,
|
|
133
|
+
rootPath,
|
|
134
|
+
documents: {},
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
// ============================================================================
|
|
138
|
+
// Section Index Operations
|
|
139
|
+
// ============================================================================
|
|
140
|
+
|
|
141
|
+
export const loadSectionIndex = (
|
|
142
|
+
storage: IndexStorage,
|
|
143
|
+
): Effect.Effect<SectionIndex | null, Error> =>
|
|
144
|
+
readJsonFile<SectionIndex>(storage.paths.sections)
|
|
145
|
+
|
|
146
|
+
export const saveSectionIndex = (
|
|
147
|
+
storage: IndexStorage,
|
|
148
|
+
index: SectionIndex,
|
|
149
|
+
): Effect.Effect<void, Error> => writeJsonFile(storage.paths.sections, index)
|
|
150
|
+
|
|
151
|
+
export const createEmptySectionIndex = (): SectionIndex => ({
|
|
152
|
+
version: INDEX_VERSION,
|
|
153
|
+
sections: {},
|
|
154
|
+
byHeading: {},
|
|
155
|
+
byDocument: {},
|
|
156
|
+
})
|
|
157
|
+
|
|
158
|
+
// ============================================================================
|
|
159
|
+
// Link Index Operations
|
|
160
|
+
// ============================================================================
|
|
161
|
+
|
|
162
|
+
export const loadLinkIndex = (
|
|
163
|
+
storage: IndexStorage,
|
|
164
|
+
): Effect.Effect<LinkIndex | null, Error> =>
|
|
165
|
+
readJsonFile<LinkIndex>(storage.paths.links)
|
|
166
|
+
|
|
167
|
+
export const saveLinkIndex = (
|
|
168
|
+
storage: IndexStorage,
|
|
169
|
+
index: LinkIndex,
|
|
170
|
+
): Effect.Effect<void, Error> => writeJsonFile(storage.paths.links, index)
|
|
171
|
+
|
|
172
|
+
export const createEmptyLinkIndex = (): LinkIndex => ({
|
|
173
|
+
version: INDEX_VERSION,
|
|
174
|
+
forward: {},
|
|
175
|
+
backward: {},
|
|
176
|
+
broken: [],
|
|
177
|
+
})
|
|
178
|
+
|
|
179
|
+
// ============================================================================
|
|
180
|
+
// Index Existence Check
|
|
181
|
+
// ============================================================================
|
|
182
|
+
|
|
183
|
+
export const indexExists = (
|
|
184
|
+
storage: IndexStorage,
|
|
185
|
+
): Effect.Effect<boolean, Error> =>
|
|
186
|
+
Effect.tryPromise({
|
|
187
|
+
try: async () => {
|
|
188
|
+
try {
|
|
189
|
+
await fs.access(storage.paths.config)
|
|
190
|
+
return true
|
|
191
|
+
} catch {
|
|
192
|
+
return false
|
|
193
|
+
}
|
|
194
|
+
},
|
|
195
|
+
catch: (e) => new Error(`Failed to check index existence: ${e}`),
|
|
196
|
+
})
|