codetrap 0.1.2 → 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/.agents/plugins/marketplace.json +20 -0
- package/README.md +112 -33
- 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 +419 -0
- package/src/db/embedding-queries.ts +33 -0
- package/src/db/queries.ts +165 -48
- package/src/db/repository.ts +72 -15
- package/src/db/schema.ts +35 -0
- package/src/domain/trap.ts +38 -10
- package/src/index.ts +13 -1
- package/src/lib/command-requests.ts +133 -0
- package/src/lib/config.ts +102 -0
- package/src/lib/constants.ts +1 -1
- package/src/lib/doctor.ts +86 -0
- package/src/lib/embedding-health.ts +49 -0
- package/src/lib/embedding-index.ts +53 -0
- package/src/lib/format.ts +6 -2
- package/src/lib/output-json.ts +141 -0
- package/src/lib/scope-context.ts +118 -0
- package/src/lib/scope-maintenance.ts +71 -0
- package/src/lib/scope-migration.ts +315 -0
- package/src/lib/scope-path.ts +99 -0
- package/src/lib/scope.ts +16 -11
- package/src/lib/search-normalizer.ts +6 -0
- package/src/lib/search-policy.ts +365 -0
- package/src/lib/search-result-card.ts +2 -7
- package/src/lib/search-service.ts +67 -120
- package/src/lib/store.ts +129 -108
- 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-lifecycle.ts +37 -0
- package/src/lib/trap-mutation-result.ts +36 -0
- package/src/lib/trap-operations.ts +30 -9
- 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 +77 -72
- package/src/mcp/tools.ts +32 -5
package/src/lib/store.ts
CHANGED
|
@@ -1,5 +1,4 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { TrapRepository, type TrapStats } from "../db/repository";
|
|
1
|
+
import type { TrapStats } from "../db/repository";
|
|
3
2
|
import {
|
|
4
3
|
type Trap,
|
|
5
4
|
type TrapDetails,
|
|
@@ -11,9 +10,15 @@ import {
|
|
|
11
10
|
type TrapUpdate,
|
|
12
11
|
} from "../domain/trap";
|
|
13
12
|
import type { SearchMode, TrapStatus } from "./constants";
|
|
14
|
-
import { createDefaultEmbeddingProvider, type EmbeddingProvider } from "./embedder";
|
|
15
|
-
import {
|
|
13
|
+
import { createDefaultEmbeddingProvider, embeddingConfig, type EmbeddingConfig, type EmbeddingProvider } from "./embedder";
|
|
14
|
+
import { summarizeEmbeddingState, type EmbeddingStateSummary, type EmbeddingStatsResult } from "./embedding-health";
|
|
15
|
+
import { normalizeScope, ScopedRepositoryContext, type ScopedRepository } from "./scope-context";
|
|
16
16
|
import { importTrapArchive } from "./trap-archive";
|
|
17
|
+
import {
|
|
18
|
+
resolveScopedMutation,
|
|
19
|
+
type AddTrapEvidenceResult,
|
|
20
|
+
type TrapMutationResult,
|
|
21
|
+
} from "./trap-mutation-result";
|
|
17
22
|
|
|
18
23
|
export {
|
|
19
24
|
type TrapInput,
|
|
@@ -26,47 +31,51 @@ export {
|
|
|
26
31
|
type TrapStats,
|
|
27
32
|
};
|
|
28
33
|
|
|
29
|
-
type
|
|
34
|
+
export type { EmbeddingStateSummary };
|
|
35
|
+
export type TrapEmbeddingStats = EmbeddingStatsResult;
|
|
30
36
|
|
|
31
37
|
export class TrapStore {
|
|
32
|
-
private
|
|
33
|
-
private globalRepository?: TrapRepository;
|
|
34
|
-
private projectRepository?: TrapRepository;
|
|
38
|
+
private readonly scopes: ScopedRepositoryContext;
|
|
35
39
|
private readonly embedder?: EmbeddingProvider;
|
|
36
40
|
|
|
37
41
|
constructor(cwd: string, embedder: EmbeddingProvider | undefined = createDefaultEmbeddingProvider()) {
|
|
38
|
-
this.projectRoot = findProjectRoot(cwd);
|
|
39
42
|
this.embedder = embedder;
|
|
43
|
+
this.scopes = new ScopedRepositoryContext(cwd, embedder);
|
|
40
44
|
}
|
|
41
45
|
|
|
42
46
|
add(input: TrapInput): { id: number; scope: string } {
|
|
43
47
|
const scope = normalizeScope(input.scope);
|
|
44
|
-
if (scope === "project" && !this.
|
|
48
|
+
if (scope === "project" && !this.scopes.hasProject()) {
|
|
45
49
|
throw new Error("Not in a project. Run 'codetrap init' first, or use --scope global.");
|
|
46
50
|
}
|
|
47
51
|
|
|
48
|
-
const id = this.repositoryFor(scope).add({
|
|
52
|
+
const id = this.scopes.repositoryFor(scope).add({
|
|
49
53
|
...input,
|
|
50
54
|
scope,
|
|
51
|
-
project_path: scope === "project" ? this.projectRoot : null,
|
|
55
|
+
project_path: scope === "project" ? this.scopes.projectRoot() : null,
|
|
52
56
|
});
|
|
53
57
|
return { id, scope };
|
|
54
58
|
}
|
|
55
59
|
|
|
56
60
|
async search(
|
|
57
61
|
query: string,
|
|
58
|
-
opts: { category?: string; scope?: string; limit?: number; mode?: SearchMode; status?: TrapStatus | "all" } = {}
|
|
62
|
+
opts: { category?: string; scope?: string; limit?: number; mode?: SearchMode; status?: TrapStatus | "all"; path?: string; module?: string; owner?: string; rerank?: boolean; includeRankingSignals?: boolean } = {}
|
|
59
63
|
): Promise<{ results: TrapSearchResult[]; scope: string }[]> {
|
|
60
64
|
const out: { results: TrapSearchResult[]; scope: string }[] = [];
|
|
61
65
|
const limit = opts.limit ?? 20;
|
|
62
66
|
|
|
63
|
-
for (const scoped of this.repositoriesForRead(opts.scope)) {
|
|
67
|
+
for (const scoped of this.scopes.repositoriesForRead(opts.scope)) {
|
|
64
68
|
const results = await scoped.repository.search(query, {
|
|
65
69
|
category: opts.category,
|
|
66
70
|
scope: scoped.scope,
|
|
67
71
|
limit,
|
|
68
72
|
mode: opts.mode ?? "hybrid",
|
|
69
73
|
status: opts.status,
|
|
74
|
+
path: opts.path,
|
|
75
|
+
module: opts.module,
|
|
76
|
+
owner: opts.owner,
|
|
77
|
+
rerank: opts.rerank,
|
|
78
|
+
includeRankingSignals: opts.includeRankingSignals,
|
|
70
79
|
});
|
|
71
80
|
if (results.length > 0) out.push({ results, scope: scoped.scope });
|
|
72
81
|
}
|
|
@@ -75,7 +84,7 @@ export class TrapStore {
|
|
|
75
84
|
}
|
|
76
85
|
|
|
77
86
|
get(id: number, scope?: string): { trap: Trap; scope: string } | null {
|
|
78
|
-
for (const scoped of this.repositoriesForRead(scope)) {
|
|
87
|
+
for (const scoped of this.scopes.repositoriesForRead(scope)) {
|
|
79
88
|
const trap = scoped.repository.get(id);
|
|
80
89
|
if (trap) return { trap, scope: scoped.scope };
|
|
81
90
|
}
|
|
@@ -83,17 +92,17 @@ export class TrapStore {
|
|
|
83
92
|
}
|
|
84
93
|
|
|
85
94
|
getDetails(id: number, scope?: string): TrapDetails | null {
|
|
86
|
-
for (const scoped of this.repositoriesForRead(scope)) {
|
|
95
|
+
for (const scoped of this.scopes.repositoriesForRead(scope)) {
|
|
87
96
|
const details = scoped.repository.getDetails(id, scoped.scope);
|
|
88
97
|
if (details) return details;
|
|
89
98
|
}
|
|
90
99
|
return null;
|
|
91
100
|
}
|
|
92
101
|
|
|
93
|
-
list(opts: { category?: string; scope?: string; limit?: number; offset?: number; status?: TrapStatus | "all" } = {}): { traps: Trap[]; scope: string }[] {
|
|
102
|
+
list(opts: { category?: string; scope?: string; limit?: number; offset?: number; status?: TrapStatus | "all"; path?: string; module?: string; owner?: string } = {}): { traps: Trap[]; scope: string }[] {
|
|
94
103
|
const out: { traps: Trap[]; scope: string }[] = [];
|
|
95
104
|
|
|
96
|
-
for (const scoped of this.repositoriesForRead(opts.scope)) {
|
|
105
|
+
for (const scoped of this.scopes.repositoriesForRead(opts.scope)) {
|
|
97
106
|
const traps = scoped.repository.list({ ...opts, scope: scoped.scope });
|
|
98
107
|
if (traps.length > 0) out.push({ traps, scope: scoped.scope });
|
|
99
108
|
}
|
|
@@ -101,36 +110,36 @@ export class TrapStore {
|
|
|
101
110
|
return out;
|
|
102
111
|
}
|
|
103
112
|
|
|
104
|
-
update(id: number, input: TrapUpdate, scope?: string):
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
}
|
|
109
|
-
return { scope: "global", success: false };
|
|
113
|
+
update(id: number, input: TrapUpdate, scope?: string): TrapMutationResult {
|
|
114
|
+
return this.resolveMutation(scope, (scoped) => ({
|
|
115
|
+
success: scoped.repository.update(id, input),
|
|
116
|
+
}));
|
|
110
117
|
}
|
|
111
118
|
|
|
112
|
-
delete(id: number, scope?: string):
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
}
|
|
117
|
-
return { scope: "global", success: false };
|
|
119
|
+
delete(id: number, scope?: string): TrapMutationResult {
|
|
120
|
+
return this.resolveMutation(scope, (scoped) => ({
|
|
121
|
+
success: scoped.repository.delete(id),
|
|
122
|
+
}));
|
|
118
123
|
}
|
|
119
124
|
|
|
120
|
-
addEvidence(id: number, input: TrapEvidenceInput, scope?: string):
|
|
121
|
-
|
|
125
|
+
addEvidence(id: number, input: TrapEvidenceInput, scope?: string): AddTrapEvidenceResult {
|
|
126
|
+
return this.resolveMutation(
|
|
127
|
+
scope,
|
|
128
|
+
(scoped) => {
|
|
122
129
|
const evidenceId = scoped.repository.addEvidence(id, input);
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
130
|
+
return {
|
|
131
|
+
evidence_id: evidenceId,
|
|
132
|
+
success: evidenceId !== null,
|
|
133
|
+
};
|
|
134
|
+
},
|
|
135
|
+
() => ({ evidence_id: null, success: false })
|
|
136
|
+
);
|
|
126
137
|
}
|
|
127
138
|
|
|
128
|
-
archive(id: number, scope?: string):
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
}
|
|
133
|
-
return { scope: "global", success: false };
|
|
139
|
+
archive(id: number, scope?: string): TrapMutationResult {
|
|
140
|
+
return this.resolveMutation(scope, (scoped) => ({
|
|
141
|
+
success: scoped.repository.archive(id),
|
|
142
|
+
}));
|
|
134
143
|
}
|
|
135
144
|
|
|
136
145
|
supersede(
|
|
@@ -138,16 +147,14 @@ export class TrapStore {
|
|
|
138
147
|
supersededById: number,
|
|
139
148
|
scope?: string,
|
|
140
149
|
stateKey?: string
|
|
141
|
-
):
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
}
|
|
146
|
-
return { scope: "global", success: false };
|
|
150
|
+
): TrapMutationResult {
|
|
151
|
+
return this.resolveMutation(scope, (scoped) => ({
|
|
152
|
+
success: scoped.repository.supersede(id, supersededById, stateKey),
|
|
153
|
+
}));
|
|
147
154
|
}
|
|
148
155
|
|
|
149
156
|
hit(id: number, scope?: string): void {
|
|
150
|
-
for (const scoped of this.repositoriesForRead(scope)) {
|
|
157
|
+
for (const scoped of this.scopes.repositoriesForRead(scope)) {
|
|
151
158
|
if (scope || scoped.repository.get(id)) {
|
|
152
159
|
scoped.repository.hit(id);
|
|
153
160
|
return;
|
|
@@ -157,17 +164,60 @@ export class TrapStore {
|
|
|
157
164
|
|
|
158
165
|
topTraps(scope: string, limit = 20): Trap[] {
|
|
159
166
|
const resolvedScope = normalizeScope(scope);
|
|
160
|
-
return this.repositoryFor(resolvedScope).top(resolvedScope, limit);
|
|
167
|
+
return this.scopes.repositoryFor(resolvedScope).top(resolvedScope, limit);
|
|
161
168
|
}
|
|
162
169
|
|
|
163
|
-
stats(): { project: TrapStats | null; global: TrapStats } {
|
|
164
|
-
const
|
|
165
|
-
|
|
170
|
+
stats(opts: { scope?: string } = {}): { project: TrapStats | null; global: TrapStats | null } {
|
|
171
|
+
const scope = opts.scope ? normalizeScope(opts.scope) : null;
|
|
172
|
+
const project = scope === "global"
|
|
173
|
+
? null
|
|
174
|
+
: this.scopes.repositoryEntry("project")?.repository.stats({ scope: "project" }) ?? null;
|
|
175
|
+
const global = scope === "project"
|
|
176
|
+
? null
|
|
177
|
+
: this.scopes.repositoryFor("global").stats({ scope: "global" });
|
|
178
|
+
return { project, global };
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
embeddingStats(opts: { scope?: string } = {}): TrapEmbeddingStats {
|
|
182
|
+
const scope = opts.scope ? normalizeScope(opts.scope) : null;
|
|
183
|
+
const config = this.embeddingConfig();
|
|
184
|
+
const project = scope === "global"
|
|
185
|
+
? null
|
|
186
|
+
: this.scopes.repositoryEntry("project")
|
|
187
|
+
? summarizeEmbeddingState(this.scopes.repositoryFor("project").embeddingStats(config, { scope: "project" }), config)
|
|
188
|
+
: null;
|
|
189
|
+
const global = scope === "project"
|
|
190
|
+
? null
|
|
191
|
+
: summarizeEmbeddingState(this.scopes.repositoryFor("global").embeddingStats(config, { scope: "global" }), config);
|
|
192
|
+
return {
|
|
193
|
+
project,
|
|
194
|
+
global,
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
diagnostics(): {
|
|
199
|
+
mis_scoped_traps: {
|
|
200
|
+
global_db_project_traps: Pick<Trap, "id" | "title" | "scope" | "project_path" | "status">[];
|
|
201
|
+
};
|
|
202
|
+
} {
|
|
203
|
+
return {
|
|
204
|
+
mis_scoped_traps: {
|
|
205
|
+
global_db_project_traps: this.scopes.repositoryFor("global")
|
|
206
|
+
.listMisScoped("global")
|
|
207
|
+
.map((trap) => ({
|
|
208
|
+
id: trap.id,
|
|
209
|
+
title: trap.title,
|
|
210
|
+
scope: trap.scope,
|
|
211
|
+
project_path: trap.project_path,
|
|
212
|
+
status: trap.status,
|
|
213
|
+
})),
|
|
214
|
+
},
|
|
215
|
+
};
|
|
166
216
|
}
|
|
167
217
|
|
|
168
218
|
exportAll(opts: { scope?: string } = {}): TrapExportRecord[] {
|
|
169
219
|
const traps: TrapExportRecord[] = [];
|
|
170
|
-
for (const scoped of this.repositoriesForRead(opts.scope)) {
|
|
220
|
+
for (const scoped of this.scopes.repositoriesForRead(opts.scope)) {
|
|
171
221
|
traps.push(...scoped.repository.exportAll());
|
|
172
222
|
}
|
|
173
223
|
return traps;
|
|
@@ -181,11 +231,23 @@ export class TrapStore {
|
|
|
181
231
|
}
|
|
182
232
|
|
|
183
233
|
hasProject(): boolean {
|
|
184
|
-
return this.
|
|
234
|
+
return this.scopes.hasProject();
|
|
185
235
|
}
|
|
186
236
|
|
|
187
237
|
getProjectRoot(): string | null {
|
|
188
|
-
return this.projectRoot;
|
|
238
|
+
return this.scopes.projectRoot();
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
hasEmbeddingProvider(): boolean {
|
|
242
|
+
return this.embedder !== undefined;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
embeddingConfig(): EmbeddingConfig | null {
|
|
246
|
+
return this.embedder ? embeddingConfig(this.embedder) : null;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
forCwd(cwd: string): TrapStore {
|
|
250
|
+
return new TrapStore(cwd, this.embedder);
|
|
189
251
|
}
|
|
190
252
|
|
|
191
253
|
async ensureEmbeddings(opts: { scope?: string; category?: string; limit?: number; force?: boolean; batchSize?: number } = {}): Promise<{
|
|
@@ -199,7 +261,7 @@ export class TrapStore {
|
|
|
199
261
|
let skipped = 0;
|
|
200
262
|
let batches = 0;
|
|
201
263
|
|
|
202
|
-
for (const scoped of this.repositoriesForRead(opts.scope)) {
|
|
264
|
+
for (const scoped of this.scopes.repositoriesForRead(opts.scope)) {
|
|
203
265
|
const result = await scoped.repository.ensureEmbeddings({
|
|
204
266
|
scope: scoped.scope,
|
|
205
267
|
category: opts.category,
|
|
@@ -216,57 +278,16 @@ export class TrapStore {
|
|
|
216
278
|
return { generated, skipped, batches, scopes };
|
|
217
279
|
}
|
|
218
280
|
|
|
219
|
-
private
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
281
|
+
private resolveMutation<TExtra extends object = {}>(
|
|
282
|
+
scope: string | undefined,
|
|
283
|
+
mutate: (scoped: ScopedRepository) => { success: boolean } & TExtra,
|
|
284
|
+
fallback: () => { success: false } & TExtra = () => ({ success: false } as { success: false } & TExtra)
|
|
285
|
+
): { scope: "project" | "global"; success: boolean } & TExtra {
|
|
286
|
+
return resolveScopedMutation(
|
|
287
|
+
this.scopes.repositoriesForWrite(scope),
|
|
288
|
+
{ explicitScope: scope !== undefined },
|
|
289
|
+
mutate,
|
|
290
|
+
fallback
|
|
291
|
+
);
|
|
228
292
|
}
|
|
229
|
-
|
|
230
|
-
private repositoriesForWrite(scope?: string): { scope: Scope; repository: TrapRepository }[] {
|
|
231
|
-
return this.repositoriesForRead(scope);
|
|
232
|
-
}
|
|
233
|
-
|
|
234
|
-
private repositoryEntry(scope: Scope): { scope: Scope; repository: TrapRepository } | null {
|
|
235
|
-
if (scope === "project") {
|
|
236
|
-
return this.projectRoot ? { scope, repository: this.projectRepo() } : null;
|
|
237
|
-
}
|
|
238
|
-
return { scope, repository: this.globalRepo() };
|
|
239
|
-
}
|
|
240
|
-
|
|
241
|
-
private repositoryFor(scope: Scope): TrapRepository {
|
|
242
|
-
if (scope === "project") return this.projectRepo();
|
|
243
|
-
return this.globalRepo();
|
|
244
|
-
}
|
|
245
|
-
|
|
246
|
-
private projectRepo(): TrapRepository {
|
|
247
|
-
if (!this.projectRoot) {
|
|
248
|
-
throw new Error("Not in a project. Run 'codetrap init' first, or use --scope global.");
|
|
249
|
-
}
|
|
250
|
-
if (!this.projectRepository) {
|
|
251
|
-
this.projectRepository = new TrapRepository(openProject(this.projectRoot), this.embedder);
|
|
252
|
-
}
|
|
253
|
-
return this.projectRepository;
|
|
254
|
-
}
|
|
255
|
-
|
|
256
|
-
private globalRepo(): TrapRepository {
|
|
257
|
-
if (!this.globalRepository) {
|
|
258
|
-
this.globalRepository = new TrapRepository(openGlobal(), this.embedder);
|
|
259
|
-
}
|
|
260
|
-
return this.globalRepository;
|
|
261
|
-
}
|
|
262
|
-
}
|
|
263
|
-
|
|
264
|
-
function normalizeScope(scope: string): Scope {
|
|
265
|
-
if (scope === "project" || scope === "global") return scope;
|
|
266
|
-
throw new Error(`Invalid scope: ${scope}`);
|
|
267
|
-
}
|
|
268
|
-
|
|
269
|
-
function optionalScope(scope?: string): Scope | null {
|
|
270
|
-
if (!scope) return null;
|
|
271
|
-
return normalizeScope(scope);
|
|
272
293
|
}
|
package/src/lib/trap-archive.ts
CHANGED
|
@@ -1,17 +1,17 @@
|
|
|
1
1
|
import {
|
|
2
|
-
buildTrapEvidenceInput,
|
|
3
|
-
type TrapEvidence,
|
|
4
2
|
type TrapEvidenceInput,
|
|
5
|
-
type TrapExportEvidence,
|
|
6
3
|
type TrapImportEvidence,
|
|
7
4
|
type TrapImportRecord,
|
|
8
5
|
type TrapInput,
|
|
9
6
|
} from "../domain/trap";
|
|
10
7
|
import {
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
8
|
+
importEvidenceToTrapInput,
|
|
9
|
+
importRecordToTrapInput,
|
|
10
|
+
normalizeEvidenceForExport,
|
|
11
|
+
normalizeTrapForExport,
|
|
12
|
+
} from "./trap-codec";
|
|
13
|
+
|
|
14
|
+
export { normalizeEvidenceForExport, normalizeTrapForExport };
|
|
15
15
|
|
|
16
16
|
export interface TrapArchiveImportAdapter {
|
|
17
17
|
add(input: TrapInput): { id: number; scope: string };
|
|
@@ -22,13 +22,6 @@ export interface TrapArchiveImportAdapter {
|
|
|
22
22
|
): { scope: string; evidence_id: number | null; success: boolean };
|
|
23
23
|
}
|
|
24
24
|
|
|
25
|
-
export function normalizeEvidenceForExport(evidence: TrapEvidence): TrapExportEvidence {
|
|
26
|
-
return {
|
|
27
|
-
...evidence,
|
|
28
|
-
related_files: parseEvidenceRelatedFiles(evidence.related_files),
|
|
29
|
-
};
|
|
30
|
-
}
|
|
31
|
-
|
|
32
25
|
export function importTrapArchive(
|
|
33
26
|
records: TrapImportRecord[],
|
|
34
27
|
adapter: TrapArchiveImportAdapter
|
|
@@ -36,7 +29,7 @@ export function importTrapArchive(
|
|
|
36
29
|
let count = 0;
|
|
37
30
|
for (const record of records) {
|
|
38
31
|
try {
|
|
39
|
-
const result = adapter.add(
|
|
32
|
+
const result = adapter.add(importRecordToTrapInput(record));
|
|
40
33
|
importEvidenceRecords(record.evidence ?? [], result, adapter);
|
|
41
34
|
count++;
|
|
42
35
|
} catch {
|
|
@@ -55,7 +48,7 @@ function importEvidenceRecords(
|
|
|
55
48
|
try {
|
|
56
49
|
adapter.addEvidence(
|
|
57
50
|
trap.id,
|
|
58
|
-
|
|
51
|
+
importEvidenceToTrapInput(evidence),
|
|
59
52
|
trap.scope
|
|
60
53
|
);
|
|
61
54
|
} catch {
|
|
@@ -63,29 +56,3 @@ function importEvidenceRecords(
|
|
|
63
56
|
}
|
|
64
57
|
}
|
|
65
58
|
}
|
|
66
|
-
|
|
67
|
-
function toTrapInput(record: TrapImportRecord): TrapInput {
|
|
68
|
-
return {
|
|
69
|
-
title: record.title,
|
|
70
|
-
category: record.category,
|
|
71
|
-
tags: parseOptionalTrapTags(record.tags),
|
|
72
|
-
scope: record.scope,
|
|
73
|
-
context: record.context,
|
|
74
|
-
mistake: record.mistake,
|
|
75
|
-
fix: record.fix,
|
|
76
|
-
before_code: record.before_code ?? undefined,
|
|
77
|
-
after_code: record.after_code ?? undefined,
|
|
78
|
-
severity: record.severity ?? undefined,
|
|
79
|
-
project_path: record.project_path ?? undefined,
|
|
80
|
-
};
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
function toEvidenceInput(evidence: TrapImportEvidence): Record<string, unknown> {
|
|
84
|
-
return {
|
|
85
|
-
source_type: evidence.source_type,
|
|
86
|
-
source_ref: evidence.source_ref ?? undefined,
|
|
87
|
-
observed_at: evidence.observed_at ?? undefined,
|
|
88
|
-
related_files: parseOptionalEvidenceRelatedFiles(evidence.related_files),
|
|
89
|
-
note: evidence.note ?? undefined,
|
|
90
|
-
};
|
|
91
|
-
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
Trap,
|
|
3
|
+
TrapEvidence,
|
|
4
|
+
TrapEvidenceInput,
|
|
5
|
+
TrapExportEvidence,
|
|
6
|
+
TrapExportRecord,
|
|
7
|
+
TrapImportEvidence,
|
|
8
|
+
TrapImportRecord,
|
|
9
|
+
TrapInput,
|
|
10
|
+
TrapUpdate,
|
|
11
|
+
} from "../domain/trap";
|
|
12
|
+
import { buildTrapEvidenceInput } from "../domain/trap";
|
|
13
|
+
import {
|
|
14
|
+
parseEvidenceRelatedFiles,
|
|
15
|
+
parseOptionalEvidenceRelatedFiles,
|
|
16
|
+
parseOptionalTrapPathGlobs,
|
|
17
|
+
parseOptionalTrapTags,
|
|
18
|
+
parseTrapPathGlobs,
|
|
19
|
+
parseTrapTags,
|
|
20
|
+
encodeTrapPathGlobs,
|
|
21
|
+
encodeTrapTags,
|
|
22
|
+
} from "./trap-json-fields";
|
|
23
|
+
import { buildTrapSearchText } from "./trap-search-document";
|
|
24
|
+
|
|
25
|
+
export type JsonTrap = Omit<Trap, "tags" | "path_globs"> & {
|
|
26
|
+
tags: string[];
|
|
27
|
+
path_globs: string[];
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
export type JsonTrapEvidence = Omit<TrapEvidence, "related_files"> & {
|
|
31
|
+
related_files: string[];
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
export function toTrapJson(trap: Trap): JsonTrap {
|
|
35
|
+
return {
|
|
36
|
+
...trap,
|
|
37
|
+
tags: parseTrapTags(trap.tags),
|
|
38
|
+
path_globs: parseTrapPathGlobs(trap.path_globs),
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function toTrapEvidenceJson(evidence: TrapEvidence): JsonTrapEvidence {
|
|
43
|
+
return {
|
|
44
|
+
...evidence,
|
|
45
|
+
related_files: parseEvidenceRelatedFiles(evidence.related_files),
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function normalizeTrapForExport(trap: Trap): Omit<TrapExportRecord, "evidence"> {
|
|
50
|
+
return {
|
|
51
|
+
...trap,
|
|
52
|
+
path_globs: parseTrapPathGlobs(trap.path_globs),
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function normalizeEvidenceForExport(evidence: TrapEvidence): TrapExportEvidence {
|
|
57
|
+
return {
|
|
58
|
+
...evidence,
|
|
59
|
+
related_files: parseEvidenceRelatedFiles(evidence.related_files),
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function importRecordToTrapInput(record: TrapImportRecord): TrapInput {
|
|
64
|
+
return {
|
|
65
|
+
title: record.title,
|
|
66
|
+
category: record.category,
|
|
67
|
+
tags: parseOptionalTrapTags(record.tags),
|
|
68
|
+
scope: record.scope,
|
|
69
|
+
context: record.context,
|
|
70
|
+
mistake: record.mistake,
|
|
71
|
+
fix: record.fix,
|
|
72
|
+
before_code: record.before_code ?? undefined,
|
|
73
|
+
after_code: record.after_code ?? undefined,
|
|
74
|
+
severity: record.severity ?? undefined,
|
|
75
|
+
project_path: record.project_path ?? undefined,
|
|
76
|
+
path_globs: parseOptionalTrapPathGlobs(record.path_globs),
|
|
77
|
+
module: record.module ?? undefined,
|
|
78
|
+
owner: record.owner ?? undefined,
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function importEvidenceToTrapInput(evidence: TrapImportEvidence): TrapEvidenceInput {
|
|
83
|
+
return buildTrapEvidenceInput({
|
|
84
|
+
source_type: evidence.source_type,
|
|
85
|
+
source_ref: evidence.source_ref ?? undefined,
|
|
86
|
+
observed_at: evidence.observed_at ?? undefined,
|
|
87
|
+
related_files: parseOptionalEvidenceRelatedFiles(evidence.related_files),
|
|
88
|
+
note: evidence.note ?? undefined,
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function encodeTrapInsertFields(input: TrapInput): {
|
|
93
|
+
tags: string;
|
|
94
|
+
path_globs: string;
|
|
95
|
+
search_text: string;
|
|
96
|
+
} {
|
|
97
|
+
const tags = encodeTrapTags(input.tags);
|
|
98
|
+
const pathGlobs = encodeTrapPathGlobs(input.path_globs);
|
|
99
|
+
return {
|
|
100
|
+
tags,
|
|
101
|
+
path_globs: pathGlobs,
|
|
102
|
+
search_text: buildTrapSearchText({ ...input, tags, path_globs: pathGlobs }),
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export function mergeTrapUpdateForSearchText(current: Trap, input: TrapUpdate): Trap {
|
|
107
|
+
return {
|
|
108
|
+
...current,
|
|
109
|
+
...input,
|
|
110
|
+
tags: input.tags !== undefined ? encodeTrapTags(input.tags) : current.tags,
|
|
111
|
+
path_globs: input.path_globs !== undefined ? encodeTrapPathGlobs(input.path_globs) : current.path_globs,
|
|
112
|
+
};
|
|
113
|
+
}
|
|
@@ -12,6 +12,18 @@ export function encodeTrapTags(value: TrapStringArrayInput): string {
|
|
|
12
12
|
return JSON.stringify(parseTrapTags(value));
|
|
13
13
|
}
|
|
14
14
|
|
|
15
|
+
export function parseTrapPathGlobs(value: TrapStringArrayInput): string[] {
|
|
16
|
+
return parseStringArrayField(value);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function parseOptionalTrapPathGlobs(value: TrapStringArrayInput): string[] | undefined {
|
|
20
|
+
return emptyToUndefined(parseTrapPathGlobs(value));
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function encodeTrapPathGlobs(value: TrapStringArrayInput): string {
|
|
24
|
+
return JSON.stringify(parseTrapPathGlobs(value));
|
|
25
|
+
}
|
|
26
|
+
|
|
15
27
|
export function parseEvidenceRelatedFiles(value: TrapStringArrayInput): string[] {
|
|
16
28
|
return parseStringArrayField(value);
|
|
17
29
|
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import type { Trap } from "../domain/trap";
|
|
2
|
+
|
|
3
|
+
export type TrapLifecycleAdapter = {
|
|
4
|
+
get(id: number): Trap | null;
|
|
5
|
+
transaction<T>(callback: () => T): T;
|
|
6
|
+
markArchived(id: number): boolean;
|
|
7
|
+
markSuperseded(id: number, stateKey: string): boolean;
|
|
8
|
+
markSuperseding(id: number, supersedesId: number, stateKey: string): boolean;
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
export function archiveTrapLifecycle(adapter: TrapLifecycleAdapter, id: number): boolean {
|
|
12
|
+
return adapter.markArchived(id);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function supersedeTrapLifecycle(
|
|
16
|
+
adapter: TrapLifecycleAdapter,
|
|
17
|
+
id: number,
|
|
18
|
+
supersededById: number,
|
|
19
|
+
stateKey?: string
|
|
20
|
+
): boolean {
|
|
21
|
+
if (id === supersededById) return false;
|
|
22
|
+
|
|
23
|
+
const oldTrap = adapter.get(id);
|
|
24
|
+
const newTrap = adapter.get(supersededById);
|
|
25
|
+
if (!oldTrap || !newTrap) return false;
|
|
26
|
+
|
|
27
|
+
const key = lifecycleStateKey(oldTrap, newTrap, stateKey);
|
|
28
|
+
return adapter.transaction(() => {
|
|
29
|
+
adapter.markSuperseded(id, key);
|
|
30
|
+
adapter.markSuperseding(supersededById, id, key);
|
|
31
|
+
return true;
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function lifecycleStateKey(oldTrap: Trap, newTrap: Trap, requested?: string): string {
|
|
36
|
+
return requested ?? oldTrap.state_key ?? newTrap.state_key ?? `trap:${oldTrap.id}`;
|
|
37
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import type { Scope } from "./constants";
|
|
2
|
+
|
|
3
|
+
export type TrapMutationResult = {
|
|
4
|
+
scope: Scope;
|
|
5
|
+
success: boolean;
|
|
6
|
+
};
|
|
7
|
+
|
|
8
|
+
export type AddTrapEvidenceResult = TrapMutationResult & {
|
|
9
|
+
evidence_id: number | null;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
export type ScopedMutationTarget = {
|
|
13
|
+
scope: Scope;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export function resolveScopedMutation<TTarget extends ScopedMutationTarget, TExtra extends object>(
|
|
17
|
+
targets: TTarget[],
|
|
18
|
+
opts: { explicitScope: boolean; fallbackScope?: Scope },
|
|
19
|
+
mutate: (target: TTarget) => { success: boolean } & TExtra,
|
|
20
|
+
fallback: () => { success: false } & TExtra
|
|
21
|
+
): ({ scope: Scope; success: boolean } & TExtra) {
|
|
22
|
+
for (const target of targets) {
|
|
23
|
+
const result = mutate(target);
|
|
24
|
+
if (result.success || opts.explicitScope) {
|
|
25
|
+
return { scope: target.scope, ...result };
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
return { scope: opts.fallbackScope ?? "global", ...fallback() };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function mutationJsonPayload<T extends { success: boolean }>(
|
|
32
|
+
value: T,
|
|
33
|
+
error: string
|
|
34
|
+
): T | (T & { error: string }) {
|
|
35
|
+
return value.success ? value : { ...value, error };
|
|
36
|
+
}
|