septum 0.1.4 → 0.1.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +105 -105
  3. package/bin/septum.ts +4 -4
  4. package/package.json +63 -63
  5. package/src/cli/commands/check.ts +161 -161
  6. package/src/cli/commands/hook.ts +188 -188
  7. package/src/cli/commands/ingest.ts +29 -29
  8. package/src/cli/commands/init.ts +211 -211
  9. package/src/cli/commands/locate.ts +61 -61
  10. package/src/cli/commands/query.ts +64 -64
  11. package/src/cli/commands/serve.ts +5 -5
  12. package/src/cli/commands/slice.ts +48 -48
  13. package/src/cli/commands/sync.ts +31 -31
  14. package/src/cli/index.ts +210 -210
  15. package/src/cli/templates/hooks.ts +75 -75
  16. package/src/cli/templates/rules.ts +38 -38
  17. package/src/cli/templates/skill.ts +69 -69
  18. package/src/core/boundary/evaluator.ts +232 -210
  19. package/src/core/config/loader.ts +97 -97
  20. package/src/core/config/schema.ts +39 -39
  21. package/src/core/database/client.ts +44 -44
  22. package/src/core/database/repositories/dependency.repository.ts +366 -349
  23. package/src/core/database/repositories/domain.repository.ts +218 -198
  24. package/src/core/database/repositories/file.repository.ts +116 -106
  25. package/src/core/database/repositories/meta.repository.ts +33 -33
  26. package/src/core/database/repositories/symbol.repository.ts +443 -443
  27. package/src/core/database/repository.ts +264 -260
  28. package/src/core/database/schema.ts +153 -153
  29. package/src/core/discovery/topology-detector.ts +486 -486
  30. package/src/core/ingestion/hasher.ts +5 -5
  31. package/src/core/ingestion/pipeline.ts +584 -535
  32. package/src/core/parser/boundary-tracker.ts +288 -288
  33. package/src/core/parser/extractors/base.ts +62 -62
  34. package/src/core/parser/extractors/blade.ts +158 -158
  35. package/src/core/parser/extractors/frontend.ts +194 -194
  36. package/src/core/parser/extractors/go.ts +229 -180
  37. package/src/core/parser/extractors/laravel-semantic.ts +1039 -901
  38. package/src/core/parser/extractors/mcp-cli-semantic.ts +199 -199
  39. package/src/core/parser/extractors/nestjs-semantic.ts +119 -119
  40. package/src/core/parser/extractors/php.ts +153 -153
  41. package/src/core/parser/extractors/python.ts +228 -228
  42. package/src/core/parser/extractors/registry.ts +70 -70
  43. package/src/core/parser/extractors/semantic-extractor.interface.ts +21 -21
  44. package/src/core/parser/extractors/typescript.ts +181 -181
  45. package/src/core/parser/tree-sitter.ts +41 -41
  46. package/src/core/resolver/call-graph-tracer.ts +666 -668
  47. package/src/core/resolver/module-resolver.ts +408 -342
  48. package/src/core/resolver/path-resolver.ts +147 -147
  49. package/src/core/resolver/symbol-locator.ts +543 -543
  50. package/src/core/resolver/vertical-slice-tracer.ts +301 -285
  51. package/src/core/session/session-manager.ts +56 -56
  52. package/src/core/telemetry/telemetry.ts +86 -86
  53. package/src/index.ts +21 -21
  54. package/src/mcp/schemas.ts +578 -286
  55. package/src/mcp/server.ts +875 -716
  56. package/src/mcp/tools/check-boundary.ts +171 -152
  57. package/src/mcp/tools/get-domain-catalog.ts +145 -121
  58. package/src/mcp/tools/get-feature-context.ts +212 -116
  59. package/src/mcp/tools/get-symbol-hotspots.ts +60 -59
  60. package/src/mcp/tools/get-symbol-impact.ts +28 -26
  61. package/src/mcp/tools/get-symbol.ts +65 -63
  62. package/src/mcp/tools/locate-symbol.ts +171 -190
  63. package/src/mcp/tools/register-domain.ts +68 -64
  64. package/src/mcp/tools/register-feature.ts +100 -0
  65. package/src/mcp/tools/trace-vertical-slice.ts +74 -48
  66. package/src/types/index.ts +335 -315
  67. package/src/version.ts +3 -3
