septum 0.1.6 → 0.1.8
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/README.md +38 -37
- package/package.json +6 -5
- package/src/cli/commands/check.ts +34 -122
- package/src/cli/commands/hook.ts +68 -16
- package/src/cli/commands/init.ts +440 -121
- package/src/cli/commands/sync.ts +27 -17
- package/src/cli/commands/topology.ts +89 -47
- package/src/cli/index.ts +7 -68
- package/src/cli/scaffold/harness-config.ts +697 -0
- package/src/cli/templates/hooks.ts +6 -6
- package/src/cli/templates/rules.ts +8 -8
- package/src/cli/templates/skill.ts +11 -18
- package/src/cli/ui/prompts.ts +122 -0
- package/src/core/boundary/evaluator.ts +21 -25
- package/src/core/boundary/staged-auditor.ts +50 -0
- package/src/core/config/loader.ts +21 -24
- package/src/core/database/client.ts +8 -0
- package/src/core/database/repositories/dependency.repository.ts +80 -16
- package/src/core/database/repositories/domain.repository.ts +86 -33
- package/src/core/database/repositories/symbol.repository.ts +147 -65
- package/src/core/database/repository.ts +17 -5
- package/src/core/database/schema.ts +1 -1
- package/src/core/discovery/topology-detector.ts +71 -19
- package/src/core/ingestion/pipeline.ts +274 -126
- package/src/core/parser/extractors/frontend.ts +1 -0
- package/src/core/parser/extractors/go.ts +2 -2
- package/src/core/parser/extractors/laravel-semantic.ts +9 -8
- package/src/core/parser/extractors/mcp-cli-semantic.ts +56 -16
- package/src/core/parser/extractors/nestjs-semantic.ts +3 -2
- package/src/core/parser/extractors/php.ts +2 -2
- package/src/core/parser/extractors/python.ts +2 -2
- package/src/core/parser/extractors/typescript.ts +2 -2
- package/src/core/parser/schema/providers/laravel-migration-provider.ts +4 -3
- package/src/core/parser/schema/providers/prisma-schema-provider.ts +2 -1
- package/src/core/parser/tree-sitter.ts +6 -1
- package/src/core/parser/wasm-assets.ts +17 -0
- package/src/core/parser/wasm-loader.ts +3 -0
- package/src/core/resolver/call-graph-tracer.ts +4 -3
- package/src/core/resolver/module-resolver.ts +1 -10
- package/src/core/resolver/path-resolver.ts +57 -4
- package/src/core/resolver/vertical-slice-tracer.ts +1 -0
- package/src/core/resources/ignore.ts +35 -0
- package/src/core/resources/limits.ts +14 -0
- package/src/core/resources/source-files.ts +62 -0
- package/src/core/resources/work-queue.ts +74 -0
- package/src/core/session/session-manager.ts +67 -38
- package/src/core/telemetry/telemetry.ts +12 -1
- package/src/core/topology/environment-detector.ts +21 -11
- package/src/mcp/schemas.ts +381 -8
- package/src/mcp/server.ts +326 -529
- package/src/mcp/tools/boundary.ts +17 -0
- package/src/mcp/tools/check-boundary.ts +97 -43
- package/src/mcp/tools/domain.ts +40 -0
- package/src/mcp/tools/feature.ts +67 -0
- package/src/mcp/tools/get-domain-catalog.ts +15 -7
- package/src/mcp/tools/get-environment-topology.ts +2 -2
- package/src/mcp/tools/get-feature-context.ts +9 -12
- package/src/mcp/tools/get-symbol-impact.ts +5 -2
- package/src/mcp/tools/get-symbol.ts +2 -1
- package/src/mcp/tools/locate-symbol.ts +28 -5
- package/src/mcp/tools/register-domain.ts +25 -9
- package/src/mcp/tools/register-feature.ts +40 -21
- package/src/mcp/tools/slice.ts +15 -0
- package/src/mcp/tools/symbol.ts +82 -0
- package/src/mcp/workspace-manager.ts +159 -0
- package/src/mcp/workspace-watcher.ts +202 -0
- package/src/types/index.ts +21 -1
- package/src/types/wasm.d.ts +4 -0
- package/src/cli/commands/ingest.ts +0 -29
- package/src/cli/commands/locate.ts +0 -61
- package/src/cli/commands/query.ts +0 -104
- package/src/cli/commands/slice.ts +0 -50
|
@@ -1,5 +1,9 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
1
|
+
import { shouldIgnoreSourcePath } from "../resources/ignore.ts";
|
|
2
|
+
import { RESOURCE_LIMITS } from "../resources/limits.ts";
|
|
3
|
+
import { readSourceFile, SourceFileLimitError, collectSourceSkips, type SkippedSourceFile, type SourceReadFingerprint } from "../resources/source-files.ts";
|
|
4
|
+
import { existsSync, readdirSync, readFileSync, statSync, lstatSync, realpathSync } from "node:fs";
|
|
5
|
+
import { basename, dirname, isAbsolute, join, relative, resolve } from "node:path";
|
|
6
|
+
import { isPathContained, resolveWorkspaceRoot } from "../resolver/path-resolver.ts";
|
|
3
7
|
import type { ParsedFileAST } from "../../types/index.ts";
|
|
4
8
|
import type { ValidatedSeptumConfig } from "../config/schema.ts";
|
|
5
9
|
import type { SeptumRepository } from "../database/repository.ts";
|
|
@@ -8,6 +12,7 @@ import { SemanticSliceExtractorRegistry } from "../parser/extractors/registry.ts
|
|
|
8
12
|
import { TopologyDetector } from "../discovery/topology-detector.ts";
|
|
9
13
|
import { ASTParserEngine } from "../parser/tree-sitter.ts";
|
|
10
14
|
import { CallGraphTracer } from "../resolver/call-graph-tracer.ts";
|
|
15
|
+
import { ModuleResolver } from "../resolver/module-resolver.ts";
|
|
11
16
|
import { computeContentHash } from "./hasher.ts";
|
|
12
17
|
|
|
13
18
|
export interface IngestionMetrics {
|
|
@@ -33,16 +38,34 @@ export class IngestionPipeline {
|
|
|
33
38
|
}
|
|
34
39
|
}
|
|
35
40
|
|
|
36
|
-
public async run(config: ValidatedSeptumConfig): Promise<IngestionMetrics> {
|
|
41
|
+
public async run(config: ValidatedSeptumConfig, options: { verifyContent?: boolean; signal?: AbortSignal } = {}): Promise<IngestionMetrics> {
|
|
42
|
+
this.repo.setMeta("sync_state", "running");
|
|
43
|
+
try {
|
|
44
|
+
const result = await this.runSync(config, options);
|
|
45
|
+
this.repo.setMeta("sync_state", "complete");
|
|
46
|
+
this.repo.setMeta("sync_error", "");
|
|
47
|
+
return result;
|
|
48
|
+
} catch (error) {
|
|
49
|
+
this.repo.setMeta("sync_state", "error");
|
|
50
|
+
this.repo.setMeta("sync_error", String(error));
|
|
51
|
+
throw error;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
private async runSync(config: ValidatedSeptumConfig, options: { verifyContent?: boolean; signal?: AbortSignal }): Promise<IngestionMetrics> {
|
|
56
|
+
this.ensureParserRevision();
|
|
37
57
|
const startTime = performance.now();
|
|
38
58
|
let filesScanned = 0;
|
|
39
59
|
let filesUpdated = 0;
|
|
40
60
|
let filesSkipped = 0;
|
|
41
61
|
let symbolsIndexed = 0;
|
|
42
62
|
let dependenciesIndexed = 0;
|
|
63
|
+
const unsupportedFiles: string[] = [];
|
|
64
|
+
const skippedFiles: SkippedSourceFile[] = [];
|
|
65
|
+
let removedFiles = 0;
|
|
43
66
|
|
|
44
67
|
let domainNames = Object.keys(config.domains);
|
|
45
|
-
const projectRoot = this.customWorkspaceRoot ||
|
|
68
|
+
const projectRoot = this.customWorkspaceRoot || resolveWorkspaceRoot();
|
|
46
69
|
|
|
47
70
|
// Auto-discovery mode if no domains explicitly declared in config
|
|
48
71
|
if (domainNames.length === 0) {
|
|
@@ -68,27 +91,42 @@ export class IngestionPipeline {
|
|
|
68
91
|
const domainCfg = config.domains[domainName];
|
|
69
92
|
const domainId = this.repo.upsertDomain(domainName, domainCfg);
|
|
70
93
|
const workspaceRoot = this.resolveWorkspaceRoot(config, domainCfg.root);
|
|
94
|
+
// SV-02: Enforce workspace containment during ingestion (reject traversal/symlink escape)
|
|
95
|
+
if (!isPathContained(domainCfg.root, workspaceRoot)) {
|
|
96
|
+
throw new Error(`Domain root '${domainCfg.root}' for domain '${domainName}' escapes workspace boundary '${workspaceRoot}'.`);
|
|
97
|
+
}
|
|
71
98
|
const absDomainRoot = resolve(workspaceRoot, domainCfg.root);
|
|
72
99
|
|
|
73
|
-
|
|
74
|
-
|
|
100
|
+
try {
|
|
101
|
+
const rootStat = statSync(absDomainRoot);
|
|
102
|
+
if (!rootStat.isDirectory()) {
|
|
103
|
+
throw new Error(`Domain root '${domainCfg.root}' is not a directory.`);
|
|
104
|
+
}
|
|
105
|
+
} catch (err: any) {
|
|
106
|
+
if (err?.code === "ENOENT") {
|
|
107
|
+
removedFiles += this.repo.getAllFiles().filter(file => file.domain_id === domainId).length;
|
|
108
|
+
this.repo.files.deleteFilesNotInPaths(domainId, []);
|
|
109
|
+
if (removedFiles) this.repo.setMeta("slices_dirty", "1");
|
|
110
|
+
continue;
|
|
111
|
+
}
|
|
112
|
+
// SV-01: Permission or I/O failure (EACCES, EPERM, EIO). Do NOT prune, propagate error!
|
|
113
|
+
throw new Error(`Failed to access domain root '${domainCfg.root}' for domain '${domainName}': ${err.message || err}`);
|
|
75
114
|
}
|
|
76
115
|
|
|
77
|
-
const normCurrentRoot = domainCfg.root.replace(/\\/g, "/").replace(/^\.\/?/, "");
|
|
78
116
|
const childDomainRoots = domainNames
|
|
79
|
-
.filter(
|
|
80
|
-
.map(
|
|
81
|
-
.filter(
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
return r.startsWith(normCurrentRoot + "/");
|
|
117
|
+
.filter(name => name !== domainName)
|
|
118
|
+
.map(name => relative(workspaceRoot, resolve(workspaceRoot, config.domains[name].root)).replace(/\\/g, "/"))
|
|
119
|
+
.filter(child => {
|
|
120
|
+
const fromParent = relative(absDomainRoot, resolve(workspaceRoot, child)).replace(/\\/g, "/");
|
|
121
|
+
return fromParent !== "" && fromParent !== ".." && !fromParent.startsWith("../") && !isAbsolute(fromParent);
|
|
85
122
|
});
|
|
86
123
|
|
|
87
124
|
const collectedFiles = this.collectFiles(
|
|
88
125
|
absDomainRoot,
|
|
89
126
|
workspaceRoot,
|
|
90
127
|
config.settings.ignore_patterns,
|
|
91
|
-
childDomainRoots
|
|
128
|
+
childDomainRoots,
|
|
129
|
+
options.signal
|
|
92
130
|
);
|
|
93
131
|
const activeRelPaths = new Set<string>();
|
|
94
132
|
|
|
@@ -109,8 +147,43 @@ export class IngestionPipeline {
|
|
|
109
147
|
ast: ParsedFileAST;
|
|
110
148
|
}> = [];
|
|
111
149
|
|
|
150
|
+
let batchBytes = 0;
|
|
151
|
+
const commitBatch = () => {
|
|
152
|
+
this.repo.runInTransaction(() => {
|
|
153
|
+
for (const meta of pendingMetadataUpdates) {
|
|
154
|
+
this.repo.updateFileMetadataOnly(meta.id, meta.mtimeMs, meta.sizeBytes);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
if (pendingUpdates.length) this.repo.setMeta("slices_dirty", "1");
|
|
158
|
+
for (const item of pendingUpdates) {
|
|
159
|
+
const fileId = this.repo.upsertFile(
|
|
160
|
+
domainId,
|
|
161
|
+
item.relPath,
|
|
162
|
+
item.archetype,
|
|
163
|
+
item.contentHash,
|
|
164
|
+
item.lineCount,
|
|
165
|
+
item.mtimeMs,
|
|
166
|
+
item.sizeBytes
|
|
167
|
+
);
|
|
168
|
+
|
|
169
|
+
this.repo.replaceFileSymbols(fileId, item.ast.symbols);
|
|
170
|
+
this.repo.replaceFileDependencies(fileId, item.ast.dependencies);
|
|
171
|
+
|
|
172
|
+
symbolsIndexed += item.ast.symbols.length;
|
|
173
|
+
dependenciesIndexed += item.ast.dependencies.length;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
});
|
|
177
|
+
pendingUpdates.length = 0;
|
|
178
|
+
pendingMetadataUpdates.length = 0;
|
|
179
|
+
batchBytes = 0;
|
|
180
|
+
};
|
|
181
|
+
|
|
112
182
|
for (const { fullPath, relPath } of collectedFiles) {
|
|
183
|
+
options.signal?.throwIfAborted();
|
|
184
|
+
if (pendingMetadataUpdates.length >= RESOURCE_LIMITS.batchFiles) commitBatch();
|
|
113
185
|
filesScanned++;
|
|
186
|
+
if (filesScanned % RESOURCE_LIMITS.batchFiles === 0) { await new Promise<void>(done => setTimeout(done, 0)); options.signal?.throwIfAborted(); }
|
|
114
187
|
activeRelPaths.add(relPath);
|
|
115
188
|
|
|
116
189
|
let stat;
|
|
@@ -120,12 +193,13 @@ export class IngestionPipeline {
|
|
|
120
193
|
continue;
|
|
121
194
|
}
|
|
122
195
|
|
|
123
|
-
const mtimeMs =
|
|
196
|
+
const mtimeMs = stat.mtimeMs;
|
|
124
197
|
const sizeBytes = stat.size;
|
|
125
198
|
const existingFile = existingFilesMap.get(relPath);
|
|
126
199
|
|
|
127
200
|
// Tier 1: Fast OS metadata check (inode mtime + size + domain match)
|
|
128
201
|
if (
|
|
202
|
+
!options.verifyContent &&
|
|
129
203
|
existingFile &&
|
|
130
204
|
existingFile.domain_id === domainId &&
|
|
131
205
|
existingFile.mtime_ms === mtimeMs &&
|
|
@@ -136,7 +210,24 @@ export class IngestionPipeline {
|
|
|
136
210
|
}
|
|
137
211
|
|
|
138
212
|
// Tier 2: Content Hash verification
|
|
139
|
-
|
|
213
|
+
if (sizeBytes > RESOURCE_LIMITS.sourceBytes) {
|
|
214
|
+
skippedFiles.push({path: relPath, reason: "file_too_large"});
|
|
215
|
+
if (existingFile) removedFiles++;
|
|
216
|
+
this.repo.deleteFileByPath(relPath);
|
|
217
|
+
this.repo.setMeta("slices_dirty", "1");
|
|
218
|
+
continue;
|
|
219
|
+
}
|
|
220
|
+
if (pendingUpdates.length >= RESOURCE_LIMITS.batchFiles || (batchBytes + sizeBytes > RESOURCE_LIMITS.batchBytes && pendingUpdates.length > 0)) { commitBatch(); await new Promise<void>(done => setTimeout(done, 0)); options.signal?.throwIfAborted(); }
|
|
221
|
+
let content: string;
|
|
222
|
+
try { content = readSourceFile(fullPath); }
|
|
223
|
+
catch (error) {
|
|
224
|
+
if (!(error instanceof SourceFileLimitError)) throw error;
|
|
225
|
+
skippedFiles.push({path: relPath, reason: "file_too_large"});
|
|
226
|
+
if (existingFile) removedFiles++;
|
|
227
|
+
this.repo.deleteFileByPath(relPath);
|
|
228
|
+
this.repo.setMeta("slices_dirty", "1");
|
|
229
|
+
continue;
|
|
230
|
+
}
|
|
140
231
|
const currentHash = computeContentHash(content);
|
|
141
232
|
|
|
142
233
|
if (
|
|
@@ -150,11 +241,19 @@ export class IngestionPipeline {
|
|
|
150
241
|
continue;
|
|
151
242
|
}
|
|
152
243
|
|
|
153
|
-
filesUpdated++;
|
|
154
244
|
const lineCount = content.split("\n").length;
|
|
155
245
|
const archetype = detectArchetype(relPath, domainCfg.archetypes ?? {});
|
|
156
246
|
const ast = await this.parserEngine.parseFile(fullPath, content);
|
|
247
|
+
if (ast.status === "unsupported") {
|
|
248
|
+
unsupportedFiles.push(relPath);
|
|
249
|
+
this.repo.deleteFileByPath(relPath);
|
|
250
|
+
this.repo.setMeta("slices_dirty", "1");
|
|
251
|
+
continue;
|
|
252
|
+
}
|
|
253
|
+
if (ast.status === "degraded") throw new Error(`Cannot reliably index '${relPath}': parser is degraded.`);
|
|
157
254
|
|
|
255
|
+
filesUpdated++;
|
|
256
|
+
batchBytes += sizeBytes;
|
|
158
257
|
pendingUpdates.push({
|
|
159
258
|
fullPath,
|
|
160
259
|
relPath,
|
|
@@ -167,33 +266,13 @@ export class IngestionPipeline {
|
|
|
167
266
|
});
|
|
168
267
|
}
|
|
169
268
|
|
|
170
|
-
|
|
269
|
+
commitBatch();
|
|
171
270
|
this.repo.runInTransaction(() => {
|
|
172
|
-
for (const meta of pendingMetadataUpdates) {
|
|
173
|
-
this.repo.updateFileMetadataOnly(meta.id, meta.mtimeMs, meta.sizeBytes);
|
|
174
|
-
}
|
|
175
|
-
|
|
176
|
-
for (const item of pendingUpdates) {
|
|
177
|
-
const fileId = this.repo.upsertFile(
|
|
178
|
-
domainId,
|
|
179
|
-
item.relPath,
|
|
180
|
-
item.archetype,
|
|
181
|
-
item.contentHash,
|
|
182
|
-
item.lineCount,
|
|
183
|
-
item.mtimeMs,
|
|
184
|
-
item.sizeBytes
|
|
185
|
-
);
|
|
186
|
-
|
|
187
|
-
this.repo.replaceFileSymbols(fileId, item.ast.symbols);
|
|
188
|
-
this.repo.replaceFileDependencies(fileId, item.ast.dependencies);
|
|
189
|
-
|
|
190
|
-
symbolsIndexed += item.ast.symbols.length;
|
|
191
|
-
dependenciesIndexed += item.ast.dependencies.length;
|
|
192
|
-
}
|
|
193
|
-
|
|
194
271
|
// State Reconciliation: Prune deleted files from DB using pre-loaded existingFilesMap
|
|
195
272
|
for (const [existingPath, existingRecord] of existingFilesMap.entries()) {
|
|
196
273
|
if (existingRecord.domain_id === domainId && !activeRelPaths.has(existingPath)) {
|
|
274
|
+
removedFiles++;
|
|
275
|
+
this.repo.setMeta("slices_dirty", "1");
|
|
197
276
|
this.repo.deleteFileByPath(existingPath);
|
|
198
277
|
existingFilesMap.delete(existingPath);
|
|
199
278
|
}
|
|
@@ -201,36 +280,78 @@ export class IngestionPipeline {
|
|
|
201
280
|
});
|
|
202
281
|
}
|
|
203
282
|
|
|
204
|
-
|
|
205
|
-
const
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
)
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
283
|
+
options.signal?.throwIfAborted();
|
|
284
|
+
const topologyInputs = ["package.json", "composer.json", "nest-cli.json", "go.mod", "artisan", "tsconfig.json"]
|
|
285
|
+
.map(file => { const target = join(projectRoot, file); return existsSync(target) ? readSourceFile(target) : ""; });
|
|
286
|
+
const semanticRevision = computeContentHash(JSON.stringify([config.domains, topologyInputs]));
|
|
287
|
+
// Membership covers newly added semantic files outside registered domains.
|
|
288
|
+
const inventory = this.collectFiles(projectRoot, projectRoot, config.settings.ignore_patterns, [], options.signal).map(({fullPath, relPath}) => {
|
|
289
|
+
const stat = statSync(fullPath);
|
|
290
|
+
const fingerprint = `${stat.size}:${stat.mtimeMs}`;
|
|
291
|
+
return `${relPath}:${fingerprint}`;
|
|
292
|
+
}).sort();
|
|
293
|
+
const inventoryRevision = computeContentHash(JSON.stringify(inventory));
|
|
294
|
+
const priorInputs: SourceReadFingerprint[] = JSON.parse(this.repo.getMeta("semantic_inputs") || "[]");
|
|
295
|
+
let semanticInputChanged = false;
|
|
296
|
+
for (let index = 0; index < priorInputs.length; index++) {
|
|
297
|
+
if (index % RESOURCE_LIMITS.batchFiles === 0) { await new Promise<void>(done => setTimeout(done, 0)); options.signal?.throwIfAborted(); }
|
|
298
|
+
const input = priorInputs[index];
|
|
299
|
+
const file = resolve(projectRoot, input.path);
|
|
300
|
+
if (!existsSync(file)) { semanticInputChanged = true; break; }
|
|
301
|
+
const stat = statSync(file);
|
|
302
|
+
if (stat.size !== input.size || stat.mtimeMs !== input.mtime) { semanticInputChanged = true; break; }
|
|
303
|
+
if (!options.verifyContent) continue;
|
|
304
|
+
try { semanticInputChanged = computeContentHash(readSourceFile(file)) !== input.hash; }
|
|
305
|
+
catch (error) { if (error instanceof SourceFileLimitError) semanticInputChanged = input.hash !== "file_too_large"; else throw error; }
|
|
306
|
+
if (semanticInputChanged) break;
|
|
307
|
+
}
|
|
308
|
+
const extractorRevision = "1"; // Bump when extractor or inferred-chain semantics change.
|
|
309
|
+
if (this.repo.getMeta("semantic_extractor_revision") !== extractorRevision || this.repo.getMeta("semantic_inventory") !== inventoryRevision || semanticInputChanged || filesUpdated > 0 || removedFiles > 0 || this.repo.getMeta("slices_dirty") === "1" || this.repo.getMeta("semantic_revision") !== semanticRevision) {
|
|
310
|
+
const extracted = await collectSourceSkips(projectRoot, async () => {
|
|
311
|
+
const detectedTopology = TopologyDetector.detect(projectRoot);
|
|
312
|
+
const semanticSlices = await SemanticSliceExtractorRegistry.extractAllSlices(projectRoot, detectedTopology.framework);
|
|
313
|
+
return semanticSlices.length > 0 ? semanticSlices : new CallGraphTracer(this.repo, projectRoot).traceAllSlices();
|
|
314
|
+
});
|
|
315
|
+
skippedFiles.push(...extracted.skipped);
|
|
316
|
+
this.repo.runInTransaction(() => {
|
|
317
|
+
this.repo.clearVerticalSlices();
|
|
318
|
+
for (const slice of extracted.value) this.repo.upsertVerticalSlice(slice);
|
|
319
|
+
this.repo.setMeta("slices_dirty", "");
|
|
320
|
+
this.repo.setMeta("semantic_revision", semanticRevision);
|
|
321
|
+
this.repo.setMeta("semantic_extractor_revision", extractorRevision);
|
|
322
|
+
this.repo.setMeta("semantic_inventory", inventoryRevision);
|
|
323
|
+
this.repo.setMeta("semantic_inputs", JSON.stringify(extracted.inputs));
|
|
324
|
+
this.repo.setMeta("semantic_skipped_files", JSON.stringify(extracted.skipped));
|
|
325
|
+
});
|
|
326
|
+
}
|
|
222
327
|
|
|
328
|
+
skippedFiles.push(...JSON.parse(this.repo.getMeta("semantic_skipped_files") || "[]"));
|
|
223
329
|
const durationMs = Math.round(performance.now() - startTime);
|
|
224
330
|
|
|
331
|
+
// Identify uncovered eligible source files outside registered domains
|
|
332
|
+
const indexedPaths = new Set(this.repo.getAllFiles().map((file) => file.path));
|
|
333
|
+
const allWorkspaceFiles = this.collectFiles(projectRoot, projectRoot, config.settings.ignore_patterns, [], options.signal);
|
|
334
|
+
const unsupportedSet = new Set(unsupportedFiles);
|
|
335
|
+
const skippedSet = new Set(skippedFiles.map((file) => file.path));
|
|
336
|
+
const uncoveredFiles = allWorkspaceFiles
|
|
337
|
+
.map((file) => file.relPath)
|
|
338
|
+
.filter((rel) => !indexedPaths.has(rel) && !unsupportedSet.has(rel) && !skippedSet.has(rel));
|
|
339
|
+
|
|
225
340
|
// Update SSOT repo_meta watermark
|
|
226
341
|
this.repo.runInTransaction(() => {
|
|
227
342
|
this.repo.setMeta("last_sync", new Date().toISOString());
|
|
343
|
+
this.repo.setMeta("sync_duration_ms", String(durationMs));
|
|
228
344
|
this.repo.setMeta("files_scanned", String(filesScanned));
|
|
229
345
|
this.repo.setMeta("files_updated", String(filesUpdated));
|
|
230
346
|
this.repo.setMeta("symbols_indexed", String(symbolsIndexed));
|
|
231
347
|
this.repo.setMeta("dependencies_indexed", String(dependenciesIndexed));
|
|
348
|
+
this.repo.setMeta("unsupported_files", JSON.stringify(unsupportedFiles));
|
|
349
|
+
this.repo.setMeta("skipped_files", JSON.stringify([...new Map(skippedFiles.map(file => [file.path, file])).values()]));
|
|
350
|
+
this.repo.setMeta("uncovered_files", JSON.stringify(uncoveredFiles));
|
|
232
351
|
});
|
|
233
352
|
|
|
353
|
+
this.repo.checkpoint();
|
|
354
|
+
|
|
234
355
|
return {
|
|
235
356
|
domains_processed: domainNames.length,
|
|
236
357
|
files_scanned: filesScanned,
|
|
@@ -419,23 +540,43 @@ export class IngestionPipeline {
|
|
|
419
540
|
dir: string,
|
|
420
541
|
workspaceRoot: string,
|
|
421
542
|
ignorePatterns: string[],
|
|
422
|
-
childDomainRoots: string[] = []
|
|
543
|
+
childDomainRoots: string[] = [],
|
|
544
|
+
signal?: AbortSignal
|
|
423
545
|
): Array<{ fullPath: string; relPath: string }> {
|
|
424
546
|
const results: Array<{ fullPath: string; relPath: string }> = [];
|
|
425
547
|
|
|
548
|
+
// SV-02: Check if initial dir itself is an escaping symlink
|
|
549
|
+
try {
|
|
550
|
+
const dirLstat = lstatSync(dir);
|
|
551
|
+
if (dirLstat.isSymbolicLink()) {
|
|
552
|
+
const real = realpathSync(dir);
|
|
553
|
+
if (!isPathContained(real, workspaceRoot)) {
|
|
554
|
+
throw new Error(`Directory '${dir}' is a symlink escaping workspace boundary '${workspaceRoot}'.`);
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
} catch (err: any) {
|
|
558
|
+
if (err?.code !== "ENOENT") throw err;
|
|
559
|
+
}
|
|
560
|
+
|
|
426
561
|
const walk = (currentDir: string) => {
|
|
562
|
+
signal?.throwIfAborted();
|
|
427
563
|
let entries: string[] = [];
|
|
428
564
|
try {
|
|
429
565
|
entries = readdirSync(currentDir);
|
|
430
|
-
} catch {
|
|
431
|
-
|
|
566
|
+
} catch (err: any) {
|
|
567
|
+
// SV-01: Do not swallow permission/IO errors; propagate so pipeline reports incomplete/error
|
|
568
|
+
if (err?.code === "ENOENT") {
|
|
569
|
+
return;
|
|
570
|
+
}
|
|
571
|
+
throw new Error(`Failed to read directory '${currentDir}': ${err.message || err}`);
|
|
432
572
|
}
|
|
433
573
|
|
|
434
574
|
for (const entry of entries) {
|
|
575
|
+
signal?.throwIfAborted();
|
|
435
576
|
const fullPath = join(currentDir, entry);
|
|
436
577
|
const relPath = relative(workspaceRoot, fullPath).replace(/\\/g, "/");
|
|
437
578
|
|
|
438
|
-
if (
|
|
579
|
+
if (shouldIgnoreSourcePath(relPath, ignorePatterns)) {
|
|
439
580
|
continue;
|
|
440
581
|
}
|
|
441
582
|
|
|
@@ -444,14 +585,35 @@ export class IngestionPipeline {
|
|
|
444
585
|
}
|
|
445
586
|
|
|
446
587
|
try {
|
|
447
|
-
const stat =
|
|
588
|
+
const stat = lstatSync(fullPath);
|
|
589
|
+
if (stat.isSymbolicLink()) {
|
|
590
|
+
try {
|
|
591
|
+
const real = realpathSync(fullPath);
|
|
592
|
+
if (!isPathContained(real, workspaceRoot)) {
|
|
593
|
+
continue;
|
|
594
|
+
}
|
|
595
|
+
const targetStat = statSync(real);
|
|
596
|
+
if (targetStat.isDirectory()) {
|
|
597
|
+
continue;
|
|
598
|
+
} else if (targetStat.isFile() && this.isSourceFile(real)) {
|
|
599
|
+
results.push({ fullPath, relPath });
|
|
600
|
+
}
|
|
601
|
+
} catch {
|
|
602
|
+
continue;
|
|
603
|
+
}
|
|
604
|
+
continue;
|
|
605
|
+
}
|
|
606
|
+
|
|
448
607
|
if (stat.isDirectory()) {
|
|
449
608
|
walk(fullPath);
|
|
450
609
|
} else if (stat.isFile() && this.isSourceFile(fullPath)) {
|
|
451
610
|
results.push({ fullPath, relPath });
|
|
452
611
|
}
|
|
453
|
-
} catch {
|
|
454
|
-
|
|
612
|
+
} catch (err: any) {
|
|
613
|
+
if (err?.code === "ENOENT") {
|
|
614
|
+
continue;
|
|
615
|
+
}
|
|
616
|
+
throw new Error(`Failed to stat path '${fullPath}': ${err.message || err}`);
|
|
455
617
|
}
|
|
456
618
|
}
|
|
457
619
|
};
|
|
@@ -471,58 +633,37 @@ export class IngestionPipeline {
|
|
|
471
633
|
if (config.settings.db_path && config.settings.db_path !== ":memory:") {
|
|
472
634
|
const dbDir = dirname(resolve(config.settings.db_path));
|
|
473
635
|
const candidate = basename(dbDir) === ".septum" ? dirname(dbDir) : dbDir;
|
|
474
|
-
const absDomainRoot = resolve(domainRoot);
|
|
636
|
+
const absDomainRoot = isAbsolute(domainRoot) ? domainRoot : resolve(candidate, domainRoot);
|
|
475
637
|
if (absDomainRoot.startsWith(candidate)) {
|
|
476
638
|
return candidate;
|
|
477
639
|
}
|
|
478
640
|
}
|
|
479
|
-
return
|
|
641
|
+
return resolveWorkspaceRoot();
|
|
480
642
|
}
|
|
481
643
|
|
|
482
|
-
private shouldIgnore(filePath: string, ignorePatterns: string[]): boolean {
|
|
483
|
-
const normPath = filePath.replace(/\\/g, "/").replace(/^\.\//, "");
|
|
484
|
-
for (const pattern of ignorePatterns) {
|
|
485
|
-
const normPattern = pattern.replace(/\\/g, "/").replace(/^\.\//, "").trim();
|
|
486
|
-
if (!normPattern) continue;
|
|
487
|
-
|
|
488
|
-
if (normPattern === "*" || normPattern === "**") {
|
|
489
|
-
return true;
|
|
490
|
-
}
|
|
491
|
-
|
|
492
|
-
if (normPath === normPattern) return true;
|
|
493
|
-
|
|
494
|
-
if (normPattern.endsWith("/**")) {
|
|
495
|
-
const prefix = normPattern.slice(0, -3);
|
|
496
|
-
if (normPath === prefix || normPath.startsWith(prefix + "/")) return true;
|
|
497
|
-
} else if (normPattern.endsWith("/*")) {
|
|
498
|
-
const prefix = normPattern.slice(0, -2);
|
|
499
|
-
if (normPath === prefix || normPath.startsWith(prefix + "/")) return true;
|
|
500
|
-
} else if (normPath.startsWith(normPattern + "/")) {
|
|
501
|
-
return true;
|
|
502
|
-
}
|
|
503
|
-
|
|
504
|
-
if (normPattern.startsWith("*.") && normPath.endsWith(normPattern.slice(1))) {
|
|
505
|
-
return true;
|
|
506
|
-
}
|
|
507
644
|
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
return true;
|
|
511
|
-
}
|
|
512
|
-
}
|
|
513
|
-
return false;
|
|
645
|
+
private isSourceFile(path: string): boolean {
|
|
646
|
+
return ASTParserEngine.isSourceFile(path);
|
|
514
647
|
}
|
|
515
648
|
|
|
516
|
-
private
|
|
517
|
-
|
|
649
|
+
private ensureParserRevision(): void {
|
|
650
|
+
const revision = "2";
|
|
651
|
+
if (this.repo.getMeta("parser_revision") === revision) return;
|
|
652
|
+
this.repo.runInTransaction(() => {
|
|
653
|
+
for (const file of this.repo.getAllFiles()) {
|
|
654
|
+
this.repo.upsertFile(file.domain_id, file.path, file.archetype, "", file.line_count, -1, -1);
|
|
655
|
+
}
|
|
656
|
+
this.repo.clearVerticalSlices();
|
|
657
|
+
this.repo.setMeta("parser_revision", revision);
|
|
658
|
+
});
|
|
518
659
|
}
|
|
519
660
|
|
|
520
661
|
/**
|
|
521
662
|
* JIT single-file ingestion: quickly verifies and updates a single file's AST,
|
|
522
663
|
* symbols, and dependencies in SQLite if its mtime or content hash has changed.
|
|
523
|
-
* Runs in < 5ms for rapid delta sync without full project scans.
|
|
524
664
|
*/
|
|
525
665
|
public async ingestFile(targetFilePath: string, config: ValidatedSeptumConfig): Promise<boolean> {
|
|
666
|
+
this.ensureParserRevision();
|
|
526
667
|
const fullPath = resolve(targetFilePath);
|
|
527
668
|
if (!existsSync(fullPath)) return false;
|
|
528
669
|
|
|
@@ -534,28 +675,18 @@ export class IngestionPipeline {
|
|
|
534
675
|
return false;
|
|
535
676
|
}
|
|
536
677
|
|
|
537
|
-
const mtimeMs =
|
|
678
|
+
const mtimeMs = stat.mtimeMs;
|
|
538
679
|
const sizeBytes = stat.size;
|
|
539
680
|
|
|
540
|
-
const projectRoot = this.customWorkspaceRoot ||
|
|
681
|
+
const projectRoot = this.customWorkspaceRoot || resolveWorkspaceRoot();
|
|
541
682
|
const relPath = relative(projectRoot, fullPath).replace(/\\/g, "/");
|
|
542
683
|
|
|
543
|
-
if (
|
|
684
|
+
if (shouldIgnoreSourcePath(relPath, config.settings.ignore_patterns ?? [])) {
|
|
544
685
|
return false;
|
|
545
686
|
}
|
|
546
687
|
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
for (const [dName, dCfg] of Object.entries(config.domains)) {
|
|
551
|
-
const absRoot = resolve(projectRoot, dCfg.root).replace(/\\/g, "/");
|
|
552
|
-
const normFullPath = fullPath.replace(/\\/g, "/");
|
|
553
|
-
if (normFullPath === absRoot || normFullPath.startsWith(absRoot + "/")) {
|
|
554
|
-
targetDomainName = dName;
|
|
555
|
-
targetDomainCfg = dCfg;
|
|
556
|
-
break;
|
|
557
|
-
}
|
|
558
|
-
}
|
|
688
|
+
const targetDomainName = new ModuleResolver(projectRoot, this.repo).resolveSourceDomain(fullPath, config);
|
|
689
|
+
const targetDomainCfg = targetDomainName ? config.domains[targetDomainName] : null;
|
|
559
690
|
|
|
560
691
|
if (!targetDomainName || !targetDomainCfg) {
|
|
561
692
|
return false;
|
|
@@ -564,20 +695,23 @@ export class IngestionPipeline {
|
|
|
564
695
|
const domainId = this.repo.upsertDomain(targetDomainName, targetDomainCfg);
|
|
565
696
|
const existingFile = this.repo.getFileByPath(relPath);
|
|
566
697
|
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
698
|
+
let content: string;
|
|
699
|
+
try { content = readSourceFile(fullPath); }
|
|
700
|
+
catch (error) {
|
|
701
|
+
if (!(error instanceof SourceFileLimitError)) throw error;
|
|
702
|
+
this.repo.runInTransaction(() => {
|
|
703
|
+
this.repo.deleteFileByPath(relPath);
|
|
704
|
+
this.repo.clearVerticalSlices();
|
|
705
|
+
this.repo.setMeta("slices_dirty", "1");
|
|
706
|
+
const skipped: SkippedSourceFile[] = JSON.parse(this.repo.getMeta("skipped_files") || "[]");
|
|
707
|
+
this.repo.setMeta("skipped_files", JSON.stringify([...skipped.filter(file => file.path !== relPath), {path: relPath, reason: "file_too_large"}]));
|
|
708
|
+
});
|
|
709
|
+
return false;
|
|
574
710
|
}
|
|
575
|
-
|
|
576
|
-
const content = readFileSync(fullPath, "utf-8");
|
|
577
711
|
const currentHash = computeContentHash(content);
|
|
578
712
|
|
|
579
713
|
// Tier 2: Hash verification
|
|
580
|
-
if (existingFile && existingFile.content_hash === currentHash) {
|
|
714
|
+
if (existingFile && existingFile.domain_id === domainId && existingFile.content_hash === currentHash) {
|
|
581
715
|
this.repo.updateFileMetadataOnly(existingFile.id, mtimeMs, sizeBytes);
|
|
582
716
|
return false;
|
|
583
717
|
}
|
|
@@ -585,6 +719,14 @@ export class IngestionPipeline {
|
|
|
585
719
|
const lineCount = content.split("\n").length;
|
|
586
720
|
const archetype = detectArchetype(relPath, targetDomainCfg.archetypes ?? {});
|
|
587
721
|
const ast = await this.parserEngine.parseFile(fullPath, content);
|
|
722
|
+
if (ast.status === "unsupported") {
|
|
723
|
+
this.repo.deleteFileByPath(relPath);
|
|
724
|
+
const unsupported = new Set<string>(JSON.parse(this.repo.getMeta("unsupported_files") || "[]"));
|
|
725
|
+
unsupported.add(relPath);
|
|
726
|
+
this.repo.setMeta("unsupported_files", JSON.stringify([...unsupported]));
|
|
727
|
+
return false;
|
|
728
|
+
}
|
|
729
|
+
if (ast.status === "degraded") throw new Error(`Cannot reliably index '${relPath}': parser is degraded.`);
|
|
588
730
|
|
|
589
731
|
this.repo.runInTransaction(() => {
|
|
590
732
|
const fileId = this.repo.upsertFile(
|
|
@@ -598,6 +740,12 @@ export class IngestionPipeline {
|
|
|
598
740
|
);
|
|
599
741
|
this.repo.replaceFileSymbols(fileId, ast.symbols);
|
|
600
742
|
this.repo.replaceFileDependencies(fileId, ast.dependencies);
|
|
743
|
+
this.repo.clearVerticalSlices();
|
|
744
|
+
this.repo.setMeta("slices_dirty", "1");
|
|
745
|
+
const skipped: SkippedSourceFile[] = JSON.parse(this.repo.getMeta("skipped_files") || "[]");
|
|
746
|
+
this.repo.setMeta("skipped_files", JSON.stringify(skipped.filter(file => file.path !== relPath)));
|
|
747
|
+
const unsupported = JSON.parse(this.repo.getMeta("unsupported_files") || "[]") as string[];
|
|
748
|
+
this.repo.setMeta("unsupported_files", JSON.stringify(unsupported.filter(path => path !== relPath)));
|
|
601
749
|
});
|
|
602
750
|
|
|
603
751
|
return true;
|
|
@@ -123,6 +123,7 @@ export class FrontendExtractor implements CodeExtractor {
|
|
|
123
123
|
dependencies: ExtractedDependency[]
|
|
124
124
|
): Promise<void> {
|
|
125
125
|
const parsed = await this.tsExtractor.extract("component-script.ts", scriptContent);
|
|
126
|
+
if (parsed.status === "degraded") throw new Error("Cannot reliably parse component script.");
|
|
126
127
|
const delta = Math.max(0, startLine - 1);
|
|
127
128
|
|
|
128
129
|
for (const dep of parsed.dependencies) {
|
|
@@ -67,10 +67,10 @@ export class GoExtractor implements CodeExtractor {
|
|
|
67
67
|
this.extractFromAst(rootNode, filePath, content)
|
|
68
68
|
);
|
|
69
69
|
if (astResult) {
|
|
70
|
-
return astResult;
|
|
70
|
+
return { ...astResult, status: "parsed" };
|
|
71
71
|
}
|
|
72
72
|
|
|
73
|
-
return this.extractFallback(filePath, content);
|
|
73
|
+
return { ...this.extractFallback(filePath, content), status: "degraded" };
|
|
74
74
|
}
|
|
75
75
|
|
|
76
76
|
private extractFallback(filePath: string, content: string): ParsedFileAST {
|