From f8c7b6d53fa02fb8815a4193e385653181315751 Mon Sep 17 00:00:00 2001 From: Bill Date: Thu, 30 Oct 2025 10:46:16 -0400 Subject: [PATCH] feat: add word count support for read operations Extended word count functionality to read operations (read_note, stat, list) to complement existing write operation support. Changes: - read_note: Now automatically includes wordCount when returning content (with withContent or parseFrontmatter options) - stat: Added optional includeWordCount parameter with performance warning - list: Added optional includeWordCount parameter with performance warning - All operations use same word counting rules (excludes frontmatter and Obsidian comments) - Best-effort error handling for batch operations Technical details: - Updated ParsedNote and FileMetadata type definitions to include optional wordCount field - Added comprehensive test coverage (18 new tests) - Updated tool descriptions with usage notes and performance warnings - Updated CHANGELOG.md to document new features in version 1.1.0 --- CHANGELOG.md | 14 +++ src/tools/index.ts | 19 +++- src/tools/note-tools.ts | 16 ++++ src/tools/vault-tools.ts | 40 ++++++-- src/types/mcp-types.ts | 2 + tests/note-tools.test.ts | 104 +++++++++++++++++++- tests/vault-tools.test.ts | 197 ++++++++++++++++++++++++++++++++++++++ 7 files changed, 380 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5911864..9e6af6a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), ## [Unreleased] +--- + +## [1.1.0] - 2025-10-30 + ### Added - **Word Count**: `create_note`, `update_note`, and `update_sections` now automatically return word count for the note content - Excludes YAML frontmatter and Obsidian comments (`%% ... %%`) from word count @@ -18,10 +22,20 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), - Returns detailed broken link information including line number and context snippet - Provides human-readable summary (e.g., "15 links: 12 valid, 2 broken notes, 1 broken heading") - Can be disabled via `validateLinks: false` parameter for performance-critical operations +- **Word Count for Read Operations**: Extended word count support to read operations + - `read_note` now automatically includes `wordCount` when returning content (with `withContent` or `parseFrontmatter` options) + - `stat` supports optional `includeWordCount` parameter to compute word count (with performance warning) + - `list` supports optional `includeWordCount` parameter to compute word count for all files (with performance warning) + - All read operations use the same word counting rules as write operations (excludes frontmatter and Obsidian comments) + - Best-effort error handling: unreadable files are skipped in batch operations without failing the entire request ### Changed - `create_note`, `update_note`, and `update_sections` response format now includes `wordCount` and optional `linkValidation` fields - `updateNote` now returns structured JSON response instead of simple success message (includes success, path, versionId, modified, wordCount, linkValidation) +- `read_note` response now includes `wordCount` field when returning content +- `stat` response includes optional `wordCount` field in metadata when `includeWordCount: true` +- `list` response includes optional `wordCount` field for each file when `includeWordCount: true` +- Type definitions updated: `ParsedNote` and `FileMetadata` interfaces now include optional `wordCount?: number` field --- diff --git a/src/tools/index.ts b/src/tools/index.ts index 90d1d08..6af000d 100644 --- a/src/tools/index.ts +++ b/src/tools/index.ts @@ -27,7 +27,7 @@ export class ToolRegistry { return [ { name: "read_note", - description: "Read the content of a file from the Obsidian vault with optional frontmatter parsing. Use this to read the contents of a specific note or file. Path must be vault-relative (no leading slash) and include the file extension. Use list() first if you're unsure of the exact path. This only works on files, not folders. By default returns raw content. Set parseFrontmatter to true to get structured data with separated frontmatter and content.", + description: "Read the content of a file from the Obsidian vault with optional frontmatter parsing. Returns word count (excluding frontmatter and Obsidian comments) when content is included in the response. Use this to read the contents of a specific note or file. Path must be vault-relative (no leading slash) and include the file extension. Use list() first if you're unsure of the exact path. This only works on files, not folders. By default returns raw content with word count. Set parseFrontmatter to true to get structured data with separated frontmatter, content, and word count.", inputSchema: { type: "object", properties: { @@ -289,7 +289,7 @@ export class ToolRegistry { }, { name: "list", - description: "List files and/or directories with advanced filtering, recursion, and pagination. Returns structured JSON with file/directory metadata and optional frontmatter summaries. Supports glob patterns for includes/excludes, recursive traversal, type filtering, and cursor-based pagination. Use this to explore vault structure with fine-grained control.", + description: "List files and/or directories with advanced filtering, recursion, and pagination. Returns structured JSON with file/directory metadata and optional frontmatter summaries. Optional: includeWordCount (boolean) - If true, read each file's content and compute word count (excluding frontmatter and Obsidian comments). WARNING: This can be very slow for large directories or recursive listings, as it reads every file. Files that cannot be read are skipped (best effort). Only computed for files, not directories. Supports glob patterns for includes/excludes, recursive traversal, type filtering, and cursor-based pagination. Use this to explore vault structure with fine-grained control.", inputSchema: { type: "object", properties: { @@ -327,19 +327,27 @@ export class ToolRegistry { withFrontmatterSummary: { type: "boolean", description: "If true, include parsed frontmatter (title, tags, aliases) for markdown files without reading full content. Default: false." + }, + includeWordCount: { + type: "boolean", + description: "If true, read each file's content and compute word count. WARNING: Can be very slow for large directories or recursive listings. Only applies to files. Default: false" } } } }, { name: "stat", - description: "Get detailed metadata for a file or folder at a specific path. Returns existence status, kind (file or directory), and full metadata including size, dates, etc. Use this to check if a path exists and get its properties. More detailed than exists() but slightly slower. Returns structured JSON with path, exists boolean, kind, and metadata object.", + description: "Get detailed metadata for a file or folder at a specific path. Returns existence status, kind (file or directory), and full metadata including size, dates, etc. Optional: includeWordCount (boolean) - If true, read file content and compute word count (excluding frontmatter and Obsidian comments). WARNING: This requires reading the entire file and is significantly slower than metadata-only stat. Only works for files, not directories. Use this to check if a path exists and get its properties. More detailed than exists() but slightly slower. Returns structured JSON with path, exists boolean, kind, and metadata object.", inputSchema: { type: "object", properties: { path: { type: "string", description: "Vault-relative path to check (e.g., 'folder/note.md' or 'projects'). Can be a file or folder. Paths are case-sensitive on macOS/Linux. Do not use leading or trailing slashes." + }, + includeWordCount: { + type: "boolean", + description: "If true, read file content and compute word count. WARNING: Significantly slower than metadata-only stat. Only applies to files. Default: false" } }, required: ["path"] @@ -561,11 +569,12 @@ export class ToolRegistry { only: args.only, limit: args.limit, cursor: args.cursor, - withFrontmatterSummary: args.withFrontmatterSummary + withFrontmatterSummary: args.withFrontmatterSummary, + includeWordCount: args.includeWordCount }); break; case "stat": - result = await this.vaultTools.stat(args.path); + result = await this.vaultTools.stat(args.path, args.includeWordCount); break; case "exists": result = await this.vaultTools.exists(args.path); diff --git a/src/tools/note-tools.ts b/src/tools/note-tools.ts index 79542b0..852f19f 100644 --- a/src/tools/note-tools.ts +++ b/src/tools/note-tools.ts @@ -82,6 +82,17 @@ export class NoteTools { // If no special options, return simple content if (!parseFrontmatter) { + // Compute word count when returning content + if (withContent) { + const wordCount = ContentUtils.countWords(content); + const result = { + content, + wordCount + }; + return { + content: [{ type: "text", text: JSON.stringify(result, null, 2) }] + }; + } return { content: [{ type: "text", text: content }] }; @@ -110,6 +121,11 @@ export class NoteTools { result.contentWithoutFrontmatter = extracted.contentWithoutFrontmatter; } + // Add word count when content is included + if (withContent) { + result.wordCount = ContentUtils.countWords(content); + } + return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; diff --git a/src/tools/vault-tools.ts b/src/tools/vault-tools.ts index cce0067..ae192bc 100644 --- a/src/tools/vault-tools.ts +++ b/src/tools/vault-tools.ts @@ -6,6 +6,7 @@ import { GlobUtils } from '../utils/glob-utils'; import { SearchUtils } from '../utils/search-utils'; import { WaypointUtils } from '../utils/waypoint-utils'; import { LinkUtils } from '../utils/link-utils'; +import { ContentUtils } from '../utils/content-utils'; import { IVaultAdapter, IMetadataCacheAdapter } from '../adapters/interfaces'; export class VaultTools { @@ -145,6 +146,7 @@ export class VaultTools { limit?: number; cursor?: string; withFrontmatterSummary?: boolean; + includeWordCount?: boolean; }): Promise { const { path, @@ -154,7 +156,8 @@ export class VaultTools { only = 'any', limit, cursor, - withFrontmatterSummary = false + withFrontmatterSummary = false, + includeWordCount = false } = options; let items: Array = []; @@ -201,7 +204,7 @@ export class VaultTools { } // Collect items based on recursive flag - await this.collectItems(targetFolder, items, recursive, includes, excludes, only, withFrontmatterSummary); + await this.collectItems(targetFolder, items, recursive, includes, excludes, only, withFrontmatterSummary, includeWordCount); // Sort: directories first, then files, alphabetically within each group items.sort((a, b) => { @@ -259,7 +262,8 @@ export class VaultTools { includes?: string[], excludes?: string[], only?: 'files' | 'directories' | 'any', - withFrontmatterSummary?: boolean + withFrontmatterSummary?: boolean, + includeWordCount?: boolean ): Promise { for (const item of folder.children) { // Skip the vault root itself @@ -276,6 +280,18 @@ export class VaultTools { if (item instanceof TFile) { if (only !== 'directories') { const fileMetadata = await this.createFileMetadataWithFrontmatter(item, withFrontmatterSummary || false); + + // Optionally include word count (best effort) + if (includeWordCount) { + try { + const content = await this.vault.read(item); + fileMetadata.wordCount = ContentUtils.countWords(content); + } catch (error) { + // Skip word count if file can't be read (binary file, etc.) + // wordCount field simply omitted for this file + } + } + items.push(fileMetadata); } } else if (item instanceof TFolder) { @@ -285,7 +301,7 @@ export class VaultTools { // Recursively collect from subfolders if needed if (recursive) { - await this.collectItems(item, items, recursive, includes, excludes, only, withFrontmatterSummary); + await this.collectItems(item, items, recursive, includes, excludes, only, withFrontmatterSummary, includeWordCount); } } } @@ -386,7 +402,7 @@ export class VaultTools { } // Phase 3: Discovery Endpoints - async stat(path: string): Promise { + async stat(path: string, includeWordCount: boolean = false): Promise { // Validate path if (!PathUtils.isValidVaultPath(path)) { return { @@ -417,11 +433,23 @@ export class VaultTools { // Check if it's a file if (item instanceof TFile) { + const metadata = this.createFileMetadata(item); + + // Optionally include word count + if (includeWordCount) { + try { + const content = await this.vault.read(item); + metadata.wordCount = ContentUtils.countWords(content); + } catch (error) { + // Skip word count if file can't be read (binary file, etc.) + } + } + const result: StatResult = { path: normalizedPath, exists: true, kind: "file", - metadata: this.createFileMetadata(item) + metadata }; return { content: [{ diff --git a/src/types/mcp-types.ts b/src/types/mcp-types.ts index 01fb1e8..ebcb0bb 100644 --- a/src/types/mcp-types.ts +++ b/src/types/mcp-types.ts @@ -73,6 +73,7 @@ export interface FileMetadata { size: number; modified: number; created: number; + wordCount?: number; } export interface DirectoryMetadata { @@ -181,6 +182,7 @@ export interface ParsedNote { parsedFrontmatter?: Record; content: string; contentWithoutFrontmatter?: string; + wordCount?: number; } /** diff --git a/tests/note-tools.test.ts b/tests/note-tools.test.ts index 2de1a1e..81ea333 100644 --- a/tests/note-tools.test.ts +++ b/tests/note-tools.test.ts @@ -18,6 +18,18 @@ jest.mock('../src/utils/path-utils', () => ({ } })); +// Mock LinkUtils for link validation tests +jest.mock('../src/utils/link-utils', () => ({ + LinkUtils: { + validateLinks: jest.fn().mockResolvedValue({ + valid: [], + brokenNotes: [], + brokenHeadings: [], + summary: 'No links found' + }) + } +})); + // Import the mocked PathUtils import { PathUtils } from '../src/utils/path-utils'; @@ -50,7 +62,10 @@ describe('NoteTools', () => { const result = await noteTools.readNote('test.md'); expect(result.isError).toBeUndefined(); - expect(result.content[0].text).toBe(content); + // Now returns JSON with content and wordCount + const parsed = JSON.parse(result.content[0].text); + expect(parsed.content).toBe(content); + expect(parsed.wordCount).toBe(7); // Test Note This is test content expect(mockVault.read).toHaveBeenCalledWith(mockFile); }); @@ -101,6 +116,93 @@ describe('NoteTools', () => { // frontmatter field is the raw YAML string expect(parsed.frontmatter).toBeDefined(); }); + + it('should include word count when withContent is true', async () => { + const mockFile = createMockTFile('test.md'); + const content = '# Test Note\n\nThis is a test note with some words.'; + + (PathUtils.resolveFile as jest.Mock).mockReturnValue(mockFile); + mockVault.read = jest.fn().mockResolvedValue(content); + + const result = await noteTools.readNote('test.md', { withContent: true }); + + expect(result.isError).toBeUndefined(); + const parsed = JSON.parse(result.content[0].text); + expect(parsed.content).toBe(content); + expect(parsed.wordCount).toBe(11); // Test Note This is a test note with some words + }); + + it('should include word count when parseFrontmatter is true', async () => { + const mockFile = createMockTFile('test.md'); + const content = '---\ntitle: Test\n---\n\nThis is content.'; + + (PathUtils.resolveFile as jest.Mock).mockReturnValue(mockFile); + mockVault.read = jest.fn().mockResolvedValue(content); + + const result = await noteTools.readNote('test.md', { parseFrontmatter: true }); + + expect(result.isError).toBeUndefined(); + const parsed = JSON.parse(result.content[0].text); + expect(parsed.wordCount).toBe(3); // "This is content." + }); + + it('should exclude frontmatter from word count', async () => { + const mockFile = createMockTFile('test.md'); + const content = '---\ntitle: Test Note\ntags: [test, example]\n---\n\nActual content words.'; + + (PathUtils.resolveFile as jest.Mock).mockReturnValue(mockFile); + mockVault.read = jest.fn().mockResolvedValue(content); + + const result = await noteTools.readNote('test.md', { parseFrontmatter: true }); + + expect(result.isError).toBeUndefined(); + const parsed = JSON.parse(result.content[0].text); + expect(parsed.wordCount).toBe(3); // "Actual content words." + }); + + it('should exclude Obsidian comments from word count', async () => { + const mockFile = createMockTFile('test.md'); + const content = 'Visible text %% Hidden comment %% more visible.'; + + (PathUtils.resolveFile as jest.Mock).mockReturnValue(mockFile); + mockVault.read = jest.fn().mockResolvedValue(content); + + const result = await noteTools.readNote('test.md', { withContent: true }); + + expect(result.isError).toBeUndefined(); + const parsed = JSON.parse(result.content[0].text); + expect(parsed.wordCount).toBe(4); // "Visible text more visible" + }); + + it('should return 0 word count for empty file', async () => { + const mockFile = createMockTFile('empty.md'); + const content = ''; + + (PathUtils.resolveFile as jest.Mock).mockReturnValue(mockFile); + mockVault.read = jest.fn().mockResolvedValue(content); + + const result = await noteTools.readNote('empty.md', { withContent: true }); + + expect(result.isError).toBeUndefined(); + const parsed = JSON.parse(result.content[0].text); + expect(parsed.wordCount).toBe(0); + }); + + it('should return JSON format even with default options', async () => { + const mockFile = createMockTFile('test.md'); + const content = '# Test Note\n\nContent here.'; + + (PathUtils.resolveFile as jest.Mock).mockReturnValue(mockFile); + mockVault.read = jest.fn().mockResolvedValue(content); + + const result = await noteTools.readNote('test.md'); + + expect(result.isError).toBeUndefined(); + // Now returns JSON even with default options + const parsed = JSON.parse(result.content[0].text); + expect(parsed.content).toBe(content); + expect(parsed.wordCount).toBe(5); // Test Note Content here + }); }); describe('createNote', () => { diff --git a/tests/vault-tools.test.ts b/tests/vault-tools.test.ts index b9dc00b..58bddb1 100644 --- a/tests/vault-tools.test.ts +++ b/tests/vault-tools.test.ts @@ -195,6 +195,97 @@ describe('VaultTools', () => { expect(result.isError).toBe(true); expect(result.content[0].text).toContain('Invalid path'); }); + + it('should include word count when includeWordCount is true', async () => { + const mockFile = createMockTFile('test.md', { + ctime: 1000, + mtime: 2000, + size: 500 + }); + const content = '# Test Note\n\nThis is a test note with some words.'; + + mockVault.getAbstractFileByPath = jest.fn().mockReturnValue(mockFile); + mockVault.read = jest.fn().mockResolvedValue(content); + + const result = await vaultTools.stat('test.md', true); + + expect(result.isError).toBeUndefined(); + const parsed = JSON.parse(result.content[0].text); + expect(parsed.exists).toBe(true); + expect(parsed.kind).toBe('file'); + expect(parsed.metadata.wordCount).toBe(11); // Test Note This is a test note with some words + expect(mockVault.read).toHaveBeenCalledWith(mockFile); + }); + + it('should not include word count when includeWordCount is false', async () => { + const mockFile = createMockTFile('test.md', { + ctime: 1000, + mtime: 2000, + size: 500 + }); + + mockVault.getAbstractFileByPath = jest.fn().mockReturnValue(mockFile); + mockVault.read = jest.fn(); + + const result = await vaultTools.stat('test.md', false); + + expect(result.isError).toBeUndefined(); + const parsed = JSON.parse(result.content[0].text); + expect(parsed.metadata.wordCount).toBeUndefined(); + expect(mockVault.read).not.toHaveBeenCalled(); + }); + + it('should exclude frontmatter from word count in stat', async () => { + const mockFile = createMockTFile('test.md', { + ctime: 1000, + mtime: 2000, + size: 500 + }); + const content = '---\ntitle: Test Note\ntags: [test]\n---\n\nActual content words.'; + + mockVault.getAbstractFileByPath = jest.fn().mockReturnValue(mockFile); + mockVault.read = jest.fn().mockResolvedValue(content); + + const result = await vaultTools.stat('test.md', true); + + expect(result.isError).toBeUndefined(); + const parsed = JSON.parse(result.content[0].text); + expect(parsed.metadata.wordCount).toBe(3); // "Actual content words." + }); + + it('should handle read errors when computing word count', async () => { + const mockFile = createMockTFile('test.md', { + ctime: 1000, + mtime: 2000, + size: 500 + }); + + mockVault.getAbstractFileByPath = jest.fn().mockReturnValue(mockFile); + mockVault.read = jest.fn().mockRejectedValue(new Error('Cannot read file')); + + const result = await vaultTools.stat('test.md', true); + + // Should still succeed but without word count + expect(result.isError).toBeUndefined(); + const parsed = JSON.parse(result.content[0].text); + expect(parsed.exists).toBe(true); + expect(parsed.metadata.wordCount).toBeUndefined(); + }); + + it('should not include word count for directories', async () => { + const mockFolder = createMockTFolder('folder1', [ + createMockTFile('folder1/file1.md') + ]); + + mockVault.getAbstractFileByPath = jest.fn().mockReturnValue(mockFolder); + + const result = await vaultTools.stat('folder1', true); + + expect(result.isError).toBeUndefined(); + const parsed = JSON.parse(result.content[0].text); + expect(parsed.kind).toBe('directory'); + expect(parsed.metadata.wordCount).toBeUndefined(); + }); }); describe('exists', () => { @@ -486,6 +577,112 @@ describe('VaultTools', () => { expect(parsed.items.length).toBe(1); expect(parsed.items[0].frontmatterSummary).toBeUndefined(); }); + + it('should include word count when includeWordCount is true', async () => { + const mockFile1 = createMockTFile('file1.md'); + const mockFile2 = createMockTFile('file2.md'); + const mockRoot = createMockTFolder('', [mockFile1, mockFile2]); + + mockVault.getRoot = jest.fn().mockReturnValue(mockRoot); + mockVault.read = jest.fn() + .mockResolvedValueOnce('# File One\n\nThis has five words.') + .mockResolvedValueOnce('# File Two\n\nThis has more than five words here.'); + + const result = await vaultTools.list({ includeWordCount: true }); + + expect(result.isError).toBeUndefined(); + const parsed = JSON.parse(result.content[0].text); + expect(parsed.items.length).toBe(2); + expect(parsed.items[0].wordCount).toBe(7); // File One This has five words + expect(parsed.items[1].wordCount).toBe(10); // File Two This has more than five words here + }); + + it('should not include word count when includeWordCount is false', async () => { + const mockFile = createMockTFile('file.md'); + const mockRoot = createMockTFolder('', [mockFile]); + + mockVault.getRoot = jest.fn().mockReturnValue(mockRoot); + mockVault.read = jest.fn(); + + const result = await vaultTools.list({ includeWordCount: false }); + + expect(result.isError).toBeUndefined(); + const parsed = JSON.parse(result.content[0].text); + expect(parsed.items.length).toBe(1); + expect(parsed.items[0].wordCount).toBeUndefined(); + expect(mockVault.read).not.toHaveBeenCalled(); + }); + + it('should exclude frontmatter from word count in list', async () => { + const mockFile = createMockTFile('file.md'); + const mockRoot = createMockTFolder('', [mockFile]); + const content = '---\ntitle: Test\ntags: [test]\n---\n\nActual content.'; + + mockVault.getRoot = jest.fn().mockReturnValue(mockRoot); + mockVault.read = jest.fn().mockResolvedValue(content); + + const result = await vaultTools.list({ includeWordCount: true }); + + expect(result.isError).toBeUndefined(); + const parsed = JSON.parse(result.content[0].text); + expect(parsed.items[0].wordCount).toBe(2); // "Actual content" + }); + + it('should handle read errors gracefully when computing word count', async () => { + const mockFile1 = createMockTFile('file1.md'); + const mockFile2 = createMockTFile('file2.md'); + const mockRoot = createMockTFolder('', [mockFile1, mockFile2]); + + mockVault.getRoot = jest.fn().mockReturnValue(mockRoot); + mockVault.read = jest.fn() + .mockResolvedValueOnce('Content for file 1.') + .mockRejectedValueOnce(new Error('Cannot read file2')); + + const result = await vaultTools.list({ includeWordCount: true }); + + // Should still succeed but skip word count for unreadable files + expect(result.isError).toBeUndefined(); + const parsed = JSON.parse(result.content[0].text); + expect(parsed.items.length).toBe(2); + expect(parsed.items[0].wordCount).toBe(4); // "Content for file 1" + expect(parsed.items[1].wordCount).toBeUndefined(); // Error, skip word count + }); + + it('should not include word count for directories', async () => { + const mockFile = createMockTFile('file.md'); + const mockFolder = createMockTFolder('folder'); + const mockRoot = createMockTFolder('', [mockFile, mockFolder]); + + mockVault.getRoot = jest.fn().mockReturnValue(mockRoot); + mockVault.read = jest.fn().mockResolvedValue('Some content.'); + + const result = await vaultTools.list({ includeWordCount: true }); + + expect(result.isError).toBeUndefined(); + const parsed = JSON.parse(result.content[0].text); + expect(parsed.items.length).toBe(2); + const fileItem = parsed.items.find((item: any) => item.kind === 'file'); + const folderItem = parsed.items.find((item: any) => item.kind === 'directory'); + expect(fileItem.wordCount).toBe(2); // "Some content" + expect(folderItem.wordCount).toBeUndefined(); + }); + + it('should filter files and include word count', async () => { + const mockFile = createMockTFile('file.md'); + const mockFolder = createMockTFolder('folder'); + const mockRoot = createMockTFolder('', [mockFile, mockFolder]); + + mockVault.getRoot = jest.fn().mockReturnValue(mockRoot); + mockVault.read = jest.fn().mockResolvedValue('File content here.'); + + const result = await vaultTools.list({ only: 'files', includeWordCount: true }); + + expect(result.isError).toBeUndefined(); + const parsed = JSON.parse(result.content[0].text); + expect(parsed.items.length).toBe(1); + expect(parsed.items[0].kind).toBe('file'); + expect(parsed.items[0].wordCount).toBe(3); // "File content here" + }); }); describe('getBacklinks', () => {