trekoon 0.1.9 → 0.2.1

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.
@@ -3,6 +3,14 @@ import { randomUUID } from "node:crypto";
3
3
  import { Database } from "bun:sqlite";
4
4
 
5
5
  import {
6
+ type CompactEpicExpandResult,
7
+ type CompactDependencyBatchAddResult,
8
+ type CompactDependencySpec,
9
+ type CompactEntityRef,
10
+ type CompactSubtaskBatchCreateResult,
11
+ type CompactSubtaskSpec,
12
+ type CompactTaskBatchCreateResult,
13
+ type CompactTaskSpec,
6
14
  type DependencyRecord,
7
15
  DomainError,
8
16
  type EpicTreeDetailed,
@@ -10,6 +18,11 @@ import {
10
18
  type EpicTree,
11
19
  type NodeKind,
12
20
  type ReverseDependencyNode,
21
+ type SearchEntityMatch,
22
+ type SearchField,
23
+ type SearchFieldMatch,
24
+ type SearchNode,
25
+ type SearchSummary,
13
26
  type SubtaskRecord,
14
27
  type TaskTreeDetailed,
15
28
  type TaskRecord,
@@ -57,6 +70,47 @@ interface UnresolvedDependencyBlocker {
57
70
  readonly status: string;
58
71
  }
59
72
 
73
+ interface ValidatedTaskBatchSpec {
74
+ readonly tempKey: string;
75
+ readonly title: string;
76
+ readonly description: string;
77
+ readonly status: string;
78
+ }
79
+
80
+ interface ValidatedSubtaskBatchSpec {
81
+ readonly tempKey: string;
82
+ readonly taskId: string;
83
+ readonly title: string;
84
+ readonly description: string;
85
+ readonly status: string;
86
+ }
87
+
88
+ interface ResolvedDependencyBatchSpec {
89
+ readonly index: number;
90
+ readonly sourceId: string;
91
+ readonly sourceKind: "task" | "subtask";
92
+ readonly dependsOnId: string;
93
+ readonly dependsOnKind: "task" | "subtask";
94
+ }
95
+
96
+ interface DependencyBatchValidationIssue {
97
+ readonly index: number;
98
+ readonly type: "missing_id" | "duplicate" | "cycle";
99
+ readonly sourceId: string;
100
+ readonly dependsOnId: string;
101
+ readonly details: Record<string, unknown>;
102
+ }
103
+
104
+ interface DependencyBatchResolution {
105
+ readonly spec?: ResolvedDependencyBatchSpec;
106
+ readonly issues: readonly DependencyBatchValidationIssue[];
107
+ }
108
+
109
+ interface ResolvedCompactEntity {
110
+ readonly id: string;
111
+ readonly kind: "task" | "subtask";
112
+ }
113
+
60
114
  function assertNonEmpty(field: string, value: string | undefined | null): string {
61
115
  const normalized: string = (value ?? "").trim();
62
116
  if (!normalized) {
@@ -125,6 +179,55 @@ function mapDependency(row: DependencyRow): DependencyRecord {
125
179
  };
126
180
  }
127
181
 
182
+ function countMatches(value: string, searchText: string): number {
183
+ if (searchText.length === 0) {
184
+ return 0;
185
+ }
186
+
187
+ let count = 0;
188
+ let offset = 0;
189
+ while (offset <= value.length - searchText.length) {
190
+ const nextIndex = value.indexOf(searchText, offset);
191
+ if (nextIndex === -1) {
192
+ return count;
193
+ }
194
+
195
+ count += 1;
196
+ offset = nextIndex + searchText.length;
197
+ }
198
+
199
+ return count;
200
+ }
201
+
202
+ function buildMatchSnippet(value: string, searchText: string, contextSize = 24): string {
203
+ if (searchText.length === 0) {
204
+ return "";
205
+ }
206
+
207
+ const matchIndex = value.indexOf(searchText);
208
+ if (matchIndex === -1) {
209
+ return "";
210
+ }
211
+
212
+ const start = Math.max(0, matchIndex - contextSize);
213
+ const end = Math.min(value.length, matchIndex + searchText.length + contextSize);
214
+ const rawSnippet = value.slice(start, end).replace(/\s+/g, " ").trim();
215
+ const prefix = start > 0 ? "…" : "";
216
+ const suffix = end < value.length ? "…" : "";
217
+ return `${prefix}${rawSnippet}${suffix}`;
218
+ }
219
+
220
+ function summarizeMatches(matches: readonly SearchEntityMatch[]): SearchSummary {
221
+ return {
222
+ matchedEntities: matches.length,
223
+ matchedFields: matches.reduce((total, match) => total + match.fields.length, 0),
224
+ totalMatches: matches.reduce(
225
+ (total, match) => total + match.fields.reduce((fieldTotal, field) => fieldTotal + field.count, 0),
226
+ 0,
227
+ ),
228
+ };
229
+ }
230
+
128
231
  export class TrackerDomain {
129
232
  readonly #db: Database;
130
233
 
@@ -217,6 +320,43 @@ export class TrackerDomain {
217
320
  return this.getTaskOrThrow(id);
218
321
  }
219
322
 
323
+ createTaskBatch(input: { epicId: string; specs: readonly CompactTaskSpec[] }): CompactTaskBatchCreateResult {
324
+ const epicId: string = assertNonEmpty("epicId", input.epicId);
325
+ this.getEpicOrThrow(epicId);
326
+
327
+ const validatedSpecs: ValidatedTaskBatchSpec[] = input.specs.map((spec) => ({
328
+ tempKey: assertNonEmpty("tempKey", spec.tempKey),
329
+ title: assertNonEmpty("title", spec.title),
330
+ description: assertNonEmpty("description", spec.description),
331
+ status: normalizeStatus(spec.status),
332
+ }));
333
+
334
+ const tasks: TaskRecord[] = [];
335
+ for (const spec of validatedSpecs) {
336
+ const now: number = Date.now();
337
+ const id: string = randomUUID();
338
+
339
+ this.#db
340
+ .query(
341
+ "INSERT INTO tasks (id, epic_id, title, description, status, created_at, updated_at, version) VALUES (?, ?, ?, ?, ?, ?, ?, 1);",
342
+ )
343
+ .run(id, epicId, spec.title, spec.description, spec.status, now, now);
344
+
345
+ tasks.push(this.getTaskOrThrow(id));
346
+ }
347
+
348
+ return {
349
+ tasks,
350
+ result: {
351
+ mappings: tasks.map((task, index) => ({
352
+ kind: "task",
353
+ tempKey: validatedSpecs[index]?.tempKey ?? "",
354
+ id: task.id,
355
+ })),
356
+ },
357
+ };
358
+ }
359
+
220
360
  listTasks(epicId?: string): readonly TaskRecord[] {
221
361
  if (epicId) {
222
362
  this.getEpicOrThrow(epicId);
@@ -299,6 +439,89 @@ export class TrackerDomain {
299
439
  return this.getSubtaskOrThrow(id);
300
440
  }
301
441
 
442
+ createSubtaskBatch(input: { taskId: string; specs: readonly CompactSubtaskSpec[] }): CompactSubtaskBatchCreateResult {
443
+ const defaultTaskId: string = assertNonEmpty("taskId", input.taskId);
444
+ this.getTaskOrThrow(defaultTaskId);
445
+
446
+ const validatedSpecs: ValidatedSubtaskBatchSpec[] = input.specs.map((spec) => {
447
+ const taskId = spec.parent.kind === "id" ? assertNonEmpty("taskId", spec.parent.id) : defaultTaskId;
448
+ this.getTaskOrThrow(taskId);
449
+
450
+ return {
451
+ tempKey: assertNonEmpty("tempKey", spec.tempKey),
452
+ taskId,
453
+ title: assertNonEmpty("title", spec.title),
454
+ description: spec.description === undefined ? "" : assertNonEmpty("description", spec.description),
455
+ status: normalizeStatus(spec.status),
456
+ };
457
+ });
458
+
459
+ const subtasks: SubtaskRecord[] = [];
460
+ for (const spec of validatedSpecs) {
461
+ const now: number = Date.now();
462
+ const id: string = randomUUID();
463
+
464
+ this.#db
465
+ .query(
466
+ "INSERT INTO subtasks (id, task_id, title, description, status, created_at, updated_at, version) VALUES (?, ?, ?, ?, ?, ?, ?, 1);",
467
+ )
468
+ .run(id, spec.taskId, spec.title, spec.description, spec.status, now, now);
469
+
470
+ subtasks.push(this.getSubtaskOrThrow(id));
471
+ }
472
+
473
+ return {
474
+ subtasks,
475
+ result: {
476
+ mappings: subtasks.map((subtask, index) => ({
477
+ kind: "subtask",
478
+ tempKey: validatedSpecs[index]?.tempKey ?? "",
479
+ id: subtask.id,
480
+ })),
481
+ },
482
+ };
483
+ }
484
+
485
+ expandEpic(input: {
486
+ epicId: string;
487
+ taskSpecs: readonly CompactTaskSpec[];
488
+ subtaskSpecs: readonly CompactSubtaskSpec[];
489
+ dependencySpecs: readonly CompactDependencySpec[];
490
+ }): CompactEpicExpandResult {
491
+ const createdTasks = this.createTaskBatch({
492
+ epicId: input.epicId,
493
+ specs: input.taskSpecs,
494
+ });
495
+
496
+ const resolvedSubtaskSpecs = this.#resolveEpicExpandSubtaskSpecs(input.subtaskSpecs, createdTasks.result.mappings);
497
+ const createdSubtasks = resolvedSubtaskSpecs.length === 0
498
+ ? { subtasks: [], result: { mappings: [] } }
499
+ : this.createSubtaskBatch({
500
+ taskId: resolvedSubtaskSpecs[0]?.parent.kind === "id" ? resolvedSubtaskSpecs[0].parent.id : "",
501
+ specs: resolvedSubtaskSpecs,
502
+ });
503
+
504
+ const mappings = [...createdTasks.result.mappings, ...createdSubtasks.result.mappings];
505
+ const resolvedDependencySpecs = this.#resolveEpicExpandDependencySpecs(input.dependencySpecs, mappings);
506
+ const createdDependencies = resolvedDependencySpecs.length === 0
507
+ ? { dependencies: [], result: { mappings: [] } }
508
+ : this.addDependencyBatch({ specs: resolvedDependencySpecs });
509
+
510
+ return {
511
+ tasks: createdTasks.tasks,
512
+ subtasks: createdSubtasks.subtasks,
513
+ dependencies: createdDependencies.dependencies,
514
+ result: {
515
+ mappings,
516
+ counts: {
517
+ tasks: createdTasks.tasks.length,
518
+ subtasks: createdSubtasks.subtasks.length,
519
+ dependencies: createdDependencies.dependencies.length,
520
+ },
521
+ },
522
+ };
523
+ }
524
+
302
525
  listSubtasks(taskId?: string): readonly SubtaskRecord[] {
303
526
  if (taskId) {
304
527
  this.getTaskOrThrow(taskId);
@@ -433,6 +656,89 @@ export class TrackerDomain {
433
656
  };
434
657
  }
435
658
 
659
+ collectEpicSearchScope(epicId: string): readonly SearchNode[] {
660
+ const tree = this.buildEpicTreeDetailed(epicId);
661
+
662
+ return [
663
+ {
664
+ kind: "epic",
665
+ id: tree.id,
666
+ title: tree.title,
667
+ description: tree.description,
668
+ },
669
+ ...tree.tasks.flatMap((task) => [
670
+ {
671
+ kind: "task" as const,
672
+ id: task.id,
673
+ title: task.title,
674
+ description: task.description,
675
+ },
676
+ ...task.subtasks.map((subtask) => ({
677
+ kind: "subtask" as const,
678
+ id: subtask.id,
679
+ title: subtask.title,
680
+ description: subtask.description,
681
+ })),
682
+ ]),
683
+ ];
684
+ }
685
+
686
+ collectTaskSearchScope(taskId: string): readonly SearchNode[] {
687
+ const tree = this.buildTaskTreeDetailed(taskId);
688
+
689
+ return [
690
+ {
691
+ kind: "task",
692
+ id: tree.id,
693
+ title: tree.title,
694
+ description: tree.description,
695
+ },
696
+ ...tree.subtasks.map((subtask) => ({
697
+ kind: "subtask" as const,
698
+ id: subtask.id,
699
+ title: subtask.title,
700
+ description: subtask.description,
701
+ })),
702
+ ];
703
+ }
704
+
705
+ collectSubtaskSearchScope(subtaskId: string): readonly SearchNode[] {
706
+ const subtask = this.getSubtaskOrThrow(subtaskId);
707
+
708
+ return [
709
+ {
710
+ kind: "subtask",
711
+ id: subtask.id,
712
+ title: subtask.title,
713
+ description: subtask.description,
714
+ },
715
+ ];
716
+ }
717
+
718
+ searchEpicScope(epicId: string, searchText: string, fields: readonly SearchField[]): { readonly matches: readonly SearchEntityMatch[]; readonly summary: SearchSummary } {
719
+ const matches = this.collectSearchMatches(this.collectEpicSearchScope(epicId), searchText, fields);
720
+ return {
721
+ matches,
722
+ summary: summarizeMatches(matches),
723
+ };
724
+ }
725
+
726
+ searchTaskScope(taskId: string, searchText: string, fields: readonly SearchField[]): { readonly matches: readonly SearchEntityMatch[]; readonly summary: SearchSummary } {
727
+ const matches = this.collectSearchMatches(this.collectTaskSearchScope(taskId), searchText, fields);
728
+ return {
729
+ matches,
730
+ summary: summarizeMatches(matches),
731
+ };
732
+ }
733
+
734
+ searchSubtaskScope(subtaskId: string, searchText: string, fields: readonly SearchField[]): { readonly matches: readonly SearchEntityMatch[]; readonly summary: SearchSummary } {
735
+ const matches = this.collectSearchMatches(this.collectSubtaskSearchScope(subtaskId), searchText, fields);
736
+ return {
737
+ matches,
738
+ summary: summarizeMatches(matches),
739
+ };
740
+ }
741
+
436
742
  resolveNodeKind(id: string): "task" | "subtask" {
437
743
  const task = this.getTask(id);
438
744
  if (task) {
@@ -496,6 +802,54 @@ export class TrackerDomain {
496
802
  return this.getDependencyOrThrow(id);
497
803
  }
498
804
 
805
+ addDependencyBatch(input: { specs: readonly CompactDependencySpec[] }): CompactDependencyBatchAddResult {
806
+ const resolutions = input.specs.map((spec, index) => this.#resolveDependencyBatchSpec(index, spec));
807
+ const resolvedSpecs = resolutions.flatMap((resolution) => (resolution.spec === undefined ? [] : [resolution.spec]));
808
+ const issues = resolutions.flatMap((resolution) => resolution.issues).concat(this.#collectDependencyBatchIssues(resolvedSpecs));
809
+ if (issues.length > 0) {
810
+ const orderedIssues = issues.sort((left, right) => left.index - right.index || left.type.localeCompare(right.type));
811
+ const firstIssue = orderedIssues[0] ?? null;
812
+ throw new DomainError({
813
+ code: "invalid_dependency",
814
+ message:
815
+ firstIssue?.type === "missing_id"
816
+ ? "dependency batch contains missing ids"
817
+ : firstIssue?.type === "duplicate"
818
+ ? "dependency batch contains duplicate edges"
819
+ : "dependency batch contains cycles",
820
+ details: {
821
+ issues: orderedIssues,
822
+ firstIssue,
823
+ },
824
+ });
825
+ }
826
+
827
+ const dependencies: DependencyRecord[] = [];
828
+ for (const spec of resolvedSpecs) {
829
+ const existing = this.#getDependencyByEdge(spec.sourceId, spec.dependsOnId);
830
+ if (existing) {
831
+ dependencies.push(existing);
832
+ continue;
833
+ }
834
+
835
+ const id: string = randomUUID();
836
+ const now: number = Date.now();
837
+
838
+ this.#db
839
+ .query(
840
+ "INSERT INTO dependencies (id, source_id, source_kind, depends_on_id, depends_on_kind, created_at, updated_at, version) VALUES (?, ?, ?, ?, ?, ?, ?, 1);",
841
+ )
842
+ .run(id, spec.sourceId, spec.sourceKind, spec.dependsOnId, spec.dependsOnKind, now, now);
843
+
844
+ dependencies.push(this.getDependencyOrThrow(id));
845
+ }
846
+
847
+ return {
848
+ dependencies,
849
+ result: { mappings: [] },
850
+ };
851
+ }
852
+
499
853
  removeDependency(sourceId: string, dependsOnId: string): number {
500
854
  const normalizedSourceId: string = assertNonEmpty("sourceId", sourceId);
501
855
  const normalizedDependsOnId: string = assertNonEmpty("dependsOnId", dependsOnId);
@@ -570,6 +924,326 @@ export class TrackerDomain {
570
924
  return mapDependency(row);
571
925
  }
572
926
 
927
+ #getDependencyByEdge(sourceId: string, dependsOnId: string): DependencyRecord | null {
928
+ const row = this.#db
929
+ .query(
930
+ "SELECT id, source_id, source_kind, depends_on_id, depends_on_kind, created_at, updated_at FROM dependencies WHERE source_id = ? AND depends_on_id = ?;",
931
+ )
932
+ .get(sourceId, dependsOnId) as DependencyRow | null;
933
+
934
+ return row ? mapDependency(row) : null;
935
+ }
936
+
937
+ #resolveDependencyBatchSpec(index: number, spec: CompactDependencySpec): DependencyBatchResolution {
938
+ const sourceResolution = this.#resolveDependencyBatchId(spec.source, "source", index);
939
+ const dependsOnResolution = this.#resolveDependencyBatchId(spec.dependsOn, "dependsOn", index);
940
+ const issues = [...sourceResolution.issues, ...dependsOnResolution.issues];
941
+ const sourceId = sourceResolution.id;
942
+ const dependsOnId = dependsOnResolution.id;
943
+
944
+ if (sourceId === undefined || dependsOnId === undefined) {
945
+ return {
946
+ issues,
947
+ };
948
+ }
949
+
950
+ if (sourceId === dependsOnId) {
951
+ return {
952
+ issues: [
953
+ ...issues,
954
+ {
955
+ index,
956
+ type: "cycle",
957
+ sourceId,
958
+ dependsOnId,
959
+ details: { sourceId, dependsOnId, reason: "self_reference" },
960
+ },
961
+ ],
962
+ };
963
+ }
964
+
965
+ return {
966
+ spec: {
967
+ index,
968
+ sourceId,
969
+ sourceKind: this.resolveNodeKind(sourceId),
970
+ dependsOnId,
971
+ dependsOnKind: this.resolveNodeKind(dependsOnId),
972
+ },
973
+ issues,
974
+ };
975
+ }
976
+
977
+ #resolveDependencyBatchId(
978
+ reference: CompactEntityRef,
979
+ field: "source" | "dependsOn",
980
+ index: number,
981
+ ): { readonly id?: string; readonly issues: readonly DependencyBatchValidationIssue[] } {
982
+ if (reference.kind === "temp_key") {
983
+ return {
984
+ issues: [
985
+ {
986
+ index,
987
+ type: "missing_id",
988
+ sourceId: field === "source" ? `@${reference.tempKey}` : "",
989
+ dependsOnId: field === "dependsOn" ? `@${reference.tempKey}` : "",
990
+ details: {
991
+ field,
992
+ tempKey: reference.tempKey,
993
+ message: `Unresolved temp key @${reference.tempKey}`,
994
+ },
995
+ },
996
+ ],
997
+ };
998
+ }
999
+
1000
+ const id = assertNonEmpty(field === "source" ? "sourceId" : "dependsOnId", reference.id);
1001
+ const task = this.getTask(id);
1002
+ const subtask = this.getSubtask(id);
1003
+ if (!task && !subtask) {
1004
+ return {
1005
+ issues: [
1006
+ {
1007
+ index,
1008
+ type: "missing_id",
1009
+ sourceId: field === "source" ? id : "",
1010
+ dependsOnId: field === "dependsOn" ? id : "",
1011
+ details: {
1012
+ field,
1013
+ id,
1014
+ message: `Node not found: ${id}`,
1015
+ },
1016
+ },
1017
+ ],
1018
+ };
1019
+ }
1020
+
1021
+ return { id, issues: [] };
1022
+ }
1023
+
1024
+ #resolveEpicExpandSubtaskSpecs(
1025
+ specs: readonly CompactSubtaskSpec[],
1026
+ mappings: readonly { tempKey: string; id: string; kind: "task" | "subtask" }[],
1027
+ ): CompactSubtaskSpec[] {
1028
+ return specs.map((spec, index) => {
1029
+ const parent = this.#resolveEpicExpandEntityRef(spec.parent, mappings, "subtask", index, "parent");
1030
+ if (parent.kind !== "task") {
1031
+ throw new DomainError({
1032
+ code: "invalid_input",
1033
+ message: `Subtask parent must resolve to a task in --subtask spec ${index + 1}`,
1034
+ details: {
1035
+ index,
1036
+ field: "parent",
1037
+ kind: parent.kind,
1038
+ id: parent.id,
1039
+ },
1040
+ });
1041
+ }
1042
+
1043
+ return {
1044
+ ...spec,
1045
+ parent: {
1046
+ kind: "id",
1047
+ id: parent.id,
1048
+ },
1049
+ };
1050
+ });
1051
+ }
1052
+
1053
+ #resolveEpicExpandDependencySpecs(
1054
+ specs: readonly CompactDependencySpec[],
1055
+ mappings: readonly { tempKey: string; id: string; kind: "task" | "subtask" }[],
1056
+ ): CompactDependencySpec[] {
1057
+ return specs.map((spec, index) => ({
1058
+ source: {
1059
+ kind: "id",
1060
+ id: this.#resolveEpicExpandEntityRef(spec.source, mappings, "dep", index, "source").id,
1061
+ },
1062
+ dependsOn: {
1063
+ kind: "id",
1064
+ id: this.#resolveEpicExpandEntityRef(spec.dependsOn, mappings, "dep", index, "dependsOn").id,
1065
+ },
1066
+ }));
1067
+ }
1068
+
1069
+ #resolveEpicExpandEntityRef(
1070
+ reference: CompactEntityRef,
1071
+ mappings: readonly { tempKey: string; id: string; kind: "task" | "subtask" }[],
1072
+ option: "subtask" | "dep",
1073
+ index: number,
1074
+ field: "parent" | "source" | "dependsOn",
1075
+ ): ResolvedCompactEntity {
1076
+ if (reference.kind === "temp_key") {
1077
+ const mapping = mappings.find((candidate) => candidate.tempKey === reference.tempKey);
1078
+ if (mapping === undefined) {
1079
+ throw new DomainError({
1080
+ code: "invalid_input",
1081
+ message: `Unknown temp key @${reference.tempKey} in --${option} spec ${index + 1}`,
1082
+ details: {
1083
+ index,
1084
+ field,
1085
+ tempKey: reference.tempKey,
1086
+ option,
1087
+ },
1088
+ });
1089
+ }
1090
+
1091
+ return {
1092
+ id: mapping.id,
1093
+ kind: mapping.kind,
1094
+ };
1095
+ }
1096
+
1097
+ const id = assertNonEmpty(field === "parent" ? "taskId" : `${field}Id`, reference.id);
1098
+ return {
1099
+ id,
1100
+ kind: this.resolveNodeKind(id),
1101
+ };
1102
+ }
1103
+
1104
+ #collectDependencyBatchIssues(specs: readonly ResolvedDependencyBatchSpec[]): DependencyBatchValidationIssue[] {
1105
+ const issues: DependencyBatchValidationIssue[] = [];
1106
+ const seenEdges = new Map<string, number>();
1107
+ const adjacency = this.#buildDependencyAdjacency();
1108
+
1109
+ for (const spec of specs) {
1110
+ const edgeKey = `${spec.sourceId}->${spec.dependsOnId}`;
1111
+ const existingIndex = seenEdges.get(edgeKey);
1112
+ if (existingIndex !== undefined) {
1113
+ issues.push({
1114
+ index: spec.index,
1115
+ type: "duplicate",
1116
+ sourceId: spec.sourceId,
1117
+ dependsOnId: spec.dependsOnId,
1118
+ details: {
1119
+ sourceId: spec.sourceId,
1120
+ dependsOnId: spec.dependsOnId,
1121
+ firstIndex: existingIndex,
1122
+ duplicateIndex: spec.index,
1123
+ duplicateKind: "batch",
1124
+ },
1125
+ });
1126
+ continue;
1127
+ }
1128
+
1129
+ if (this.#getDependencyByEdge(spec.sourceId, spec.dependsOnId) !== null) {
1130
+ issues.push({
1131
+ index: spec.index,
1132
+ type: "duplicate",
1133
+ sourceId: spec.sourceId,
1134
+ dependsOnId: spec.dependsOnId,
1135
+ details: {
1136
+ sourceId: spec.sourceId,
1137
+ dependsOnId: spec.dependsOnId,
1138
+ duplicateKind: "existing",
1139
+ },
1140
+ });
1141
+ continue;
1142
+ }
1143
+
1144
+ if (this.#wouldCreateCycleInAdjacency(adjacency, spec.sourceId, spec.dependsOnId)) {
1145
+ issues.push({
1146
+ index: spec.index,
1147
+ type: "cycle",
1148
+ sourceId: spec.sourceId,
1149
+ dependsOnId: spec.dependsOnId,
1150
+ details: {
1151
+ sourceId: spec.sourceId,
1152
+ dependsOnId: spec.dependsOnId,
1153
+ },
1154
+ });
1155
+ continue;
1156
+ }
1157
+
1158
+ const nextNeighbors = adjacency.get(spec.sourceId) ?? new Set<string>();
1159
+ nextNeighbors.add(spec.dependsOnId);
1160
+ adjacency.set(spec.sourceId, nextNeighbors);
1161
+ seenEdges.set(edgeKey, spec.index);
1162
+ }
1163
+
1164
+ return issues.sort((left, right) => left.index - right.index || left.type.localeCompare(right.type));
1165
+ }
1166
+
1167
+ #buildDependencyAdjacency(): Map<string, Set<string>> {
1168
+ const rows = this.#db.query("SELECT source_id, depends_on_id FROM dependencies ORDER BY source_id ASC, depends_on_id ASC;").all() as Array<{
1169
+ source_id: string;
1170
+ depends_on_id: string;
1171
+ }>;
1172
+ const adjacency = new Map<string, Set<string>>();
1173
+
1174
+ for (const row of rows) {
1175
+ const neighbors = adjacency.get(row.source_id) ?? new Set<string>();
1176
+ neighbors.add(row.depends_on_id);
1177
+ adjacency.set(row.source_id, neighbors);
1178
+ }
1179
+
1180
+ return adjacency;
1181
+ }
1182
+
1183
+ #wouldCreateCycleInAdjacency(adjacency: ReadonlyMap<string, ReadonlySet<string>>, sourceId: string, dependsOnId: string): boolean {
1184
+ const visited = new Set<string>();
1185
+ const queue: string[] = [dependsOnId];
1186
+
1187
+ while (queue.length > 0) {
1188
+ const current = queue.shift();
1189
+ if (current === undefined || visited.has(current)) {
1190
+ continue;
1191
+ }
1192
+
1193
+ if (current === sourceId) {
1194
+ return true;
1195
+ }
1196
+
1197
+ visited.add(current);
1198
+ const neighbors = adjacency.get(current);
1199
+ if (neighbors === undefined) {
1200
+ continue;
1201
+ }
1202
+
1203
+ for (const neighbor of neighbors) {
1204
+ if (!visited.has(neighbor)) {
1205
+ queue.push(neighbor);
1206
+ }
1207
+ }
1208
+ }
1209
+
1210
+ return false;
1211
+ }
1212
+
1213
+ private collectSearchMatches(
1214
+ nodes: readonly SearchNode[],
1215
+ searchText: string,
1216
+ fields: readonly SearchField[],
1217
+ ): readonly SearchEntityMatch[] {
1218
+ const matches: SearchEntityMatch[] = [];
1219
+
1220
+ for (const node of nodes) {
1221
+ const matchedFields: SearchFieldMatch[] = [];
1222
+ for (const field of fields) {
1223
+ const count = countMatches(node[field], searchText);
1224
+ if (count > 0) {
1225
+ matchedFields.push({
1226
+ field,
1227
+ count,
1228
+ snippet: buildMatchSnippet(node[field], searchText),
1229
+ });
1230
+ }
1231
+ }
1232
+
1233
+ if (matchedFields.length === 0) {
1234
+ continue;
1235
+ }
1236
+
1237
+ matches.push({
1238
+ kind: node.kind,
1239
+ id: node.id,
1240
+ fields: matchedFields,
1241
+ });
1242
+ }
1243
+
1244
+ return matches;
1245
+ }
1246
+
573
1247
  private wouldCreateCycle(sourceId: string, dependsOnId: string): boolean {
574
1248
  const row = this.#db
575
1249
  .query(