codetrap 0.1.3 → 0.1.4
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/README.md +5 -1
- package/package.json +1 -1
- package/src/commands/workflow.ts +17 -64
- package/src/db/queries.ts +46 -28
- package/src/db/repository.ts +36 -16
- package/src/domain/trap.ts +7 -8
- package/src/lib/command-requests.ts +133 -0
- package/src/lib/doctor.ts +11 -1
- package/src/lib/embedding-health.ts +3 -3
- package/src/lib/embedding-index.ts +53 -0
- package/src/lib/format.ts +1 -1
- package/src/lib/output-json.ts +33 -8
- package/src/lib/scope-context.ts +6 -4
- package/src/lib/scope-maintenance.ts +71 -0
- package/src/lib/scope-migration.ts +23 -68
- package/src/lib/scope-path.ts +99 -0
- package/src/lib/scope.ts +16 -11
- package/src/lib/search-policy.ts +91 -2
- package/src/lib/search-result-card.ts +1 -7
- package/src/lib/search-service.ts +43 -34
- package/src/lib/store.ts +39 -7
- package/src/lib/trap-lifecycle.ts +37 -0
- package/src/lib/trap-operations.ts +5 -5
- package/src/mcp/server.ts +11 -24
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import type { Database } from "bun:sqlite";
|
|
2
|
+
import * as embeddingQueries from "../db/embedding-queries";
|
|
3
|
+
import type { Trap } from "../domain/trap";
|
|
4
|
+
import type { TrapStatus } from "./constants";
|
|
5
|
+
import type {
|
|
6
|
+
EmbeddingConfig,
|
|
7
|
+
FreshEmbedding,
|
|
8
|
+
StoredEmbedding,
|
|
9
|
+
} from "./embedder";
|
|
10
|
+
import type { EmbeddingStateCounts } from "./embedding-health";
|
|
11
|
+
|
|
12
|
+
export type EmbeddingIndexFilter = {
|
|
13
|
+
scope?: string;
|
|
14
|
+
category?: string;
|
|
15
|
+
status?: TrapStatus | "all";
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export type EmbeddingRefreshFilter = EmbeddingIndexFilter & {
|
|
19
|
+
force?: boolean;
|
|
20
|
+
limit?: number;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
export class DatabaseEmbeddingIndex {
|
|
24
|
+
constructor(private readonly db: Database) {}
|
|
25
|
+
|
|
26
|
+
get(trapId: number): StoredEmbedding | null {
|
|
27
|
+
return embeddingQueries.getEmbedding(this.db, trapId);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
save(record: StoredEmbedding): void {
|
|
31
|
+
embeddingQueries.upsertEmbedding(this.db, record);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
delete(trapId: number): void {
|
|
35
|
+
embeddingQueries.deleteEmbedding(this.db, trapId);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
freshEmbeddings(config: EmbeddingConfig, filter: EmbeddingIndexFilter = {}): FreshEmbedding[] {
|
|
39
|
+
return embeddingQueries.getAllFreshEmbeddings(this.db, config, filter);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
trapsNeedingEmbeddings(config: EmbeddingConfig, filter: EmbeddingRefreshFilter = {}): Trap[] {
|
|
43
|
+
return embeddingQueries.getTrapsNeedingEmbeddings(this.db, config, filter);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
countEmbeddable(filter: EmbeddingIndexFilter = {}): number {
|
|
47
|
+
return embeddingQueries.countEmbeddableTraps(this.db, filter);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
stateCounts(config: EmbeddingConfig | null, filter: EmbeddingIndexFilter = {}): EmbeddingStateCounts {
|
|
51
|
+
return embeddingQueries.getEmbeddingStateCounts(this.db, config, filter);
|
|
52
|
+
}
|
|
53
|
+
}
|
package/src/lib/format.ts
CHANGED
|
@@ -18,7 +18,7 @@ Why relevant: ${card.why_relevant}
|
|
|
18
18
|
Avoid: ${card.avoid}
|
|
19
19
|
Do instead: ${card.do_instead}
|
|
20
20
|
Score: ${formatScore(card.score)} (${sourceLabel})
|
|
21
|
-
Next:
|
|
21
|
+
Next: codetrap show ${card.trap_id} --scope ${card.scope} --json`;
|
|
22
22
|
}
|
|
23
23
|
|
|
24
24
|
export function formatTrapDetail(t: Trap, scopeLabel: string): string {
|
package/src/lib/output-json.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { RankingSignal, TrapActionCard, TrapDetails } from "../domain/trap";
|
|
1
|
+
import type { RankingSignal, TrapActionCard, TrapDetails, TrapSearchDiagnostic } from "../domain/trap";
|
|
2
2
|
import type { Scope } from "./constants";
|
|
3
3
|
import type { DoctorReport } from "./doctor";
|
|
4
4
|
import type { EmbeddingStatsResult } from "./embedding-health";
|
|
@@ -16,11 +16,24 @@ export type JsonTrapDetails = {
|
|
|
16
16
|
evidence: JsonTrapEvidence[];
|
|
17
17
|
};
|
|
18
18
|
|
|
19
|
-
export type CliTrapActionCard =
|
|
19
|
+
export type CliTrapActionCard = TrapActionCard & {
|
|
20
20
|
next_action: {
|
|
21
21
|
command: string;
|
|
22
22
|
};
|
|
23
23
|
ranking_signals?: RankingSignal[];
|
|
24
|
+
diagnostics?: TrapSearchDiagnostic[];
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export type McpTrapActionCard = TrapActionCard & {
|
|
28
|
+
next_action: {
|
|
29
|
+
details_tool: "get_trap";
|
|
30
|
+
details_args: {
|
|
31
|
+
id: number;
|
|
32
|
+
scope: Scope;
|
|
33
|
+
};
|
|
34
|
+
};
|
|
35
|
+
ranking_signals?: RankingSignal[];
|
|
36
|
+
diagnostics?: TrapSearchDiagnostic[];
|
|
24
37
|
};
|
|
25
38
|
|
|
26
39
|
export type McpTextResult = {
|
|
@@ -43,6 +56,7 @@ export function toCliSearchJson(cards: TrapActionCard[]): CliTrapActionCard[] {
|
|
|
43
56
|
severity: card.severity,
|
|
44
57
|
score: card.score,
|
|
45
58
|
sources: card.sources,
|
|
59
|
+
...(card.diagnostics ? { diagnostics: card.diagnostics } : {}),
|
|
46
60
|
...(card.ranking_signals ? { ranking_signals: card.ranking_signals } : {}),
|
|
47
61
|
next_action: {
|
|
48
62
|
command: `codetrap show ${card.trap_id} --scope ${card.scope} --json`,
|
|
@@ -50,8 +64,17 @@ export function toCliSearchJson(cards: TrapActionCard[]): CliTrapActionCard[] {
|
|
|
50
64
|
}));
|
|
51
65
|
}
|
|
52
66
|
|
|
53
|
-
export function toMcpSearchJson(cards: TrapActionCard[]):
|
|
54
|
-
return cards
|
|
67
|
+
export function toMcpSearchJson(cards: TrapActionCard[]): McpTrapActionCard[] {
|
|
68
|
+
return cards.map((card) => ({
|
|
69
|
+
...card,
|
|
70
|
+
next_action: {
|
|
71
|
+
details_tool: "get_trap",
|
|
72
|
+
details_args: {
|
|
73
|
+
id: card.trap_id,
|
|
74
|
+
scope: card.scope,
|
|
75
|
+
},
|
|
76
|
+
},
|
|
77
|
+
}));
|
|
55
78
|
}
|
|
56
79
|
|
|
57
80
|
export function toMcpTextJson(value: unknown, isError = false): McpTextResult {
|
|
@@ -102,10 +125,12 @@ export function toStatsJson(stats: TrapStatsResult, embeddings?: EmbeddingStatsR
|
|
|
102
125
|
embeddings: embeddings?.project ?? null,
|
|
103
126
|
}
|
|
104
127
|
: null,
|
|
105
|
-
global:
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
128
|
+
global: stats.global
|
|
129
|
+
? {
|
|
130
|
+
...stats.global,
|
|
131
|
+
embeddings: embeddings?.global ?? null,
|
|
132
|
+
}
|
|
133
|
+
: null,
|
|
109
134
|
};
|
|
110
135
|
}
|
|
111
136
|
|
package/src/lib/scope-context.ts
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
1
|
-
import { join, resolve } from "node:path";
|
|
2
1
|
import { openGlobal, openProject } from "../db/connection";
|
|
3
2
|
import { TrapRepository } from "../db/repository";
|
|
4
3
|
import { CODETRAP_DIR, TRAPS_DB_FILE } from "./constants";
|
|
5
4
|
import type { Scope } from "./constants";
|
|
6
5
|
import type { EmbeddingProvider } from "./embedder";
|
|
7
|
-
import { findProjectRoot, getGlobalDB } from "./scope";
|
|
6
|
+
import { findProjectRoot, getGlobalDB, resolveScopePath } from "./scope";
|
|
7
|
+
import { ScopePathResolver } from "./scope-path";
|
|
8
|
+
|
|
9
|
+
const scopePath = new ScopePathResolver();
|
|
8
10
|
|
|
9
11
|
export type ScopeContext = {
|
|
10
12
|
cwd: string;
|
|
@@ -14,12 +16,12 @@ export type ScopeContext = {
|
|
|
14
16
|
};
|
|
15
17
|
|
|
16
18
|
export function createScopeContext(cwd = process.cwd()): ScopeContext {
|
|
17
|
-
const resolvedCwd =
|
|
19
|
+
const resolvedCwd = resolveScopePath(cwd);
|
|
18
20
|
const projectRoot = findProjectRoot(resolvedCwd);
|
|
19
21
|
return {
|
|
20
22
|
cwd: resolvedCwd,
|
|
21
23
|
project_root: projectRoot,
|
|
22
|
-
project_db: projectRoot ? join(projectRoot, CODETRAP_DIR, TRAPS_DB_FILE) : null,
|
|
24
|
+
project_db: projectRoot ? scopePath.join(projectRoot, CODETRAP_DIR, TRAPS_DB_FILE) : null,
|
|
23
25
|
global_db: getGlobalDB(),
|
|
24
26
|
};
|
|
25
27
|
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { Database } from "bun:sqlite";
|
|
2
|
+
import { existsSync, mkdirSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { basename, dirname, join } from "node:path";
|
|
4
|
+
import { TrapRepository } from "../db/repository";
|
|
5
|
+
import { CODETRAP_DIR, TRAPS_DB_FILE } from "./constants";
|
|
6
|
+
import { ScopePathResolver } from "./scope-path";
|
|
7
|
+
|
|
8
|
+
const scopePath = new ScopePathResolver();
|
|
9
|
+
|
|
10
|
+
export type ScopeMaintenanceCommand = "repair-scope" | "migrate-project";
|
|
11
|
+
|
|
12
|
+
export type ScopeMaintenancePaths = {
|
|
13
|
+
command: ScopeMaintenanceCommand;
|
|
14
|
+
fromProjectPath: string;
|
|
15
|
+
toProjectPath: string;
|
|
16
|
+
sourceDb: string;
|
|
17
|
+
destinationDb: string;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export function buildScopeMaintenancePaths(input: {
|
|
21
|
+
command: ScopeMaintenanceCommand;
|
|
22
|
+
fromProjectPath: string;
|
|
23
|
+
toProjectPath: string;
|
|
24
|
+
}): ScopeMaintenancePaths {
|
|
25
|
+
return {
|
|
26
|
+
command: input.command,
|
|
27
|
+
fromProjectPath: input.fromProjectPath,
|
|
28
|
+
toProjectPath: input.toProjectPath,
|
|
29
|
+
sourceDb: projectDbPath(input.fromProjectPath),
|
|
30
|
+
destinationDb: projectDbPath(input.toProjectPath),
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function validateScopeMaintenancePaths(input: ScopeMaintenancePaths): void {
|
|
35
|
+
if (input.command === "migrate-project" && scopePath.same(input.fromProjectPath, input.toProjectPath)) {
|
|
36
|
+
throw new Error("--from-project-path and --to-project-path must be different.");
|
|
37
|
+
}
|
|
38
|
+
if (scopePath.same(input.sourceDb, input.destinationDb)) {
|
|
39
|
+
throw new Error("Source and destination database paths are the same.");
|
|
40
|
+
}
|
|
41
|
+
if (!existsSync(scopePath.join(input.toProjectPath, CODETRAP_DIR))) {
|
|
42
|
+
throw new Error(`Destination project is not initialized: ${input.toProjectPath}. Run 'codetrap init' first.`);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function projectDbPath(projectPath: string): string {
|
|
47
|
+
return scopePath.join(projectPath, CODETRAP_DIR, TRAPS_DB_FILE);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function withReadOnlyScopeRepository<T>(dbPath: string, callback: (repository: TrapRepository) => T): T {
|
|
51
|
+
const db = new Database(dbPath, { readonly: true });
|
|
52
|
+
try {
|
|
53
|
+
return callback(new TrapRepository(db));
|
|
54
|
+
} finally {
|
|
55
|
+
db.close();
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function backupScopeDatabase(dbPath: string, label: string): string {
|
|
60
|
+
const backupDir = join(dirname(dbPath), "backups");
|
|
61
|
+
mkdirSync(backupDir, { recursive: true });
|
|
62
|
+
const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
|
|
63
|
+
const backupPath = join(backupDir, `${basename(dbPath)}.${label}.${timestamp}.backup`);
|
|
64
|
+
const db = new Database(dbPath, { readonly: true });
|
|
65
|
+
try {
|
|
66
|
+
writeFileSync(backupPath, db.serialize());
|
|
67
|
+
} finally {
|
|
68
|
+
db.close();
|
|
69
|
+
}
|
|
70
|
+
return backupPath;
|
|
71
|
+
}
|
|
@@ -1,19 +1,23 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { existsSync, mkdirSync, realpathSync, writeFileSync } from "node:fs";
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
3
2
|
import { homedir } from "node:os";
|
|
4
|
-
import { basename, dirname, join, resolve } from "node:path";
|
|
5
3
|
import { openDatabase } from "../db/connection";
|
|
6
4
|
import { TrapRepository } from "../db/repository";
|
|
7
5
|
import type { TrapExportRecord } from "../domain/trap";
|
|
8
|
-
import {
|
|
9
|
-
import {
|
|
6
|
+
import { findProjectRoot, resolveScopePath } from "./scope";
|
|
7
|
+
import {
|
|
8
|
+
backupScopeDatabase,
|
|
9
|
+
buildScopeMaintenancePaths,
|
|
10
|
+
validateScopeMaintenancePaths,
|
|
11
|
+
withReadOnlyScopeRepository,
|
|
12
|
+
type ScopeMaintenanceCommand,
|
|
13
|
+
} from "./scope-maintenance";
|
|
10
14
|
import {
|
|
11
15
|
deleteTransferredSourceTraps,
|
|
12
16
|
importProjectTrapTransfer,
|
|
13
17
|
type TrapTransferMapping,
|
|
14
18
|
} from "./trap-transfer";
|
|
15
19
|
|
|
16
|
-
export type ScopeMigrationCommand =
|
|
20
|
+
export type ScopeMigrationCommand = ScopeMaintenanceCommand;
|
|
17
21
|
export type ScopeMigrationMode = "dry-run" | "apply";
|
|
18
22
|
|
|
19
23
|
export type ScopeMigrationCandidate = {
|
|
@@ -151,16 +155,16 @@ export function formatScopeMigrationText(result: ScopeMigrationResult): string {
|
|
|
151
155
|
|
|
152
156
|
function buildScopeMigrationPlan(options: ScopeMigrationOptions): ScopeMigrationPlan {
|
|
153
157
|
const resolved = resolveScopeMigrationOptions(options);
|
|
154
|
-
|
|
158
|
+
validateScopeMaintenancePaths(resolved);
|
|
155
159
|
const sourceDbExists = existsSync(resolved.sourceDb);
|
|
156
160
|
const destinationDbExists = existsSync(resolved.destinationDb);
|
|
157
161
|
const records = sourceDbExists
|
|
158
|
-
?
|
|
162
|
+
? withReadOnlyScopeRepository(resolved.sourceDb, (repository) =>
|
|
159
163
|
repository.exportProjectTrapsByPath(resolved.fromProjectPath)
|
|
160
164
|
)
|
|
161
165
|
: [];
|
|
162
166
|
const destinationBefore = destinationDbExists
|
|
163
|
-
?
|
|
167
|
+
? withReadOnlyScopeRepository(resolved.destinationDb, (repository) =>
|
|
164
168
|
repository.countProjectTrapsByPath(resolved.toProjectPath)
|
|
165
169
|
)
|
|
166
170
|
: 0;
|
|
@@ -177,8 +181,8 @@ function buildScopeMigrationPlan(options: ScopeMigrationOptions): ScopeMigration
|
|
|
177
181
|
|
|
178
182
|
function applyScopeMigrationPlan(plan: ScopeMigrationPlan): ScopeMigrationApplyResult {
|
|
179
183
|
const backups = {
|
|
180
|
-
source_db:
|
|
181
|
-
destination_db: plan.destinationDbExists ?
|
|
184
|
+
source_db: backupScopeDatabase(plan.sourceDb, "source"),
|
|
185
|
+
destination_db: plan.destinationDbExists ? backupScopeDatabase(plan.destinationDb, "destination") : null,
|
|
182
186
|
};
|
|
183
187
|
|
|
184
188
|
const applyResult = applyScopeMigration(
|
|
@@ -197,45 +201,27 @@ function applyScopeMigrationPlan(plan: ScopeMigrationPlan): ScopeMigrationApplyR
|
|
|
197
201
|
}
|
|
198
202
|
|
|
199
203
|
function resolveScopeMigrationOptions(options: ScopeMigrationOptions): ResolvedScopeMigration {
|
|
200
|
-
const cwd =
|
|
204
|
+
const cwd = resolveScopePath(options.cwd ?? process.cwd());
|
|
201
205
|
const projectRoot = findProjectRoot(cwd);
|
|
202
|
-
const fromProjectPath =
|
|
206
|
+
const fromProjectPath = resolveScopePath(options.fromProjectPath ?? homedir());
|
|
203
207
|
const rawToProjectPath = options.toProjectPath ?? projectRoot;
|
|
204
208
|
|
|
205
209
|
if (!rawToProjectPath) {
|
|
206
210
|
throw new Error("Destination project not found. Run 'codetrap init' first, or pass --to-project-path.");
|
|
207
211
|
}
|
|
208
|
-
const toProjectPath =
|
|
212
|
+
const toProjectPath = resolveScopePath(rawToProjectPath);
|
|
209
213
|
|
|
210
214
|
return {
|
|
211
|
-
|
|
215
|
+
...buildScopeMaintenancePaths({
|
|
216
|
+
command: options.command,
|
|
217
|
+
fromProjectPath,
|
|
218
|
+
toProjectPath,
|
|
219
|
+
}),
|
|
212
220
|
mode: options.apply ? "apply" : "dry-run",
|
|
213
221
|
apply: options.apply === true,
|
|
214
|
-
fromProjectPath,
|
|
215
|
-
toProjectPath,
|
|
216
|
-
sourceDb: projectDbPath(fromProjectPath),
|
|
217
|
-
destinationDb: projectDbPath(toProjectPath),
|
|
218
222
|
};
|
|
219
223
|
}
|
|
220
224
|
|
|
221
|
-
function validateMigrationPaths(input: {
|
|
222
|
-
command: ScopeMigrationCommand;
|
|
223
|
-
fromProjectPath: string;
|
|
224
|
-
toProjectPath: string;
|
|
225
|
-
sourceDb: string;
|
|
226
|
-
destinationDb: string;
|
|
227
|
-
}): void {
|
|
228
|
-
if (input.command === "migrate-project" && input.fromProjectPath === input.toProjectPath) {
|
|
229
|
-
throw new Error("--from-project-path and --to-project-path must be different.");
|
|
230
|
-
}
|
|
231
|
-
if (canonicalPath(input.sourceDb) === canonicalPath(input.destinationDb)) {
|
|
232
|
-
throw new Error("Source and destination database paths are the same.");
|
|
233
|
-
}
|
|
234
|
-
if (!existsSync(join(input.toProjectPath, CODETRAP_DIR))) {
|
|
235
|
-
throw new Error(`Destination project is not initialized: ${input.toProjectPath}. Run 'codetrap init' first.`);
|
|
236
|
-
}
|
|
237
|
-
}
|
|
238
|
-
|
|
239
225
|
function applyScopeMigration(
|
|
240
226
|
sourceDbPath: string,
|
|
241
227
|
destinationDbPath: string,
|
|
@@ -307,29 +293,6 @@ function toCandidate(record: TrapExportRecord): ScopeMigrationCandidate {
|
|
|
307
293
|
};
|
|
308
294
|
}
|
|
309
295
|
|
|
310
|
-
function withReadOnlyRepository<T>(dbPath: string, callback: (repository: TrapRepository) => T): T {
|
|
311
|
-
const db = new Database(dbPath, { readonly: true });
|
|
312
|
-
try {
|
|
313
|
-
return callback(new TrapRepository(db));
|
|
314
|
-
} finally {
|
|
315
|
-
db.close();
|
|
316
|
-
}
|
|
317
|
-
}
|
|
318
|
-
|
|
319
|
-
function backupDatabase(dbPath: string, label: string): string {
|
|
320
|
-
const backupDir = join(dirname(dbPath), "backups");
|
|
321
|
-
mkdirSync(backupDir, { recursive: true });
|
|
322
|
-
const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
|
|
323
|
-
const backupPath = join(backupDir, `${basename(dbPath)}.${label}.${timestamp}.backup`);
|
|
324
|
-
const db = new Database(dbPath, { readonly: true });
|
|
325
|
-
try {
|
|
326
|
-
writeFileSync(backupPath, db.serialize());
|
|
327
|
-
} finally {
|
|
328
|
-
db.close();
|
|
329
|
-
}
|
|
330
|
-
return backupPath;
|
|
331
|
-
}
|
|
332
|
-
|
|
333
296
|
function buildApplyCommand(
|
|
334
297
|
command: ScopeMigrationCommand,
|
|
335
298
|
fromProjectPath: string,
|
|
@@ -347,14 +310,6 @@ function buildApplyCommand(
|
|
|
347
310
|
].join(" ");
|
|
348
311
|
}
|
|
349
312
|
|
|
350
|
-
function projectDbPath(projectPath: string): string {
|
|
351
|
-
return join(projectPath, CODETRAP_DIR, TRAPS_DB_FILE);
|
|
352
|
-
}
|
|
353
|
-
|
|
354
|
-
function canonicalPath(path: string): string {
|
|
355
|
-
return existsSync(path) ? realpathSync(path) : path;
|
|
356
|
-
}
|
|
357
|
-
|
|
358
313
|
function shellQuote(value: string): string {
|
|
359
314
|
return `'${value.replace(/'/g, "'\\''")}'`;
|
|
360
315
|
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { existsSync, realpathSync } from "node:fs";
|
|
2
|
+
import { posix, win32 } from "node:path";
|
|
3
|
+
|
|
4
|
+
export type ScopePathFlavor = "posix" | "win32";
|
|
5
|
+
|
|
6
|
+
export type ScopePathFs = {
|
|
7
|
+
exists(path: string): boolean;
|
|
8
|
+
realpath(path: string): string;
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
export type ScopePathResolverOptions = {
|
|
12
|
+
fs?: ScopePathFs;
|
|
13
|
+
platform?: NodeJS.Platform;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
const nodeFs: ScopePathFs = {
|
|
17
|
+
exists: existsSync,
|
|
18
|
+
realpath: realpathSync,
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
export class ScopePathResolver {
|
|
22
|
+
private readonly fs: ScopePathFs;
|
|
23
|
+
private readonly platform: NodeJS.Platform;
|
|
24
|
+
|
|
25
|
+
constructor(options: ScopePathResolverOptions = {}) {
|
|
26
|
+
this.fs = options.fs ?? nodeFs;
|
|
27
|
+
this.platform = options.platform ?? process.platform;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
resolve(path: string, relatedPath = path): string {
|
|
31
|
+
return this.resolveWithFlavor(path, this.flavorFor(path, relatedPath));
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
canonical(path: string, relatedPath = path): string {
|
|
35
|
+
const flavor = this.flavorFor(path, relatedPath);
|
|
36
|
+
const resolved = this.resolveWithFlavor(path, flavor);
|
|
37
|
+
const real = this.fs.exists(resolved) ? this.fs.realpath(resolved) : resolved;
|
|
38
|
+
const normalized = pathApi(flavor).normalize(real);
|
|
39
|
+
return flavor === "win32" ? normalized.toLowerCase() : normalized;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
same(left: string, right: string): boolean {
|
|
43
|
+
const flavor = this.flavorFor(left, right);
|
|
44
|
+
return this.canonicalWithFlavor(left, flavor) === this.canonicalWithFlavor(right, flavor);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
join(base: string, ...segments: string[]): string {
|
|
48
|
+
return pathApi(this.flavorFor(base)).join(base, ...segments);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
exists(path: string): boolean {
|
|
52
|
+
return this.fs.exists(path);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
dirname(path: string): string {
|
|
56
|
+
return pathApi(this.flavorFor(path)).dirname(path);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
flavorFor(...paths: string[]): ScopePathFlavor {
|
|
60
|
+
if (this.platform === "win32") return "win32";
|
|
61
|
+
return paths.some((path) => isWindowsAbsolutePath(path) || isMsysAbsolutePath(path)) ? "win32" : "posix";
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
private canonicalWithFlavor(path: string, flavor: ScopePathFlavor): string {
|
|
65
|
+
const resolved = this.resolveWithFlavor(path, flavor);
|
|
66
|
+
const real = this.fs.exists(resolved) ? this.fs.realpath(resolved) : resolved;
|
|
67
|
+
const normalized = pathApi(flavor).normalize(real);
|
|
68
|
+
return flavor === "win32" ? normalized.toLowerCase() : normalized;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
private resolveWithFlavor(path: string, flavor: ScopePathFlavor): string {
|
|
72
|
+
return pathApi(flavor).resolve(flavor === "win32" ? msysToWindowsPath(path) : path);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export const defaultScopePathResolver = new ScopePathResolver();
|
|
77
|
+
|
|
78
|
+
export function resolveScopePath(path: string, relatedPath = path): string {
|
|
79
|
+
return defaultScopePathResolver.resolve(path, relatedPath);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function pathApi(flavor: ScopePathFlavor): typeof posix {
|
|
83
|
+
return flavor === "win32" ? win32 : posix;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function msysToWindowsPath(path: string): string {
|
|
87
|
+
const match = path.match(/^\/([a-zA-Z])(?:\/(.*))?$/);
|
|
88
|
+
if (!match) return path;
|
|
89
|
+
const [, drive, rest = ""] = match;
|
|
90
|
+
return `${drive.toUpperCase()}:\\${rest.replaceAll("/", "\\")}`;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function isWindowsAbsolutePath(path: string): boolean {
|
|
94
|
+
return /^[a-zA-Z]:[\\/]/.test(path) || /^\\\\/.test(path);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function isMsysAbsolutePath(path: string): boolean {
|
|
98
|
+
return /^\/[a-zA-Z](?:\/|$)/.test(path);
|
|
99
|
+
}
|
package/src/lib/scope.ts
CHANGED
|
@@ -1,32 +1,37 @@
|
|
|
1
1
|
import { existsSync, mkdirSync } from "node:fs";
|
|
2
|
-
import { dirname, join, resolve } from "node:path";
|
|
3
2
|
import { homedir } from "node:os";
|
|
4
3
|
import { CODETRAP_DIR, TRAPS_DB_FILE } from "./constants";
|
|
4
|
+
import { defaultScopePathResolver, resolveScopePath, ScopePathResolver } from "./scope-path";
|
|
5
|
+
|
|
6
|
+
export { resolveScopePath, ScopePathResolver } from "./scope-path";
|
|
5
7
|
|
|
6
8
|
export function getGlobalDir(): string {
|
|
7
|
-
const dir = join(homedir(), CODETRAP_DIR);
|
|
9
|
+
const dir = defaultScopePathResolver.join(homedir(), CODETRAP_DIR);
|
|
8
10
|
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
9
11
|
return dir;
|
|
10
12
|
}
|
|
11
13
|
|
|
12
14
|
export function getGlobalDB(): string {
|
|
13
|
-
return join(getGlobalDir(), TRAPS_DB_FILE);
|
|
15
|
+
return defaultScopePathResolver.join(getGlobalDir(), TRAPS_DB_FILE);
|
|
14
16
|
}
|
|
15
17
|
|
|
16
|
-
export function findProjectRoot(
|
|
17
|
-
|
|
18
|
-
|
|
18
|
+
export function findProjectRoot(
|
|
19
|
+
cwd: string,
|
|
20
|
+
homeDir = homedir(),
|
|
21
|
+
resolver: ScopePathResolver = defaultScopePathResolver
|
|
22
|
+
): string | null {
|
|
23
|
+
let dir = resolver.resolve(cwd, homeDir);
|
|
19
24
|
while (true) {
|
|
20
|
-
if (dir
|
|
21
|
-
if (
|
|
22
|
-
const parent = dirname(dir);
|
|
25
|
+
if (resolver.same(dir, homeDir)) return null;
|
|
26
|
+
if (resolver.exists(resolver.join(dir, CODETRAP_DIR))) return dir;
|
|
27
|
+
const parent = resolver.dirname(dir);
|
|
23
28
|
if (parent === dir) return null;
|
|
24
29
|
dir = parent;
|
|
25
30
|
}
|
|
26
31
|
}
|
|
27
32
|
|
|
28
33
|
export function getProjectDB(root: string): string {
|
|
29
|
-
const dir = join(root, CODETRAP_DIR);
|
|
34
|
+
const dir = defaultScopePathResolver.join(root, CODETRAP_DIR);
|
|
30
35
|
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
31
|
-
return join(dir, TRAPS_DB_FILE);
|
|
36
|
+
return defaultScopePathResolver.join(dir, TRAPS_DB_FILE);
|
|
32
37
|
}
|
package/src/lib/search-policy.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { RankingSignal, Trap, TrapSearchResult } from "../domain/trap";
|
|
2
|
+
import type { TrapStatus } from "./constants";
|
|
2
3
|
import { EmbeddingProviderUnavailableError } from "./embedder";
|
|
3
4
|
import { parseTrapPathGlobs, parseTrapTags } from "./trap-json-fields";
|
|
4
5
|
import {
|
|
@@ -8,11 +9,39 @@ import {
|
|
|
8
9
|
} from "./trap-scope-match";
|
|
9
10
|
|
|
10
11
|
export interface SearchPolicyOptions extends ApplicabilityFilter {
|
|
12
|
+
category?: string;
|
|
13
|
+
scope?: string;
|
|
14
|
+
status?: TrapStatus | "all";
|
|
11
15
|
limit?: number;
|
|
12
16
|
rerank?: boolean;
|
|
13
17
|
includeRankingSignals?: boolean;
|
|
14
18
|
}
|
|
15
19
|
|
|
20
|
+
export type SearchStorageFilter = {
|
|
21
|
+
category?: string;
|
|
22
|
+
scope?: string;
|
|
23
|
+
status?: TrapStatus | "all";
|
|
24
|
+
module?: string;
|
|
25
|
+
owner?: string;
|
|
26
|
+
limit?: number;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
export type SemanticStorageFilter = {
|
|
30
|
+
category?: string;
|
|
31
|
+
scope?: string;
|
|
32
|
+
status?: TrapStatus | "all";
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
export type SearchRetrievalSource = "fts" | "semantic";
|
|
36
|
+
|
|
37
|
+
export interface SearchRetrievalPlan {
|
|
38
|
+
resultLimit: number;
|
|
39
|
+
candidateLimit: number;
|
|
40
|
+
ftsStorageFilter: SearchStorageFilter & { limit: number };
|
|
41
|
+
semanticStorageFilter: SemanticStorageFilter;
|
|
42
|
+
applicabilityFilter: ApplicabilityFilter;
|
|
43
|
+
}
|
|
44
|
+
|
|
16
45
|
export interface RankingConfig {
|
|
17
46
|
rrfK: number;
|
|
18
47
|
semanticMinScore: number;
|
|
@@ -48,6 +77,33 @@ export const DEFAULT_RANKING_CONFIG: RankingConfig = {
|
|
|
48
77
|
export class TrapSearchPolicy {
|
|
49
78
|
constructor(private readonly ranking: RankingConfig = DEFAULT_RANKING_CONFIG) {}
|
|
50
79
|
|
|
80
|
+
plan(opts: SearchPolicyOptions, defaultLimit: number): SearchRetrievalPlan {
|
|
81
|
+
const resultLimit = opts.limit ?? defaultLimit;
|
|
82
|
+
const candidateLimit = this.candidateLimit(opts, resultLimit);
|
|
83
|
+
return {
|
|
84
|
+
resultLimit,
|
|
85
|
+
candidateLimit,
|
|
86
|
+
ftsStorageFilter: {
|
|
87
|
+
category: opts.category,
|
|
88
|
+
scope: opts.scope,
|
|
89
|
+
status: opts.status,
|
|
90
|
+
module: opts.module,
|
|
91
|
+
owner: opts.owner,
|
|
92
|
+
limit: candidateLimit,
|
|
93
|
+
},
|
|
94
|
+
semanticStorageFilter: {
|
|
95
|
+
category: opts.category,
|
|
96
|
+
scope: opts.scope,
|
|
97
|
+
status: opts.status,
|
|
98
|
+
},
|
|
99
|
+
applicabilityFilter: {
|
|
100
|
+
path: opts.path,
|
|
101
|
+
module: opts.module,
|
|
102
|
+
owner: opts.owner,
|
|
103
|
+
},
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
|
|
51
107
|
candidateLimit(opts: SearchPolicyOptions, resultLimit: number): number {
|
|
52
108
|
return shouldOverfetch(opts) ? Math.max(resultLimit * 5, 50) : resultLimit;
|
|
53
109
|
}
|
|
@@ -68,6 +124,30 @@ export class TrapSearchPolicy {
|
|
|
68
124
|
return traps.filter((trap) => this.matchesTrap(trap, filter));
|
|
69
125
|
}
|
|
70
126
|
|
|
127
|
+
prepareRetrievedResults(
|
|
128
|
+
results: TrapSearchResult[],
|
|
129
|
+
source: SearchRetrievalSource,
|
|
130
|
+
plan: SearchRetrievalPlan
|
|
131
|
+
): TrapSearchResult[] {
|
|
132
|
+
const applicable = this.filterResults(results, plan.applicabilityFilter);
|
|
133
|
+
if (source === "semantic") {
|
|
134
|
+
return applicable
|
|
135
|
+
.filter((result) => (result.score ?? 0) >= this.semanticMinScore())
|
|
136
|
+
.sort((a, b) => (b.score ?? 0) - (a.score ?? 0))
|
|
137
|
+
.slice(0, plan.candidateLimit);
|
|
138
|
+
}
|
|
139
|
+
return applicable.slice(0, plan.candidateLimit);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
finalizeResults(
|
|
143
|
+
results: TrapSearchResult[],
|
|
144
|
+
query: string,
|
|
145
|
+
opts: SearchPolicyOptions,
|
|
146
|
+
plan: SearchRetrievalPlan
|
|
147
|
+
): TrapSearchResult[] {
|
|
148
|
+
return this.rankResults(results, query, opts, plan.resultLimit);
|
|
149
|
+
}
|
|
150
|
+
|
|
71
151
|
rankResults(
|
|
72
152
|
results: TrapSearchResult[],
|
|
73
153
|
query: string,
|
|
@@ -95,12 +175,21 @@ export class TrapSearchPolicy {
|
|
|
95
175
|
score: applyLengthNormalization(result.score, result.trap, this.ranking),
|
|
96
176
|
rank: applyLengthNormalization(result.score, result.trap, this.ranking),
|
|
97
177
|
}))
|
|
98
|
-
.sort((a, b) => (b.score ?? 0) - (a.score ?? 0))
|
|
99
|
-
.slice(0, limit);
|
|
178
|
+
.sort((a, b) => (b.score ?? 0) - (a.score ?? 0));
|
|
100
179
|
|
|
101
180
|
return this.rankResults(fused, query, opts, limit);
|
|
102
181
|
}
|
|
103
182
|
|
|
183
|
+
fuseAndFinalize(
|
|
184
|
+
ftsResults: TrapSearchResult[],
|
|
185
|
+
semanticResults: TrapSearchResult[],
|
|
186
|
+
query: string,
|
|
187
|
+
opts: SearchPolicyOptions,
|
|
188
|
+
plan: SearchRetrievalPlan
|
|
189
|
+
): TrapSearchResult[] {
|
|
190
|
+
return this.fuse(ftsResults, semanticResults, query, opts, plan.resultLimit);
|
|
191
|
+
}
|
|
192
|
+
|
|
104
193
|
withDiagnostics(
|
|
105
194
|
results: TrapSearchResult[],
|
|
106
195
|
diagnostic: { code: string; message: string }
|