From b4e30fb46b39412a4657d3cdbd73f9a1952f870a Mon Sep 17 00:00:00 2001 From: Ryoichi Izumita Date: Thu, 1 Jan 2026 23:15:49 +0900 Subject: [PATCH 1/6] feat(server): add terse why mode and rangeSource handshake --- CHANGELOG.md | 9 +++++ docs/api-and-client.md | 26 ++++++++++--- docs/tools-reference.md | 42 +++++++++++---------- scripts/assay/kiri-adapter.ts | 1 + scripts/test/verify-all.ts | 14 +++++-- src/server/handlers.ts | 58 +++++++++++++++++++++++++++-- src/server/handlers/snippets-get.ts | 29 ++++++++++++++- src/server/output-schemas.ts | 1 + src/server/rpc.ts | 30 +++++++++++++++ tests/server/context.bundle.spec.ts | 38 +++++++++++++++++++ tests/server/snippets.get.spec.ts | 56 ++++++++++++++++++++++++++++ 11 files changed, 272 insertions(+), 32 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8a54ce0..90e63b6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- `context_bundle`: emits a `rangeSource` flag (`symbol` / `window` / `clamped`) and accepts `why_mode: "terse"` for compact `why` tags. +- `snippets_get`: understands the optional `range_source` hint and now keeps explicit `[start_line,end_line]` windows even when the default view is `symbol`, protecting context_bundle workflows. + +### Changed + +- Default `snippets_get` view is now `symbol`. Override via the `KIRI_SNIPPETS_DEFAULT_VIEW` environment variable if you need the legacy `auto` behavior. + ## [0.25.8] - 2025-12-30 ### Fixed diff --git a/docs/api-and-client.md b/docs/api-and-client.md index f9b64ae..522e4ff 100644 --- a/docs/api-and-client.md +++ b/docs/api-and-client.md @@ -22,14 +22,15 @@ The server implements MCP standard endpoints `initialize` / `tools/list`, enabli - `deps_closure(paths[], direction="out"|"in", depth=2)` - Analyze dependencies - `recent.changed(since="30d", path_prefix?)` - Find recently changed files - `who.owns(path)` - Get ownership information from blame summary -- `snippets_get(path, start_line?, end_line?, compact?, include_line_numbers?)` - Retrieve code snippets; `compact` omits content, `include_line_numbers` prefixes each line when content is returned +- `snippets_get(path, start_line?, end_line?, compact?, include_line_numbers?, range_source?)` - Retrieve code snippets; default view is `symbol` (override via `KIRI_SNIPPETS_DEFAULT_VIEW`). Provide `range_source` from `context_bundle` to preserve clamped windows, `compact` omits content, `include_line_numbers` prefixes each line when content is returned - `semantic_rerank(candidates[], text, k=20)` - Semantic reranking (VSS only) -- `context_bundle(goal, artifacts, includeTokensEstimate?)` ← **Most Important** +- `context_bundle(goal, artifacts, includeTokensEstimate?, why_mode?)` ← **Most Important** - `goal`: Natural language description (e.g., "Fix failing test: test_verify_token in Auth") - `artifacts`: {`editing_path`?, `failing_tests`?, `last_diff`?, `hints`?[]} - Providing `editing_path` with the file you're touching strongly boosts that file and nearby dependencies, returning a cohesive set of related files. - `hints`: Optional list of short function/file breadcrumbs. They are merged into the search query so abstract goals like "nonparametric test" can still surface concrete implementations. - - Output: Fragment list (path, [start,end], why[], score, optional preview) and `tokens_estimate` **only** when `includeTokensEstimate: true` + - `why_mode`: `"full"` (default) returns verbose reasons; `"terse"` emits compact prefixes like `["sym:startServer","txt:server"]`. + - Output: Fragment list (path, [start,end], `rangeSource`, why[], score, optional preview) and `tokens_estimate` **only** when `includeTokensEstimate: true` ## `context_bundle` Request/Response Example @@ -45,7 +46,8 @@ The server implements MCP standard endpoints `initialize` / `tools/list`, enabli "last_diff": "...", "hints": ["verifyToken", "src/auth/keys.ts"] }, - "includeTokensEstimate": true + "includeTokensEstimate": true, + "why_mode": "terse" } } @@ -55,6 +57,7 @@ The server implements MCP standard endpoints `initialize` / `tools/list`, enabli { "path": "src/auth/jwt.ts", "range": [12, 78], + "rangeSource": "symbol", "why": ["symbol:verifyToken", "dep:src/auth/keys.ts", "recent:7d"], "score": 0.86, "preview": "function verifyToken(token:string){...}" @@ -62,6 +65,7 @@ The server implements MCP standard endpoints `initialize` / `tools/list`, enabli { "path": "src/auth/keys.ts", "range": [1, 120], + "rangeSource": "window", "why": ["dep<-jwt.ts"], "score": 0.74 } @@ -92,6 +96,7 @@ for (const item of result.context.slice(0, 3)) { path: item.path, start_line: item.range[0], end_line: item.range[1], + range_source: item.rangeSource, // preserves clamped windows even if defaults change }); // Perform detailed analysis with content } @@ -118,12 +123,14 @@ for (const item of result.context.slice(0, 3)) { - Immediate code preview is needed - Retrieving only a few files (1-3) +- You need inline previews with `why_mode: "full"` for audit logs ### 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. - `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. +- `context_bundle(..., why_mode: "terse")` shrinks `why` tags (`symbol:` → `sym:`) without changing ranking; flip back to `"full"` when humans need verbose traceability. ### Real Example: Lambda Function Investigation @@ -247,7 +254,7 @@ Watch mode (`--watch`) monitors repository file changes and automatically reinde - `deps_closure(paths[], direction="out"|"in", depth=2)` - `recent.changed(since="30d", path_prefix?)` - `who.owns(path)` → `blame_summary` を要約 -- `snippets_get(path, start_line?, end_line?, compact?, include_line_numbers?)` +- `snippets_get(path, start_line?, end_line?, compact?, include_line_numbers?, range_source?)` - `semantic_rerank(candidates[], text, k=20)`(VSS 有効時のみ) - `context_bundle(goal, artifacts, includeTokensEstimate?)` ← **最重要** - `goal`: 自然文(例: "Auth の失敗テスト test_verify_token を修す") @@ -333,6 +340,7 @@ for (const item of result.context.slice(0, 3)) { path: item.path, start_line: item.range[0], end_line: item.range[1], + range_source: item.rangeSource, }); // contentを使って詳細分析 } @@ -359,6 +367,9 @@ for (const item of result.context.slice(0, 3)) { - すぐにコードプレビューが必要な場合 - 少数のファイル(1-3件)のみを取得する場合 +- `why_mode: "full"` で詳細な `why` 説明が必要な場合(監査ログなど) + +> **Tip**: `context_bundle(..., why_mode: "terse")` + `snippets_get(..., range_source: item.rangeSource)` の組み合わせで、トークンを抑えつつ必要に応じて詳細コードへ拡張できます。 ### 実例:Lambda関数の調査 @@ -369,7 +380,8 @@ for (const item of result.context.slice(0, 3)) { "params": { "goal": "ask-agent Lambda handler logic, runtime execution flow", "limit": 10, - "compact": true + "compact": true, + "why_mode": "terse" } } @@ -379,6 +391,7 @@ for (const item of result.context.slice(0, 3)) { { "path": "lambda/ask-agent/handler.ts", "range": [15, 89], + "rangeSource": "symbol", "why": ["phrase:ask-agent", "path-phrase:handler", "boost:impl-file"], "score": 0.92 // preview フィールドなし → トークン節約 @@ -386,6 +399,7 @@ for (const item of result.context.slice(0, 3)) { { "path": "lambda/ask-agent/runtime.ts", "range": [42, 156], + "rangeSource": "window", "why": ["phrase:ask-agent", "dep:handler.ts"], "score": 0.85 } diff --git a/docs/tools-reference.md b/docs/tools-reference.md index 512ce26..cb1a572 100644 --- a/docs/tools-reference.md +++ b/docs/tools-reference.md @@ -34,15 +34,16 @@ The most powerful tool for getting started with unfamiliar code. Provide a task ### Parameters -| Parameter | Type | Required | Default | Description | -| ------------------ | ------- | -------- | --------- | ------------------------------------------- | -| `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 | -| `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 | -| `metadata_filters` | object | No | - | Filter by document metadata | +| Parameter | Type | Required | Default | Description | +| ------------------ | ------- | -------- | --------- | ---------------------------------------------------------- | +| `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 | +| `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 | +| `metadata_filters` | object | No | - | Filter by document metadata | +| `why_mode` | string | No | "full" | `"full"` returns verbose tags, `"terse"` shortens prefixes | ### Boost Profiles @@ -88,7 +89,7 @@ The most powerful tool for getting started with unfamiliar code. Provide a task - **Be specific**: Include file names, error messages, symptoms - **Avoid imperatives**: "auth flow JWT validation" not "Find where authentication happens" - **Use compact mode**: Default is `compact: true` (95% token savings) -- **Follow up with snippets_get**: Get full code after identifying relevant files +- **Follow up with snippets_get**: Get full code after identifying relevant files. Pass `range_source: item.rangeSource` so clamped windows stay intact even if the default view changes. --- @@ -144,24 +145,27 @@ Get specific code sections from a file, aligned to function/class boundaries. ### Parameters -| Parameter | Type | Required | Default | Description | -| ---------------------- | ------- | -------- | ------- | ------------------------------------- | -| `path` | string | Yes | - | File path relative to repository root | -| `start_line` | number | No | - | Starting line number | -| `end_line` | number | No | - | Ending line number | -| `view` | string | No | "auto" | Retrieval strategy | -| `compact` | boolean | No | false | Return only metadata | -| `include_line_numbers` | boolean | No | false | Prefix lines with numbers | +| Parameter | Type | Required | Default | Description | +| ---------------------- | ------- | -------- | -------- | --------------------------------------------------------------- | +| `path` | string | Yes | - | File path relative to repository root | +| `start_line` | number | No | - | Starting line number | +| `end_line` | number | No | - | Ending line number | +| `view` | string | No | "symbol" | Retrieval strategy (override with `KIRI_SNIPPETS_DEFAULT_VIEW`) | +| `compact` | boolean | No | false | Return only metadata | +| `include_line_numbers` | boolean | No | false | Prefix lines with numbers | +| `range_source` | string | No | - | `"symbol"`, `"window"`, or `"clamped"` from `context_bundle` | ### View Modes | Mode | Behavior | | -------- | ---------------------------------------------------- | | `auto` | Uses symbol boundaries if available, else line range | -| `symbol` | Forces symbol-based snippets | +| `symbol` | Forces symbol-based snippets (default behavior) | | `lines` | Line-based retrieval (ignores symbols) | | `full` | Returns entire file (500 line limit) | +> **Hint**: Pass the `rangeSource` emitted by `context_bundle` as `range_source` so that clamped windows stay compact even when the default view is symbol. + ### Examples **Get entire file:** diff --git a/scripts/assay/kiri-adapter.ts b/scripts/assay/kiri-adapter.ts index f9b283a..8cf76ad 100644 --- a/scripts/assay/kiri-adapter.ts +++ b/scripts/assay/kiri-adapter.ts @@ -185,6 +185,7 @@ export class KiriSearchAdapter implements SearchAdapter { goal: query.text, limit: k, compact: this.config.compact, + why_mode: "terse", }; if (this.config.boostProfile && this.config.boostProfile !== "default") { diff --git a/scripts/test/verify-all.ts b/scripts/test/verify-all.ts index 21358d4..d3f410c 100755 --- a/scripts/test/verify-all.ts +++ b/scripts/test/verify-all.ts @@ -277,8 +277,16 @@ async function runMCPToolsTests(_options: VerificationOptions): Promise = { + artifact: "art", + dictionary: "dict", + phrase: "phr", + text: "txt", + metadata: "meta", + substring: "sub", + "path-phrase": "pphr", + structural: "str", + cochange: "co", + "path-segment": "pseg", + "path-keyword": "pkey", + dep: "dep", + near: "near", + boost: "boost", + recent: "recent", + symbol: "sym", + penalty: "pen", + keyword: "kw", + coverage: "cov", + fallback: "fb", + graph: "graph", + "abbr-path": "abbr", +}; // 項目3: whyタグの優先度マップ(低い数値ほど高優先度) // All actual tag prefixes used in the codebase @@ -936,7 +966,7 @@ function parseOutputOptions(params: { * * DoS protection: Limits processing to first 1000 reasons to prevent CPU exhaustion. */ -function selectWhyTags(reasons: Set): string[] { +function selectWhyTags(reasons: Set, mode: WhyMode = "full"): string[] { // Protect against DoS: limit reasons processed if (reasons.size > 1000) { reasons = new Set(Array.from(reasons).slice(0, 1000)); @@ -979,7 +1009,25 @@ function selectWhyTags(reasons: Set): string[] { selected.add(reason); // Set automatically deduplicates } - return Array.from(selected); + return formatWhyTags(selected, mode); +} + +function formatWhyTags(reasons: Set, mode: WhyMode): string[] { + if (mode === "full") { + return Array.from(reasons); + } + const formatted: string[] = []; + for (const reason of reasons) { + const [prefix, ...restParts] = reason.split(":"); + const abbreviation = WHY_PREFIX_ABBREVIATIONS[prefix]; + if (!abbreviation) { + formatted.push(reason); + continue; + } + const suffix = restParts.join(":"); + formatted.push(suffix.length > 0 ? `${abbreviation}:${suffix}` : abbreviation); + } + return formatted; } /** @@ -4048,6 +4096,7 @@ async function contextBundleImpl( const substringHints = hintBuckets.substringHints; const includeTokensEstimate = params.includeTokensEstimate === true; const isCompact = params.compact === true; + const whyMode: WhyMode = params.why_mode ?? "full"; const pathPrefix = params.path_prefix && params.path_prefix.length > 0 ? normalizePathPrefix(params.path_prefix) @@ -5035,6 +5084,7 @@ async function contextBundleImpl( let startLine: number; let endLine: number; + let rangeSource: SnippetRangeSource = selected ? "symbol" : "window"; if (selected) { startLine = selected.start_line; endLine = selected.end_line; @@ -5064,6 +5114,7 @@ async function contextBundleImpl( } startLine = clampedStart; endLine = Math.max(clampedStart, clampedEnd); + rangeSource = "clamped"; } } @@ -5081,11 +5132,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 why = selectWhyTags(reasons, whyMode); const item: ContextBundleItem = { path: candidate.path, range: [startLine, endLine], + rangeSource, why, score: roundedScore, }; diff --git a/src/server/handlers/snippets-get.ts b/src/server/handlers/snippets-get.ts index 3cc9901..4610aca 100644 --- a/src/server/handlers/snippets-get.ts +++ b/src/server/handlers/snippets-get.ts @@ -1,3 +1,5 @@ +import process from "node:process"; + import { ServerContext } from "../context.js"; /** @@ -11,6 +13,7 @@ import { ServerContext } from "../context.js"; * - "full": ファイル全体を返す(安全上限付き) */ export type SnippetsGetView = "auto" | "symbol" | "lines" | "full"; +export type SnippetRangeSource = "symbol" | "clamped" | "window"; export interface SnippetsGetParams { path: string; @@ -19,6 +22,7 @@ export interface SnippetsGetParams { compact?: boolean; // If true, omit content payload entirely includeLineNumbers?: boolean; // If true, prefix content lines with line numbers view?: SnippetsGetView; // 取得戦略を明示的に制御 + rangeSource?: SnippetRangeSource; // context_bundle 由来の範囲の性質 } /** @@ -65,6 +69,18 @@ interface SnippetRow { const DEFAULT_SNIPPET_WINDOW = 150; const MAX_SNIPPET_LINES = 500; // 全モード共通の安全上限 const MAX_SNIPPET_CHARS = 200_000; // 返却内容の安全上限(1レスポンスの最大文字数) +const VALID_SNIPPET_VIEWS: SnippetsGetView[] = ["auto", "symbol", "lines", "full"]; + +function resolveDefaultSnippetsView(): SnippetsGetView { + const envValue = process.env.KIRI_SNIPPETS_DEFAULT_VIEW; + if (typeof envValue === "string") { + const normalized = envValue.toLowerCase(); + if (VALID_SNIPPET_VIEWS.includes(normalized as SnippetsGetView)) { + return normalized as SnippetsGetView; + } + } + return "symbol"; +} /** * 行番号をプレフィックスとして追加する(動的幅調整) @@ -160,7 +176,18 @@ export async function snippetsGet( [repoId, params.path] ); - const view = params.view ?? "auto"; + const explicitView = params.view; + let view = explicitView ?? resolveDefaultSnippetsView(); + const rangeSource = params.rangeSource; + + const hasExplicitRange = params.end_line !== undefined; + if ( + view === "symbol" && + explicitView === undefined && + (hasExplicitRange || rangeSource === "clamped" || rangeSource === "window") + ) { + view = "lines"; + } // view パラメータに基づいて取得戦略を決定 let useSymbolSnippets: boolean; diff --git a/src/server/output-schemas.ts b/src/server/output-schemas.ts index c8d1b90..3808d59 100644 --- a/src/server/output-schemas.ts +++ b/src/server/output-schemas.ts @@ -19,6 +19,7 @@ import { z } from "zod"; export const ContextBundleItemSchema = z.object({ path: z.string().describe("ファイルパス"), range: z.tuple([z.number(), z.number()]).describe("行範囲 [start, end]"), + rangeSource: z.enum(["symbol", "clamped", "window"]).describe("rangeが生成された根拠"), preview: z.string().optional().describe("コードプレビュー(compact=falseの場合)"), why: z.array(z.string()).describe("スコアリング理由"), score: z.number().describe("関連度スコア"), diff --git a/src/server/rpc.ts b/src/server/rpc.ts index a72d126..4bc8f7f 100644 --- a/src/server/rpc.ts +++ b/src/server/rpc.ts @@ -264,6 +264,11 @@ const TOOL_DESCRIPTORS: ToolDescriptor[] = [ additionalProperties: true, description: "Filter by YAML frontmatter (e.g., {tags: ['api'], category: 'auth'}).", }, + why_mode: { + type: "string", + enum: ["full", "terse"], + description: "Return why tags as full strings or terse prefixes.", + }, }, }, outputSchema: OUTPUT_SCHEMAS.context_bundle, @@ -354,6 +359,11 @@ const TOOL_DESCRIPTORS: ToolDescriptor[] = [ description: "auto=symbol boundaries, lines=exact range, full=entire file (max 500 lines).", }, + range_source: { + type: "string", + enum: ["symbol", "clamped", "window"], + description: "Pass context_bundle rangeSource to preserve windows when defaults change.", + }, }, }, outputSchema: OUTPUT_SCHEMAS.snippets_get, @@ -499,6 +509,14 @@ function parseSnippetsGetParams(input: unknown): SnippetsGetParams { throw new Error(`Invalid view: "${record.view}". Valid values are: ${validViews.join(", ")}`); } } + const rangeSourceValue = record.range_source ?? record.rangeSource; + if ( + rangeSourceValue === "symbol" || + rangeSourceValue === "clamped" || + rangeSourceValue === "window" + ) { + params.rangeSource = rangeSourceValue; + } return params; } @@ -668,6 +686,18 @@ function parseContextBundleParams(input: unknown, context: ServerContext): Conte } } + if (typeof record.why_mode === "string") { + const normalized = record.why_mode.trim().toLowerCase(); + if (normalized === "full" || normalized === "terse") { + params.why_mode = normalized === "terse" ? "terse" : "full"; + } else { + context.warningManager.warnForRequest( + "why-mode-invalid", + 'why_mode must be either "full" or "terse". The provided value was ignored.' + ); + } + } + return params; } diff --git a/tests/server/context.bundle.spec.ts b/tests/server/context.bundle.spec.ts index 178ff81..a1dcc22 100644 --- a/tests/server/context.bundle.spec.ts +++ b/tests/server/context.bundle.spec.ts @@ -97,6 +97,7 @@ describe("context_bundle", () => { expect(editing).toBeDefined(); expect(editing?.why).toContain("artifact:editing_path"); expect(editing?.why.some((reason) => reason.startsWith("structural:"))).toBe(true); + expect(editing?.rangeSource).toBeDefined(); const helper = bundle.context.find((item) => item.path === "src/utils/helper.ts"); expect(helper).toBeDefined(); @@ -160,6 +161,43 @@ describe("context_bundle", () => { expect(withHints.context[0]?.path).toBe("src/stats/rank-biserial.ts"); }, 10000); + it("returns terse why tags when why_mode is set", async () => { + const repo = await createTempRepo({ + "src/service/foo.ts": `export function foo() {\n return "foo";\n}\n`, + }); + cleanupTargets.push({ dispose: repo.cleanup }); + + const dbDir = await mkdtemp(join(tmpdir(), "kiri-db-terse-")); + 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: "foo", + why_mode: "terse", + }); + + expect(bundle.context.length).toBeGreaterThan(0); + const reasons = bundle.context[0]?.why ?? []; + expect(reasons.length).toBeGreaterThan(0); + expect(reasons.some((reason) => reason.startsWith("sym:"))).toBe(true); + expect(reasons.every((reason) => !reason.startsWith("symbol:"))).toBe(true); + }, 10000); + it("applies YAML/env path penalties when merging boost profile multipliers", async () => { const repo = await createTempRepo({ "src/core/highlight.ts": `// highlight implementation in src\nexport const marker = "src";\nexport function choose() { return "src"; }\n`, diff --git a/tests/server/snippets.get.spec.ts b/tests/server/snippets.get.spec.ts index 012ee88..4baba1a 100644 --- a/tests/server/snippets.get.spec.ts +++ b/tests/server/snippets.get.spec.ts @@ -646,5 +646,61 @@ describe("snippets_get", () => { }); expect(snippetWithEndLine.symbolName).toBeNull(); }); + + it("respects explicit ranges when default view is symbol and rangeSource is clamped", async () => { + const repo = await createTempRepo({ + "src/window.ts": [ + "export function first() {", + " return 1;", + "}", + "", + "export function second() {", + " return 2;", + "}", + ].join("\n"), + }); + cleanupTargets.push({ dispose: repo.cleanup }); + + const dbDir = await mkdtemp(join(tmpdir(), "kiri-db-view-guard-")); + 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 originalDefault = process.env.KIRI_SNIPPETS_DEFAULT_VIEW; + process.env.KIRI_SNIPPETS_DEFAULT_VIEW = "symbol"; + try { + const snippet = await snippetsGet(context, { + path: "src/window.ts", + start_line: 5, + end_line: 6, + rangeSource: "clamped", + }); + expect(snippet.startLine).toBe(5); + expect(snippet.endLine).toBe(6); + expect(snippet.symbolName).toBeNull(); + } finally { + if (originalDefault === undefined) { + delete process.env.KIRI_SNIPPETS_DEFAULT_VIEW; + } else { + process.env.KIRI_SNIPPETS_DEFAULT_VIEW = originalDefault; + } + } + }); }); }); From ada3369300c9c4beff0df61d123f46ff4e29e624 Mon Sep 17 00:00:00 2001 From: Ryoichi Izumita Date: Thu, 1 Jan 2026 23:47:41 +0900 Subject: [PATCH 2/6] feat(server): propagate rangeSource metadata --- scripts/audit/export-log.ts | 8 ++++- scripts/test/verify-all.ts | 69 +++++++++++++++++++++++++++++++------ src/server/handlers.ts | 2 +- types/index.d.ts | 1 + 4 files changed, 68 insertions(+), 12 deletions(-) diff --git a/scripts/audit/export-log.ts b/scripts/audit/export-log.ts index 3f446fc..a028745 100644 --- a/scripts/audit/export-log.ts +++ b/scripts/audit/export-log.ts @@ -7,6 +7,7 @@ import { maskValue } from "../../src/shared/security/masker.js"; export interface AuditEntry { path: string; range: [number, number]; + rangeSource: "symbol" | "window" | "clamped"; rationale: string; } @@ -27,7 +28,12 @@ const executedDirectly = if (executedDirectly) { const sample: AuditEntry[] = [ - { path: "src/server/main.ts", range: [1, 20], rationale: "MCP起動処理の確認" }, + { + path: "src/server/main.ts", + range: [1, 20], + rangeSource: "clamped", + rationale: "MCP起動処理の確認", + }, ]; const output = exportAuditLog(sample, process.argv[2] ?? "var/audit/sample-log.json"); console.info(`監査ログを出力しました: ${output}`); diff --git a/scripts/test/verify-all.ts b/scripts/test/verify-all.ts index d3f410c..05193de 100755 --- a/scripts/test/verify-all.ts +++ b/scripts/test/verify-all.ts @@ -285,7 +285,7 @@ async function runMCPToolsTests(_options: VerificationOptions): Promise) + : []; + if (entries.length > 0 && Array.isArray(entries[0]?.range)) { + const entry = entries[0]!; + const [start, end] = entry.range as [number, number]; + lastBundleEntry = { + path: entry.path, + range: [start, end], + rangeSource: entry.rangeSource, + }; + } else { + lastBundleEntry = null; + } + } + // Verify balanced profile returns docs/ files if (tool.name.includes("balanced")) { - const items = Array.isArray(result.result) - ? result.result - : (result.result as { context?: unknown[] })?.context; + let items: unknown[] | undefined; + if (Array.isArray(result.result)) { + items = result.result; + } else if (Array.isArray((result.result as { context?: unknown[] })?.context)) { + items = (result.result as { context?: unknown[] }).context; + } else if (Array.isArray((result.result as { results?: unknown[] })?.results)) { + items = (result.result as { results?: unknown[] }).results; + } // ✅ Strict validation: Ensure items is defined and is an array if (!items || !Array.isArray(items)) { diff --git a/src/server/handlers.ts b/src/server/handlers.ts index 8d2b40a..3f4ddea 100644 --- a/src/server/handlers.ts +++ b/src/server/handlers.ts @@ -1018,7 +1018,7 @@ function formatWhyTags(reasons: Set, mode: WhyMode): string[] { } const formatted: string[] = []; for (const reason of reasons) { - const [prefix, ...restParts] = reason.split(":"); + const [prefix = "", ...restParts] = reason.split(":"); const abbreviation = WHY_PREFIX_ABBREVIATIONS[prefix]; if (!abbreviation) { formatted.push(reason); diff --git a/types/index.d.ts b/types/index.d.ts index 7a51012..59efa72 100644 --- a/types/index.d.ts +++ b/types/index.d.ts @@ -1,6 +1,7 @@ export interface Snippet { path: string; range: [number, number]; + rangeSource: "symbol" | "window" | "clamped"; symbols: string[]; } From 4c55207142081cdf695e201d2ff48a5088250206 Mon Sep 17 00:00:00 2001 From: Ryoichi Izumita Date: Fri, 2 Jan 2026 09:15:13 +0900 Subject: [PATCH 3/6] fix(indexer): expand watcher directory events --- src/indexer/watch.ts | 91 ++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 88 insertions(+), 3 deletions(-) diff --git a/src/indexer/watch.ts b/src/indexer/watch.ts index 071a656..9ba6b0b 100644 --- a/src/indexer/watch.ts +++ b/src/indexer/watch.ts @@ -1,5 +1,6 @@ import { realpathSync, mkdirSync } from "node:fs"; -import { resolve, relative, sep, dirname, isAbsolute } from "node:path"; +import { readdir, stat } from "node:fs/promises"; +import { resolve, relative, sep, dirname, isAbsolute, join } from "node:path"; import { performance } from "node:perf_hooks"; import watcher, { type AsyncSubscription, type Event } from "@parcel/watcher"; @@ -375,6 +376,80 @@ export class IndexWatcher { this.pendingReindex = true; } + /** + * Expands directory paths into individual file paths so incremental indexing + * can operate on concrete files even when the watcher only reports folder-level + * events (common when creating new directories). + */ + private async expandChangedPaths(changedPaths: string[]): Promise { + if (changedPaths.length === 0) { + return []; + } + + const expanded = new Set(); + + for (const relativePath of changedPaths) { + const absPath = join(this.rawRepoRoot, relativePath); + try { + const stats = await stat(absPath); + if (stats.isDirectory()) { + const files = await this.collectFilesUnder(absPath); + if (files.length === 0) { + expanded.add(relativePath); + } else { + for (const file of files) { + expanded.add(file); + } + } + } else if (stats.isFile()) { + expanded.add(relativePath); + } else { + expanded.add(relativePath); + } + } catch { + // File may have been deleted before we could stat it + expanded.add(relativePath); + } + } + + return Array.from(expanded); + } + + private async collectFilesUnder(absDir: string): Promise { + const collected: string[] = []; + const stack: string[] = [absDir]; + + while (stack.length > 0) { + const current = stack.pop()!; + let entries; + try { + entries = await readdir(current, { withFileTypes: true }); + } catch { + continue; + } + + for (const entry of entries) { + if (entry.isSymbolicLink()) { + continue; + } + + const entryPath = join(current, entry.name); + const relativePath = this.normalizePathForRepo(entryPath); + if (!relativePath || this.shouldIgnore(relativePath)) { + continue; + } + + if (entry.isDirectory()) { + stack.push(entryPath); + } else if (entry.isFile()) { + collected.push(relativePath); + } + } + } + + return collected; + } + /** * Executes an incremental reindex operation for changed files only. * @@ -442,15 +517,25 @@ export class IndexWatcher { throw error; } + let targetPaths = changedPaths; + try { + targetPaths = await this.expandChangedPaths(changedPaths); + } catch (error) { + const reason = error instanceof Error ? error.message : String(error); + process.stderr.write( + `⚠️ Failed to expand directory changes (${reason}). Using original paths.\n` + ); + } + // Run incremental reindex for changed files only const start = performance.now(); - process.stderr.write(`🔄 Incrementally reindexing ${changedPaths.length} file(s)...\n`); + process.stderr.write(`🔄 Incrementally reindexing ${targetPaths.length} file(s)...\n`); await runIndexer({ repoRoot: this.rawRepoRoot, databasePath: this.options.databasePath, full: false, - changedPaths, + changedPaths: targetPaths, skipLocking: true, // Watcher already holds the lock }); From 0381d6dc6d90763a5b942e74212057de33bd3ab1 Mon Sep 17 00:00:00 2001 From: Ryoichi Izumita Date: Fri, 2 Jan 2026 09:33:19 +0900 Subject: [PATCH 4/6] fix: return rangeSource and scope watch events --- src/indexer/watch.ts | 69 ++++++++++++++++++++++++----- src/server/handlers/snippets-get.ts | 4 ++ 2 files changed, 62 insertions(+), 11 deletions(-) diff --git a/src/indexer/watch.ts b/src/indexer/watch.ts index 9ba6b0b..002a29a 100644 --- a/src/indexer/watch.ts +++ b/src/indexer/watch.ts @@ -1,7 +1,9 @@ +import { execFile } from "node:child_process"; import { realpathSync, mkdirSync } from "node:fs"; import { readdir, stat } from "node:fs/promises"; import { resolve, relative, sep, dirname, isAbsolute, join } from "node:path"; import { performance } from "node:perf_hooks"; +import { promisify } from "node:util"; import watcher, { type AsyncSubscription, type Event } from "@parcel/watcher"; @@ -81,6 +83,7 @@ export class IndexWatcher { private isStopping = false; // Flag to prevent new reindexes during shutdown private denylistFilter: DenylistFilter | null = null; private ignoredRelativePaths = new Set(); + private readonly execGit = promisify(execFile); constructor(options: IndexWatcherOptions) { this.rawRepoRoot = resolve(options.repoRoot); @@ -393,13 +396,21 @@ export class IndexWatcher { try { const stats = await stat(absPath); if (stats.isDirectory()) { - const files = await this.collectFilesUnder(absPath); + const gitPaths = await this.listGitChangesFor(relativePath); + if (gitPaths.length > 0) { + for (const gitPath of gitPaths) { + expanded.add(gitPath); + } + continue; + } + + const files = await this.collectFilesUnder(absPath, { maxDepth: 1 }); if (files.length === 0) { expanded.add(relativePath); - } else { - for (const file of files) { - expanded.add(file); - } + continue; + } + for (const file of files) { + expanded.add(file); } } else if (stats.isFile()) { expanded.add(relativePath); @@ -415,15 +426,19 @@ export class IndexWatcher { return Array.from(expanded); } - private async collectFilesUnder(absDir: string): Promise { + private async collectFilesUnder( + absDir: string, + options?: { maxDepth?: number } + ): Promise { const collected: string[] = []; - const stack: string[] = [absDir]; + const maxDepth = options?.maxDepth ?? Infinity; + const stack: Array<{ path: string; depth: number }> = [{ path: absDir, depth: 0 }]; while (stack.length > 0) { - const current = stack.pop()!; + const { path, depth } = stack.pop()!; let entries; try { - entries = await readdir(current, { withFileTypes: true }); + entries = await readdir(path, { withFileTypes: true }); } catch { continue; } @@ -433,14 +448,16 @@ export class IndexWatcher { continue; } - const entryPath = join(current, entry.name); + const entryPath = join(path, entry.name); const relativePath = this.normalizePathForRepo(entryPath); if (!relativePath || this.shouldIgnore(relativePath)) { continue; } if (entry.isDirectory()) { - stack.push(entryPath); + if (depth + 1 <= maxDepth) { + stack.push({ path: entryPath, depth: depth + 1 }); + } } else if (entry.isFile()) { collected.push(relativePath); } @@ -450,6 +467,36 @@ export class IndexWatcher { return collected; } + private async listGitChangesFor(relativePath: string): Promise { + try { + const { stdout } = await this.execGit( + "git", + ["status", "--porcelain=1", "--", relativePath], + { + cwd: this.rawRepoRoot, + } + ); + const results: string[] = []; + for (const rawLine of stdout.split(/\r?\n/)) { + const line = rawLine.trim(); + if (!line) continue; + const payload = line.slice(3); // drop XY status + const renameParts = payload.split(" -> "); + const finalPath = (renameParts[renameParts.length - 1] ?? payload).trim(); + if (!finalPath) continue; + const normalized = finalPath.replace(/\\/g, "/"); + const absPath = join(this.rawRepoRoot, normalized); + const rel = this.normalizePathForRepo(absPath); + if (rel) { + results.push(rel); + } + } + return results; + } catch { + return []; + } + } + /** * Executes an incremental reindex operation for changed files only. * diff --git a/src/server/handlers/snippets-get.ts b/src/server/handlers/snippets-get.ts index 4610aca..a7e008a 100644 --- a/src/server/handlers/snippets-get.ts +++ b/src/server/handlers/snippets-get.ts @@ -312,10 +312,14 @@ export async function snippetsGet( } } + const derivedRangeSource: SnippetRangeSource = + rangeSource ?? (useSymbolSnippets ? "symbol" : "window"); + return { path: row.path, startLine, endLine, + rangeSource: derivedRangeSource, ...(content !== undefined && { content }), totalLines, symbolName, From 6f3e1133c8671b2948133a89d0e402fd3a659f4e Mon Sep 17 00:00:00 2001 From: Ryoichi Izumita Date: Fri, 2 Jan 2026 11:44:45 +0900 Subject: [PATCH 5/6] fix(server): fallback security config path --- docs/doc_index.yaml | 3 + docs/uncertainty-issue-210.md | 86 +++++++++++++++++++++++++++++ src/server/handlers/snippets-get.ts | 1 + src/shared/security/config.ts | 40 ++++++++++++-- 4 files changed, 124 insertions(+), 6 deletions(-) create mode 100644 docs/uncertainty-issue-210.md diff --git a/docs/doc_index.yaml b/docs/doc_index.yaml index 43ad382..512794b 100644 --- a/docs/doc_index.yaml +++ b/docs/doc_index.yaml @@ -47,6 +47,9 @@ documents: - doc_id: RUN-002 path: docs/operations.md title: "Operations" + - doc_id: RUN-210 + path: docs/uncertainty-issue-210.md + title: "Issue 210 – Uncertainty Register" # Guide - ユーザー/開発者向けガイド - doc_id: GUIDE-001 diff --git a/docs/uncertainty-issue-210.md b/docs/uncertainty-issue-210.md new file mode 100644 index 0000000..7ee6b8b --- /dev/null +++ b/docs/uncertainty-issue-210.md @@ -0,0 +1,86 @@ +--- +doc_id: "RUN-210" +title: "Issue 210 – Uncertainty Register" +category: "runbook" +tags: + - issue-210 + - uncertainty +service: "kiri" +--- + +# Issue 210 – Uncertainty Register (2026-01-02) + +## Decision (1-3 lines) + +- **Decision**: Ship issue #210 (symbol-first snippets_get + range metadata) via PR #213 without regressing daemon/watch flows or compact-mode defaults. +- **Deadline**: 2026-01-03 (before weekend freeze). +- **Stakes**: Blocking CI prevents token-usage optimizations from landing; daemon regressions would strand MCP server operators. +- **Constraints**: Keep scope within server runtime + config assets; no breaking API surface or new config files in this patch. + +## Register (max 10 to start) + +| ID | Category | Uncertainty (question) | Current hypothesis | Impact (1-5) | Evidence (1-5) | Urgency (1-5) | Effort (1-5) | Priority | Next observation | +| ---: | -------------- | -------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------- | -----------: | -------------: | ------------: | -----------: | -------: | ---------------------------------------------------------------------------- | +| U-01 | Build/runtime | Will daemon/watch tests keep failing because dist bundles miss `config/security.yml`? | Runtime currently resolves `dist/config/security.yml`; CI dist lacks copy, so fallback/root detection fix will unblock. | 5 | 2 | 5 | 2 | 50 | Read `src/shared/security/config.ts`, add fallback path, rerun daemon tests. | +| U-02 | Packaging | Are there other non-TS assets (locks, YAML) that dist/runtime needs but tsconfig skips? | Probably only `config/security.yml`; verify other `config/*.yml` references to avoid future ENOENT. | 4 | 3 | 3 | 3 | 12 | Grep for `config/` loads, confirm watchers/integration tests cover them. | +| U-03 | Feature parity | Did issue/210 actually flip `snippets_get` default view to `symbol` after rebasing on compact-mode main? | `resolveDefaultSnippetsView()` returns `symbol`, but need to ensure env var + docs match. | 3 | 4 | 3 | 2 | 9 | Inspect handler/tests + docs for default statements. | +| U-04 | Documentation | Do docs/tests teach users about compact default + symbol default interplay? | Most doc updates merged, but verify API references mention both defaults. | 2 | 3 | 2 | 3 | 4 | Spot-check `docs/tools-reference.md` + API doc for defaults. | + +### Top Priorities + +- **P1**: U-01 (Priority 50) – fix runtime fallback so CI passes. +- **P2**: U-02 (Priority 12) – ensure no other asset gaps remain. +- **P3**: U-03 (Priority 9) – confirm snippet defaults and note in PR. + +## Observation Backlog + +### T-01: Patch security config fallback (for U-01) + +- **Hypothesis**: Allowing `loadSecurityConfig` to fall back to repository `config/security.yml` when `dist/config/security.yml` is absent will let daemon/watch tests pass without copying assets. +- **Method**: Code inspection + targeted daemon test run. +- **Timebox**: 2h (includes coding + test run). +- **Steps**: + 1. Add helper in `src/shared/security/config.ts` that probes `dist/.../config/security.yml` and then `config/security.yml`. + 2. Update loader/evaluator to use helper; add regression tests covering both paths. + 3. Run `pnpm vitest tests/shared/security` and `pnpm vitest tests/daemon/daemon.watch.spec.ts`. +- **Decision rule**: + - If tests pass locally, accept change and push. + - If daemon test still fails with ENOENT, escalate to asset-copy approach. +- **Evidence artifact to collect**: Vitest logs for security + daemon suites. +- **Output**: Patched TypeScript + new tests. +- **Owner**: Codex. +- **Status**: Done (2026-01-02) – fallback implemented + daemon/spec + security suites passing. + +### T-02: Audit other config assets (for U-02) + +- **Hypothesis**: `security.yml` is the only runtime-critical YAML referenced via relative disk paths. +- **Method**: Repo search + doc review. +- **Timebox**: 30m. +- **Steps**: + 1. `rg -n \"config/.*\\.yml\" src -g\"*.ts\"` to list runtime references. + 2. Verify each reference already works under dist (either uses `resolve()` or bundler). + 3. Document findings alongside PR notes. +- **Decision rule**: + - If new asset gaps found, add similar fallbacks or copy logic. + - Otherwise, mark risk as mitigated. +- **Evidence artifact**: Search log & summary note. +- **Output**: Confidence note + optional doc snippet. +- **Owner**: Codex. +- **Status**: Done (2026-01-02) – only `config/security.yml` needed fallback; other loaders already probe multiple paths. + +### T-03: Confirm symbol default coverage (for U-03) + +- **Hypothesis**: Handler + docs already default to `view=symbol` unless explicit view/range overrides. +- **Method**: Read `src/server/handlers/snippets-get.ts` + tests + docs. +- **Timebox**: 20m. +- **Steps**: + 1. Inspect `resolveDefaultSnippetsView` and call sites. + 2. Review tests ensuring default view is symbol. + 3. Check API docs for mention; update if missing. +- **Decision rule**: + - If default mismatch found, file follow-up. + - If consistent, cite evidence in PR summary. +- **Evidence artifact**: Code references + doc diff if needed. +- **Output**: Verified statement in PR description. +- **Owner**: Codex. +- **Status**: Done (2026-01-02) – handler/test/doc review confirms default `view` is `symbol` absent explicit overrides. diff --git a/src/server/handlers/snippets-get.ts b/src/server/handlers/snippets-get.ts index a7e008a..ee4e351 100644 --- a/src/server/handlers/snippets-get.ts +++ b/src/server/handlers/snippets-get.ts @@ -36,6 +36,7 @@ export interface SnippetResult { totalLines: number; symbolName: string | null; symbolKind: string | null; + rangeSource: SnippetRangeSource; truncated?: boolean; // 行数/文字数の安全上限で切り詰められた場合 true } diff --git a/src/shared/security/config.ts b/src/shared/security/config.ts index 4babc6c..53bc046 100644 --- a/src/shared/security/config.ts +++ b/src/shared/security/config.ts @@ -1,5 +1,5 @@ import { createHash } from "node:crypto"; -import { mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { mkdirSync, readFileSync, writeFileSync, existsSync } from "node:fs"; import { dirname, join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; @@ -33,9 +33,37 @@ const SecurityConfigSchema = z.object({ sensitive_tokens: z.array(z.string()), }); +function resolveSecurityConfigPath(configPath?: string): string { + if (configPath) { + const absoluteConfigPath = resolve(configPath); + if (!existsSync(absoluteConfigPath)) { + throw new Error( + `Security configuration is missing at ${absoluteConfigPath}. Provide a valid config path or run 'pnpm exec tsx src/client/cli.ts security init'.` + ); + } + return absoluteConfigPath; + } + + const compiledConfigPath = join( + fileURLToPath(import.meta.url), + "../../../../config/security.yml" + ); + if (existsSync(compiledConfigPath)) { + return compiledConfigPath; + } + + const repoConfigPath = resolve("config/security.yml"); + if (existsSync(repoConfigPath)) { + return repoConfigPath; + } + + throw new Error( + "Security configuration file config/security.yml was not found in the build output nor in the repository root. Provide --config to continue." + ); +} + export function loadSecurityConfig(configPath?: string): { config: SecurityConfig; hash: string } { - const path = - configPath ?? join(fileURLToPath(import.meta.url), "../../../../config/security.yml"); + const path = resolveSecurityConfigPath(configPath); const content = readFileSync(path, "utf8"); const parsed = parseSimpleYaml(content); @@ -59,12 +87,12 @@ export function readSecurityLock(lockPath?: string): string | null { } export function evaluateSecurityStatus(configPath?: string, lockPath?: string): SecurityStatus { - const { config, hash } = loadSecurityConfig(configPath); + const resolvedConfigPath = resolveSecurityConfigPath(configPath); + const { config, hash } = loadSecurityConfig(resolvedConfigPath); const stored = readSecurityLock(lockPath); - const defaultConfigPath = join(fileURLToPath(import.meta.url), "../../../../config/security.yml"); return { config, - configPath: configPath ?? defaultConfigPath, + configPath: resolvedConfigPath, lockPath: resolve(lockPath ?? "var/security.lock"), hash, lockHash: stored, From e0b752d4cb4852894157d86e1aa76ed851332f59 Mon Sep 17 00:00:00 2001 From: Ryoichi Izumita Date: Fri, 2 Jan 2026 12:23:49 +0900 Subject: [PATCH 6/6] test(shared): cover security config fallback --- src/shared/security/config.ts | 11 +++++++---- tests/shared/security/config.spec.ts | 21 +++++++++++++++++++++ 2 files changed, 28 insertions(+), 4 deletions(-) create mode 100644 tests/shared/security/config.spec.ts diff --git a/src/shared/security/config.ts b/src/shared/security/config.ts index 53bc046..23cc862 100644 --- a/src/shared/security/config.ts +++ b/src/shared/security/config.ts @@ -33,10 +33,13 @@ const SecurityConfigSchema = z.object({ sensitive_tokens: z.array(z.string()), }); -function resolveSecurityConfigPath(configPath?: string): string { +export function resolveSecurityConfigPath( + configPath?: string, + fsExists: (path: string) => boolean = existsSync +): string { if (configPath) { const absoluteConfigPath = resolve(configPath); - if (!existsSync(absoluteConfigPath)) { + if (!fsExists(absoluteConfigPath)) { throw new Error( `Security configuration is missing at ${absoluteConfigPath}. Provide a valid config path or run 'pnpm exec tsx src/client/cli.ts security init'.` ); @@ -48,12 +51,12 @@ function resolveSecurityConfigPath(configPath?: string): string { fileURLToPath(import.meta.url), "../../../../config/security.yml" ); - if (existsSync(compiledConfigPath)) { + if (fsExists(compiledConfigPath)) { return compiledConfigPath; } const repoConfigPath = resolve("config/security.yml"); - if (existsSync(repoConfigPath)) { + if (fsExists(repoConfigPath)) { return repoConfigPath; } diff --git a/tests/shared/security/config.spec.ts b/tests/shared/security/config.spec.ts new file mode 100644 index 0000000..f30a461 --- /dev/null +++ b/tests/shared/security/config.spec.ts @@ -0,0 +1,21 @@ +import { resolve } from "node:path"; + +import { describe, expect, it } from "vitest"; + +import { resolveSecurityConfigPath } from "../../../src/shared/security/config.js"; + +describe("resolveSecurityConfigPath", () => { + it("falls back to repo config when the compiled path is missing", () => { + const calls: string[] = []; + const resolved = resolveSecurityConfigPath(undefined, (candidate) => { + calls.push(candidate); + if (calls.length === 1) { + return false; + } + return true; + }); + + expect(calls.length).toBeGreaterThanOrEqual(2); + expect(resolved).toBe(resolve("config/security.yml")); + }); +});