codetrap 0.1.3 → 0.1.5

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.
@@ -60,10 +60,15 @@ export interface TrapSearchResult {
60
60
  rank: number;
61
61
  sources?: ("fts" | "semantic")[];
62
62
  score?: number;
63
- diagnostics?: { code: string; message: string }[];
63
+ diagnostics?: TrapSearchDiagnostic[];
64
64
  ranking_signals?: RankingSignal[];
65
65
  }
66
66
 
67
+ export interface TrapSearchDiagnostic {
68
+ code: string;
69
+ message: string;
70
+ }
71
+
67
72
  export interface RankingSignal {
68
73
  code: string;
69
74
  weight: number;
@@ -80,14 +85,8 @@ export interface TrapActionCard {
80
85
  severity: string;
81
86
  score: number | null;
82
87
  sources: ("fts" | "semantic")[];
88
+ diagnostics?: TrapSearchDiagnostic[];
83
89
  ranking_signals?: RankingSignal[];
84
- next_action: {
85
- details_tool: "get_trap";
86
- details_args: {
87
- id: number;
88
- scope: Scope;
89
- };
90
- };
91
90
  }
92
91
 
93
92
  export type TrapUpdate = Partial<Omit<TrapInput, "scope" | "project_path">>;
@@ -247,7 +246,7 @@ export function trapEvidenceInputSchema(): JsonSchema {
247
246
  enum: [...EVIDENCE_SOURCE_TYPES] as string[],
248
247
  description: "Where this evidence came from",
249
248
  },
250
- source_ref: { type: "string", description: "Optional file path, commit SHA, issue URL, or transcript ID" },
249
+ source_ref: { type: "string", description: "Optional file path, commit SHA, issue/article URL, or transcript ID" },
251
250
  observed_at: { type: "string", description: "When this was observed (ISO-like timestamp, optional)" },
252
251
  related_files: {
253
252
  type: "array",
package/src/index.ts CHANGED
@@ -59,6 +59,7 @@ function showHelp(): void {
59
59
  console.log(" --path <file> Filter/boost traps scoped to a file path");
60
60
  console.log(" --module <name> Filter/boost traps scoped to a module");
61
61
  console.log(" --owner <name> Filter/boost traps scoped to an owner/team");
62
+ console.log(" --source_type <type> Evidence source: manual, conversation, commit, issue, test_failure, article");
62
63
  console.log(" --no-rerank Disable query-aware search reranking");
63
64
  console.log(" --ranking-signals Include search ranking diagnostics in JSON cards");
64
65
  console.log(" --batch-size <n> Embedding generation batch size");
@@ -0,0 +1,133 @@
1
+ import { SEARCH_MODES, type SearchMode } from "./constants";
2
+ import type { SearchDefaults } from "./config";
3
+ import type { SearchTrapsArgs, ListTrapsArgs } from "./trap-operations";
4
+
5
+ type RawArgs = Record<string, unknown>;
6
+
7
+ export type EmbedRequest = {
8
+ scope?: string;
9
+ category?: string;
10
+ limit?: number;
11
+ force?: boolean;
12
+ batchSize?: number;
13
+ };
14
+
15
+ export type StatsRequest = {
16
+ scope?: string;
17
+ };
18
+
19
+ export function searchRequestFromArgs(query: string, args: RawArgs, defaults: SearchDefaults): SearchTrapsArgs {
20
+ return {
21
+ query,
22
+ category: stringOption(args, "category"),
23
+ scope: stringOption(args, "scope") ?? defaults.scope,
24
+ limit: intOption(args, "limit", defaults.limit),
25
+ mode: searchModeOption(args, "mode") ?? defaults.mode,
26
+ status: stringOption(args, "status"),
27
+ path: stringOption(args, "path"),
28
+ module: stringOption(args, "module"),
29
+ owner: stringOption(args, "owner"),
30
+ rerank: flagPresent(args, "no-rerank") ? false : booleanOption(args, "rerank") ?? defaults.rerank,
31
+ includeRankingSignals: booleanOption(args, "ranking_signals", "ranking-signals") ?? false,
32
+ };
33
+ }
34
+
35
+ export function listRequestFromArgs(args: RawArgs): ListTrapsArgs {
36
+ return {
37
+ category: stringOption(args, "category"),
38
+ scope: stringOption(args, "scope"),
39
+ status: stringOption(args, "status"),
40
+ path: stringOption(args, "path"),
41
+ module: stringOption(args, "module"),
42
+ owner: stringOption(args, "owner"),
43
+ limit: intOption(args, "limit", 50),
44
+ };
45
+ }
46
+
47
+ export function statsRequestFromArgs(args: RawArgs): StatsRequest {
48
+ return {
49
+ scope: stringOption(args, "scope"),
50
+ };
51
+ }
52
+
53
+ export function embedRequestFromArgs(args: RawArgs): EmbedRequest {
54
+ return {
55
+ scope: stringOption(args, "scope"),
56
+ category: stringOption(args, "category"),
57
+ limit: optionalIntOption(args, "limit"),
58
+ force: booleanOption(args, "force") === true,
59
+ batchSize: optionalIntOption(args, "batch_size", "batch-size"),
60
+ };
61
+ }
62
+
63
+ export function evidenceRequestFromArgs(args: RawArgs): RawArgs {
64
+ return {
65
+ source_type: stringOption(args, "source_type", "source-type"),
66
+ source_ref: stringOption(args, "source_ref", "source-ref"),
67
+ observed_at: stringOption(args, "observed_at", "observed-at"),
68
+ related_files: csvOrArrayOption(args, "related_files", "related-files"),
69
+ note: stringOption(args, "note"),
70
+ };
71
+ }
72
+
73
+ function stringOption(args: RawArgs, ...keys: string[]): string | undefined {
74
+ for (const key of keys) {
75
+ const value = args[key];
76
+ if (typeof value === "string" && value.trim() !== "") return value;
77
+ }
78
+ return undefined;
79
+ }
80
+
81
+ function intOption(args: RawArgs, key: string, fallback: number): number {
82
+ return optionalIntOption(args, key) ?? fallback;
83
+ }
84
+
85
+ function optionalIntOption(args: RawArgs, ...keys: string[]): number | undefined {
86
+ for (const key of keys) {
87
+ const value = args[key];
88
+ if (value === undefined) continue;
89
+ const parsed = typeof value === "number" ? value : Number.parseInt(String(value), 10);
90
+ if (Number.isInteger(parsed) && parsed > 0) return parsed;
91
+ throw new Error(`Invalid number: ${String(value)}`);
92
+ }
93
+ return undefined;
94
+ }
95
+
96
+ function searchModeOption(args: RawArgs, key: string): SearchMode | undefined {
97
+ const value = stringOption(args, key);
98
+ if (!value) return undefined;
99
+ if ((SEARCH_MODES as readonly string[]).includes(value)) return value as SearchMode;
100
+ throw new Error(`Invalid search mode: ${value}. Expected one of: ${SEARCH_MODES.join(", ")}`);
101
+ }
102
+
103
+ function booleanOption(args: RawArgs, ...keys: string[]): boolean | undefined {
104
+ for (const key of keys) {
105
+ const value = args[key];
106
+ if (value === undefined) continue;
107
+ if (typeof value === "boolean") return value;
108
+ if (typeof value === "string") {
109
+ if (["true", "1", "yes", "on"].includes(value.toLowerCase())) return true;
110
+ if (["false", "0", "no", "off"].includes(value.toLowerCase())) return false;
111
+ }
112
+ throw new Error(`Invalid boolean: ${String(value)}`);
113
+ }
114
+ return undefined;
115
+ }
116
+
117
+ function flagPresent(args: RawArgs, key: string): boolean {
118
+ return args[key] !== undefined;
119
+ }
120
+
121
+ function csvOrArrayOption(args: RawArgs, ...keys: string[]): string[] | undefined {
122
+ for (const key of keys) {
123
+ const value = args[key];
124
+ if (Array.isArray(value)) return value.map(String);
125
+ if (typeof value === "string" && value.trim() !== "") {
126
+ return value
127
+ .split(",")
128
+ .map((item) => item.trim())
129
+ .filter(Boolean);
130
+ }
131
+ }
132
+ return undefined;
133
+ }
@@ -37,7 +37,7 @@ export type Scope = (typeof SCOPES)[number];
37
37
  export const TRAP_STATUSES = ["active", "superseded", "archived"] as const;
38
38
  export type TrapStatus = (typeof TRAP_STATUSES)[number];
39
39
 
40
- export const EVIDENCE_SOURCE_TYPES = ["manual", "conversation", "commit", "issue", "test_failure"] as const;
40
+ export const EVIDENCE_SOURCE_TYPES = ["manual", "conversation", "commit", "issue", "test_failure", "article"] as const;
41
41
  export type EvidenceSourceType = (typeof EVIDENCE_SOURCE_TYPES)[number];
42
42
 
43
43
  export const SEARCH_MODES = ["fts", "semantic", "hybrid"] as const;
package/src/lib/doctor.ts CHANGED
@@ -18,6 +18,11 @@ export type DoctorReport = {
18
18
  semantic_available: boolean;
19
19
  fallback_reason: HybridFallbackReason | null;
20
20
  };
21
+ diagnostics: {
22
+ mis_scoped_traps: {
23
+ global_db_project_traps: ReturnType<TrapStore["diagnostics"]>["mis_scoped_traps"]["global_db_project_traps"];
24
+ };
25
+ };
21
26
  mcp_hint: string;
22
27
  };
23
28
 
@@ -35,13 +40,16 @@ export function buildDoctorReport(
35
40
  ...scope,
36
41
  traps: {
37
42
  project: stats.project?.total ?? null,
38
- global: stats.global.total,
43
+ global: stats.global?.total ?? 0,
39
44
  },
40
45
  embeddings,
41
46
  hybrid_search: {
42
47
  semantic_available: semanticAvailable,
43
48
  fallback_reason: hybridFallbackReason(semanticAvailable, embeddings),
44
49
  },
50
+ diagnostics: {
51
+ mis_scoped_traps: store.diagnostics().mis_scoped_traps,
52
+ },
45
53
  mcp_hint: "Pass cwd in MCP tool calls, or restart codetrap serve after changing projects.",
46
54
  };
47
55
  }
@@ -60,6 +68,8 @@ export function formatDoctorText(report: DoctorReport): string {
60
68
  "Hybrid search:",
61
69
  ` semantic_available: ${report.hybrid_search.semantic_available ? "yes" : "no"}`,
62
70
  ` fallback_reason: ${report.hybrid_search.fallback_reason ?? "(none)"}`,
71
+ "Diagnostics:",
72
+ ` global_db_project_traps: ${report.diagnostics.mis_scoped_traps.global_db_project_traps.length}`,
63
73
  `mcp_hint: ${report.mcp_hint}`,
64
74
  ].join("\n");
65
75
  }
