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.
Files changed (53) hide show
  1. package/.agents/plugins/marketplace.json +20 -0
  2. package/README.md +112 -33
  3. package/docs/installation.md +18 -10
  4. package/package.json +4 -1
  5. package/plugins/codetrap-agent/.codex-plugin/plugin.json +34 -0
  6. package/plugins/codetrap-agent/hooks/post-flight-capture.example.md +25 -0
  7. package/plugins/codetrap-agent/hooks/pre-edit.example.sh +10 -0
  8. package/plugins/codetrap-agent/hooks.json +11 -0
  9. package/plugins/codetrap-agent/skills/codetrap-capture/SKILL.md +19 -0
  10. package/plugins/codetrap-agent/skills/codetrap-check/SKILL.md +14 -0
  11. package/plugins/codetrap-agent/templates/AGENTS.codetrap.md +25 -0
  12. package/scripts/release-preflight.ts +55 -0
  13. package/skills/codetrap-add/SKILL.md +4 -1
  14. package/skills/codetrap-check/SKILL.md +24 -4
  15. package/skills/codetrap-search/SKILL.md +32 -12
  16. package/src/commands/command-result.ts +29 -0
  17. package/src/commands/router.ts +6 -400
  18. package/src/commands/workflow.ts +419 -0
  19. package/src/db/embedding-queries.ts +33 -0
  20. package/src/db/queries.ts +165 -48
  21. package/src/db/repository.ts +72 -15
  22. package/src/db/schema.ts +35 -0
  23. package/src/domain/trap.ts +38 -10
  24. package/src/index.ts +13 -1
  25. package/src/lib/command-requests.ts +133 -0
  26. package/src/lib/config.ts +102 -0
  27. package/src/lib/constants.ts +1 -1
  28. package/src/lib/doctor.ts +86 -0
  29. package/src/lib/embedding-health.ts +49 -0
  30. package/src/lib/embedding-index.ts +53 -0
  31. package/src/lib/format.ts +6 -2
  32. package/src/lib/output-json.ts +141 -0
  33. package/src/lib/scope-context.ts +118 -0
  34. package/src/lib/scope-maintenance.ts +71 -0
  35. package/src/lib/scope-migration.ts +315 -0
  36. package/src/lib/scope-path.ts +99 -0
  37. package/src/lib/scope.ts +16 -11
  38. package/src/lib/search-normalizer.ts +6 -0
  39. package/src/lib/search-policy.ts +365 -0
  40. package/src/lib/search-result-card.ts +2 -7
  41. package/src/lib/search-service.ts +67 -120
  42. package/src/lib/store.ts +129 -108
  43. package/src/lib/trap-archive.ts +9 -42
  44. package/src/lib/trap-codec.ts +113 -0
  45. package/src/lib/trap-json-fields.ts +12 -0
  46. package/src/lib/trap-lifecycle.ts +37 -0
  47. package/src/lib/trap-mutation-result.ts +36 -0
  48. package/src/lib/trap-operations.ts +30 -9
  49. package/src/lib/trap-scope-match.ts +112 -0
  50. package/src/lib/trap-search-document.ts +8 -1
  51. package/src/lib/trap-transfer.ts +88 -0
  52. package/src/mcp/server.ts +77 -72
  53. package/src/mcp/tools.ts +32 -5
package/src/lib/store.ts CHANGED
@@ -1,5 +1,4 @@
1
- import { openGlobal, openProject } from "../db/connection";
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 { findProjectRoot } from "./scope";
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 Scope = "project" | "global";
34
+ export type { EmbeddingStateSummary };
35
+ export type TrapEmbeddingStats = EmbeddingStatsResult;
30
36
 
31
37
  export class TrapStore {
32
- private projectRoot: string | null;
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.projectRoot) {
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): { scope: string; success: boolean } {
105
- for (const scoped of this.repositoriesForWrite(scope)) {
106
- const success = scoped.repository.update(id, input);
107
- if (success || scope) return { scope: scoped.scope, success };
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): { scope: string; success: boolean } {
113
- for (const scoped of this.repositoriesForWrite(scope)) {
114
- const success = scoped.repository.delete(id);
115
- if (success || scope) return { scope: scoped.scope, success };
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): { scope: string; evidence_id: number | null; success: boolean } {
121
- for (const scoped of this.repositoriesForWrite(scope)) {
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
- if (evidenceId || scope) return { scope: scoped.scope, evidence_id: evidenceId, success: evidenceId !== null };
124
- }
125
- return { scope: "global", evidence_id: null, success: false };
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): { scope: string; success: boolean } {
129
- for (const scoped of this.repositoriesForWrite(scope)) {
130
- const success = scoped.repository.archive(id);
131
- if (success || scope) return { scope: scoped.scope, success };
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
- ): { scope: string; success: boolean } {
142
- for (const scoped of this.repositoriesForWrite(scope)) {
143
- const success = scoped.repository.supersede(id, supersededById, stateKey);
144
- if (success || scope) return { scope: scoped.scope, success };
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 project = this.projectRoot ? this.projectRepo().stats() : null;
165
- return { project, global: this.globalRepo().stats() };
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.projectRoot !== null;
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 repositoriesForRead(scope?: string): { scope: Scope; repository: TrapRepository }[] {
220
- const resolvedScope = optionalScope(scope);
221
- if (resolvedScope) return this.repositoryEntry(resolvedScope) ? [this.repositoryEntry(resolvedScope)!] : [];
222
-
223
- const repositories: { scope: Scope; repository: TrapRepository }[] = [];
224
- const project = this.repositoryEntry("project");
225
- if (project) repositories.push(project);
226
- repositories.push({ scope: "global", repository: this.globalRepo() });
227
- return repositories;
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
  }
@@ -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
- parseEvidenceRelatedFiles,
12
- parseOptionalEvidenceRelatedFiles,
13
- parseOptionalTrapTags,
14
- } from "./trap-json-fields";
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(toTrapInput(record));
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
- buildTrapEvidenceInput(toEvidenceInput(evidence)),
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
+ }