From 2c2511490c2f687e4dcacac86369c89624af9795 Mon Sep 17 00:00:00 2001 From: unohee Date: Sun, 20 Sep 2026 13:31:25 +0900 Subject: [PATCH 1/2] fix(knowledge-registry): surface incomplete scans and untracked discovery (AGT-3490) --- src/knowledge/gitInfo.ts | 53 ++++++++++++- src/knowledge/untrackedDiscovery.test.ts | 44 +++++++++++ src/registry/bsDetector.ts | 34 +++++++- src/registry/entityScanner.ts | 23 +++++- src/registry/entityWarningsBudget.test.ts | 42 ++++++++++ src/registry/graphql/resolvers.ts | 12 ++- src/registry/incompleteCoverage.test.ts | 95 +++++++++++++++++++++++ src/registry/sqliteStore.ts | 4 +- 8 files changed, 293 insertions(+), 14 deletions(-) create mode 100644 src/knowledge/untrackedDiscovery.test.ts create mode 100644 src/registry/entityWarningsBudget.test.ts create mode 100644 src/registry/incompleteCoverage.test.ts diff --git a/src/knowledge/gitInfo.ts b/src/knowledge/gitInfo.ts index e3e23708..08eaa530 100644 --- a/src/knowledge/gitInfo.ts +++ b/src/knowledge/gitInfo.ts @@ -145,21 +145,68 @@ export async function getRecentlyChangedFiles( projectPath: string, sinceTimestamp: number, ): Promise { + const files = new Set(); try { const sinceDate = new Date(sinceTimestamp).toISOString(); - const output = await runGitCommand(projectPath, [ + const committed = await runGitCommand(projectPath, [ 'log', `--since=${sinceDate}`, '--name-only', '--format=', ]); + for (const line of committed.split('\n')) { + const trimmed = line.trim(); + if (trimmed) files.add(trimmed); + } + + const untracked = await runGitCommand(projectPath, [ + 'ls-files', + '--others', + '--exclude-standard', + '-z', + ]); + for (const token of untracked.split('\0')) { + const trimmed = token.trim(); + if (trimmed) files.add(trimmed); + } + + return Array.from(files); + } catch { + return []; + } +} - const files = new Set(); - for (const line of output.split('\n')) { +/** + * Older form kept for callers that already have a since-date string. + */ +export async function getFilesChangedSince( + projectPath: string, + sinceDate: string, +): Promise { + const files = new Set(); + try { + const committed = await runGitCommand(projectPath, [ + 'log', + `--since=${sinceDate}`, + '--name-only', + '--format=', + ]); + for (const line of committed.split('\n')) { const trimmed = line.trim(); if (trimmed) files.add(trimmed); } + const untracked = await runGitCommand(projectPath, [ + 'ls-files', + '--others', + '--exclude-standard', + '-z', + ]); + for (const token of untracked.split('\0')) { + const trimmed = token.trim(); + if (trimmed) files.add(trimmed); + } + return Array.from(files); } catch { return []; diff --git a/src/knowledge/untrackedDiscovery.test.ts b/src/knowledge/untrackedDiscovery.test.ts new file mode 100644 index 00000000..14c1e5fe --- /dev/null +++ b/src/knowledge/untrackedDiscovery.test.ts @@ -0,0 +1,44 @@ +// AGT-3490 — incremental refresh must discover newly created untracked files. + +import { execFile } from 'node:child_process'; +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { promisify } from 'node:util'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { getRecentlyChangedFiles } from './gitInfo.js'; + +const execFileAsync = promisify(execFile); +let tmp: string; + +beforeEach(async () => { + tmp = await mkdtemp(join(tmpdir(), 'openswarm-knowledge-')); + await execFileAsync('git', ['init'], { cwd: tmp }); + await execFileAsync('git', ['config', 'user.email', 'test@example.com'], { cwd: tmp }); + await execFileAsync('git', ['config', 'user.name', 'test'], { cwd: tmp }); +}); + +afterEach(async () => { + await rm(tmp, { recursive: true, force: true }); +}); + +describe('incremental refresh discovery', () => { + it('includes newly created untracked source files alongside committed history', async () => { + await mkdir(join(tmp, 'src'), { recursive: true }); + await writeFile(join(tmp, 'src/committed.ts'), 'export const committed = 1;\n', 'utf-8'); + await execFileAsync('git', ['add', 'src/committed.ts'], { cwd: tmp }); + await execFileAsync('git', ['commit', '-m', 'committed'], { cwd: tmp }); + + await writeFile(join(tmp, 'src/untracked.ts'), 'export const untracked = 1;\n', 'utf-8'); + const since = new Date(Date.now() - 60_000).getTime(); + const changed = await getRecentlyChangedFiles(tmp, since); + + expect(changed).toContain('src/committed.ts'); + expect(changed).toContain('src/untracked.ts'); + }); + + it('returns an empty list when git discovery fails entirely', async () => { + const changed = await getRecentlyChangedFiles(join(tmp, 'not-a-repo'), Date.now()); + expect(changed).toEqual([]); + }); +}); diff --git a/src/registry/bsDetector.ts b/src/registry/bsDetector.ts index 0a6c3007..f2045602 100644 --- a/src/registry/bsDetector.ts +++ b/src/registry/bsDetector.ts @@ -24,6 +24,8 @@ export interface BsIssue { export interface BsScanResult { issues: BsIssue[]; filesScanned: number; + incomplete: boolean; + incompleteReasons: string[]; critical: number; warning: number; minor: number; @@ -268,7 +270,11 @@ export async function scanFile(filePath: string): Promise { // ============ 결과 집계 ============ -export function aggregateResults(issues: BsIssue[], filesScanned: number): BsScanResult { +export function aggregateResults( + issues: BsIssue[], + filesScanned: number, + incompleteReasons: string[] = [], +): BsScanResult { const critical = issues.filter(i => i.severity === 'critical').length; const warning = issues.filter(i => i.severity === 'warning').length; const minor = issues.filter(i => i.severity === 'minor').length; @@ -276,7 +282,16 @@ export function aggregateResults(issues: BsIssue[], filesScanned: number): BsSca ? (critical * 10 + warning * 3 + minor * 1) / filesScanned : 0; - return { issues, filesScanned, critical, warning, minor, bsScore }; + return { + issues, + filesScanned, + incomplete: incompleteReasons.length > 0, + incompleteReasons: Array.from(new Set(incompleteReasons)).slice(0, 50), + critical, + warning, + minor, + bsScore, + }; } // ============ 레포 전체 스캔 ============ @@ -332,6 +347,7 @@ export async function scanRepository( let filesScanned = 0; const deadline = Date.now() + 30_000; const maxFiles = 10_000; + const incompleteReasons: string[] = []; async function walk(dirPath: string, relPath: string): Promise { let entries; @@ -344,7 +360,14 @@ export async function scanRepository( } for (const entry of entries) { - if (Date.now() >= deadline || filesScanned >= maxFiles) return; + if (Date.now() >= deadline) { + incompleteReasons.push(`${relPath || '.'}: bs scan timeout reached`); + return; + } + if (filesScanned >= maxFiles) { + incompleteReasons.push(`${relPath || '.'}: bs scan file cap ${maxFiles} reached`); + return; + } const fullPath = join(dirPath, entry.name); const entryRelPath = relPath ? `${relPath}/${entry.name}` : entry.name; @@ -369,6 +392,9 @@ export async function scanRepository( } } catch (err) { allIssues.push(makeFilesystemIssue('readFile', entryRelPath, err)); + if (err instanceof Error && err.message.startsWith('Source file exceeds')) { + incompleteReasons.push(`${entryRelPath}: source file excluded by size limit`); + } if (verbose) console.log(` [bs] readFile failed ${entryRelPath}: ${err instanceof Error ? err.message : String(err)}`); continue; } @@ -377,5 +403,5 @@ export async function scanRepository( } await walk(projectPath, ''); - return aggregateResults(allIssues, filesScanned); + return aggregateResults(allIssues, filesScanned, incompleteReasons); } diff --git a/src/registry/entityScanner.ts b/src/registry/entityScanner.ts index 9ef78ff1..39280ea0 100644 --- a/src/registry/entityScanner.ts +++ b/src/registry/entityScanner.ts @@ -663,6 +663,8 @@ export interface ScanResult { updated: number; removed: number; testsMapped: number; + incomplete: boolean; + incompleteReasons: string[]; errors: string[]; durationMs: number; languageBreakdown: Record; @@ -700,10 +702,17 @@ export async function scanRepository( const languageBreakdown: Record = {}; const scannedSourceFiles = new Set(); let scannedFiles = 0; + const incompleteReasons: string[] = []; async function walk(dirPath: string, relPath: string, depth: number): Promise { - if (depth > maxDepth) return; - if (Date.now() - startTime > timeoutMs) return; + if (depth > maxDepth) { + incompleteReasons.push(`${relPath || '.'}: depth limit ${maxDepth} reached`); + return; + } + if (Date.now() - startTime > timeoutMs) { + incompleteReasons.push(`${relPath || '.'}: scan timeout ${timeoutMs}ms reached`); + return; + } let entries; try { @@ -715,7 +724,10 @@ export async function scanRepository( } for (const entry of entries) { - if (Date.now() - startTime > timeoutMs) return; + if (Date.now() - startTime > timeoutMs) { + incompleteReasons.push(`${relPath || '.'}: scan timeout ${timeoutMs}ms reached`); + return; + } const fullPath = join(dirPath, entry.name); const entryRelPath = relPath ? `${relPath}/${entry.name}` : entry.name; @@ -748,6 +760,9 @@ export async function scanRepository( } } catch (err) { errors.push(`${entryRelPath}: ${err instanceof Error ? err.message : String(err)}`); + if (err instanceof Error && err.message.startsWith('Source file exceeds')) { + incompleteReasons.push(`${entryRelPath}: source file excluded by size limit`); + } } } } @@ -880,6 +895,8 @@ export async function scanRepository( updated, removed, testsMapped, + incomplete: incompleteReasons.length > 0, + incompleteReasons: Array.from(new Set(incompleteReasons)).slice(0, 50), errors, durationMs: Date.now() - startTime, languageBreakdown, diff --git a/src/registry/entityWarningsBudget.test.ts b/src/registry/entityWarningsBudget.test.ts new file mode 100644 index 00000000..afb41d8e --- /dev/null +++ b/src/registry/entityWarningsBudget.test.ts @@ -0,0 +1,42 @@ +// AGT-3490 — entityWarnings needs a request-wide result budget, not per-page caps. + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +let warned: Array<{ offset: number; limit: number }> = []; + +vi.mock('./sqliteStore.js', () => ({ + getRegistryStore: () => ({ + getUnresolvedWarnings: (_severity: unknown, _projectId: unknown, limit: number, offset: number) => { + warned.push({ limit, offset }); + return []; + }, + }), +})); + +let registryResolvers: (typeof import('./graphql/resolvers.js'))['registryResolvers']; + +beforeEach(async () => { + warned = []; + ({ registryResolvers } = await import('./graphql/resolvers.js')); +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe('entityWarnings request budget', () => { + it('accepts a page that fits within the request-wide budget', async () => { + await registryResolvers.Query.entityWarnings(undefined, { limit: 50, offset: 100 }); + expect(warned).toEqual([{ limit: 50, offset: 100 }]); + }); + + it('rejects an offset+limit window beyond the request-wide budget', async () => { + try { + await registryResolvers.Query.entityWarnings(undefined, { limit: 200, offset: 150 }); + expect.unreachable('the window should have been rejected'); + } catch (err) { + expect((err as Error).message).toContain('exceeds the request budget of 200'); + } + expect(warned).toEqual([]); + }); +}); diff --git a/src/registry/graphql/resolvers.ts b/src/registry/graphql/resolvers.ts index 053ed2ab..592e5680 100644 --- a/src/registry/graphql/resolvers.ts +++ b/src/registry/graphql/resolvers.ts @@ -17,6 +17,7 @@ const MAX_SEARCH_LIMIT = 100; const DEFAULT_EVENT_LIMIT = 20; const MAX_EVENT_LIMIT = 200; const MAX_BULK_REGISTER_ENTITIES = 100; +const MAX_ENTITY_WARNINGS_RESULTS = 200; function clampLimit(limit: number | undefined, defaultLimit: number, maxLimit: number): number { if (limit === undefined || !Number.isInteger(limit)) return defaultLimit; @@ -129,11 +130,18 @@ export const registryResolvers = { entityWarnings: (_: unknown, { severity, projectId, limit, offset }: { severity?: WarningSeverity; projectId?: string; limit?: number; offset?: number; }) => { + const offsetValue = clampOffset(offset); + const pageSize = clampLimit(limit, DEFAULT_ENTITY_LIMIT, MAX_ENTITY_LIMIT); + if (offsetValue + pageSize > MAX_ENTITY_WARNINGS_RESULTS) { + throw new Error( + `entityWarnings result window exceeds the request budget of ${MAX_ENTITY_WARNINGS_RESULTS} (offset ${offsetValue} + limit ${pageSize})`, + ); + } return getRegistryStore().getUnresolvedWarnings( severity, projectId, - clampLimit(limit, DEFAULT_ENTITY_LIMIT, MAX_ENTITY_LIMIT), - clampOffset(offset), + pageSize, + offsetValue, ); }, diff --git a/src/registry/incompleteCoverage.test.ts b/src/registry/incompleteCoverage.test.ts new file mode 100644 index 00000000..06932fef --- /dev/null +++ b/src/registry/incompleteCoverage.test.ts @@ -0,0 +1,95 @@ +// AGT-3490 — scan limits must produce an explicit incompleteness signal, +// not silently absent coverage. + +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +let tmp: string; + +beforeEach(async () => { + tmp = await mkdtemp(join(tmpdir(), 'openswarm-coverage-')); +}); + +afterEach(async () => { + await rm(tmp, { recursive: true, force: true }); + vi.restoreAllMocks(); +}); + +describe('incomplete scan coverage', () => { + it('reports oversized source files in the entity scanner instead of dropping them silently', async () => { + const store: { warnings: string[] } = { warnings: [] }; + vi.doMock('./sqliteStore.js', () => ({ + LIST_ENTITIES_MAX_LIMIT: 2, + getRegistryStore: () => ({ + listEntities: () => ({ entities: [], total: 0 }), + registerEntity: (input: { name: string }) => { + store.warnings.push(input.name); + }, + updateEntity: () => null, + changeEntityStatus: () => null, + }), + })); + const { scanRepository } = await import('./entityScanner.js'); + + await mkdir(join(tmp, 'src'), { recursive: true }); + await writeFile(join(tmp, 'src/normal.ts'), 'export function normalFn(): void {\n return;\n}\n', 'utf-8'); + await writeFile( + join(tmp, 'src/huge.ts'), + `// ${'x'.repeat(600 * 1024)}\nexport function hugeFn(): void {\n return;\n}\n`, + 'utf-8', + ); + + const result = await scanRepository(tmp, 'test-project', { allowNonRepo: true }); + expect(result.scanned).toBe(1); + expect(result.errors.some((error) => error.includes('src/huge.ts'))).toBe(true); + expect(result.incomplete).toBe(true); + expect(result.incompleteReasons.some((reason) => reason.includes('src/huge.ts'))).toBe(true); + expect(result.errors.length).toBe(1); + }); + + it('reports depth and timeout truncation in the entity scanner', async () => { + vi.doMock('./sqliteStore.js', () => ({ + LIST_ENTITIES_MAX_LIMIT: 2, + getRegistryStore: () => ({ + listEntities: () => ({ entities: [], total: 0 }), + registerEntity: () => null, + updateEntity: () => null, + changeEntityStatus: () => null, + }), + })); + const { scanRepository } = await import('./entityScanner.js'); + + await mkdir(join(tmp, 'a/b/c/d/e/f/g'), { recursive: true }); + await writeFile( + join(tmp, 'a/b/c/d/e/f/g/deep.ts'), + 'export function deepFn(): void {\n return;\n}\n', + 'utf-8', + ); + + const result = await scanRepository(tmp, 'test-project', { + allowNonRepo: true, + maxDepth: 2, + timeoutMs: 180_000, + }); + expect(result.incomplete).toBe(true); + expect(result.incompleteReasons.some((reason) => reason.includes('depth limit 2'))).toBe(true); + }); + + it('reports oversized source files in the BS detector instead of dropping them silently', async () => { + const { scanRepository } = await import('./bsDetector.js'); + + await mkdir(join(tmp, 'src'), { recursive: true }); + await writeFile( + join(tmp, 'src/huge.ts'), + `// ${'x'.repeat(600 * 1024)}\nfunction huge(): void {}\n`, + 'utf-8', + ); + + const result = await scanRepository(tmp, { verbose: false }); + expect(result.incomplete).toBe(true); + expect(result.incompleteReasons.some((reason) => reason.includes('src/huge.ts'))).toBe(true); + expect(result.issues.some((issue) => issue.message.includes('src/huge.ts'))).toBe(true); + }); +}); diff --git a/src/registry/sqliteStore.ts b/src/registry/sqliteStore.ts index 076029e2..f53eee19 100644 --- a/src/registry/sqliteStore.ts +++ b/src/registry/sqliteStore.ts @@ -674,7 +674,7 @@ export class SqliteRegistryStore { WHERE ${conditions.join(' AND ')} ORDER BY CASE w.severity WHEN 'critical' THEN 0 WHEN 'error' THEN 1 WHEN 'warning' THEN 2 ELSE 3 END, w.created_at DESC LIMIT ? OFFSET ?` - ).all(...params, clampInteger(limit, 200, 200), clampInteger(offset, 0)) as WarningRow[]).map(this.rowToWarning); + ).all(...params, clampInteger(limit, 200, 200), clampInteger(offset, 0, 200)) as WarningRow[]).map(this.rowToWarning); } // ============ 관계 ============ @@ -882,7 +882,7 @@ export class SqliteRegistryStore { if (projectId) params.push(projectId); const rows = this.db.prepare(`${query} ORDER BY e.file_path, e.line_start NULLS LAST, e.name LIMIT ? OFFSET ?`) - .all(...params, clampInteger(limit, 200, 200), clampInteger(offset, 0)) as EntityRow[]; + .all(...params, clampInteger(limit, 200, 200), clampInteger(offset, 0, 200)) as EntityRow[]; return this.rowsToEntities(rows); } From fe04b2fc1bd832a4b1c5d62e13c3a719c9d353ee Mon Sep 17 00:00:00 2001 From: unohee Date: Sun, 20 Sep 2026 14:03:08 +0900 Subject: [PATCH 2/2] fix(knowledge-registry): consume the incomplete signal and drop the dead helper (AGT-3490) --- src/automation/autonomousRunner.ts | 6 +++++ src/cli/checkHandler.ts | 14 +++++++++++ src/knowledge/gitInfo.ts | 37 ------------------------------ 3 files changed, 20 insertions(+), 37 deletions(-) diff --git a/src/automation/autonomousRunner.ts b/src/automation/autonomousRunner.ts index 913fd617..5f286ad7 100644 --- a/src/automation/autonomousRunner.ts +++ b/src/automation/autonomousRunner.ts @@ -2336,6 +2336,12 @@ export class AutonomousRunner { this.registryScanAt.set(resolvedPath, Date.now()); void scanRepository(resolvedPath, projectId, { timeoutMs: 180_000 }) .then((result) => { + if (result.incomplete) { + this.syslog( + `Registry scan ${projectId} was INCOMPLETE after ${result.durationMs}ms: ` + + result.incompleteReasons.slice(0, 5).join('; '), + ); + } this.syslog( `Registry scan ${projectId}: ${result.extracted} entities ` + `(+${result.registered}/~${result.updated}) in ${result.durationMs}ms`, diff --git a/src/cli/checkHandler.ts b/src/cli/checkHandler.ts index 768280c8..ca988e3f 100644 --- a/src/cli/checkHandler.ts +++ b/src/cli/checkHandler.ts @@ -164,6 +164,13 @@ export async function handleCheck( console.log(` Tests mapped: ${c.cyan(String(result.testsMapped))}`); console.log(` Duration: ${c.dim(`${result.durationMs}ms`)}`); + if (result.incomplete) { + console.log(`\n ${c.yellow('Incomplete coverage — the scan was truncated:')}`); + for (const reason of result.incompleteReasons) { + console.log(` ${c.yellow(reason)}`); + } + } + if (Object.keys(result.languageBreakdown).length > 0) { console.log(`\n ${c.dim('By language:')}`); for (const [lang, count] of Object.entries(result.languageBreakdown).sort((a, b) => b[1] - a[1])) { @@ -212,6 +219,13 @@ export async function handleCheck( console.log(` WARNING: ${(result.warning > 0 ? c.yellow : c.green)(String(result.warning))}`); console.log(` MINOR: ${(result.minor > 0 ? c.dim : c.green)(String(result.minor))}`); + if (result.incomplete) { + console.log(` ${c.yellow('INCOMPLETE — the scan was truncated:')}`); + for (const reason of result.incompleteReasons.slice(0, 5)) { + console.log(` ${c.dim(reason)}`); + } + } + if (result.issues.length > 0) { // CRITICAL 먼저 const criticals = result.issues.filter(i => i.severity === 'critical'); diff --git a/src/knowledge/gitInfo.ts b/src/knowledge/gitInfo.ts index 08eaa530..add8d33d 100644 --- a/src/knowledge/gitInfo.ts +++ b/src/knowledge/gitInfo.ts @@ -175,40 +175,3 @@ export async function getRecentlyChangedFiles( return []; } } - -/** - * Older form kept for callers that already have a since-date string. - */ -export async function getFilesChangedSince( - projectPath: string, - sinceDate: string, -): Promise { - const files = new Set(); - try { - const committed = await runGitCommand(projectPath, [ - 'log', - `--since=${sinceDate}`, - '--name-only', - '--format=', - ]); - for (const line of committed.split('\n')) { - const trimmed = line.trim(); - if (trimmed) files.add(trimmed); - } - - const untracked = await runGitCommand(projectPath, [ - 'ls-files', - '--others', - '--exclude-standard', - '-z', - ]); - for (const token of untracked.split('\0')) { - const trimmed = token.trim(); - if (trimmed) files.add(trimmed); - } - - return Array.from(files); - } catch { - return []; - } -}