sweet-search 0.0.1 → 2.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +190 -0
- package/NOTICE +23 -0
- package/core/cli.js +51 -0
- package/core/config.js +27 -0
- package/core/embedding/embedding-cache.js +467 -0
- package/core/embedding/embedding-local-model.js +845 -0
- package/core/embedding/embedding-remote.js +492 -0
- package/core/embedding/embedding-service.js +712 -0
- package/core/embedding/embedding-telemetry.js +219 -0
- package/core/embedding/index.js +40 -0
- package/core/graph/community-detector.js +294 -0
- package/core/graph/graph-expansion.js +839 -0
- package/core/graph/graph-extractor.js +2304 -0
- package/core/graph/graph-search.js +2148 -0
- package/core/graph/hcgs-generator.js +666 -0
- package/core/graph/index.js +16 -0
- package/core/graph/leiden-algorithm.js +547 -0
- package/core/graph/relationship-resolver.js +366 -0
- package/core/graph/repo-map.js +408 -0
- package/core/graph/summary-manager.js +549 -0
- package/core/indexing/artifact-builder.js +1054 -0
- package/core/indexing/ast-chunker.js +709 -0
- package/core/indexing/chunking/chunk-builder.js +170 -0
- package/core/indexing/chunking/markdown-chunker.js +503 -0
- package/core/indexing/chunking/plaintext-chunker.js +104 -0
- package/core/indexing/dedup/dedup-phase.js +159 -0
- package/core/indexing/dedup/exemplar-selector.js +65 -0
- package/core/indexing/document-chunker.js +56 -0
- package/core/indexing/incremental-parser.js +390 -0
- package/core/indexing/incremental-tracker.js +761 -0
- package/core/indexing/index-codebase-v21.js +472 -0
- package/core/indexing/index-maintainer.mjs +1674 -0
- package/core/indexing/index.js +90 -0
- package/core/indexing/indexer-ann.js +1077 -0
- package/core/indexing/indexer-build.js +742 -0
- package/core/indexing/indexer-phases.js +800 -0
- package/core/indexing/indexer-pool.js +764 -0
- package/core/indexing/indexer-sparse-gram.js +98 -0
- package/core/indexing/indexer-utils.js +536 -0
- package/core/indexing/indexer-worker.js +148 -0
- package/core/indexing/li-skip-policy.js +225 -0
- package/core/indexing/merkle-tracker.js +244 -0
- package/core/indexing/model-pool.js +166 -0
- package/core/infrastructure/code-graph-repository.js +120 -0
- package/core/infrastructure/codebase-repository.js +131 -0
- package/core/infrastructure/config/dedup.js +54 -0
- package/core/infrastructure/config/embedding.js +298 -0
- package/core/infrastructure/config/graph.js +80 -0
- package/core/infrastructure/config/index.js +82 -0
- package/core/infrastructure/config/indexing.js +8 -0
- package/core/infrastructure/config/platform.js +254 -0
- package/core/infrastructure/config/ranking.js +221 -0
- package/core/infrastructure/config/search.js +396 -0
- package/core/infrastructure/config/translation.js +89 -0
- package/core/infrastructure/config/vector-store.js +114 -0
- package/core/infrastructure/constants.js +86 -0
- package/core/infrastructure/coreml-cascade.js +909 -0
- package/core/infrastructure/coreml-cascade.json +46 -0
- package/core/infrastructure/coreml-provider.js +81 -0
- package/core/infrastructure/db-utils.js +69 -0
- package/core/infrastructure/dedup-hashing.js +83 -0
- package/core/infrastructure/hardware-capability.js +332 -0
- package/core/infrastructure/index.js +104 -0
- package/core/infrastructure/language-patterns/maps.js +121 -0
- package/core/infrastructure/language-patterns/registry-core.js +323 -0
- package/core/infrastructure/language-patterns/registry-data-query.js +155 -0
- package/core/infrastructure/language-patterns/registry-object-oriented.js +285 -0
- package/core/infrastructure/language-patterns/registry-tooling.js +240 -0
- package/core/infrastructure/language-patterns/registry-web-style.js +143 -0
- package/core/infrastructure/language-patterns/registry.js +19 -0
- package/core/infrastructure/language-patterns.js +141 -0
- package/core/infrastructure/llm-provider.js +733 -0
- package/core/infrastructure/manifest.json +46 -0
- package/core/infrastructure/maxsim.wasm +0 -0
- package/core/infrastructure/model-fetcher.js +423 -0
- package/core/infrastructure/model-registry.js +214 -0
- package/core/infrastructure/native-inference.js +587 -0
- package/core/infrastructure/native-resolver.js +187 -0
- package/core/infrastructure/native-sparse-gram.js +257 -0
- package/core/infrastructure/native-tokenizer.js +160 -0
- package/core/infrastructure/onnx-mutex.js +45 -0
- package/core/infrastructure/onnx-session-utils.js +261 -0
- package/core/infrastructure/ort-pipeline.js +111 -0
- package/core/infrastructure/project-detector.js +102 -0
- package/core/infrastructure/quantization.js +410 -0
- package/core/infrastructure/simd-distance.js +502 -0
- package/core/infrastructure/simd-distance.wasm +0 -0
- package/core/infrastructure/tree-sitter-provider.js +665 -0
- package/core/infrastructure/webgpu-maxsim.js +222 -0
- package/core/query/index.js +35 -0
- package/core/query/intent-detector.js +201 -0
- package/core/query/intent-router.js +156 -0
- package/core/query/query-router-catboost.js +222 -0
- package/core/query/query-router-ml.js +266 -0
- package/core/query/query-router.js +213 -0
- package/core/ranking/cascaded-scorer.js +379 -0
- package/core/ranking/flashrank.js +810 -0
- package/core/ranking/index.js +49 -0
- package/core/ranking/late-interaction-index.js +2383 -0
- package/core/ranking/late-interaction-model.js +812 -0
- package/core/ranking/local-reranker.js +374 -0
- package/core/ranking/mmr.js +379 -0
- package/core/ranking/quality-scorer.js +363 -0
- package/core/search/context-expander.js +1167 -0
- package/core/search/dedup/sibling-expander.js +327 -0
- package/core/search/index.js +16 -0
- package/core/search/search-boost.js +259 -0
- package/core/search/search-cli.js +544 -0
- package/core/search/search-format.js +282 -0
- package/core/search/search-fusion.js +327 -0
- package/core/search/search-hybrid.js +204 -0
- package/core/search/search-pattern-chunks.js +337 -0
- package/core/search/search-pattern-planner.js +439 -0
- package/core/search/search-pattern-prefilter.js +412 -0
- package/core/search/search-pattern-ripgrep.js +663 -0
- package/core/search/search-pattern.js +463 -0
- package/core/search/search-postprocess.js +452 -0
- package/core/search/search-semantic.js +706 -0
- package/core/search/search-server.js +554 -0
- package/core/search/session-daemon-prewarm.mjs +164 -0
- package/core/search/session-warmup.js +595 -0
- package/core/search/sweet-search.js +632 -0
- package/core/search/warmup-metrics.js +532 -0
- package/core/start-server.js +6 -0
- package/core/training/query-router/features/extractor.js +762 -0
- package/core/training/query-router/features/multilingual-patterns.js +431 -0
- package/core/training/query-router/features/text-segmenter.js +303 -0
- package/core/training/query-router/features/unicode-utils.js +383 -0
- package/core/training/query-router/output/v45_router_d4.js +11521 -0
- package/core/training/query-router/output/v46_router_d4.js +11498 -0
- package/core/vector-store/binary-heap.js +227 -0
- package/core/vector-store/binary-hnsw-index.js +1004 -0
- package/core/vector-store/float-vector-store.js +234 -0
- package/core/vector-store/hnsw-index.js +580 -0
- package/core/vector-store/index.js +39 -0
- package/core/vector-store/seismic-index.js +498 -0
- package/core/vocabulary/index.js +84 -0
- package/core/vocabulary/vocab-constants.js +20 -0
- package/core/vocabulary/vocab-miner-extractors.js +375 -0
- package/core/vocabulary/vocab-miner-nl.js +404 -0
- package/core/vocabulary/vocab-miner-utils.js +146 -0
- package/core/vocabulary/vocab-miner.js +574 -0
- package/core/vocabulary/vocab-prewarm-cli.js +110 -0
- package/core/vocabulary/vocab-ranker.js +492 -0
- package/core/vocabulary/vocab-warmer.js +523 -0
- package/core/vocabulary/vocab-warmup-orchestrator.js +425 -0
- package/core/vocabulary/vocabulary-utils.js +704 -0
- package/crates/wasm-router/pkg/package.json +13 -0
- package/crates/wasm-router/pkg/query_router_wasm.d.ts +36 -0
- package/crates/wasm-router/pkg/query_router_wasm.js +271 -0
- package/crates/wasm-router/pkg/query_router_wasm_bg.wasm +0 -0
- package/crates/wasm-router/pkg/query_router_wasm_bg.wasm.d.ts +19 -0
- package/mcp/config-gen.js +121 -0
- package/mcp/server.js +335 -0
- package/mcp/tool-handlers.js +476 -0
- package/package.json +131 -9
- package/scripts/benchmark-harness.js +794 -0
- package/scripts/init.js +1058 -0
- package/scripts/smoke-test.js +435 -0
- package/scripts/uninstall.js +478 -0
- package/scripts/verify-runtime.js +176 -0
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import fs from 'fs/promises';
|
|
2
|
+
import { existsSync } from 'fs';
|
|
3
|
+
import path from 'path';
|
|
4
|
+
|
|
5
|
+
import { DB_PATHS, PROJECT_ROOT } from '../infrastructure/config/index.js';
|
|
6
|
+
import { CODE_FILE_EXTENSIONS } from '../infrastructure/constants.js';
|
|
7
|
+
import {
|
|
8
|
+
buildSparseGramIndexArtifact,
|
|
9
|
+
hasNativeSparseGramSupport,
|
|
10
|
+
resolveSparseSymbolMask,
|
|
11
|
+
} from '../infrastructure/native-sparse-gram.js';
|
|
12
|
+
import { atomicSwapDatabase, log } from './indexer-utils.js';
|
|
13
|
+
|
|
14
|
+
async function unlinkIfExists(filePath) {
|
|
15
|
+
try {
|
|
16
|
+
await fs.unlink(filePath);
|
|
17
|
+
} catch (err) {
|
|
18
|
+
if (err.code !== 'ENOENT') throw err;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
async function collectFileSymbolMasks(codeFiles) {
|
|
23
|
+
const masks = new Map(codeFiles.map((filePath) => [filePath, 0]));
|
|
24
|
+
if (!existsSync(DB_PATHS.codebase)) {
|
|
25
|
+
return codeFiles.map(() => 0);
|
|
26
|
+
}
|
|
27
|
+
const Database = (await import('better-sqlite3')).default;
|
|
28
|
+
const db = new Database(DB_PATHS.codebase, { readonly: true });
|
|
29
|
+
|
|
30
|
+
try {
|
|
31
|
+
const rows = db.prepare('SELECT file_path, metadata FROM vectors').iterate();
|
|
32
|
+
for (const row of rows) {
|
|
33
|
+
if (!masks.has(row.file_path)) continue;
|
|
34
|
+
try {
|
|
35
|
+
const metadata = JSON.parse(row.metadata || '{}');
|
|
36
|
+
const typeMask = resolveSparseSymbolMask(metadata.type);
|
|
37
|
+
if (typeMask === 0) continue;
|
|
38
|
+
masks.set(row.file_path, masks.get(row.file_path) | typeMask);
|
|
39
|
+
} catch {
|
|
40
|
+
// Ignore malformed metadata rows; sparse gram build is best effort.
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
} finally {
|
|
44
|
+
db.close();
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
return codeFiles.map((filePath) => masks.get(filePath) || 0);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export async function buildSparseGramArtifact(allFiles, dryRun) {
|
|
51
|
+
if (dryRun) {
|
|
52
|
+
log('DRY RUN: Skipping sparse gram artifact build', 'magenta');
|
|
53
|
+
return { skipped: true, reason: 'dry_run' };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
if (!Array.isArray(allFiles) || allFiles.length === 0) {
|
|
57
|
+
log('Skipping sparse gram artifact: no files discovered', 'yellow');
|
|
58
|
+
return { skipped: true, reason: 'no_files' };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const codeFiles = allFiles.filter((filePath) => {
|
|
62
|
+
const ext = path.extname(filePath).slice(1).toLowerCase();
|
|
63
|
+
return CODE_FILE_EXTENSIONS.has(ext);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
if (codeFiles.length === 0) {
|
|
67
|
+
log('Skipping sparse gram artifact: no code-search files discovered', 'yellow');
|
|
68
|
+
return { skipped: true, reason: 'no_code_files' };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
if (!hasNativeSparseGramSupport()) {
|
|
72
|
+
log('Skipping sparse gram artifact: native addon unavailable', 'yellow');
|
|
73
|
+
return { skipped: true, reason: 'native_unavailable' };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const stagedPath = DB_PATHS.sparseGramIndex + '.tmp';
|
|
77
|
+
await fs.mkdir(path.dirname(stagedPath), { recursive: true });
|
|
78
|
+
|
|
79
|
+
try {
|
|
80
|
+
const fileSymbolMasks = await collectFileSymbolMasks(codeFiles);
|
|
81
|
+
log('Building sparse gram artifact...', 'yellow');
|
|
82
|
+
const result = buildSparseGramIndexArtifact({
|
|
83
|
+
projectRoot: PROJECT_ROOT,
|
|
84
|
+
files: codeFiles,
|
|
85
|
+
fileSymbolMasks,
|
|
86
|
+
outputPath: stagedPath,
|
|
87
|
+
});
|
|
88
|
+
await atomicSwapDatabase(stagedPath, DB_PATHS.sparseGramIndex);
|
|
89
|
+
log(
|
|
90
|
+
`Sparse gram artifact promoted (${result.filesIndexed} files, ${result.grams} grams, ${result.postings} postings)`,
|
|
91
|
+
'green'
|
|
92
|
+
);
|
|
93
|
+
return result;
|
|
94
|
+
} catch (err) {
|
|
95
|
+
await unlinkIfExists(stagedPath);
|
|
96
|
+
throw err;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
@@ -0,0 +1,536 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Indexer Utilities - SQLite config, logging, atomic swap, WSL paths, gitignore, file discovery.
|
|
3
|
+
* Extracted from index-codebase-v21.js for file size compliance (<500 lines).
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import fs from 'fs/promises';
|
|
7
|
+
import { existsSync } from 'fs';
|
|
8
|
+
import { spawn } from 'child_process';
|
|
9
|
+
import path from 'path';
|
|
10
|
+
import fg from 'fast-glob';
|
|
11
|
+
|
|
12
|
+
import { PROJECT_ROOT, setQuietMode as setGlobalQuietMode, loadProjectConfig, AGENTIC_GITIGNORE_ALLOWLIST } from '../infrastructure/config/index.js';
|
|
13
|
+
|
|
14
|
+
const glob = fg.glob || fg;
|
|
15
|
+
|
|
16
|
+
// =============================================================================
|
|
17
|
+
// P0: SQLITE WRITE OPTIMIZATION (WAL Mode + Batch Insert Chunking)
|
|
18
|
+
// =============================================================================
|
|
19
|
+
|
|
20
|
+
export function isWalSafe(dbPath) {
|
|
21
|
+
// WSL + NTFS mount: WAL is unreliable due to lack of proper file locking
|
|
22
|
+
if (process.env.WSL_DISTRO_NAME && dbPath && /^\/mnt\/[a-zA-Z]\//.test(dbPath)) return false;
|
|
23
|
+
// WAL works on Linux, macOS (APFS/HFS+), and most modern filesystems.
|
|
24
|
+
// Only known-bad: WSL/NTFS mounts and network filesystems.
|
|
25
|
+
return true;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function configureJournalMode(db, dbPath, sqliteFastMode) {
|
|
29
|
+
if (sqliteFastMode) {
|
|
30
|
+
db.pragma('synchronous = OFF');
|
|
31
|
+
db.pragma('journal_mode = MEMORY');
|
|
32
|
+
db.pragma('cache_size = -64000');
|
|
33
|
+
return 'MEMORY';
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
if (isWalSafe(dbPath)) {
|
|
37
|
+
db.pragma('journal_mode = WAL');
|
|
38
|
+
db.pragma('synchronous = NORMAL');
|
|
39
|
+
// Indexing-optimized: larger WAL before auto-checkpoint (~16 MB vs default ~4 MB)
|
|
40
|
+
db.pragma('wal_autocheckpoint = 4000');
|
|
41
|
+
// 1 GB mmap for reads during build, 64 MB page cache
|
|
42
|
+
db.pragma('mmap_size = 1073741824');
|
|
43
|
+
db.pragma('cache_size = -64000');
|
|
44
|
+
// Cap WAL file growth at 64 MB
|
|
45
|
+
db.pragma('journal_size_limit = 67108864');
|
|
46
|
+
return 'WAL';
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
db.pragma('journal_mode = DELETE');
|
|
50
|
+
db.pragma('synchronous = NORMAL');
|
|
51
|
+
return 'DELETE';
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Force WAL checkpoint and truncate. Call after all inserts complete
|
|
56
|
+
* and before long-running read transactions (e.g., HNSW build streaming).
|
|
57
|
+
*/
|
|
58
|
+
export function checkpointWal(db) {
|
|
59
|
+
try {
|
|
60
|
+
db.pragma('wal_checkpoint(TRUNCATE)');
|
|
61
|
+
} catch (_err) {
|
|
62
|
+
// Not in WAL mode or checkpoint not possible — safe to ignore
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// =============================================================================
|
|
67
|
+
// COLORS AND LOGGING (quiet-mode aware)
|
|
68
|
+
// =============================================================================
|
|
69
|
+
|
|
70
|
+
export const colors = {
|
|
71
|
+
reset: '\x1b[0m',
|
|
72
|
+
bright: '\x1b[1m',
|
|
73
|
+
dim: '\x1b[2m',
|
|
74
|
+
red: '\x1b[31m',
|
|
75
|
+
green: '\x1b[32m',
|
|
76
|
+
yellow: '\x1b[33m',
|
|
77
|
+
blue: '\x1b[34m',
|
|
78
|
+
magenta: '\x1b[35m',
|
|
79
|
+
cyan: '\x1b[36m',
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
let quietMode = false;
|
|
83
|
+
let verboseMode = false;
|
|
84
|
+
|
|
85
|
+
export function setQuietMode(enabled) {
|
|
86
|
+
quietMode = enabled;
|
|
87
|
+
setGlobalQuietMode(enabled);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function isQuietMode() {
|
|
91
|
+
return quietMode;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function setVerboseMode(enabled) {
|
|
95
|
+
verboseMode = enabled;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function isVerboseMode() {
|
|
99
|
+
return verboseMode;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function log(message, color = 'reset') {
|
|
103
|
+
if (quietMode) return;
|
|
104
|
+
console.log(`${colors[color]}${message}${colors.reset}`);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
let lastLoggedPercent = {};
|
|
108
|
+
|
|
109
|
+
export function logProgress(current, total, label) {
|
|
110
|
+
if (quietMode) return;
|
|
111
|
+
const percentNum = (current / total) * 100;
|
|
112
|
+
const percent = percentNum.toFixed(1);
|
|
113
|
+
const bar = '█'.repeat(Math.floor(current / total * 30));
|
|
114
|
+
const empty = '░'.repeat(30 - bar.length);
|
|
115
|
+
// In verbose mode or non-TTY, use newlines so output isn't swallowed by pipes.
|
|
116
|
+
// Throttle to every ~2% to avoid flooding.
|
|
117
|
+
if (verboseMode || !process.stdout.isTTY) {
|
|
118
|
+
const lastPct = lastLoggedPercent[label] || 0;
|
|
119
|
+
if (percentNum - lastPct >= 2 || current === total || current <= 1) {
|
|
120
|
+
lastLoggedPercent[label] = percentNum;
|
|
121
|
+
console.log(`${colors.cyan}${label}: [${bar}${empty}] ${percent}% (${current}/${total})${colors.reset}`);
|
|
122
|
+
}
|
|
123
|
+
} else {
|
|
124
|
+
process.stdout.write(`\r${colors.cyan}${label}: [${bar}${empty}] ${percent}% (${current}/${total})${colors.reset}`);
|
|
125
|
+
if (current === total) {
|
|
126
|
+
process.stdout.write('\n');
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export function logError(message) {
|
|
132
|
+
console.error(`[indexer] ${message}`);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// =============================================================================
|
|
136
|
+
// ATOMIC DATABASE SWAP (Windows/WSL EBUSY Handling)
|
|
137
|
+
// =============================================================================
|
|
138
|
+
|
|
139
|
+
export async function atomicSwapDatabase(tmpPath, finalPath) {
|
|
140
|
+
const bakPath = finalPath + '.bak';
|
|
141
|
+
const MAX_RETRIES = 5;
|
|
142
|
+
const RETRY_DELAY = 500;
|
|
143
|
+
|
|
144
|
+
for (let attempt = 0; attempt < MAX_RETRIES; attempt++) {
|
|
145
|
+
try {
|
|
146
|
+
try {
|
|
147
|
+
await fs.unlink(bakPath);
|
|
148
|
+
} catch (err) {
|
|
149
|
+
// No stale backup
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
let hadOriginal = false;
|
|
153
|
+
if (existsSync(finalPath)) {
|
|
154
|
+
await fs.rename(finalPath, bakPath);
|
|
155
|
+
hadOriginal = true;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
await fs.rename(tmpPath, finalPath);
|
|
159
|
+
|
|
160
|
+
if (hadOriginal) {
|
|
161
|
+
try {
|
|
162
|
+
await fs.unlink(bakPath);
|
|
163
|
+
} catch (err) {
|
|
164
|
+
if (err.code !== 'ENOENT') {
|
|
165
|
+
logError(`WARN: Failed to clean up backup ${bakPath}: ${err.message}`);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
return true;
|
|
171
|
+
} catch (err) {
|
|
172
|
+
if (err.code === 'EBUSY' && attempt < MAX_RETRIES - 1) {
|
|
173
|
+
logError(`Database busy, retry ${attempt + 1}/${MAX_RETRIES}...`);
|
|
174
|
+
await new Promise(r => setTimeout(r, RETRY_DELAY));
|
|
175
|
+
continue;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
if (existsSync(bakPath) && !existsSync(finalPath)) {
|
|
179
|
+
try {
|
|
180
|
+
await fs.rename(bakPath, finalPath);
|
|
181
|
+
log(`⚠ Swap failed, restored from backup: ${err.message}`, 'yellow');
|
|
182
|
+
} catch (restoreErr) {
|
|
183
|
+
logError(`CRITICAL: Swap failed AND restore failed: ${restoreErr.message}`);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
throw err;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
return false;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// =============================================================================
|
|
193
|
+
// STDIN FILE LIST READING (for --files-from-stdin)
|
|
194
|
+
// =============================================================================
|
|
195
|
+
|
|
196
|
+
export const WSL_UNC_PATTERN = /^\/\/wsl(?:\.localhost|\$)\/[^/]+/;
|
|
197
|
+
|
|
198
|
+
export function stripWslUncPrefix(filePath) {
|
|
199
|
+
if (!filePath) return filePath;
|
|
200
|
+
let normalized = filePath.replace(/\\/g, '/');
|
|
201
|
+
const match = normalized.match(WSL_UNC_PATTERN);
|
|
202
|
+
if (match) {
|
|
203
|
+
return normalized.slice(match[0].length);
|
|
204
|
+
}
|
|
205
|
+
return filePath;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
export function toPosixPath(filePath) {
|
|
209
|
+
return filePath.replace(/\\/g, '/');
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
export async function readFilesFromStdin() {
|
|
213
|
+
return new Promise((resolve, reject) => {
|
|
214
|
+
let data = '';
|
|
215
|
+
const timeout = setTimeout(() => {
|
|
216
|
+
if (process.stdin.isTTY) {
|
|
217
|
+
resolve([]);
|
|
218
|
+
}
|
|
219
|
+
}, 100);
|
|
220
|
+
|
|
221
|
+
process.stdin.setEncoding('utf-8');
|
|
222
|
+
|
|
223
|
+
process.stdin.on('data', (chunk) => {
|
|
224
|
+
clearTimeout(timeout);
|
|
225
|
+
data += chunk;
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
process.stdin.on('end', () => {
|
|
229
|
+
clearTimeout(timeout);
|
|
230
|
+
|
|
231
|
+
const lines = data
|
|
232
|
+
.split('\n')
|
|
233
|
+
.map(line => line.trim())
|
|
234
|
+
.filter(line => line.length > 0);
|
|
235
|
+
|
|
236
|
+
const validPaths = [];
|
|
237
|
+
const seenPaths = new Set();
|
|
238
|
+
|
|
239
|
+
for (let line of lines) {
|
|
240
|
+
line = stripWslUncPrefix(line);
|
|
241
|
+
let normalizedPath = line;
|
|
242
|
+
|
|
243
|
+
if (path.isAbsolute(line)) {
|
|
244
|
+
if (line.startsWith(PROJECT_ROOT)) {
|
|
245
|
+
normalizedPath = path.relative(PROJECT_ROOT, line);
|
|
246
|
+
} else {
|
|
247
|
+
if (!quietMode) {
|
|
248
|
+
logError(`Skipping path outside project: ${line}`);
|
|
249
|
+
}
|
|
250
|
+
continue;
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
if (normalizedPath.startsWith('./')) {
|
|
255
|
+
normalizedPath = normalizedPath.slice(2);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
if (seenPaths.has(normalizedPath)) {
|
|
259
|
+
continue;
|
|
260
|
+
}
|
|
261
|
+
seenPaths.add(normalizedPath);
|
|
262
|
+
|
|
263
|
+
validPaths.push(normalizedPath);
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
resolve(validPaths);
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
process.stdin.on('error', (err) => {
|
|
270
|
+
clearTimeout(timeout);
|
|
271
|
+
reject(err);
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
if (process.stdin.isTTY) {
|
|
275
|
+
clearTimeout(timeout);
|
|
276
|
+
resolve([]);
|
|
277
|
+
}
|
|
278
|
+
});
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// =============================================================================
|
|
282
|
+
// GITIGNORE ALIGNMENT
|
|
283
|
+
// =============================================================================
|
|
284
|
+
|
|
285
|
+
export function isGitignoreAllowlistedAgenticPath(relativePath) {
|
|
286
|
+
const normalized = toPosixPath(relativePath).replace(/^\.\//, '');
|
|
287
|
+
const basename = path.posix.basename(normalized);
|
|
288
|
+
|
|
289
|
+
if (AGENTIC_GITIGNORE_ALLOWLIST.files.includes(basename)) {
|
|
290
|
+
return true;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
if (AGENTIC_GITIGNORE_ALLOWLIST.filePrefixes.some(prefix => basename.startsWith(prefix))) {
|
|
294
|
+
return true;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
return AGENTIC_GITIGNORE_ALLOWLIST.directories.some(dirPrefix =>
|
|
298
|
+
normalized.startsWith(dirPrefix) || normalized.includes(`/${dirPrefix}`)
|
|
299
|
+
);
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* Run `git check-ignore` on a single batch of paths.
|
|
304
|
+
* Returns a Set of ignored paths, or null on fatal error.
|
|
305
|
+
*/
|
|
306
|
+
function checkIgnoreBatch(batch, reportError) {
|
|
307
|
+
return new Promise((resolve) => {
|
|
308
|
+
const ignoredChunks = [];
|
|
309
|
+
let settled = false;
|
|
310
|
+
|
|
311
|
+
const git = spawn('git', ['check-ignore', '-z', '--stdin'], { cwd: PROJECT_ROOT });
|
|
312
|
+
|
|
313
|
+
git.stdout.on('data', chunk => ignoredChunks.push(chunk));
|
|
314
|
+
git.stderr.on('data', () => {}); // Suppress — batched caller handles partial failures
|
|
315
|
+
|
|
316
|
+
git.on('error', (err) => {
|
|
317
|
+
if (settled) return;
|
|
318
|
+
settled = true;
|
|
319
|
+
reportError(`WARN: Unable to run git check-ignore (${err.message})`);
|
|
320
|
+
resolve(null);
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
git.on('close', (code) => {
|
|
324
|
+
if (settled) return;
|
|
325
|
+
settled = true;
|
|
326
|
+
|
|
327
|
+
// code 0 = some ignored, code 1 = none ignored, both valid.
|
|
328
|
+
// code 128 = fatal (e.g. path beyond symlink) — still use partial stdout.
|
|
329
|
+
if (code !== 0 && code !== 1 && ignoredChunks.length === 0) {
|
|
330
|
+
resolve(null);
|
|
331
|
+
return;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
const ignored = Buffer.concat(ignoredChunks)
|
|
335
|
+
.toString('utf8')
|
|
336
|
+
.split('\0')
|
|
337
|
+
.filter(Boolean)
|
|
338
|
+
.map(toPosixPath);
|
|
339
|
+
|
|
340
|
+
resolve(ignored);
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
const stdinPayload = `${batch.map(toPosixPath).join('\0')}\0`;
|
|
344
|
+
git.stdin.on('error', () => {}); // Suppress EPIPE if git exits early
|
|
345
|
+
git.stdin.end(stdinPayload);
|
|
346
|
+
});
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
const CHECK_IGNORE_BATCH_SIZE = 5000;
|
|
350
|
+
|
|
351
|
+
/**
|
|
352
|
+
* Find directory components that are symlinks, so we can filter out paths
|
|
353
|
+
* that traverse them (git check-ignore fatals on "beyond a symbolic link").
|
|
354
|
+
*/
|
|
355
|
+
async function findSymlinkDirs(paths) {
|
|
356
|
+
const checked = new Map();
|
|
357
|
+
const symlinkPrefixes = [];
|
|
358
|
+
|
|
359
|
+
for (const p of paths) {
|
|
360
|
+
const parts = p.split('/');
|
|
361
|
+
let dir = '';
|
|
362
|
+
for (let i = 0; i < parts.length - 1; i++) {
|
|
363
|
+
dir = dir ? `${dir}/${parts[i]}` : parts[i];
|
|
364
|
+
if (checked.has(dir)) continue;
|
|
365
|
+
try {
|
|
366
|
+
const stat = await fs.lstat(path.join(PROJECT_ROOT, dir));
|
|
367
|
+
const isLink = stat.isSymbolicLink();
|
|
368
|
+
checked.set(dir, isLink);
|
|
369
|
+
if (isLink) symlinkPrefixes.push(dir + '/');
|
|
370
|
+
} catch {
|
|
371
|
+
checked.set(dir, false);
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
return symlinkPrefixes;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
export async function getGitIgnoredPathSet(paths, options = {}) {
|
|
380
|
+
const silent = options.silent ?? false;
|
|
381
|
+
const reportError = silent ? () => {} : logError;
|
|
382
|
+
|
|
383
|
+
if (paths.length === 0) {
|
|
384
|
+
return new Set();
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
const ignored = new Set();
|
|
388
|
+
|
|
389
|
+
// Pre-filter paths that traverse symlinks — git check-ignore fatals on these.
|
|
390
|
+
// Files beyond symlinks are also checked: if the symlink dir itself is ignored,
|
|
391
|
+
// all files under it are treated as ignored too.
|
|
392
|
+
const symlinkPrefixes = await findSymlinkDirs(paths);
|
|
393
|
+
let safePaths = paths;
|
|
394
|
+
if (symlinkPrefixes.length > 0) {
|
|
395
|
+
// Check if the symlink directories themselves are ignored
|
|
396
|
+
const symlinkDirs = symlinkPrefixes.map(p => p.slice(0, -1)); // remove trailing /
|
|
397
|
+
const symlinkIgnored = await checkIgnoreBatch(symlinkDirs, reportError);
|
|
398
|
+
const ignoredSymlinks = new Set(symlinkIgnored || []);
|
|
399
|
+
|
|
400
|
+
safePaths = [];
|
|
401
|
+
for (const p of paths) {
|
|
402
|
+
const matchedPrefix = symlinkPrefixes.find(prefix => p.startsWith(prefix));
|
|
403
|
+
if (matchedPrefix) {
|
|
404
|
+
// Path traverses a symlink — check if symlink dir is gitignored
|
|
405
|
+
const dir = matchedPrefix.slice(0, -1);
|
|
406
|
+
if (ignoredSymlinks.has(dir)) {
|
|
407
|
+
ignored.add(toPosixPath(p)); // inherit parent's ignored status
|
|
408
|
+
}
|
|
409
|
+
// Either way, skip git check-ignore (would fatal)
|
|
410
|
+
} else {
|
|
411
|
+
safePaths.push(p);
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
let failedBatches = 0;
|
|
417
|
+
|
|
418
|
+
for (let i = 0; i < safePaths.length; i += CHECK_IGNORE_BATCH_SIZE) {
|
|
419
|
+
const batch = safePaths.slice(i, i + CHECK_IGNORE_BATCH_SIZE);
|
|
420
|
+
const result = await checkIgnoreBatch(batch, reportError);
|
|
421
|
+
if (result) {
|
|
422
|
+
for (const p of result) ignored.add(p);
|
|
423
|
+
} else {
|
|
424
|
+
failedBatches++;
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
const totalBatches = Math.ceil(safePaths.length / CHECK_IGNORE_BATCH_SIZE);
|
|
429
|
+
if (failedBatches === totalBatches && totalBatches > 0) {
|
|
430
|
+
reportError('WARN: git check-ignore failed on all batches — gitignore filtering disabled');
|
|
431
|
+
return null;
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
return ignored;
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
export async function applyGitignoreAlignment(files, respectGitignore, options = {}) {
|
|
438
|
+
if (!respectGitignore || !existsSync(path.join(PROJECT_ROOT, '.git'))) {
|
|
439
|
+
return { files, gitignored: 0 };
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
const bypassGitignore = new Set();
|
|
443
|
+
const candidates = [];
|
|
444
|
+
for (const file of files) {
|
|
445
|
+
if (isGitignoreAllowlistedAgenticPath(file)) {
|
|
446
|
+
bypassGitignore.add(file);
|
|
447
|
+
} else {
|
|
448
|
+
candidates.push(file);
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
const ignoredSet = await getGitIgnoredPathSet(candidates, options);
|
|
453
|
+
if (!ignoredSet) {
|
|
454
|
+
return { files, gitignored: 0 };
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
const kept = [];
|
|
458
|
+
let gitignored = 0;
|
|
459
|
+
for (const file of files) {
|
|
460
|
+
if (bypassGitignore.has(file)) {
|
|
461
|
+
kept.push(file);
|
|
462
|
+
continue;
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
const normalized = toPosixPath(file);
|
|
466
|
+
if (ignoredSet.has(normalized)) {
|
|
467
|
+
gitignored++;
|
|
468
|
+
continue;
|
|
469
|
+
}
|
|
470
|
+
kept.push(file);
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
return { files: kept, gitignored };
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
// =============================================================================
|
|
477
|
+
// FILE DISCOVERY
|
|
478
|
+
// =============================================================================
|
|
479
|
+
|
|
480
|
+
export async function discoverFiles(options = {}) {
|
|
481
|
+
const {
|
|
482
|
+
projectRoot = PROJECT_ROOT,
|
|
483
|
+
silent = false,
|
|
484
|
+
} = options;
|
|
485
|
+
|
|
486
|
+
const writeLog = silent ? () => {} : log;
|
|
487
|
+
|
|
488
|
+
writeLog('\n━━━ Discovering Files ━━━', 'bright');
|
|
489
|
+
|
|
490
|
+
const projectConfig = loadProjectConfig(projectRoot);
|
|
491
|
+
const respectGitignore = projectConfig.respectGitignore !== false;
|
|
492
|
+
const maxFileSize = projectConfig.maxFileSize || (1 * 1024 * 1024);
|
|
493
|
+
|
|
494
|
+
const discovered = await glob(projectConfig.include, {
|
|
495
|
+
ignore: projectConfig.exclude,
|
|
496
|
+
cwd: projectRoot,
|
|
497
|
+
absolute: false,
|
|
498
|
+
onlyFiles: true,
|
|
499
|
+
dot: true,
|
|
500
|
+
});
|
|
501
|
+
|
|
502
|
+
const { files: allFiles, gitignored } = await applyGitignoreAlignment(discovered, respectGitignore, { silent });
|
|
503
|
+
|
|
504
|
+
const files = [];
|
|
505
|
+
let oversized = 0;
|
|
506
|
+
for (const file of allFiles) {
|
|
507
|
+
try {
|
|
508
|
+
const stat = await fs.stat(path.join(projectRoot, file));
|
|
509
|
+
if (stat.size > maxFileSize) {
|
|
510
|
+
oversized++;
|
|
511
|
+
} else {
|
|
512
|
+
files.push(file);
|
|
513
|
+
}
|
|
514
|
+
} catch {
|
|
515
|
+
// File disappeared between glob and stat
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
writeLog(`✓ Found ${files.length} files to index`, 'green');
|
|
520
|
+
if (gitignored > 0) {
|
|
521
|
+
writeLog(` Skipped ${gitignored} files via .gitignore alignment`, 'yellow');
|
|
522
|
+
}
|
|
523
|
+
if (oversized > 0) {
|
|
524
|
+
writeLog(` Skipped ${oversized} files exceeding ${(maxFileSize / 1024 / 1024).toFixed(1)} MB size limit`, 'yellow');
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
const byType = {};
|
|
528
|
+
for (const file of files) {
|
|
529
|
+
const ext = path.extname(file).toLowerCase() || '.other';
|
|
530
|
+
byType[ext] = (byType[ext] || 0) + 1;
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
writeLog(' File types: ' + Object.entries(byType).map(([ext, count]) => `${ext}(${count})`).join(', '), 'dim');
|
|
534
|
+
|
|
535
|
+
return files;
|
|
536
|
+
}
|