diff --git a/gitnexus/src/cli/analyze-config.ts b/gitnexus/src/cli/analyze-config.ts index 49896538e..43c368a73 100644 --- a/gitnexus/src/cli/analyze-config.ts +++ b/gitnexus/src/cli/analyze-config.ts @@ -33,8 +33,10 @@ import path from 'node:path'; import { readRepoControlFile } from '../config/repo-control-file.js'; import { InvalidBranchError, + sanitizeDetectedBranch, validateBranchName as validateBranchNameCore, } from '../core/git-ref.js'; +export { sanitizeDetectedBranch }; import type { AnalyzeOptions } from './analyze-options.js'; export const GITNEXUS_RC_FILENAME = '.gitnexusrc'; @@ -172,20 +174,6 @@ export function validateBranchName(value: string, source: string): string { } } -/** - * Best-effort validation for an auto-detected branch (from git). Never throws — - * returns `undefined` for anything unusable so the resolver falls back to the - * next precedence tier. - */ -export function sanitizeDetectedBranch(value: string | null | undefined): string | undefined { - if (!value) return undefined; - try { - return validateBranchName(value, 'detected branch'); - } catch { - return undefined; - } -} - const normalizeValue = (kind: ValueKind, value: unknown, key: string): unknown => { const source = `${GITNEXUS_RC_FILENAME} "${key}"`; switch (kind) { diff --git a/gitnexus/src/core/git-ref.ts b/gitnexus/src/core/git-ref.ts index e7d101eed..e712dd3a2 100644 --- a/gitnexus/src/core/git-ref.ts +++ b/gitnexus/src/core/git-ref.ts @@ -1,10 +1,10 @@ /** * Git ref-name validation used by both the CLI and the HTTP analyze route. * - * Lives in `core/` so `server/api.ts` does not import `cli/analyze-config` - * (that import closed a cli → server → cli cycle: `cli/serve.ts` already - * imports `createServer`). The CLI keeps a thin wrapper that rethrows - * {@link InvalidBranchError} as `GitNexusRcError`. + * Lives in `core/` so `server/api.ts` and `run-analyze.ts` do not import + * `cli/analyze-config` (that import closed a cli → server → cli cycle: + * `cli/serve.ts` already imports `createServer`). The CLI keeps a thin + * wrapper that rethrows {@link InvalidBranchError} as `GitNexusRcError`. */ /** Git refs longer than this are almost certainly a mistake / injection attempt. */ @@ -126,3 +126,49 @@ export function validateBranchName(value: string, source: string): string { } return trimmed; } + +/** + * Best-effort validation for an auto-detected branch (from git). Returns the + * trimmed name, or `undefined` for anything unusable so callers fall back to + * the next precedence tier or leave the index unlabeled. Swallows + * {@link InvalidBranchError} only; unexpected errors are rethrown. + */ +export function sanitizeDetectedBranch(value: string | null | undefined): string | undefined { + if (!value) return undefined; + try { + return validateBranchName(value, 'detected branch'); + } catch (err) { + if (err instanceof InvalidBranchError) return undefined; + throw err; + } +} + +/** + * Render a rejected checkout name for `onLog`. Hidden / bidi / control code + * points become `\uXXXX` so a git-legal U+202E name cannot reverse the + * warning in a terminal. C1 controls (U+0080–U+009F, including NEL U+0085) + * and remaining Unicode whitespace (`/\s/` — NBSP, U+2028/U+2029, ideographic + * space, etc.) are not all in {@link isHiddenOrControl}; escape them the same + * way so the ASCII escape survives `stripControlCharacters` and the warning + * stays one line. ASCII `"` is escaped; other characters (including backticks) + * stay visible. + */ +export function formatRejectedBranchForLog(value: string): string { + const shouldEscapeRejectedBranchChar = (cp: number, ch: string): boolean => + isHiddenOrControl(cp) || (cp >= 0x80 && cp <= 0x9f) || /\s/.test(ch); + + let out = ''; + for (const ch of value) { + const cp = ch.codePointAt(0); + if (cp !== undefined && shouldEscapeRejectedBranchChar(cp, ch)) { + out += `\\u${cp.toString(16).padStart(4, '0')}`; + continue; + } + if (ch === '"') { + out += '\\"'; + continue; + } + out += ch; + } + return out; +} diff --git a/gitnexus/src/core/run-analyze.ts b/gitnexus/src/core/run-analyze.ts index 8ea0b0f66..d8d16484a 100644 --- a/gitnexus/src/core/run-analyze.ts +++ b/gitnexus/src/core/run-analyze.ts @@ -234,7 +234,7 @@ import { type CachedEmbeddingsSnapshot, } from './embeddings/embedding-restore-spill.js'; import { generateAIContextFiles } from '../cli/ai-context.js'; -import { sanitizeDetectedBranch } from '../cli/analyze-config.js'; +import { formatRejectedBranchForLog, sanitizeDetectedBranch } from './git-ref.js'; import { EMBEDDING_TABLE_NAME, EMBEDDING_DIMS, @@ -1052,12 +1052,16 @@ export const pdgModeMismatch = (recorded: RepoMeta['pdg'], options: PdgOptions): * directory (#2658). `metaDir` — not `getStoragePaths(repoPath, options.branch)` * — is the lock scope: a `--branch X` that owns the flat slot resolves to the * flat `.gitnexus`, so scoping off the raw option would lock the wrong dir. + * `rejectedDetectedBranch` is log-only (the detect-reject warning after lock + * settle); it does not change placement. */ interface WriteTarget { storagePath: string; repoHasGit: boolean; currentCommit: string; checkedOutBranch: string | null; + /** Raw checkout name when git returned one the branch-name rules reject. */ + rejectedDetectedBranch: string | null; branchLabel: string | null; placement: { branch?: string }; lbugPath: string; @@ -1089,9 +1093,12 @@ async function resolveWriteTarget(repoPath: string, options: AnalyzeOptions): Pr // validated (#2106 R1): a git ref the branch-name rules forbid becomes `null` // → the flat slot, matching that a later `--branch ` query would // also be rejected. A normal ref round-trips index-time/query-time labels. - const checkedOutBranch = repoHasGit - ? (sanitizeDetectedBranch(getCurrentBranch(repoPath)) ?? null) - : null; + // Keep the raw rejected name so `runFullAnalysis` can warn once after the + // lock settles. Detached / non-git / empty detect stay `null` here and silent. + const rawDetectedBranch = repoHasGit ? getCurrentBranch(repoPath) : null; + const checkedOutBranch = sanitizeDetectedBranch(rawDetectedBranch) ?? null; + const rejectedDetectedBranch = + rawDetectedBranch != null && checkedOutBranch === null ? rawDetectedBranch : null; // Analyze indexes the working tree, not an arbitrary ref. An explicit // `--branch X` while a DIFFERENT branch Y is checked out would write Y's // content into X's slot, corrupting X (#2106). Refuse the mismatch. Detached @@ -1112,6 +1119,7 @@ async function resolveWriteTarget(repoPath: string, options: AnalyzeOptions): Pr repoHasGit, currentCommit, checkedOutBranch, + rejectedDetectedBranch, branchLabel, placement, lbugPath, @@ -1222,6 +1230,11 @@ export async function runFullAnalysis( ); } } + if (writeTarget.rejectedDetectedBranch) { + log( + `Warning: checkout "${formatRejectedBranchForLog(writeTarget.rejectedDetectedBranch)}" is not a usable index label; continuing.`, + ); + } return await runFullAnalysisInner( repoPath, options, diff --git a/gitnexus/test/helpers/detect-reject-warning.ts b/gitnexus/test/helpers/detect-reject-warning.ts new file mode 100644 index 000000000..abbea7852 --- /dev/null +++ b/gitnexus/test/helpers/detect-reject-warning.ts @@ -0,0 +1,6 @@ +/** + * Matches the detect-reject warning emitted by `run-analyze` after lock settle. + * Shared so unit and integration tests keep one regex. + */ +export const isDetectRejectWarning = (message: string): boolean => + /^Warning:.*not a usable index label.*continuing\.$/.test(message); diff --git a/gitnexus/test/integration/multi-branch-analyze.test.ts b/gitnexus/test/integration/multi-branch-analyze.test.ts index bf2fabc9b..c61428059 100644 --- a/gitnexus/test/integration/multi-branch-analyze.test.ts +++ b/gitnexus/test/integration/multi-branch-analyze.test.ts @@ -5,6 +5,7 @@ import path from 'path'; import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import { getStoragePaths, loadMeta, listRegisteredRepos } from '../../src/storage/repo-manager.js'; import { createTempDir } from '../helpers/test-db.js'; +import { isDetectRejectWarning } from '../helpers/detect-reject-warning.js'; /** * #2106/#2354 — branch handling end-to-end. Proves that a plain analyze @@ -220,7 +221,12 @@ describe('multi-branch analyze (#2106)', () => { execFileSync('git', ['branch', '-M', 'feat`x'], { cwd: repo, stdio: 'pipe' }); const { runFullAnalysis } = await import('../../src/core/run-analyze.js'); - await runFullAnalysis(repo, {}, { onProgress: () => {} }); + const logs: string[] = []; + await runFullAnalysis( + repo, + {}, + { onProgress: () => {}, onLog: (message) => logs.push(message) }, + ); // The forbidden ref was normalized to null → flat slot, no branch field, // and no branches/ sub-directory created for an unqueryable slug. @@ -228,6 +234,9 @@ describe('multi-branch analyze (#2106)', () => { expect(existsSync(flat.lbugPath)).toBe(true); expect((await loadMeta(flat.storagePath))?.branch).toBeUndefined(); expect(existsSync(path.join(flat.storagePath, 'branches'))).toBe(false); + const warnings = logs.filter(isDetectRejectWarning); + expect(warnings).toHaveLength(1); + expect(warnings[0]).toContain('feat`x'); } finally { await tmp.cleanup(); } diff --git a/gitnexus/test/unit/git-ref.test.ts b/gitnexus/test/unit/git-ref.test.ts index af2492926..7ecf54679 100644 --- a/gitnexus/test/unit/git-ref.test.ts +++ b/gitnexus/test/unit/git-ref.test.ts @@ -1,5 +1,10 @@ import { describe, expect, it } from 'vitest'; -import { InvalidBranchError, validateBranchName } from '../../src/core/git-ref.js'; +import { + InvalidBranchError, + formatRejectedBranchForLog, + sanitizeDetectedBranch, + validateBranchName, +} from '../../src/core/git-ref.js'; describe('core/git-ref', () => { it('throws InvalidBranchError with name "InvalidBranchError"', () => { @@ -12,4 +17,46 @@ describe('core/git-ref', () => { expect((err as Error).name).toBe('InvalidBranchError'); } }); + + it('sanitizeDetectedBranch returns the trimmed name for a legal branch', () => { + expect(sanitizeDetectedBranch('develop')).toBe('develop'); + expect(sanitizeDetectedBranch(' feature/foo-bar ')).toBe('feature/foo-bar'); + }); + + it('sanitizeDetectedBranch returns undefined for null, empty, or whitespace', () => { + expect(sanitizeDetectedBranch(null)).toBeUndefined(); + expect(sanitizeDetectedBranch(undefined)).toBeUndefined(); + expect(sanitizeDetectedBranch('')).toBeUndefined(); + expect(sanitizeDetectedBranch(' ')).toBeUndefined(); + }); + + it('sanitizeDetectedBranch swallows InvalidBranchError and does not throw', () => { + expect(sanitizeDetectedBranch('feat`x')).toBeUndefined(); + expect(sanitizeDetectedBranch('main`evil')).toBeUndefined(); + expect(sanitizeDetectedBranch('HEAD')).toBeUndefined(); + expect(() => sanitizeDetectedBranch('feat`x')).not.toThrow(); + }); + + it('formatRejectedBranchForLog keeps backticks visible and escapes bidi, quotes, and line separators', () => { + expect(formatRejectedBranchForLog('feat`x')).toBe('feat`x'); + expect(formatRejectedBranchForLog('a"b')).toBe('a\\"b'); + expect(formatRejectedBranchForLog(`ok${'\u202e'}bad`)).toBe('ok\\u202ebad'); + expect(formatRejectedBranchForLog(`zw${'\u200b'}sp`)).toBe('zw\\u200bsp'); + expect(formatRejectedBranchForLog(`foo${'\u2028'}bar`)).toBe('foo\\u2028bar'); + expect(formatRejectedBranchForLog(`foo${'\u2029'}bar`)).toBe('foo\\u2029bar'); + }); + + it('formatRejectedBranchForLog escapes NBSP, NEL, and other C1 as \\uXXXX', () => { + expect(formatRejectedBranchForLog(`foo${'\u00a0'}bar`)).toBe('foo\\u00a0bar'); + const nelBacktick = formatRejectedBranchForLog(`feat${'\u0085'}\``); + expect(nelBacktick).toContain('\\u0085'); + expect(nelBacktick).toContain('`'); + expect(nelBacktick).not.toContain('\u0085'); + expect(formatRejectedBranchForLog(`x${'\u009f'}y`)).toBe('x\\u009fy'); + }); + + it('sanitizeDetectedBranch rejects git-legal U+2028/U+2029 as whitespace', () => { + expect(sanitizeDetectedBranch(`foo${'\u2028'}bar`)).toBeUndefined(); + expect(sanitizeDetectedBranch(`foo${'\u2029'}bar`)).toBeUndefined(); + }); }); diff --git a/gitnexus/test/unit/run-analyze.test.ts b/gitnexus/test/unit/run-analyze.test.ts index 3dbf0e3f5..fe1dc8660 100644 --- a/gitnexus/test/unit/run-analyze.test.ts +++ b/gitnexus/test/unit/run-analyze.test.ts @@ -1,4 +1,4 @@ -import { execSync } from 'child_process'; +import { execFileSync, execSync } from 'child_process'; import fs from 'fs/promises'; import path from 'path'; import { pathToFileURL } from 'url'; @@ -21,6 +21,7 @@ import { import { SCHEMA_FINGERPRINT } from '../../src/core/lbug/schema.js'; import { taintModelVersion } from '../../src/core/ingestion/taint/typescript-model.js'; import { createTempDir } from '../helpers/test-db.js'; +import { isDetectRejectWarning } from '../helpers/detect-reject-warning.js'; import { readEmbeddingNodeIds } from '../helpers/embedding-seed.js'; import { getIndexIncompleteReasons } from '../../src/core/index-freshness.js'; import { CLASS_FRAMEWORK_ANNOTATIONS_FEATURE } from '../../src/core/analysis-features.js'; @@ -40,6 +41,14 @@ describe('run-analyze module', () => { expect(typeof mod.runFullAnalysis).toBe('function'); }); + it('does not import cli/analyze-config', async () => { + const source = await fs.readFile( + path.resolve(__dirname, '../../src/core/run-analyze.ts'), + 'utf8', + ); + expect(source).not.toMatch(/cli\/analyze-config/); + }); + it('exports PHASE_LABELS', async () => { const mod = await import('../../src/core/run-analyze.js'); expect(mod.PHASE_LABELS).toBeDefined(); @@ -1015,18 +1024,26 @@ describe('run-analyze module', () => { // Detached HEAD → branchLabel is null → the restamp block must not // fire: the existing stamp survives, mirroring the end-of-run write. + // Null detect (detached / getCurrentBranch null / non-git) must stay + // silent — it is not a rejected checkout name. const { runFullAnalysis } = await import('../../src/core/run-analyze.js'); - const result = await runFullAnalysis(tmpRepo.dbPath, {}, { onProgress: () => {} }); + const logs: string[] = []; + const result = await runFullAnalysis( + tmpRepo.dbPath, + {}, + { onProgress: () => {}, onLog: (message) => logs.push(message) }, + ); expect(result.alreadyUpToDate).toBe(true); const flatMeta = await loadMeta(flat.storagePath); expect(flatMeta?.branch).toBe('main'); + expect(logs.filter(isDetectRejectWarning)).toEqual([]); } finally { if (savedHome === undefined) delete process.env.GITNEXUS_HOME; else process.env.GITNEXUS_HOME = savedHome; await tmpHome.cleanup(); await tmpRepo.cleanup(); } - }); + }, 180_000); it('reports isPrimaryBranch false for an up-to-date explicit --branch run (#2106 R2)', async () => { const tmpRepo = await createTempDir('gitnexus-run-analyze-nonprimary-'); @@ -1102,6 +1119,230 @@ describe('run-analyze module', () => { await tmpRepo.cleanup(); } }); + + it('warns once when the checkout name is not a usable index label', async () => { + const tmpRepo = await createTempDir('gitnexus-run-analyze-detect-reject-'); + try { + execSync('git init', { cwd: tmpRepo.dbPath, stdio: 'pipe' }); + execSync('git -c user.name=test -c user.email=test@test commit --allow-empty -m init', { + cwd: tmpRepo.dbPath, + stdio: 'pipe', + }); + execFileSync('git', ['branch', '-M', 'feat`x'], { cwd: tmpRepo.dbPath, stdio: 'pipe' }); + const currentCommit = execSync('git rev-parse HEAD', { + cwd: tmpRepo.dbPath, + encoding: 'utf-8', + }).trim(); + const { storagePath } = getStoragePaths(tmpRepo.dbPath); + await saveMeta(storagePath, { + repoPath: tmpRepo.dbPath, + lastCommit: currentCommit, + indexedAt: new Date().toISOString(), + schemaFingerprint: SCHEMA_FINGERPRINT, + analysisFeatures: CURRENT_ANALYSIS_FEATURES, + runnerIdentity: currentRunnerIdentity(), + }); + + const { runFullAnalysis } = await import('../../src/core/run-analyze.js'); + const logs: string[] = []; + const result = await runFullAnalysis( + tmpRepo.dbPath, + {}, + { onProgress: () => {}, onLog: (message) => logs.push(message) }, + ); + + expect(result.alreadyUpToDate).toBe(true); + expect((await loadMeta(storagePath))?.branch).toBeUndefined(); + await expect(fs.access(path.join(storagePath, 'branches'))).rejects.toThrow(); + const warnings = logs.filter(isDetectRejectWarning); + expect(warnings).toHaveLength(1); + expect(warnings[0]).toContain('feat`x'); + expect(warnings[0]).toMatch(/^Warning:.*continuing\.$/); + expect(warnings[0]).not.toMatch(/stamp|cleared|unlabeled/i); + } finally { + await tmpRepo.cleanup(); + } + }, 180_000); + + it('applies an explicit --branch on a rejected checkout and still warns', async () => { + const tmpRepo = await createTempDir('gitnexus-run-analyze-detect-reject-pin-'); + try { + execSync('git init', { cwd: tmpRepo.dbPath, stdio: 'pipe' }); + execSync('git -c user.name=test -c user.email=test@test commit --allow-empty -m init', { + cwd: tmpRepo.dbPath, + stdio: 'pipe', + }); + execFileSync('git', ['branch', '-M', 'feat`x'], { cwd: tmpRepo.dbPath, stdio: 'pipe' }); + const currentCommit = execSync('git rev-parse HEAD', { + cwd: tmpRepo.dbPath, + encoding: 'utf-8', + }).trim(); + const { storagePath } = getStoragePaths(tmpRepo.dbPath); + await saveMeta(storagePath, { + repoPath: tmpRepo.dbPath, + lastCommit: currentCommit, + indexedAt: new Date().toISOString(), + schemaFingerprint: SCHEMA_FINGERPRINT, + analysisFeatures: CURRENT_ANALYSIS_FEATURES, + runnerIdentity: currentRunnerIdentity(), + }); + + const { runFullAnalysis } = await import('../../src/core/run-analyze.js'); + const logs: string[] = []; + const result = await runFullAnalysis( + tmpRepo.dbPath, + { branch: 'main' }, + { onProgress: () => {}, onLog: (message) => logs.push(message) }, + ); + + expect(result.alreadyUpToDate).toBe(true); + expect((await loadMeta(storagePath))?.branch).toBe('main'); + const warnings = logs.filter(isDetectRejectWarning); + expect(warnings).toHaveLength(1); + expect(warnings[0]).toContain('feat`x'); + } finally { + await tmpRepo.cleanup(); + } + }, 180_000); + + it('keeps the detect-reject warning on one line for U+2028 checkout names', async () => { + const tmpRepo = await createTempDir('gitnexus-run-analyze-detect-reject-ls-'); + try { + execSync('git init', { cwd: tmpRepo.dbPath, stdio: 'pipe' }); + execSync('git -c user.name=test -c user.email=test@test commit --allow-empty -m init', { + cwd: tmpRepo.dbPath, + stdio: 'pipe', + }); + execFileSync('git', ['branch', '-M', 'foo\u2028bar'], { + cwd: tmpRepo.dbPath, + stdio: 'pipe', + }); + const currentCommit = execSync('git rev-parse HEAD', { + cwd: tmpRepo.dbPath, + encoding: 'utf-8', + }).trim(); + const { storagePath } = getStoragePaths(tmpRepo.dbPath); + await saveMeta(storagePath, { + repoPath: tmpRepo.dbPath, + lastCommit: currentCommit, + indexedAt: new Date().toISOString(), + schemaFingerprint: SCHEMA_FINGERPRINT, + analysisFeatures: CURRENT_ANALYSIS_FEATURES, + runnerIdentity: currentRunnerIdentity(), + }); + + const { runFullAnalysis } = await import('../../src/core/run-analyze.js'); + const logs: string[] = []; + const result = await runFullAnalysis( + tmpRepo.dbPath, + {}, + { onProgress: () => {}, onLog: (message) => logs.push(message) }, + ); + + expect(result.alreadyUpToDate).toBe(true); + const warnings = logs.filter(isDetectRejectWarning); + expect(warnings).toHaveLength(1); + expect(warnings[0]).toContain('\\u2028'); + expect(warnings[0]).not.toContain('\u2028'); + expect(warnings[0].split(/\n|\r|\u2028|\u2029/)).toHaveLength(1); + expect(warnings[0]).toMatch(/^Warning:.*continuing\.$/); + } finally { + await tmpRepo.cleanup(); + } + }, 180_000); + + it('keeps the detect-reject warning on one line for NEL+backtick checkout names', async () => { + const tmpRepo = await createTempDir('gitnexus-run-analyze-detect-reject-nel-'); + try { + execSync('git init', { cwd: tmpRepo.dbPath, stdio: 'pipe' }); + execSync('git -c user.name=test -c user.email=test@test commit --allow-empty -m init', { + cwd: tmpRepo.dbPath, + stdio: 'pipe', + }); + execFileSync('git', ['branch', '-M', 'feat\u0085`'], { + cwd: tmpRepo.dbPath, + stdio: 'pipe', + }); + const currentCommit = execSync('git rev-parse HEAD', { + cwd: tmpRepo.dbPath, + encoding: 'utf-8', + }).trim(); + const { storagePath } = getStoragePaths(tmpRepo.dbPath); + await saveMeta(storagePath, { + repoPath: tmpRepo.dbPath, + lastCommit: currentCommit, + indexedAt: new Date().toISOString(), + schemaFingerprint: SCHEMA_FINGERPRINT, + analysisFeatures: CURRENT_ANALYSIS_FEATURES, + runnerIdentity: currentRunnerIdentity(), + }); + + const { runFullAnalysis } = await import('../../src/core/run-analyze.js'); + const logs: string[] = []; + const result = await runFullAnalysis( + tmpRepo.dbPath, + {}, + { onProgress: () => {}, onLog: (message) => logs.push(message) }, + ); + + expect(result.alreadyUpToDate).toBe(true); + const warnings = logs.filter(isDetectRejectWarning); + expect(warnings).toHaveLength(1); + expect(warnings[0]).toContain('\\u0085'); + expect(warnings[0]).not.toContain('\u0085'); + expect(warnings[0]).toContain('`'); + expect(warnings[0].split(/\n|\r|\u2028|\u2029/)).toHaveLength(1); + expect(warnings[0]).toMatch(/^Warning:.*continuing\.$/); + } finally { + await tmpRepo.cleanup(); + } + }, 180_000); + + it('keeps the detect-reject warning on one line for NBSP checkout names', async () => { + const tmpRepo = await createTempDir('gitnexus-run-analyze-detect-reject-nbsp-'); + try { + execSync('git init', { cwd: tmpRepo.dbPath, stdio: 'pipe' }); + execSync('git -c user.name=test -c user.email=test@test commit --allow-empty -m init', { + cwd: tmpRepo.dbPath, + stdio: 'pipe', + }); + execFileSync('git', ['branch', '-M', 'foo\u00a0bar'], { + cwd: tmpRepo.dbPath, + stdio: 'pipe', + }); + const currentCommit = execSync('git rev-parse HEAD', { + cwd: tmpRepo.dbPath, + encoding: 'utf-8', + }).trim(); + const { storagePath } = getStoragePaths(tmpRepo.dbPath); + await saveMeta(storagePath, { + repoPath: tmpRepo.dbPath, + lastCommit: currentCommit, + indexedAt: new Date().toISOString(), + schemaFingerprint: SCHEMA_FINGERPRINT, + analysisFeatures: CURRENT_ANALYSIS_FEATURES, + runnerIdentity: currentRunnerIdentity(), + }); + + const { runFullAnalysis } = await import('../../src/core/run-analyze.js'); + const logs: string[] = []; + const result = await runFullAnalysis( + tmpRepo.dbPath, + {}, + { onProgress: () => {}, onLog: (message) => logs.push(message) }, + ); + + expect(result.alreadyUpToDate).toBe(true); + const warnings = logs.filter(isDetectRejectWarning); + expect(warnings).toHaveLength(1); + expect(warnings[0]).toContain('\\u00a0'); + expect(warnings[0]).not.toContain('\u00a0'); + expect(warnings[0].split(/\n|\r|\u2028|\u2029/)).toHaveLength(1); + expect(warnings[0]).toMatch(/^Warning:.*continuing\.$/); + } finally { + await tmpRepo.cleanup(); + } + }, 180_000); }); describe('collectBranchCacheKeys (#2106 R6)', () => {