@@ -1,198 +1,218 @@
1
- import type { Database } from "bun:sqlite";
2
- import type {
3
- DomainCatalogResponse,
4
- DomainConfig,
5
- DomainRecord,
6
- FileRecord,
7
- SymbolKind,
8
- SymbolRecord,
9
- } from "../../../types/index.ts";
10
-
11
- export class DomainRepository {
12
- constructor(private db: Database) {}
13
-
14
- public upsertDomain(name: string, config: DomainConfig): number {
15
- const normalizedName = name.trim();
16
- const existing = this.getDomainByName(normalizedName);
17
-
18
- // Merge dependencies and archetypes if existing and incoming is empty
19
- let allowed = config.allowed_dependencies ?? [];
20
- let forbidden = config.forbidden_dependencies ?? [];
21
- let archetypes = config.archetypes ?? {};
22
-
23
- if (existing) {
24
- if (allowed.length === 0 && existing.allowed_deps_json && existing.allowed_deps_json !== "[]") {
25
- try {
26
- allowed = JSON.parse(existing.allowed_deps_json);
27
- } catch {}
28
- }
29
- if (forbidden.length === 0 && existing.forbidden_deps_json && existing.forbidden_deps_json !== "[]") {
30
- try {
31
- forbidden = JSON.parse(existing.forbidden_deps_json);
32
- } catch {}
33
- }
34
- if (Object.keys(archetypes).length === 0 && existing.archetypes_json && existing.archetypes_json !== "{}") {
35
- try {
36
- archetypes = JSON.parse(existing.archetypes_json);
37
- } catch {}
38
- }
39
- }
40
-
41
- const allowedJson = JSON.stringify(allowed);
42
- const forbiddenJson = JSON.stringify(forbidden);
43
- const archetypesJson = JSON.stringify(archetypes);
44
-
45
- const query = `
46
- INSERT INTO domains (name, root_path, allowed_deps_json, forbidden_deps_json, archetypes_json)
47
- VALUES (?, ?, ?, ?, ?)
48
- ON CONFLICT(name) DO UPDATE SET
49
- root_path = excluded.root_path,
50
- allowed_deps_json = excluded.allowed_deps_json,
51
- forbidden_deps_json = excluded.forbidden_deps_json,
52
- archetypes_json = excluded.archetypes_json,
53
- updated_at = CURRENT_TIMESTAMP
54
- `;
55
- this.db.query(query).run(normalizedName, config.root, allowedJson, forbiddenJson, archetypesJson);
56
-
57
- const record = this.getDomainByName(normalizedName);
58
- return record?.id ?? 0;
59
- }
60
-
61
- public getDomainByName(name: string): DomainRecord | null {
62
- return this.db
63
- .query<DomainRecord, [string]>("SELECT * FROM domains WHERE LOWER(name) = LOWER(?)")
64
- .get(name.trim());
65
- }
66
-
67
- public getAllDomains(): DomainRecord[] {
68
- return this.db.query<DomainRecord, []>("SELECT * FROM domains ORDER BY name ASC").all();
69
- }
70
-
71
- public getDomainCatalog(
72
- domainName: string,
73
- archetypeFilter?: string
74
- ): DomainCatalogResponse | null {
75
- const domain = this.getDomainByName(domainName);
76
- if (!domain) return null;
77
-
78
- const allowedDeps: string[] = JSON.parse(domain.allowed_deps_json || "[]");
79
- const forbiddenDeps: string[] = JSON.parse(domain.forbidden_deps_json || "[]");
80
-
81
- let filesQuery = "SELECT * FROM files WHERE domain_id = ?";
82
- const params: [number, string?] = [domain.id];
83
-
84
- if (archetypeFilter) {
85
- filesQuery += " AND archetype = ?";
86
- params.push(archetypeFilter);
87
- }
88
- filesQuery += " ORDER BY path ASC";
89
-
90
- const files = this.db.query<FileRecord, any>(filesQuery).all(...params);
91
-
92
- const catalogFiles: DomainCatalogResponse["files"] = [];
93
-
94
- for (const file of files) {
95
- const symbols = this.db
96
- .query<SymbolRecord, [number]>(
97
- "SELECT * FROM symbols WHERE file_id = ? ORDER BY line_start ASC"
98
- )
99
- .all(file.id);
100
-
101
- const symbolMap = new Map<
102
- string,
103
- {
104
- name: string;
105
- kind: SymbolKind;
106
- properties: string[];
107
- methods: string[];
108
- signatures: string[];
109
- }
110
- >();
111
-
112
- for (const sym of symbols) {
113
- if (
114
- sym.kind === "class" ||
115
- sym.kind === "interface" ||
116
- sym.kind === "trait" ||
117
- sym.kind === "enum"
118
- ) {
119
- if (!symbolMap.has(sym.name)) {
120
- symbolMap.set(sym.name, {
121
- name: sym.name,
122
- kind: sym.kind,
123
- properties: [],
124
- methods: [],
125
- signatures: [sym.signature],
126
- });
127
- }
128
- } else if (sym.kind === "method") {
129
- let targetContainer = Array.from(symbolMap.values()).pop();
130
- if (sym.name.includes("::")) {
131
- const containerName = sym.name.split("::")[0];
132
- if (symbolMap.has(containerName)) {
133
- targetContainer = symbolMap.get(containerName);
134
- }
135
- }
136
- if (targetContainer) {
137
- targetContainer.methods.push(sym.signature);
138
- } else {
139
- symbolMap.set(sym.name, {
140
- name: sym.name,
141
- kind: sym.kind,
142
- properties: [],
143
- methods: [],
144
- signatures: [sym.signature],
145
- });
146
- }
147
- } else if (sym.kind === "property") {
148
- let targetContainer = Array.from(symbolMap.values()).pop();
149
- if (sym.name.includes("::")) {
150
- const containerName = sym.name.split("::")[0];
151
- if (symbolMap.has(containerName)) {
152
- targetContainer = symbolMap.get(containerName);
153
- }
154
- }
155
- if (targetContainer) {
156
- targetContainer.properties.push(sym.signature);
157
- } else {
158
- symbolMap.set(sym.name, {
159
- name: sym.name,
160
- kind: sym.kind,
161
- properties: [sym.signature],
162
- methods: [],
163
- signatures: [sym.signature],
164
- });
165
- }
166
- } else {
167
- symbolMap.set(sym.name, {
168
- name: sym.name,
169
- kind: sym.kind,
170
- properties: [],
171
- methods: [],
172
- signatures: [sym.signature],
173
- });
174
- }
175
- }
176
-
177
- catalogFiles.push({
178
- path: file.path,
179
- archetype: file.archetype,
180
- symbols: Array.from(symbolMap.values()).map((s) => ({
181
- name: s.name,
182
- kind: s.kind,
183
- properties: s.properties.length > 0 ? s.properties : undefined,
184
- methods: s.methods.length > 0 ? s.methods : undefined,
185
- signatures: s.signatures.length > 0 ? s.signatures : undefined,
186
- })),
187
- });
188
- }
189
-
190
- return {
191
- domain: domain.name,
192
- root: domain.root_path,
193
- allowed_dependencies: allowedDeps,
194
- forbidden_dependencies: forbiddenDeps,
195
- files: catalogFiles,
196
- };
197
- }
198
- }
1
+ import type { Database } from "bun:sqlite";
2
+ import type {
3
+ DomainCatalogResponse,
4
+ DomainConfig,
5
+ DomainRecord,
6
+ FileRecord,
7
+ SymbolKind,
8
+ SymbolRecord,
9
+ } from "../../../types/index.ts";
10
+
11
+ export class DomainRepository {
12
+ constructor(private db: Database) {}
13
+
14
+ public upsertDomain(name: string, config: DomainConfig): number {
15
+ const normalizedName = name.trim();
16
+ const existing = this.getDomainByName(normalizedName);
17
+
18
+ // Merge dependencies and archetypes if existing and incoming is empty
19
+ let allowed = config.allowed_dependencies ?? [];
20
+ let forbidden = config.forbidden_dependencies ?? [];
21
+ let archetypes = config.archetypes ?? {};
22
+
23
+ if (existing) {
24
+ if (allowed.length === 0 && existing.allowed_deps_json && existing.allowed_deps_json !== "[]") {
25
+ try {
26
+ allowed = JSON.parse(existing.allowed_deps_json);
27
+ } catch {}
28
+ }
29
+ if (forbidden.length === 0 && existing.forbidden_deps_json && existing.forbidden_deps_json !== "[]") {
30
+ try {
31
+ forbidden = JSON.parse(existing.forbidden_deps_json);
32
+ } catch {}
33
+ }
34
+ if (Object.keys(archetypes).length === 0 && existing.archetypes_json && existing.archetypes_json !== "{}") {
35
+ try {
36
+ archetypes = JSON.parse(existing.archetypes_json);
37
+ } catch {}
38
+ }
39
+ }
40
+
41
+ const allowedJson = JSON.stringify(allowed);
42
+ const forbiddenJson = JSON.stringify(forbidden);
43
+ const archetypesJson = JSON.stringify(archetypes);
44
+
45
+ const query = `
46
+ INSERT INTO domains (name, root_path, allowed_deps_json, forbidden_deps_json, archetypes_json)
47
+ VALUES (?, ?, ?, ?, ?)
48
+ ON CONFLICT(name) DO UPDATE SET
49
+ root_path = excluded.root_path,
50
+ allowed_deps_json = excluded.allowed_deps_json,
51
+ forbidden_deps_json = excluded.forbidden_deps_json,
52
+ archetypes_json = excluded.archetypes_json,
53
+ updated_at = CURRENT_TIMESTAMP
54
+ `;
55
+ this.db.query(query).run(normalizedName, config.root, allowedJson, forbiddenJson, archetypesJson);
56
+
57
+ const record = this.getDomainByName(normalizedName);
58
+ return record?.id ?? 0;
59
+ }
60
+
61
+ public getDomainByName(name: string): DomainRecord | null {
62
+ return this.db
63
+ .query<DomainRecord, [string]>("SELECT * FROM domains WHERE LOWER(name) = LOWER(?)")
64
+ .get(name.trim());
65
+ }
66
+
67
+ public getAllDomains(): DomainRecord[] {
68
+ return this.db.query<DomainRecord, []>("SELECT * FROM domains ORDER BY name ASC").all();
69
+ }
70
+
71
+ public getDomainCatalog(
72
+ domainName: string,
73
+ archetypeFilter?: string
74
+ ): DomainCatalogResponse | null {
75
+ const domain = this.getDomainByName(domainName);
76
+ if (!domain) return null;
77
+
78
+ const allowedDeps: string[] = JSON.parse(domain.allowed_deps_json || "[]");
79
+ const forbiddenDeps: string[] = JSON.parse(domain.forbidden_deps_json || "[]");
80
+
81
+ let filesQuery = "SELECT * FROM files WHERE domain_id = ?";
82
+ const params: [number, string?] = [domain.id];
83
+
84
+ if (archetypeFilter) {
85
+ filesQuery += " AND archetype = ?";
86
+ params.push(archetypeFilter);
87
+ }
88
+ filesQuery += " ORDER BY path ASC";
89
+
90
+ const files = this.db.query<FileRecord, any>(filesQuery).all(...params);
91
+
92
+ const catalogFiles: DomainCatalogResponse["files"] = [];
93
+
94
+ // Batch retrieve symbols for all files in one query to eliminate N+1 roundtrips
95
+ const symbolsByFileId = new Map<number, SymbolRecord[]>();
96
+ for (const f of files) {
97
+ symbolsByFileId.set(f.id, []);
98
+ }
99
+
100
+ if (files.length > 0) {
101
+ const allDomainSymbols = this.db
102
+ .query<SymbolRecord, [number]>(
103
+ `SELECT s.* FROM symbols s
104
+ JOIN files f ON s.file_id = f.id
105
+ WHERE f.domain_id = ?
106
+ ORDER BY s.file_id ASC, s.line_start ASC`
107
+ )
108
+ .all(domain.id);
109
+
110
+ for (const sym of allDomainSymbols) {
111
+ const list = symbolsByFileId.get(sym.file_id);
112
+ if (list) {
113
+ list.push(sym);
114
+ }
115
+ }
116
+ }
117
+
118
+ for (const file of files) {
119
+ const symbols = symbolsByFileId.get(file.id) ?? [];
120
+
121
+ const symbolMap = new Map<
122
+ string,
123
+ {
124
+ name: string;
125
+ kind: SymbolKind;
126
+ properties: string[];
127
+ methods: string[];
128
+ signatures: string[];
129
+ }
130
+ >();
131
+
132
+ for (const sym of symbols) {
133
+ if (
134
+ sym.kind === "class" ||
135
+ sym.kind === "interface" ||
136
+ sym.kind === "trait" ||
137
+ sym.kind === "enum"
138
+ ) {
139
+ if (!symbolMap.has(sym.name)) {
140
+ symbolMap.set(sym.name, {
141
+ name: sym.name,
142
+ kind: sym.kind,
143
+ properties: [],
144
+ methods: [],
145
+ signatures: [sym.signature],
146
+ });
147
+ }
148
+ } else if (sym.kind === "method") {
149
+ let targetContainer = Array.from(symbolMap.values()).pop();
150
+ if (sym.name.includes("::")) {
151
+ const containerName = sym.name.split("::")[0];
152
+ if (symbolMap.has(containerName)) {
153
+ targetContainer = symbolMap.get(containerName);
154
+ }
155
+ }
156
+ if (targetContainer) {
157
+ targetContainer.methods.push(sym.signature);
158
+ } else {
159
+ symbolMap.set(sym.name, {
160
+ name: sym.name,
161
+ kind: sym.kind,
162
+ properties: [],
163
+ methods: [],
164
+ signatures: [sym.signature],
165
+ });
166
+ }
167
+ } else if (sym.kind === "property") {
168
+ let targetContainer = Array.from(symbolMap.values()).pop();
169
+ if (sym.name.includes("::")) {
170
+ const containerName = sym.name.split("::")[0];
171
+ if (symbolMap.has(containerName)) {
172
+ targetContainer = symbolMap.get(containerName);
173
+ }
174
+ }
175
+ if (targetContainer) {
176
+ targetContainer.properties.push(sym.signature);
177
+ } else {
178
+ symbolMap.set(sym.name, {
179
+ name: sym.name,
180
+ kind: sym.kind,
181
+ properties: [sym.signature],
182
+ methods: [],
183
+ signatures: [sym.signature],
184
+ });
185
+ }
186
+ } else {
187
+ symbolMap.set(sym.name, {
188
+ name: sym.name,
189
+ kind: sym.kind,
190
+ properties: [],
191
+ methods: [],
192
+ signatures: [sym.signature],
193
+ });
194
+ }
195
+ }
196
+
197
+ catalogFiles.push({
198
+ path: file.path,
199
+ archetype: file.archetype,
200
+ symbols: Array.from(symbolMap.values()).map((s) => ({
201
+ name: s.name,
202
+ kind: s.kind,
203
+ properties: s.properties.length > 0 ? s.properties : undefined,
204
+ methods: s.methods.length > 0 ? s.methods : undefined,
205
+ signatures: s.signatures.length > 0 ? s.signatures : undefined,
206
+ })),
207
+ });
208
+ }
209
+
210
+ return {
211
+ domain: domain.name,
212
+ root: domain.root_path,
213
+ allowed_dependencies: allowedDeps,
214
+ forbidden_dependencies: forbiddenDeps,
215
+ files: catalogFiles,
216
+ };
217
+ }
218
+ }
@@ -1,106 +1,116 @@
1
- import type { Database } from "bun:sqlite";
2
- import type { ArchetypeKind, DomainRecord, FileRecord } from "../../../types/index.ts";
3
-
4
- export class FileRepository {
5
- constructor(private db: Database) {}
6
-
7
- public getFileByPath(path: string): FileRecord | null {
8
- return this.db
9
- .query<FileRecord, [string]>("SELECT * FROM files WHERE path = ?")
10
- .get(path);
11
- }
12
-
13
- public getFileById(id: number): FileRecord | null {
14
- return this.db
15
- .query<FileRecord, [number]>("SELECT * FROM files WHERE id = ?")
16
- .get(id);
17
- }
18
-
19
- public getFilesByDomain(domainId: number): FileRecord[] {
20
- return this.db
21
- .query<FileRecord, [number]>("SELECT * FROM files WHERE domain_id = ? ORDER BY path ASC")
22
- .all(domainId);
23
- }
24
-
25
- public countFiles(): number {
26
- const row = this.db
27
- .query<{ count: number }, []>("SELECT COUNT(*) as count FROM files")
28
- .get();
29
- return row?.count ?? 0;
30
- }
31
-
32
- public upsertFile(
33
- domainId: number,
34
- path: string,
35
- archetype: ArchetypeKind,
36
- contentHash: string,
37
- lineCount: number,
38
- mtimeMs: number = 0,
39
- sizeBytes: number = 0
40
- ): number {
41
- const existing = this.getFileByPath(path);
42
- if (existing) {
43
- this.db
44
- .query(
45
- `UPDATE files
46
- SET domain_id = ?, archetype = ?, content_hash = ?, line_count = ?, mtime_ms = ?, size_bytes = ?, last_scanned_at = CURRENT_TIMESTAMP
47
- WHERE id = ?`
48
- )
49
- .run(domainId, archetype, contentHash, lineCount, mtimeMs, sizeBytes, existing.id);
50
- return existing.id;
51
- } else {
52
- const result = this.db
53
- .query(
54
- `INSERT INTO files (domain_id, path, archetype, content_hash, line_count, mtime_ms, size_bytes)
55
- VALUES (?, ?, ?, ?, ?, ?, ?)`
56
- )
57
- .run(domainId, path, archetype, contentHash, lineCount, mtimeMs, sizeBytes);
58
- return Number(result.lastInsertRowid);
59
- }
60
- }
61
-
62
- public updateFileMetadataOnly(id: number, mtimeMs: number, sizeBytes: number): void {
63
- this.db
64
- .query(
65
- `UPDATE files
66
- SET mtime_ms = ?, size_bytes = ?, last_scanned_at = CURRENT_TIMESTAMP
67
- WHERE id = ?`
68
- )
69
- .run(mtimeMs, sizeBytes, id);
70
- }
71
-
72
- public deleteFilesNotInPaths(domainId: number, validPaths: string[]): number {
73
- if (validPaths.length === 0) {
74
- const res = this.db.query("DELETE FROM files WHERE domain_id = ?").run(domainId);
75
- return Number(res.changes);
76
- }
77
- const existingFiles = this.getFilesByDomain(domainId);
78
- const validSet = new Set(validPaths);
79
- let deletedCount = 0;
80
- for (const f of existingFiles) {
81
- if (!validSet.has(f.path)) {
82
- this.db.query("DELETE FROM files WHERE id = ?").run(f.id);
83
- deletedCount++;
84
- }
85
- }
86
- return deletedCount;
87
- }
88
-
89
- public deleteFile(path: string): void {
90
- this.db.query("DELETE FROM files WHERE path = ?").run(path);
91
- }
92
-
93
- public getFileWithDomain(
94
- filePath: string
95
- ): { file: FileRecord; domain: DomainRecord } | null {
96
- const file = this.getFileByPath(filePath);
97
- if (!file) return null;
98
-
99
- const domain = this.db
100
- .query<DomainRecord, [number]>("SELECT * FROM domains WHERE id = ?")
101
- .get(file.domain_id);
102
- if (!domain) return null;
103
-
104
- return { file, domain };
105
- }
106
- }
1
+ import type { Database } from "bun:sqlite";
2
+ import type { ArchetypeKind, DomainRecord, FileRecord } from "../../../types/index.ts";
3
+
4
+ export class FileRepository {
5
+ constructor(private db: Database) {}
6
+
7
+ public getFileByPath(path: string): FileRecord | null {
8
+ return this.db
9
+ .query<FileRecord, [string]>("SELECT * FROM files WHERE path = ?")
10
+ .get(path);
11
+ }
12
+
13
+ public getFileById(id: number): FileRecord | null {
14
+ return this.db
15
+ .query<FileRecord, [number]>("SELECT * FROM files WHERE id = ?")
16
+ .get(id);
17
+ }
18
+
19
+ public getFilesByDomain(domainId: number): FileRecord[] {
20
+ return this.db
21
+ .query<FileRecord, [number]>("SELECT * FROM files WHERE domain_id = ? ORDER BY path ASC")
22
+ .all(domainId);
23
+ }
24
+
25
+ public getAllFiles(): FileRecord[] {
26
+ return this.db
27
+ .query<FileRecord, []>("SELECT * FROM files ORDER BY path ASC")
28
+ .all();
29
+ }
30
+
31
+ public countFiles(): number {
32
+ const row = this.db
33
+ .query<{ count: number }, []>("SELECT COUNT(*) as count FROM files")
34
+ .get();
35
+ return row?.count ?? 0;
36
+ }
37
+
38
+ public upsertFile(
39
+ domainId: number,
40
+ path: string,
41
+ archetype: ArchetypeKind,
42
+ contentHash: string,
43
+ lineCount: number,
44
+ mtimeMs: number = 0,
45
+ sizeBytes: number = 0
46
+ ): number {
47
+ const existing = this.getFileByPath(path);
48
+ if (existing) {
49
+ this.db
50
+ .query(
51
+ `UPDATE files
52
+ SET domain_id = ?, archetype = ?, content_hash = ?, line_count = ?, mtime_ms = ?, size_bytes = ?, last_scanned_at = CURRENT_TIMESTAMP
53
+ WHERE id = ?`
54
+ )
55
+ .run(domainId, archetype, contentHash, lineCount, mtimeMs, sizeBytes, existing.id);
56
+ return existing.id;
57
+ } else {
58
+ const result = this.db
59
+ .query(
60
+ `INSERT INTO files (domain_id, path, archetype, content_hash, line_count, mtime_ms, size_bytes)
61
+ VALUES (?, ?, ?, ?, ?, ?, ?)`
62
+ )
63
+ .run(domainId, path, archetype, contentHash, lineCount, mtimeMs, sizeBytes);
64
+ return Number(result.lastInsertRowid);
65
+ }
66
+ }
67
+
68
+ public updateFileMetadataOnly(id: number, mtimeMs: number, sizeBytes: number): void {
69
+ this.db
70
+ .query(
71
+ `UPDATE files
72
+ SET mtime_ms = ?, size_bytes = ?, last_scanned_at = CURRENT_TIMESTAMP
73
+ WHERE id = ?`
74
+ )
75
+ .run(mtimeMs, sizeBytes, id);
76
+ }
77
+
78
+ public deleteFilesNotInPaths(domainId: number, validPaths: string[]): number {
79
+ if (validPaths.length === 0) {
80
+ const res = this.db.query("DELETE FROM files WHERE domain_id = ?").run(domainId);
81
+ return Number(res.changes);
82
+ }
83
+ const existingFiles = this.getFilesByDomain(domainId);
84
+ const validSet = new Set(validPaths);
85
+ const idsToDelete = existingFiles.filter((f) => !validSet.has(f.path)).map((f) => f.id);
86
+ if (idsToDelete.length === 0) return 0;
87
+
88
+ const chunkSize = 200;
89
+ let totalDeleted = 0;
90
+ for (let i = 0; i < idsToDelete.length; i += chunkSize) {
91
+ const chunk = idsToDelete.slice(i, i + chunkSize);
92
+ const placeholders = chunk.map(() => "?").join(",");
93
+ const res = this.db.query(`DELETE FROM files WHERE id IN (${placeholders})`).run(...chunk);
94
+ totalDeleted += Number(res.changes);
95
+ }
96
+ return totalDeleted;
97
+ }
98
+
99
+ public deleteFile(path: string): void {
100
+ this.db.query("DELETE FROM files WHERE path = ?").run(path);
101
+ }
102
+
103
+ public getFileWithDomain(
104
+ filePath: string
105
+ ): { file: FileRecord; domain: DomainRecord } | null {
106
+ const file = this.getFileByPath(filePath);
107
+ if (!file) return null;
108
+
109
+ const domain = this.db
110
+ .query<DomainRecord, [number]>("SELECT * FROM domains WHERE id = ?")
111
+ .get(file.domain_id);
112
+ if (!domain) return null;
113
+
114
+ return { file, domain };
115
+ }
116
+ }