diff --git a/CHANGELOG.md b/CHANGELOG.md index 8a54ce0..65de52e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Changed + +- **Compact-mode defaults**: `files_search` now defaults to `compact: true`, matching `context_bundle` so previews are omitted unless explicitly requested. +- **Why tag control**: `context_bundle` compact responses omit `why[]` arrays by default to save ~170 bytes per item; callers can pass `includeWhy: true` to retain them. +- **Preview clamp**: `files_search` fallback previews are truncated to 100 characters (was 240) for additional token savings. + ## [0.25.8] - 2025-12-30 ### Fixed diff --git a/docs/api-and-client.md b/docs/api-and-client.md index f9b64ae..cbe7999 100644 --- a/docs/api-and-client.md +++ b/docs/api-and-client.md @@ -101,9 +101,11 @@ for (const item of result.context.slice(0, 3)) { | Mode | Tokens | Information Included | | ---------------------------------- | ------- | ------------------------------------ | -| `compact: true` (default, v0.8.0+) | ~2,500 | path, range, why, score | +| `compact: true` (default, v0.8.0+) | ~2,500 | path, range, why\*, score | | `compact: false` | ~55,000 | path, range, why, score, **preview** | +`*` Pass `includeWhy: true` if you still need `why[]` explanations in compact mode. + **Reduction: 95%** ### Usage Guidelines @@ -121,7 +123,7 @@ for (const item of result.context.slice(0, 3)) { ### Lightweight inspection options -- `files_search(..., compact: true)` removes previews from every result for 60–70% fewer tokens during keyword scans. Use `compact: false` only when the preview text is required inline. +- `files_search(..., compact: true)` (now the default) removes previews from every result for 60–70% fewer tokens during keyword scans. Use `compact: false` only when the preview text is required inline. - `snippets_get(..., compact: true)` returns only metadata (`path`, `startLine`, `endLine`, totals, symbol info) so that you can confirm the symbol boundaries without streaming full text. - `snippets_get(..., includeLineNumbers: true)` prefixes each returned line with an aligned counter such as ` 1375→export async function...`, making it easier to quote exact locations when copying into bug reports or chats. diff --git a/docs/tools-reference.md b/docs/tools-reference.md index 512ce26..c4e4285 100644 --- a/docs/tools-reference.md +++ b/docs/tools-reference.md @@ -39,6 +39,7 @@ The most powerful tool for getting started with unfamiliar code. Provide a task | `goal` | string | Yes | - | Task description or question about the code | | `limit` | number | No | 7 | Max snippets to return (max: 20) | | `compact` | boolean | No | true | Return only metadata without preview | +| `includeWhy` | boolean | No | false | Keep `why[]` even when compact mode is used | | `boost_profile` | string | No | "default" | File type boosting mode | | `path_prefix` | string | No | - | Filter by path prefix | | `category` | string | No | - | Query category for adaptive K | @@ -100,16 +101,16 @@ Fast search across all indexed files with BM25 ranking. ### Parameters -| Parameter | Type | Required | Default | Description | -| ------------------ | ------- | -------- | --------- | --------------------------- | -| `query` | string | Yes | - | Search keywords or phrase | -| `limit` | number | No | 50 | Max results (max: 200) | -| `lang` | string | No | - | Filter by language | -| `ext` | string | No | - | Filter by extension | -| `path_prefix` | string | No | - | Filter by path prefix | -| `boost_profile` | string | No | "default" | File type boosting mode | -| `compact` | boolean | No | false | Omit previews | -| `metadata_filters` | object | No | - | Filter by document metadata | +| Parameter | Type | Required | Default | Description | +| ------------------ | ------- | -------- | --------- | -------------------------------- | +| `query` | string | Yes | - | Search keywords or phrase | +| `limit` | number | No | 50 | Max results (max: 200) | +| `lang` | string | No | - | Filter by language | +| `ext` | string | No | - | Filter by extension | +| `path_prefix` | string | No | - | Filter by path prefix | +| `boost_profile` | string | No | "default" | File type boosting mode | +| `compact` | boolean | No | true | Omit previews unless you opt out | +| `metadata_filters` | object | No | - | Filter by document metadata | ### Query Syntax diff --git a/src/server/compact-mode.ts b/src/server/compact-mode.ts new file mode 100644 index 0000000..b09718d --- /dev/null +++ b/src/server/compact-mode.ts @@ -0,0 +1,5 @@ +export const DEFAULT_COMPACT_MODE = true; + +export function resolveCompactFlag(value: boolean | undefined): boolean { + return value ?? DEFAULT_COMPACT_MODE; +} diff --git a/src/server/handlers.ts b/src/server/handlers.ts index 4ef32f2..0def3ee 100644 --- a/src/server/handlers.ts +++ b/src/server/handlers.ts @@ -450,6 +450,7 @@ export interface ContextBundleParams { profile?: string; boost_profile?: BoostProfileName; compact?: boolean; // If true, omit preview field to reduce token usage + includeWhy?: boolean; // When true, keep why tags even in compact mode includeTokensEstimate?: boolean; // If true, compute tokens_estimate (slower) metadata_filters?: Record; requestId?: string; // Optional request ID for tracing/debugging @@ -461,7 +462,7 @@ export interface ContextBundleItem { path: string; range: [number, number]; preview?: string; // Omitted when compact: true - why: string[]; + why?: string[]; // Omitted when compact mode suppresses reasons score: number; } @@ -1122,21 +1123,28 @@ function normalizeLimit(limit?: number): number { return Math.min(Math.max(1, Math.floor(limit)), 100); } +const PREVIEW_MATCH_WINDOW = 120; +const PREVIEW_MAX_LENGTH = 100; + function buildPreview(content: string, query: string): { preview: string; line: number } { const lowerContent = content.toLowerCase(); const lowerQuery = query.toLowerCase(); const index = lowerContent.indexOf(lowerQuery); if (index === -1) { - return { preview: content.slice(0, 240), line: 1 }; + return { preview: content.slice(0, PREVIEW_MAX_LENGTH), line: 1 }; } const prefix = content.slice(0, index); const prefixLines = prefix.split(/\r?\n/); const matchLine = prefix.length === 0 ? 1 : prefixLines.length; - const snippetStart = Math.max(0, index - 120); - const snippetEnd = Math.min(content.length, index + query.length + 120); - const preview = content.slice(snippetStart, snippetEnd); + const snippetStart = Math.max(0, index - PREVIEW_MATCH_WINDOW); + const snippetEnd = Math.min(content.length, index + query.length + PREVIEW_MATCH_WINDOW); + const previewSlice = content.slice(snippetStart, snippetEnd); + const preview = + previewSlice.length > PREVIEW_MAX_LENGTH + ? `${previewSlice.slice(0, PREVIEW_MAX_LENGTH - 1)}…` + : previewSlice; return { preview, line: matchLine }; } @@ -2332,15 +2340,17 @@ function selectSnippet(snippets: SnippetRow[], matchLine: number | null): Snippe return lastSnippet ?? firstSnippet; } +const SNIPPET_PREVIEW_MAX_LENGTH = 240; + function buildSnippetPreview(content: string, startLine: number, endLine: number): string { const lines = content.split(/\r?\n/); const startIndex = Math.max(0, Math.min(startLine - 1, lines.length)); const endIndex = Math.max(startIndex, Math.min(endLine, lines.length)); const snippet = lines.slice(startIndex, endIndex).join("\n"); - if (snippet.length <= 240) { + if (snippet.length <= SNIPPET_PREVIEW_MAX_LENGTH) { return snippet; } - return `${snippet.slice(0, 239)}…`; + return `${snippet.slice(0, SNIPPET_PREVIEW_MAX_LENGTH - 1)}…`; } /** @@ -5080,14 +5090,12 @@ async function contextBundleImpl( const roundedScore = Number.isFinite(normalizedScore) ? Number(normalizedScore.toFixed(3)) : 0; - // Select why tags with diversity guarantee (reserves slots for dep/symbol/near) - const why = selectWhyTags(reasons); - + const shouldIncludeWhy = params.includeWhy ?? !isCompact; const item: ContextBundleItem = { path: candidate.path, range: [startLine, endLine], - why, score: roundedScore, + ...(shouldIncludeWhy ? { why: selectWhyTags(reasons) } : {}), }; // Add preview only if not in compact mode diff --git a/src/server/output-schemas.ts b/src/server/output-schemas.ts index c8d1b90..9e9f5fb 100644 --- a/src/server/output-schemas.ts +++ b/src/server/output-schemas.ts @@ -20,7 +20,10 @@ export const ContextBundleItemSchema = z.object({ path: z.string().describe("ファイルパス"), range: z.tuple([z.number(), z.number()]).describe("行範囲 [start, end]"), preview: z.string().optional().describe("コードプレビュー(compact=falseの場合)"), - why: z.array(z.string()).describe("スコアリング理由"), + why: z + .array(z.string()) + .optional() + .describe("スコアリング理由(compact=false または includeWhy=true の場合)"), score: z.number().describe("関連度スコア"), }); diff --git a/src/server/rpc.ts b/src/server/rpc.ts index a72d126..b22e23c 100644 --- a/src/server/rpc.ts +++ b/src/server/rpc.ts @@ -8,6 +8,7 @@ import { } from "../shared/adaptive-k-categories.js"; import { maskValue } from "../shared/security/masker.js"; +import { resolveCompactFlag } from "./compact-mode.js"; import { isValidBoostProfile, BOOST_PROFILES } from "./boost-profiles.js"; import { ServerContext } from "./context.js"; import { DegradeController } from "./fallbacks/degradeController.js"; @@ -452,9 +453,8 @@ function parseFilesSearchParams(input: unknown): FilesSearchParams { params.boost_profile = selectProfileFromQuery(params.query, "default"); } - if (typeof record.compact === "boolean") { - params.compact = record.compact; - } + const compactValue = typeof record.compact === "boolean" ? record.compact : undefined; + params.compact = resolveCompactFlag(compactValue); if (record.metadata_filters && typeof record.metadata_filters === "object") { params.metadata_filters = record.metadata_filters as Record; @@ -624,23 +624,25 @@ function parseContextBundleParams(input: unknown, context: ServerContext): Conte params.path_prefix = normalizedPrefix; } - // Parse compact parameter (default: true for token efficiency) - if (typeof record.compact === "boolean") { - params.compact = record.compact; - } else { - params.compact = true; // Default to compact mode (v0.8.0+: breaking change) - - // Show one-time warning about breaking change using WarningManager - // forResponse: true adds this warning to the API response + const compactValue = typeof record.compact === "boolean" ? record.compact : undefined; + params.compact = resolveCompactFlag(compactValue); + if (compactValue === undefined) { context.warningManager.warnOnce( "compact-default-v0.8.0", "BREAKING CHANGE (v0.8.0): compact mode is now default. " + "Set compact: false to restore previous behavior. " + "See CHANGELOG.md for details.", - true // Add to API response + true ); } + const includeWhyValue = record.includeWhy ?? record.include_why; + if (typeof includeWhyValue === "boolean") { + params.includeWhy = includeWhyValue; + } else { + params.includeWhy = false; + } + const includeTokensEstimate = record.includeTokensEstimate ?? record.include_tokens_estimate; if (typeof includeTokensEstimate === "boolean") { params.includeTokensEstimate = includeTokensEstimate; @@ -771,8 +773,11 @@ async function executeToolByName( case "files_search": { const params = parseFilesSearchParams(toolParams); if (degrade.current.active && allowDegrade) { - // Use same output option logic as normal mode for consistency - const includePreview = params.compact !== true; + // When DuckDB is unavailable, keep previews to preserve readability regardless of compact + const includePreview = + toolParams && typeof (toolParams as Record).compact === "boolean" + ? params.compact !== true + : true; const results = degrade.search(params.query, params.limit ?? 20).map((hit) => { const result = { path: hit.path, diff --git a/tests/indexer/fts-incremental.spec.ts b/tests/indexer/fts-incremental.spec.ts index 5b68d4e..dcc74e0 100644 --- a/tests/indexer/fts-incremental.spec.ts +++ b/tests/indexer/fts-incremental.spec.ts @@ -12,7 +12,7 @@ */ import { existsSync, unlinkSync } from "node:fs"; -import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { mkdtemp, rm, writeFile, mkdir } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -234,7 +234,6 @@ describe("FTS incremental rebuild via IndexWatcher (Issue #158)", () => { "src/initial.ts": ["export const initial = 'value';"].join("\n"), }); cleanupTargets.push({ dispose: repo.cleanup }); - const testId = Math.random().toString(36).substring(7); const dbDir = await mkdtemp(join(tmpdir(), `kiri-fts-watch-${testId}-`)); const dbPath = join(dbDir, "index.duckdb"); @@ -331,6 +330,13 @@ describe("FTS incremental rebuild via IndexWatcher (Issue #158)", () => { }); cleanupTargets.push({ dispose: repo.cleanup }); + const { execa } = await import("execa"); + const fixturesDir = join(repo.path, "tests/fixtures"); + await mkdir(fixturesDir, { recursive: true }); + await writeFile(join(fixturesDir, ".gitkeep"), ""); + await execa("git", ["add", "tests/fixtures/.gitkeep"], { cwd: repo.path }); + await execa("git", ["commit", "-m", "Prepare fixtures directory"], { cwd: repo.path }); + const testId = Math.random().toString(36).substring(7); const dbDir = await mkdtemp(join(tmpdir(), `kiri-fts-gherkin-${testId}-`)); const dbPath = join(dbDir, "index.duckdb"); @@ -375,17 +381,14 @@ describe("FTS incremental rebuild via IndexWatcher (Issue #158)", () => { When I perform an action Then I should see a result `; - const { mkdir } = await import("node:fs/promises"); - await mkdir(join(repo.path, "tests/fixtures"), { recursive: true }); await writeFile(join(repo.path, "tests/fixtures/test.feature"), gherkinContent); // 5. ファイルをgitにステージしてコミット - const { execa } = await import("execa"); await execa("git", ["add", "tests/fixtures/test.feature"], { cwd: repo.path }); await execa("git", ["commit", "-m", "Add gherkin feature file"], { cwd: repo.path }); // 6. Watcherがリインデックスを完了するまで待機 - await waitForCondition(() => watcher.getStatistics().reindexCount >= 1); + await waitForCondition(() => watcher.getStatistics().reindexCount >= 1, 30_000); // 7. DB接続してFTS検索 const db = await DuckDBClient.connect({ databasePath: dbPath }); @@ -403,7 +406,7 @@ describe("FTS incremental rebuild via IndexWatcher (Issue #158)", () => { db.all<{ hash: string; content: string }>( "SELECT hash, content FROM blob WHERE content LIKE '%GHERKINUNIQUE158%'" ); - await waitForCondition(async () => (await fetchBlobResults()).length > 0); + await waitForCondition(async () => (await fetchBlobResults()).length > 0, 30_000); const blobResults = await fetchBlobResults(); expect(blobResults.length).toBeGreaterThan(0); @@ -419,5 +422,5 @@ describe("FTS incremental rebuild via IndexWatcher (Issue #158)", () => { // Issue #158: FTS検索で新規追加されたblobが見つかることを確認 expect(ftsResults.length).toBeGreaterThan(0); - }, 30000); + }, 45000); }); diff --git a/tests/server/boosting-helpers.spec.ts b/tests/server/boosting-helpers.spec.ts index 98d1cca..d410cfd 100644 --- a/tests/server/boosting-helpers.spec.ts +++ b/tests/server/boosting-helpers.spec.ts @@ -6,7 +6,12 @@ import { afterEach, describe, expect, it } from "vitest"; import { runIndexer } from "../../src/indexer/cli.js"; import { ServerContext } from "../../src/server/context.js"; -import { checkTableAvailability, contextBundle, resolveRepoId } from "../../src/server/handlers.js"; +import { + checkTableAvailability, + contextBundle, + resolveRepoId, + type ContextBundleItem, +} from "../../src/server/handlers.js"; import { WarningManager } from "../../src/server/rpc.js"; import { createServerServices } from "../../src/server/services/index.js"; import { DuckDBClient } from "../../src/shared/duckdb.js"; @@ -16,6 +21,15 @@ interface CleanupTarget { dispose: () => Promise; } +const expectReasons = (item: ContextBundleItem, label: string): string[] => { + const reasons = item.why; + expect(reasons, `${label} should include reason metadata`).toBeDefined(); + if (!reasons) { + throw new Error(`${label} is missing reasons`); + } + return reasons; +}; + /** * Tests for helper functions extracted in v0.7.0 refactoring: * - applyPathBasedScoring @@ -752,13 +766,18 @@ describe("Boosting Helper Functions (v0.7.0+)", () => { expect(buttonRank).toBeLessThan(configRank); expect(formatterRank).toBeLessThan(configRank); + const authReasons = expectReasons(authFile, "src/auth/handler.ts"); + const buttonReasons = expectReasons(buttonFile, "src/components/Button.tsx"); + const formatterReasons = expectReasons(formatterFile, "src/lib/formatter.ts"); + const configReasons = expectReasons(configFile, "package.json"); + // Implementation files should not have penalty reasons - expect(authFile.why.some((reason) => reason.startsWith("penalty:"))).toBe(false); - expect(buttonFile.why.some((reason) => reason.startsWith("penalty:"))).toBe(false); - expect(formatterFile.why.some((reason) => reason.startsWith("penalty:"))).toBe(false); + expect(authReasons.some((reason) => reason.startsWith("penalty:"))).toBe(false); + expect(buttonReasons.some((reason) => reason.startsWith("penalty:"))).toBe(false); + expect(formatterReasons.some((reason) => reason.startsWith("penalty:"))).toBe(false); // Config file should have penalty reason - expect(configFile.why.some((reason) => reason === "penalty:config-file")).toBe(true); + expect(configReasons.some((reason) => reason === "penalty:config-file")).toBe(true); } }); @@ -815,12 +834,18 @@ describe("Boosting Helper Functions (v0.7.0+)", () => { expect(settingsRank).toBeLessThan(tsconfigRank); // Implementation files should not have config penalty - expect(databaseFile.why.some((reason) => reason === "penalty:config-file")).toBe(false); - expect(settingsFile.why.some((reason) => reason === "penalty:config-file")).toBe(false); + const databaseReasons = expectReasons(databaseFile, "src/config/database.ts"); + const settingsReasons = expectReasons(settingsFile, "src/config/settings.ts"); + const appConfigReasons = expectReasons(appConfigFile, "config/app.config.js"); + const tsconfigReasons = expectReasons(tsconfigFile, "tsconfig.json"); + + // Implementation files should not have config penalty + expect(databaseReasons.some((reason) => reason === "penalty:config-file")).toBe(false); + expect(settingsReasons.some((reason) => reason === "penalty:config-file")).toBe(false); // Actual config files should have penalty - expect(appConfigFile.why.some((reason) => reason === "penalty:config-file")).toBe(true); - expect(tsconfigFile.why.some((reason) => reason === "penalty:config-file")).toBe(true); + expect(appConfigReasons.some((reason) => reason === "penalty:config-file")).toBe(true); + expect(tsconfigReasons.some((reason) => reason === "penalty:config-file")).toBe(true); } }); @@ -881,37 +906,45 @@ describe("Boosting Helper Functions (v0.7.0+)", () => { // All config files found should rank lower than implementation file if (pythonReqFile) { expect(implRank).toBeLessThan(bundle.context.indexOf(pythonReqFile)); - expect(pythonReqFile.why.some((reason) => reason === "penalty:config-file")).toBe(true); + const pythonReqReasons = expectReasons(pythonReqFile, "requirements.txt"); + expect(pythonReqReasons.some((reason) => reason === "penalty:config-file")).toBe(true); } if (pythonTomlFile) { expect(implRank).toBeLessThan(bundle.context.indexOf(pythonTomlFile)); - expect(pythonTomlFile.why.some((reason) => reason === "penalty:config-file")).toBe(true); + const pythonTomlReasons = expectReasons(pythonTomlFile, "pyproject.toml"); + expect(pythonTomlReasons.some((reason) => reason === "penalty:config-file")).toBe(true); } if (gemFile) { expect(implRank).toBeLessThan(bundle.context.indexOf(gemFile)); - expect(gemFile.why.some((reason) => reason === "penalty:config-file")).toBe(true); + const gemReasons = expectReasons(gemFile, "Gemfile"); + expect(gemReasons.some((reason) => reason === "penalty:config-file")).toBe(true); } if (goModFile) { expect(implRank).toBeLessThan(bundle.context.indexOf(goModFile)); - expect(goModFile.why.some((reason) => reason === "penalty:config-file")).toBe(true); + const goModReasons = expectReasons(goModFile, "go.mod"); + expect(goModReasons.some((reason) => reason === "penalty:config-file")).toBe(true); } if (cargoFile) { expect(implRank).toBeLessThan(bundle.context.indexOf(cargoFile)); - expect(cargoFile.why.some((reason) => reason === "penalty:config-file")).toBe(true); + const cargoReasons = expectReasons(cargoFile, "Cargo.toml"); + expect(cargoReasons.some((reason) => reason === "penalty:config-file")).toBe(true); } if (dockerComposeFile) { expect(implRank).toBeLessThan(bundle.context.indexOf(dockerComposeFile)); - expect(dockerComposeFile.why.some((reason) => reason === "penalty:config-file")).toBe( + const dockerComposeReasons = expectReasons(dockerComposeFile, "docker-compose.yml"); + expect(dockerComposeReasons.some((reason) => reason === "penalty:config-file")).toBe( true ); } if (dockerFile) { expect(implRank).toBeLessThan(bundle.context.indexOf(dockerFile)); - expect(dockerFile.why.some((reason) => reason === "penalty:config-file")).toBe(true); + const dockerReasons = expectReasons(dockerFile, "Dockerfile"); + expect(dockerReasons.some((reason) => reason === "penalty:config-file")).toBe(true); } // Implementation file should not have config penalty - expect(implFile.why.some((reason) => reason === "penalty:config-file")).toBe(false); + const implReasons = expectReasons(implFile, "src/main.py"); + expect(implReasons.some((reason) => reason === "penalty:config-file")).toBe(false); } }); @@ -974,13 +1007,15 @@ describe("Boosting Helper Functions (v0.7.0+)", () => { expect(swiftRank).toBeLessThan(bundle.context.indexOf(shrinkwrapFile)); expect(csRank).toBeLessThan(bundle.context.indexOf(shrinkwrapFile)); expect(jsRank).toBeLessThan(bundle.context.indexOf(shrinkwrapFile)); - expect(shrinkwrapFile.why.some((reason) => reason === "penalty:config-file")).toBe(true); + const shrinkwrapReasons = expectReasons(shrinkwrapFile, "npm-shrinkwrap.json"); + expect(shrinkwrapReasons.some((reason) => reason === "penalty:config-file")).toBe(true); } if (packageResolvedFile) { expect(swiftRank).toBeLessThan(bundle.context.indexOf(packageResolvedFile)); expect(csRank).toBeLessThan(bundle.context.indexOf(packageResolvedFile)); expect(jsRank).toBeLessThan(bundle.context.indexOf(packageResolvedFile)); - expect(packageResolvedFile.why.some((reason) => reason === "penalty:config-file")).toBe( + const packageResolvedReasons = expectReasons(packageResolvedFile, "Package.resolved"); + expect(packageResolvedReasons.some((reason) => reason === "penalty:config-file")).toBe( true ); } @@ -988,15 +1023,17 @@ describe("Boosting Helper Functions (v0.7.0+)", () => { expect(swiftRank).toBeLessThan(bundle.context.indexOf(packagesLockFile)); expect(csRank).toBeLessThan(bundle.context.indexOf(packagesLockFile)); expect(jsRank).toBeLessThan(bundle.context.indexOf(packagesLockFile)); - expect(packagesLockFile.why.some((reason) => reason === "penalty:config-file")).toBe( - true - ); + const packagesLockReasons = expectReasons(packagesLockFile, "packages.lock.json"); + expect(packagesLockReasons.some((reason) => reason === "penalty:config-file")).toBe(true); } // Implementation files should not have config penalty - expect(swiftFile.why.some((reason) => reason === "penalty:config-file")).toBe(false); - expect(csFile.why.some((reason) => reason === "penalty:config-file")).toBe(false); - expect(jsFile.why.some((reason) => reason === "penalty:config-file")).toBe(false); + const swiftReasons = expectReasons(swiftFile, "src/app.swift"); + const csReasons = expectReasons(csFile, "src/Program.cs"); + const jsReasons = expectReasons(jsFile, "src/index.js"); + expect(swiftReasons.some((reason) => reason === "penalty:config-file")).toBe(false); + expect(csReasons.some((reason) => reason === "penalty:config-file")).toBe(false); + expect(jsReasons.some((reason) => reason === "penalty:config-file")).toBe(false); } }); @@ -1059,34 +1096,42 @@ describe("Boosting Helper Functions (v0.7.0+)", () => { // All config directory files should rank lower than implementation if (bootstrapFile) { expect(controllerRank).toBeLessThan(bundle.context.indexOf(bootstrapFile)); - expect(bootstrapFile.why.some((reason) => reason === "penalty:config-file")).toBe(true); + const bootstrapReasons = expectReasons(bootstrapFile, "bootstrap/app.php"); + expect(bootstrapReasons.some((reason) => reason === "penalty:config-file")).toBe(true); } if (configFile) { expect(controllerRank).toBeLessThan(bundle.context.indexOf(configFile)); - expect(configFile.why.some((reason) => reason === "penalty:config-file")).toBe(true); + const configReasons = expectReasons(configFile, "config/database.php"); + expect(configReasons.some((reason) => reason === "penalty:config-file")).toBe(true); } if (migrationFile) { expect(controllerRank).toBeLessThan(bundle.context.indexOf(migrationFile)); // v1.0.0: Migration files now use "penalty:low-value-file" instead of "penalty:migration-file" - expect(migrationFile.why.some((reason) => reason === "penalty:low-value-file")).toBe( - true - ); + const migrationReasons = expectReasons(migrationFile, "migrations/2024_create_users.php"); + expect(migrationReasons.some((reason) => reason === "penalty:low-value-file")).toBe(true); } if (localeFile) { expect(controllerRank).toBeLessThan(bundle.context.indexOf(localeFile)); - expect(localeFile.why.some((reason) => reason === "penalty:config-file")).toBe(true); + const localeReasons = expectReasons(localeFile, "locales/en.json"); + expect(localeReasons.some((reason) => reason === "penalty:config-file")).toBe(true); } if (caddyFile) { expect(controllerRank).toBeLessThan(bundle.context.indexOf(caddyFile)); - expect(caddyFile.why.some((reason) => reason === "penalty:config-file")).toBe(true); + const caddyReasons = expectReasons(caddyFile, "Caddyfile"); + expect(caddyReasons.some((reason) => reason === "penalty:config-file")).toBe(true); } if (nginxFile) { expect(controllerRank).toBeLessThan(bundle.context.indexOf(nginxFile)); - expect(nginxFile.why.some((reason) => reason === "penalty:config-file")).toBe(true); + const nginxReasons = expectReasons(nginxFile, "nginx.conf"); + expect(nginxReasons.some((reason) => reason === "penalty:config-file")).toBe(true); } // Implementation file should not have config penalty - expect(controllerFile.why.some((reason) => reason === "penalty:config-file")).toBe(false); + const controllerReasons = expectReasons( + controllerFile, + "src/controllers/UserController.php" + ); + expect(controllerReasons.some((reason) => reason === "penalty:config-file")).toBe(false); } }); }); diff --git a/tests/server/context.bundle.spec.ts b/tests/server/context.bundle.spec.ts index 178ff81..b5ae7e0 100644 --- a/tests/server/context.bundle.spec.ts +++ b/tests/server/context.bundle.spec.ts @@ -53,6 +53,79 @@ describe("context_bundle", () => { } }); + it("omits why array in compact mode by default", async () => { + const repo = await createTempRepo({ + "src/app.ts": "export function alpha() { return 1; }\n", + }); + cleanupTargets.push({ dispose: repo.cleanup }); + + const dbDir = await mkdtemp(join(tmpdir(), "kiri-compact-why-default-")); + const dbPath = join(dbDir, "index.duckdb"); + cleanupTargets.push({ dispose: async () => await rm(dbDir, { recursive: true, force: true }) }); + + await runIndexer({ repoRoot: repo.path, databasePath: dbPath, full: true }); + + const db = await DuckDBClient.connect({ databasePath: dbPath }); + cleanupTargets.push({ dispose: async () => await db.close() }); + + const repoId = await resolveRepoId(db, repo.path); + const tableAvailability = await checkTableAvailability(db); + const context: ServerContext = { + db, + repoId, + services: createServerServices(db), + tableAvailability, + warningManager: new WarningManager(), + }; + + const bundle = await contextBundle(context, { + goal: "alpha", + limit: 1, + compact: true, + }); + + expect(bundle.context.length).toBeGreaterThan(0); + const first = bundle.context[0]; + expect(first?.why).toBeUndefined(); + }); + + it("restores why array when includeWhy=true in compact mode", async () => { + const repo = await createTempRepo({ + "src/app.ts": "export function beta() { return 2; }\n", + }); + cleanupTargets.push({ dispose: repo.cleanup }); + + const dbDir = await mkdtemp(join(tmpdir(), "kiri-compact-why-include-")); + const dbPath = join(dbDir, "index.duckdb"); + cleanupTargets.push({ dispose: async () => await rm(dbDir, { recursive: true, force: true }) }); + + await runIndexer({ repoRoot: repo.path, databasePath: dbPath, full: true }); + + const db = await DuckDBClient.connect({ databasePath: dbPath }); + cleanupTargets.push({ dispose: async () => await db.close() }); + + const repoId = await resolveRepoId(db, repo.path); + const tableAvailability = await checkTableAvailability(db); + const context: ServerContext = { + db, + repoId, + services: createServerServices(db), + tableAvailability, + warningManager: new WarningManager(), + }; + + const bundle = await contextBundle(context, { + goal: "beta", + limit: 1, + compact: true, + includeWhy: true, + }); + + const first = bundle.context[0]; + expect(first?.why).toBeDefined(); + expect(first?.why?.length ?? 0).toBeGreaterThan(0); + }); + it("combines string matches, dependencies, and proximity", async () => { const repo = await createTempRepo({ "src/auth/token.ts": `import { calculateExpiry } from "../utils/helper";\n\nexport function verifyToken(token: string): boolean {\n if (!token) {\n return false;\n }\n const expires = calculateExpiry(token);\n return Date.now() < expires;\n}\n`, @@ -95,16 +168,16 @@ describe("context_bundle", () => { const editing = bundle.context.find((item) => item.path === "src/auth/token.ts"); expect(editing).toBeDefined(); - expect(editing?.why).toContain("artifact:editing_path"); - expect(editing?.why.some((reason) => reason.startsWith("structural:"))).toBe(true); + expect(editing?.why ?? []).toContain("artifact:editing_path"); + expect((editing?.why ?? []).some((reason) => reason.startsWith("structural:"))).toBe(true); const helper = bundle.context.find((item) => item.path === "src/utils/helper.ts"); expect(helper).toBeDefined(); - expect(helper?.why.some((reason) => reason.startsWith("dep:"))).toBe(true); + expect((helper?.why ?? []).some((reason) => reason.startsWith("dep:"))).toBe(true); const nearby = bundle.context.find((item) => item.path === "src/auth/validator.ts"); expect(nearby).toBeDefined(); - expect(nearby?.why.some((reason) => reason.startsWith("near:"))).toBe(true); + expect((nearby?.why ?? []).some((reason) => reason.startsWith("near:"))).toBe(true); }, 10000); it("promotes files via artifact hints when the goal lacks concrete keywords", async () => { diff --git a/tests/server/mcp-standard.spec.ts b/tests/server/mcp-standard.spec.ts index 69acabb..b2adeb1 100644 --- a/tests/server/mcp-standard.spec.ts +++ b/tests/server/mcp-standard.spec.ts @@ -893,6 +893,7 @@ describe("MCP標準エンドポイント", () => { arguments: { goal: "login authentication", limit: 5, + includeWhy: true, }, }, };