codetrap 0.1.2 → 0.1.3
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/.agents/plugins/marketplace.json +20 -0
- package/README.md +107 -32
- package/docs/installation.md +18 -10
- package/package.json +4 -1
- package/plugins/codetrap-agent/.codex-plugin/plugin.json +34 -0
- package/plugins/codetrap-agent/hooks/post-flight-capture.example.md +25 -0
- package/plugins/codetrap-agent/hooks/pre-edit.example.sh +10 -0
- package/plugins/codetrap-agent/hooks.json +11 -0
- package/plugins/codetrap-agent/skills/codetrap-capture/SKILL.md +19 -0
- package/plugins/codetrap-agent/skills/codetrap-check/SKILL.md +14 -0
- package/plugins/codetrap-agent/templates/AGENTS.codetrap.md +25 -0
- package/scripts/release-preflight.ts +55 -0
- package/skills/codetrap-add/SKILL.md +4 -1
- package/skills/codetrap-check/SKILL.md +24 -4
- package/skills/codetrap-search/SKILL.md +32 -12
- package/src/commands/command-result.ts +29 -0
- package/src/commands/router.ts +6 -400
- package/src/commands/workflow.ts +466 -0
- package/src/db/embedding-queries.ts +33 -0
- package/src/db/queries.ts +119 -20
- package/src/db/repository.ts +39 -2
- package/src/db/schema.ts +35 -0
- package/src/domain/trap.ts +31 -2
- package/src/index.ts +13 -1
- package/src/lib/config.ts +102 -0
- package/src/lib/constants.ts +1 -1
- package/src/lib/doctor.ts +76 -0
- package/src/lib/embedding-health.ts +49 -0
- package/src/lib/format.ts +5 -1
- package/src/lib/output-json.ts +116 -0
- package/src/lib/scope-context.ts +116 -0
- package/src/lib/scope-migration.ts +360 -0
- package/src/lib/search-normalizer.ts +6 -0
- package/src/lib/search-policy.ts +276 -0
- package/src/lib/search-result-card.ts +1 -0
- package/src/lib/search-service.ts +36 -98
- package/src/lib/store.ts +96 -107
- package/src/lib/trap-archive.ts +9 -42
- package/src/lib/trap-codec.ts +113 -0
- package/src/lib/trap-json-fields.ts +12 -0
- package/src/lib/trap-mutation-result.ts +36 -0
- package/src/lib/trap-operations.ts +27 -6
- package/src/lib/trap-scope-match.ts +112 -0
- package/src/lib/trap-search-document.ts +8 -1
- package/src/lib/trap-transfer.ts +88 -0
- package/src/mcp/server.ts +75 -57
- package/src/mcp/tools.ts +32 -5
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import type { EmbeddingConfig } from "./embedder";
|
|
2
|
+
|
|
3
|
+
export type EmbeddingStateCounts = {
|
|
4
|
+
total: number;
|
|
5
|
+
fresh: number;
|
|
6
|
+
stale: number;
|
|
7
|
+
missing: number;
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
export type EmbeddingStateSummary = EmbeddingStateCounts & {
|
|
11
|
+
provider_available: boolean;
|
|
12
|
+
provider: string | null;
|
|
13
|
+
model: string | null;
|
|
14
|
+
dimensions: number | null;
|
|
15
|
+
passage_version: number | null;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export type EmbeddingStatsResult = {
|
|
19
|
+
project: EmbeddingStateSummary | null;
|
|
20
|
+
global: EmbeddingStateSummary;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
export type HybridFallbackReason = "semantic_unavailable" | "semantic_no_candidates";
|
|
24
|
+
|
|
25
|
+
export function summarizeEmbeddingState(
|
|
26
|
+
counts: EmbeddingStateCounts,
|
|
27
|
+
config: EmbeddingConfig | null
|
|
28
|
+
): EmbeddingStateSummary {
|
|
29
|
+
return {
|
|
30
|
+
...counts,
|
|
31
|
+
provider_available: config !== null,
|
|
32
|
+
provider: config?.provider ?? null,
|
|
33
|
+
model: config?.model ?? null,
|
|
34
|
+
dimensions: config?.dimensions ?? null,
|
|
35
|
+
passage_version: config?.passageVersion ?? null,
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function hybridFallbackReason(
|
|
40
|
+
providerAvailable: boolean,
|
|
41
|
+
embeddings: EmbeddingStatsResult
|
|
42
|
+
): HybridFallbackReason | null {
|
|
43
|
+
if (!providerAvailable) return "semantic_unavailable";
|
|
44
|
+
|
|
45
|
+
const fresh = (embeddings.project?.fresh ?? 0) + embeddings.global.fresh;
|
|
46
|
+
const total = (embeddings.project?.total ?? 0) + embeddings.global.total;
|
|
47
|
+
if (total > 0 && fresh === 0) return "semantic_no_candidates";
|
|
48
|
+
return null;
|
|
49
|
+
}
|
package/src/lib/format.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { CATEGORY_LABELS, SEVERITY_ICONS, type Category, type Severity } from "./constants";
|
|
2
2
|
import type { Trap, TrapActionCard, TrapDetails, TrapEvidence } from "../domain/trap";
|
|
3
|
-
import { parseEvidenceRelatedFiles, parseTrapTags } from "./trap-json-fields";
|
|
3
|
+
import { parseEvidenceRelatedFiles, parseTrapPathGlobs, parseTrapTags } from "./trap-json-fields";
|
|
4
4
|
export type { Trap } from "../domain/trap";
|
|
5
5
|
|
|
6
6
|
export function formatTrapShort(t: Trap, scopeLabel: string): string {
|
|
@@ -25,6 +25,7 @@ export function formatTrapDetail(t: Trap, scopeLabel: string): string {
|
|
|
25
25
|
const sev = SEVERITY_ICONS[t.severity as Severity] ?? t.severity;
|
|
26
26
|
const cat = CATEGORY_LABELS[t.category as Category] ?? t.category;
|
|
27
27
|
const tags = parseTrapTags(t.tags);
|
|
28
|
+
const pathGlobs = parseTrapPathGlobs(t.path_globs);
|
|
28
29
|
let out = `\
|
|
29
30
|
══════════════════════════════════════════
|
|
30
31
|
#${t.id} ${t.title}
|
|
@@ -33,6 +34,9 @@ Scope: ${scopeLabel} (${t.scope})
|
|
|
33
34
|
Severity: ${sev}
|
|
34
35
|
Category: ${cat}
|
|
35
36
|
Tags: ${tags.join(", ") || "-"}
|
|
37
|
+
Paths: ${pathGlobs.join(", ") || "-"}
|
|
38
|
+
Module: ${t.module ?? "-"}
|
|
39
|
+
Owner: ${t.owner ?? "-"}
|
|
36
40
|
Hit count: ${t.hit_count}
|
|
37
41
|
Created: ${t.created_at}
|
|
38
42
|
Updated: ${t.updated_at}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import type { RankingSignal, TrapActionCard, TrapDetails } from "../domain/trap";
|
|
2
|
+
import type { Scope } from "./constants";
|
|
3
|
+
import type { DoctorReport } from "./doctor";
|
|
4
|
+
import type { EmbeddingStatsResult } from "./embedding-health";
|
|
5
|
+
import type { TrapListGroup, TrapStatsResult } from "./trap-operations";
|
|
6
|
+
import {
|
|
7
|
+
toTrapEvidenceJson,
|
|
8
|
+
toTrapJson,
|
|
9
|
+
type JsonTrap,
|
|
10
|
+
type JsonTrapEvidence,
|
|
11
|
+
} from "./trap-codec";
|
|
12
|
+
|
|
13
|
+
export type JsonTrapDetails = {
|
|
14
|
+
scope: Scope;
|
|
15
|
+
trap: JsonTrap;
|
|
16
|
+
evidence: JsonTrapEvidence[];
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export type CliTrapActionCard = Omit<TrapActionCard, "next_action"> & {
|
|
20
|
+
next_action: {
|
|
21
|
+
command: string;
|
|
22
|
+
};
|
|
23
|
+
ranking_signals?: RankingSignal[];
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
export type McpTextResult = {
|
|
27
|
+
content: { type: "text"; text: string }[];
|
|
28
|
+
isError?: boolean;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
export type McpResourceResult = {
|
|
32
|
+
contents: { uri: string; mimeType: string; text: string }[];
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
export function toCliSearchJson(cards: TrapActionCard[]): CliTrapActionCard[] {
|
|
36
|
+
return cards.map((card) => ({
|
|
37
|
+
trap_id: card.trap_id,
|
|
38
|
+
scope: card.scope,
|
|
39
|
+
title: card.title,
|
|
40
|
+
why_relevant: card.why_relevant,
|
|
41
|
+
avoid: card.avoid,
|
|
42
|
+
do_instead: card.do_instead,
|
|
43
|
+
severity: card.severity,
|
|
44
|
+
score: card.score,
|
|
45
|
+
sources: card.sources,
|
|
46
|
+
...(card.ranking_signals ? { ranking_signals: card.ranking_signals } : {}),
|
|
47
|
+
next_action: {
|
|
48
|
+
command: `codetrap show ${card.trap_id} --scope ${card.scope} --json`,
|
|
49
|
+
},
|
|
50
|
+
}));
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function toMcpSearchJson(cards: TrapActionCard[]): TrapActionCard[] {
|
|
54
|
+
return cards;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function toMcpTextJson(value: unknown, isError = false): McpTextResult {
|
|
58
|
+
return {
|
|
59
|
+
content: [{ type: "text", text: JSON.stringify(value, null, 2) }],
|
|
60
|
+
...(isError ? { isError: true } : {}),
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function toMcpTextError(message: string): McpTextResult {
|
|
65
|
+
return toMcpTextJson({ error: message }, true);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function toMcpResourceJson(uri: string, value: unknown): McpResourceResult {
|
|
69
|
+
return {
|
|
70
|
+
contents: [{ uri, mimeType: "application/json", text: JSON.stringify(value, null, 2) }],
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function toMcpResourceText(uri: string, text: string): McpResourceResult {
|
|
75
|
+
return {
|
|
76
|
+
contents: [{ uri, mimeType: "text/plain", text }],
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function toTrapDetailsJson(details: TrapDetails): JsonTrapDetails {
|
|
81
|
+
return {
|
|
82
|
+
scope: details.scope,
|
|
83
|
+
trap: toTrapJson(details.trap),
|
|
84
|
+
evidence: details.evidence.map(toTrapEvidenceJson),
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function toListJson(groups: TrapListGroup[]): JsonTrap[] {
|
|
89
|
+
return groups.flatMap((group) =>
|
|
90
|
+
group.traps.map((trap) => ({
|
|
91
|
+
...toTrapJson(trap),
|
|
92
|
+
scope: group.scope,
|
|
93
|
+
}))
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function toStatsJson(stats: TrapStatsResult, embeddings?: EmbeddingStatsResult) {
|
|
98
|
+
return {
|
|
99
|
+
project: stats.project
|
|
100
|
+
? {
|
|
101
|
+
...stats.project,
|
|
102
|
+
embeddings: embeddings?.project ?? null,
|
|
103
|
+
}
|
|
104
|
+
: null,
|
|
105
|
+
global: {
|
|
106
|
+
...stats.global,
|
|
107
|
+
embeddings: embeddings?.global ?? null,
|
|
108
|
+
},
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export function toDoctorJson(input: DoctorReport): DoctorReport {
|
|
113
|
+
return input;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export { toTrapEvidenceJson, toTrapJson, type JsonTrap, type JsonTrapEvidence };
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { join, resolve } from "node:path";
|
|
2
|
+
import { openGlobal, openProject } from "../db/connection";
|
|
3
|
+
import { TrapRepository } from "../db/repository";
|
|
4
|
+
import { CODETRAP_DIR, TRAPS_DB_FILE } from "./constants";
|
|
5
|
+
import type { Scope } from "./constants";
|
|
6
|
+
import type { EmbeddingProvider } from "./embedder";
|
|
7
|
+
import { findProjectRoot, getGlobalDB } from "./scope";
|
|
8
|
+
|
|
9
|
+
export type ScopeContext = {
|
|
10
|
+
cwd: string;
|
|
11
|
+
project_root: string | null;
|
|
12
|
+
project_db: string | null;
|
|
13
|
+
global_db: string;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export function createScopeContext(cwd = process.cwd()): ScopeContext {
|
|
17
|
+
const resolvedCwd = resolve(cwd);
|
|
18
|
+
const projectRoot = findProjectRoot(resolvedCwd);
|
|
19
|
+
return {
|
|
20
|
+
cwd: resolvedCwd,
|
|
21
|
+
project_root: projectRoot,
|
|
22
|
+
project_db: projectRoot ? join(projectRoot, CODETRAP_DIR, TRAPS_DB_FILE) : null,
|
|
23
|
+
global_db: getGlobalDB(),
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export type ScopedRepository = {
|
|
28
|
+
scope: Scope;
|
|
29
|
+
repository: TrapRepository;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
export class ScopedRepositoryContext {
|
|
33
|
+
private readonly context: ScopeContext;
|
|
34
|
+
private globalRepository?: TrapRepository;
|
|
35
|
+
private projectRepository?: TrapRepository;
|
|
36
|
+
|
|
37
|
+
constructor(cwd = process.cwd(), private readonly embedder?: EmbeddingProvider) {
|
|
38
|
+
this.context = createScopeContext(cwd);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
hasProject(): boolean {
|
|
42
|
+
return this.context.project_root !== null;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
projectRoot(): string | null {
|
|
46
|
+
return this.context.project_root;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
repositoriesForRead(scope?: string): ScopedRepository[] {
|
|
50
|
+
const resolvedScope = optionalScope(scope);
|
|
51
|
+
if (resolvedScope) {
|
|
52
|
+
const entry = this.repositoryEntry(resolvedScope);
|
|
53
|
+
return entry ? [entry] : [];
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const repositories: ScopedRepository[] = [];
|
|
57
|
+
const project = this.repositoryEntry("project");
|
|
58
|
+
if (project) repositories.push(project);
|
|
59
|
+
repositories.push({ scope: "global", repository: this.globalRepo() });
|
|
60
|
+
return repositories;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
repositoriesForWrite(scope?: string): ScopedRepository[] {
|
|
64
|
+
return this.repositoriesForRead(scope);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
repositoryFor(scope: Scope): TrapRepository {
|
|
68
|
+
const entry = this.repositoryEntry(scope);
|
|
69
|
+
if (!entry) {
|
|
70
|
+
throw new Error("Not in a project. Run 'codetrap init' first, or use --scope global.");
|
|
71
|
+
}
|
|
72
|
+
return entry.repository;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
repositoryEntry(scope: Scope): ScopedRepository | null {
|
|
76
|
+
if (scope === "project") {
|
|
77
|
+
return this.context.project_root ? { scope, repository: this.projectRepo() } : null;
|
|
78
|
+
}
|
|
79
|
+
return { scope, repository: this.globalRepo() };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
private projectRepo(): TrapRepository {
|
|
83
|
+
const projectRoot = this.context.project_root;
|
|
84
|
+
if (!projectRoot) {
|
|
85
|
+
throw new Error("Not in a project. Run 'codetrap init' first, or use --scope global.");
|
|
86
|
+
}
|
|
87
|
+
if (!this.projectRepository) {
|
|
88
|
+
this.projectRepository = new TrapRepository(openProject(projectRoot), this.embedder);
|
|
89
|
+
}
|
|
90
|
+
return this.projectRepository;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
private globalRepo(): TrapRepository {
|
|
94
|
+
if (!this.globalRepository) {
|
|
95
|
+
this.globalRepository = new TrapRepository(openGlobal(), this.embedder);
|
|
96
|
+
}
|
|
97
|
+
return this.globalRepository;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export function normalizeScope(scope: string): Scope {
|
|
102
|
+
if (scope === "project" || scope === "global") return scope;
|
|
103
|
+
throw new Error(`Invalid scope: ${scope}`);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export function optionalScope(scope?: string): Scope | null {
|
|
107
|
+
if (!scope) return null;
|
|
108
|
+
return normalizeScope(scope);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export function storeForScopeContext<T extends { forCwd(cwd: string): T }>(store: T, cwd?: unknown): T {
|
|
112
|
+
if (typeof cwd === "string" && cwd.trim() !== "") {
|
|
113
|
+
return store.forCwd(cwd);
|
|
114
|
+
}
|
|
115
|
+
return store;
|
|
116
|
+
}
|
|
@@ -0,0 +1,360 @@
|
|
|
1
|
+
import { Database } from "bun:sqlite";
|
|
2
|
+
import { existsSync, mkdirSync, realpathSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
import { basename, dirname, join, resolve } from "node:path";
|
|
5
|
+
import { openDatabase } from "../db/connection";
|
|
6
|
+
import { TrapRepository } from "../db/repository";
|
|
7
|
+
import type { TrapExportRecord } from "../domain/trap";
|
|
8
|
+
import { CODETRAP_DIR, TRAPS_DB_FILE } from "./constants";
|
|
9
|
+
import { findProjectRoot } from "./scope";
|
|
10
|
+
import {
|
|
11
|
+
deleteTransferredSourceTraps,
|
|
12
|
+
importProjectTrapTransfer,
|
|
13
|
+
type TrapTransferMapping,
|
|
14
|
+
} from "./trap-transfer";
|
|
15
|
+
|
|
16
|
+
export type ScopeMigrationCommand = "repair-scope" | "migrate-project";
|
|
17
|
+
export type ScopeMigrationMode = "dry-run" | "apply";
|
|
18
|
+
|
|
19
|
+
export type ScopeMigrationCandidate = {
|
|
20
|
+
id: number;
|
|
21
|
+
title: string;
|
|
22
|
+
category: string;
|
|
23
|
+
severity: string;
|
|
24
|
+
status: string;
|
|
25
|
+
project_path: string | null;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
export type ScopeMigrationCounts = {
|
|
29
|
+
source_before: number;
|
|
30
|
+
source_after: number;
|
|
31
|
+
destination_before: number;
|
|
32
|
+
destination_after: number;
|
|
33
|
+
candidates: number;
|
|
34
|
+
moved: number;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
export type ScopeMigrationBackups = {
|
|
38
|
+
source_db: string | null;
|
|
39
|
+
destination_db: string | null;
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
export type ScopeMigrationResult = {
|
|
43
|
+
command: ScopeMigrationCommand;
|
|
44
|
+
mode: ScopeMigrationMode;
|
|
45
|
+
from_project_path: string;
|
|
46
|
+
to_project_path: string;
|
|
47
|
+
source_db: string;
|
|
48
|
+
destination_db: string;
|
|
49
|
+
source_db_exists: boolean;
|
|
50
|
+
destination_db_exists: boolean;
|
|
51
|
+
candidates: ScopeMigrationCandidate[];
|
|
52
|
+
moved: TrapTransferMapping[];
|
|
53
|
+
backups: ScopeMigrationBackups;
|
|
54
|
+
counts: ScopeMigrationCounts;
|
|
55
|
+
next_action: { command: string } | null;
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
export type ScopeMigrationOptions = {
|
|
59
|
+
command: ScopeMigrationCommand;
|
|
60
|
+
cwd?: string;
|
|
61
|
+
fromProjectPath?: string;
|
|
62
|
+
toProjectPath?: string;
|
|
63
|
+
apply?: boolean;
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
type ResolvedScopeMigration = {
|
|
67
|
+
command: ScopeMigrationCommand;
|
|
68
|
+
mode: ScopeMigrationMode;
|
|
69
|
+
apply: boolean;
|
|
70
|
+
fromProjectPath: string;
|
|
71
|
+
toProjectPath: string;
|
|
72
|
+
sourceDb: string;
|
|
73
|
+
destinationDb: string;
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
type ScopeMigrationPlan = ResolvedScopeMigration & {
|
|
77
|
+
sourceDbExists: boolean;
|
|
78
|
+
destinationDbExists: boolean;
|
|
79
|
+
records: TrapExportRecord[];
|
|
80
|
+
sourceBefore: number;
|
|
81
|
+
destinationBefore: number;
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
type ScopeMigrationApplyResult = {
|
|
85
|
+
moved: TrapTransferMapping[];
|
|
86
|
+
backups: ScopeMigrationBackups;
|
|
87
|
+
sourceAfter: number;
|
|
88
|
+
destinationAfter: number;
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
export function runScopeMigration(options: ScopeMigrationOptions): ScopeMigrationResult {
|
|
92
|
+
const plan = buildScopeMigrationPlan(options);
|
|
93
|
+
|
|
94
|
+
if (!plan.apply || plan.records.length === 0) {
|
|
95
|
+
return buildScopeMigrationResult(plan, {
|
|
96
|
+
moved: [],
|
|
97
|
+
backups: { source_db: null, destination_db: null },
|
|
98
|
+
sourceAfter: plan.sourceBefore,
|
|
99
|
+
destinationAfter: plan.destinationBefore,
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
return buildScopeMigrationResult(plan, applyScopeMigrationPlan(plan));
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export function formatScopeMigrationText(result: ScopeMigrationResult): string {
|
|
107
|
+
const lines = [
|
|
108
|
+
`${result.command} ${result.mode}`,
|
|
109
|
+
`from_project_path: ${result.from_project_path}`,
|
|
110
|
+
`to_project_path: ${result.to_project_path}`,
|
|
111
|
+
`source_db: ${result.source_db}${result.source_db_exists ? "" : " (missing)"}`,
|
|
112
|
+
`destination_db: ${result.destination_db}${result.destination_db_exists ? "" : " (missing)"}`,
|
|
113
|
+
`candidates: ${result.counts.candidates}`,
|
|
114
|
+
];
|
|
115
|
+
|
|
116
|
+
if (result.candidates.length > 0) {
|
|
117
|
+
lines.push("candidate traps:");
|
|
118
|
+
lines.push(...result.candidates.map((trap) =>
|
|
119
|
+
` #${trap.id} [${trap.severity}] [${trap.category}] [${trap.status}] ${trap.title}`
|
|
120
|
+
));
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
if (result.mode === "apply") {
|
|
124
|
+
lines.push(`moved: ${result.counts.moved}`);
|
|
125
|
+
if (result.backups.source_db || result.backups.destination_db) {
|
|
126
|
+
lines.push("backups:");
|
|
127
|
+
if (result.backups.source_db) lines.push(` source_db: ${result.backups.source_db}`);
|
|
128
|
+
if (result.backups.destination_db) lines.push(` destination_db: ${result.backups.destination_db}`);
|
|
129
|
+
}
|
|
130
|
+
if (result.moved.length > 0) {
|
|
131
|
+
lines.push("id mapping:");
|
|
132
|
+
lines.push(...result.moved.map((item) =>
|
|
133
|
+
` #${item.source_id} -> #${item.destination_id} ${item.title}`
|
|
134
|
+
));
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
lines.push(
|
|
139
|
+
`source_count: ${result.counts.source_before} -> ${result.counts.source_after}`,
|
|
140
|
+
`destination_count: ${result.counts.destination_before} -> ${result.counts.destination_after}`
|
|
141
|
+
);
|
|
142
|
+
|
|
143
|
+
if (result.next_action) {
|
|
144
|
+
lines.push(`Next: ${result.next_action.command}`);
|
|
145
|
+
}
|
|
146
|
+
if (result.candidates.length === 0) {
|
|
147
|
+
lines.push("No matching project traps found.");
|
|
148
|
+
}
|
|
149
|
+
return lines.join("\n");
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function buildScopeMigrationPlan(options: ScopeMigrationOptions): ScopeMigrationPlan {
|
|
153
|
+
const resolved = resolveScopeMigrationOptions(options);
|
|
154
|
+
validateMigrationPaths(resolved);
|
|
155
|
+
const sourceDbExists = existsSync(resolved.sourceDb);
|
|
156
|
+
const destinationDbExists = existsSync(resolved.destinationDb);
|
|
157
|
+
const records = sourceDbExists
|
|
158
|
+
? withReadOnlyRepository(resolved.sourceDb, (repository) =>
|
|
159
|
+
repository.exportProjectTrapsByPath(resolved.fromProjectPath)
|
|
160
|
+
)
|
|
161
|
+
: [];
|
|
162
|
+
const destinationBefore = destinationDbExists
|
|
163
|
+
? withReadOnlyRepository(resolved.destinationDb, (repository) =>
|
|
164
|
+
repository.countProjectTrapsByPath(resolved.toProjectPath)
|
|
165
|
+
)
|
|
166
|
+
: 0;
|
|
167
|
+
|
|
168
|
+
return {
|
|
169
|
+
...resolved,
|
|
170
|
+
sourceDbExists,
|
|
171
|
+
destinationDbExists,
|
|
172
|
+
records,
|
|
173
|
+
sourceBefore: records.length,
|
|
174
|
+
destinationBefore,
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function applyScopeMigrationPlan(plan: ScopeMigrationPlan): ScopeMigrationApplyResult {
|
|
179
|
+
const backups = {
|
|
180
|
+
source_db: backupDatabase(plan.sourceDb, "source"),
|
|
181
|
+
destination_db: plan.destinationDbExists ? backupDatabase(plan.destinationDb, "destination") : null,
|
|
182
|
+
};
|
|
183
|
+
|
|
184
|
+
const applyResult = applyScopeMigration(
|
|
185
|
+
plan.sourceDb,
|
|
186
|
+
plan.destinationDb,
|
|
187
|
+
plan.fromProjectPath,
|
|
188
|
+
plan.toProjectPath
|
|
189
|
+
);
|
|
190
|
+
|
|
191
|
+
return {
|
|
192
|
+
moved: applyResult.moved,
|
|
193
|
+
backups,
|
|
194
|
+
sourceAfter: applyResult.sourceAfter,
|
|
195
|
+
destinationAfter: applyResult.destinationAfter,
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function resolveScopeMigrationOptions(options: ScopeMigrationOptions): ResolvedScopeMigration {
|
|
200
|
+
const cwd = resolve(options.cwd ?? process.cwd());
|
|
201
|
+
const projectRoot = findProjectRoot(cwd);
|
|
202
|
+
const fromProjectPath = resolve(options.fromProjectPath ?? homedir());
|
|
203
|
+
const rawToProjectPath = options.toProjectPath ?? projectRoot;
|
|
204
|
+
|
|
205
|
+
if (!rawToProjectPath) {
|
|
206
|
+
throw new Error("Destination project not found. Run 'codetrap init' first, or pass --to-project-path.");
|
|
207
|
+
}
|
|
208
|
+
const toProjectPath = resolve(rawToProjectPath);
|
|
209
|
+
|
|
210
|
+
return {
|
|
211
|
+
command: options.command,
|
|
212
|
+
mode: options.apply ? "apply" : "dry-run",
|
|
213
|
+
apply: options.apply === true,
|
|
214
|
+
fromProjectPath,
|
|
215
|
+
toProjectPath,
|
|
216
|
+
sourceDb: projectDbPath(fromProjectPath),
|
|
217
|
+
destinationDb: projectDbPath(toProjectPath),
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
|
|
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
|
+
function applyScopeMigration(
|
|
240
|
+
sourceDbPath: string,
|
|
241
|
+
destinationDbPath: string,
|
|
242
|
+
fromProjectPath: string,
|
|
243
|
+
toProjectPath: string
|
|
244
|
+
): { moved: TrapTransferMapping[]; sourceAfter: number; destinationAfter: number } {
|
|
245
|
+
const sourceDb = openDatabase(sourceDbPath);
|
|
246
|
+
const destinationDb = openDatabase(destinationDbPath);
|
|
247
|
+
try {
|
|
248
|
+
const sourceRepository = new TrapRepository(sourceDb);
|
|
249
|
+
const destinationRepository = new TrapRepository(destinationDb);
|
|
250
|
+
const records = sourceRepository.exportProjectTrapsByPath(fromProjectPath);
|
|
251
|
+
const moved = importProjectTrapTransfer(destinationRepository, records, toProjectPath);
|
|
252
|
+
const deleted = deleteTransferredSourceTraps(sourceRepository, records);
|
|
253
|
+
if (deleted !== records.length) {
|
|
254
|
+
throw new Error(`Expected to delete ${records.length} source traps, deleted ${deleted}.`);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
return {
|
|
258
|
+
moved,
|
|
259
|
+
sourceAfter: sourceRepository.countProjectTrapsByPath(fromProjectPath),
|
|
260
|
+
destinationAfter: destinationRepository.countProjectTrapsByPath(toProjectPath),
|
|
261
|
+
};
|
|
262
|
+
} finally {
|
|
263
|
+
sourceDb.close();
|
|
264
|
+
destinationDb.close();
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
function buildScopeMigrationResult(
|
|
269
|
+
plan: ScopeMigrationPlan,
|
|
270
|
+
applied: ScopeMigrationApplyResult
|
|
271
|
+
): ScopeMigrationResult {
|
|
272
|
+
const candidates = plan.records.map(toCandidate);
|
|
273
|
+
return {
|
|
274
|
+
command: plan.command,
|
|
275
|
+
mode: plan.mode,
|
|
276
|
+
from_project_path: plan.fromProjectPath,
|
|
277
|
+
to_project_path: plan.toProjectPath,
|
|
278
|
+
source_db: plan.sourceDb,
|
|
279
|
+
destination_db: plan.destinationDb,
|
|
280
|
+
source_db_exists: plan.sourceDbExists,
|
|
281
|
+
destination_db_exists: plan.destinationDbExists,
|
|
282
|
+
candidates,
|
|
283
|
+
moved: applied.moved,
|
|
284
|
+
backups: applied.backups,
|
|
285
|
+
counts: {
|
|
286
|
+
source_before: plan.sourceBefore,
|
|
287
|
+
source_after: applied.sourceAfter,
|
|
288
|
+
destination_before: plan.destinationBefore,
|
|
289
|
+
destination_after: applied.destinationAfter,
|
|
290
|
+
candidates: candidates.length,
|
|
291
|
+
moved: applied.moved.length,
|
|
292
|
+
},
|
|
293
|
+
next_action: plan.mode === "dry-run" && candidates.length > 0
|
|
294
|
+
? { command: buildApplyCommand(plan.command, plan.fromProjectPath, plan.toProjectPath) }
|
|
295
|
+
: null,
|
|
296
|
+
};
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
function toCandidate(record: TrapExportRecord): ScopeMigrationCandidate {
|
|
300
|
+
return {
|
|
301
|
+
id: record.id,
|
|
302
|
+
title: record.title,
|
|
303
|
+
category: record.category,
|
|
304
|
+
severity: record.severity,
|
|
305
|
+
status: record.status,
|
|
306
|
+
project_path: record.project_path,
|
|
307
|
+
};
|
|
308
|
+
}
|
|
309
|
+
|
|
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
|
+
function buildApplyCommand(
|
|
334
|
+
command: ScopeMigrationCommand,
|
|
335
|
+
fromProjectPath: string,
|
|
336
|
+
toProjectPath: string
|
|
337
|
+
): string {
|
|
338
|
+
return [
|
|
339
|
+
"codetrap",
|
|
340
|
+
command,
|
|
341
|
+
"--from-project-path",
|
|
342
|
+
shellQuote(fromProjectPath),
|
|
343
|
+
"--to-project-path",
|
|
344
|
+
shellQuote(toProjectPath),
|
|
345
|
+
"--apply",
|
|
346
|
+
"--json",
|
|
347
|
+
].join(" ");
|
|
348
|
+
}
|
|
349
|
+
|
|
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
|
+
function shellQuote(value: string): string {
|
|
359
|
+
return `'${value.replace(/'/g, "'\\''")}'`;
|
|
360
|
+
}
|
|
@@ -18,6 +18,9 @@ export type SearchTextFields = {
|
|
|
18
18
|
tags?: string | string[];
|
|
19
19
|
before_code?: string | null;
|
|
20
20
|
after_code?: string | null;
|
|
21
|
+
path_globs?: string | string[] | null;
|
|
22
|
+
module?: string | null;
|
|
23
|
+
owner?: string | null;
|
|
21
24
|
};
|
|
22
25
|
|
|
23
26
|
export const SEARCH_TEXT_FIELD_NAMES = [
|
|
@@ -28,6 +31,9 @@ export const SEARCH_TEXT_FIELD_NAMES = [
|
|
|
28
31
|
"tags",
|
|
29
32
|
"before_code",
|
|
30
33
|
"after_code",
|
|
34
|
+
"path_globs",
|
|
35
|
+
"module",
|
|
36
|
+
"owner",
|
|
31
37
|
] as const;
|
|
32
38
|
|
|
33
39
|
export function bigramCJK(input: string): string[] {
|