@@ -17,7 +17,7 @@ export type EmbeddingStateSummary = EmbeddingStateCounts & {
17
17
 
18
18
  export type EmbeddingStatsResult = {
19
19
  project: EmbeddingStateSummary | null;
20
- global: EmbeddingStateSummary;
20
+ global: EmbeddingStateSummary | null;
21
21
  };
22
22
 
23
23
  export type HybridFallbackReason = "semantic_unavailable" | "semantic_no_candidates";
@@ -42,8 +42,8 @@ export function hybridFallbackReason(
42
42
  ): HybridFallbackReason | null {
43
43
  if (!providerAvailable) return "semantic_unavailable";
44
44
 
45
- const fresh = (embeddings.project?.fresh ?? 0) + embeddings.global.fresh;
46
- const total = (embeddings.project?.total ?? 0) + embeddings.global.total;
45
+ const fresh = (embeddings.project?.fresh ?? 0) + (embeddings.global?.fresh ?? 0);
46
+ const total = (embeddings.project?.total ?? 0) + (embeddings.global?.total ?? 0);
47
47
  if (total > 0 && fresh === 0) return "semantic_no_candidates";
48
48
  return null;
49
49
  }
@@ -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: get_trap id=${card.next_action.details_args.id} scope=${card.next_action.details_args.scope}`;
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 {
@@ -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 = Omit<TrapActionCard, "next_action"> & {
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[]): 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
- ...stats.global,
107
- embeddings: embeddings?.global ?? null,
108
- },
128
+ global: stats.global
129
+ ? {
130
+ ...stats.global,
131
+ embeddings: embeddings?.global ?? null,
132
+ }
133
+ : null,
109
134
  };
110
135
  }
111
136
 
@@ -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 = resolve(cwd);
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 { Database } from "bun:sqlite";
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 { CODETRAP_DIR, TRAPS_DB_FILE } from "./constants";
9
- import { findProjectRoot } from "./scope";
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 = "repair-scope" | "migrate-project";
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
- validateMigrationPaths(resolved);
158
+ validateScopeMaintenancePaths(resolved);
155
159
  const sourceDbExists = existsSync(resolved.sourceDb);
156
160
  const destinationDbExists = existsSync(resolved.destinationDb);
157
161
  const records = sourceDbExists
158
- ? withReadOnlyRepository(resolved.sourceDb, (repository) =>
162
+ ? withReadOnlyScopeRepository(resolved.sourceDb, (repository) =>
159
163
  repository.exportProjectTrapsByPath(resolved.fromProjectPath)
160
164
  )
161
165
  : [];
162
166
  const destinationBefore = destinationDbExists
163
- ? withReadOnlyRepository(resolved.destinationDb, (repository) =>
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: backupDatabase(plan.sourceDb, "source"),
181
- destination_db: plan.destinationDbExists ? backupDatabase(plan.destinationDb, "destination") : null,
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 = resolve(options.cwd ?? process.cwd());
204
+ const cwd = resolveScopePath(options.cwd ?? process.cwd());
201
205
  const projectRoot = findProjectRoot(cwd);
202
- const fromProjectPath = resolve(options.fromProjectPath ?? homedir());
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 = resolve(rawToProjectPath);
212
+ const toProjectPath = resolveScopePath(rawToProjectPath);
209
213
 
210
214
  return {
211
- command: options.command,
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
  }