septum 0.1.6 → 0.1.7
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 +23 -3
- package/package.json +5 -5
- package/src/cli/commands/check.ts +32 -122
- package/src/cli/commands/hook.ts +9 -4
- package/src/cli/index.ts +2 -1
- package/src/core/boundary/evaluator.ts +21 -25
- package/src/core/boundary/staged-auditor.ts +47 -0
- package/src/core/config/loader.ts +21 -24
- package/src/core/database/repositories/dependency.repository.ts +3 -3
- package/src/core/database/repositories/symbol.repository.ts +71 -0
- package/src/core/ingestion/pipeline.ts +50 -36
- package/src/core/parser/extractors/frontend.ts +1 -0
- package/src/core/parser/extractors/go.ts +2 -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/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/module-resolver.ts +1 -10
- package/src/core/resolver/vertical-slice-tracer.ts +1 -0
- package/src/core/session/session-manager.ts +55 -39
- package/src/mcp/server.ts +99 -88
- package/src/mcp/tools/check-boundary.ts +15 -33
- package/src/mcp/tools/get-domain-catalog.ts +6 -1
- package/src/mcp/tools/locate-symbol.ts +25 -2
- package/src/mcp/tools/register-feature.ts +9 -7
- package/src/types/index.ts +20 -1
- package/src/types/wasm.d.ts +4 -0
package/README.md
CHANGED
|
@@ -2,13 +2,13 @@
|
|
|
2
2
|
|
|
3
3
|
> **Deterministic Bounded-Context & Anti-Poisoning Architecture Guard for AI Coding Agents.**
|
|
4
4
|
|
|
5
|
-
[](package.json)
|
|
6
6
|
[](https://bun.sh)
|
|
7
7
|
[](https://modelcontextprotocol.io/)
|
|
8
8
|
[-003B57?logo=sqlite)](https://sqlite.org/)
|
|
9
9
|
[](LICENSE)
|
|
10
10
|
|
|
11
|
-
Septum
|
|
11
|
+
Septum helps AI coding agents navigate code and respect declared architectural boundaries. It indexes your codebase into an embedded SQLite SSOT (`.septum/septum.db`), enforces strict bounded-context domains, and intercepts unauthorized cross-domain edits before they hit disk.
|
|
12
12
|
|
|
13
13
|
---
|
|
14
14
|
|
|
@@ -59,7 +59,7 @@ Add Septum to your MCP client (`mcp.json` or `claude_desktop_config.json`):
|
|
|
59
59
|
|---|---|
|
|
60
60
|
| `septum init` | Detect topology, scaffold boundary guards, and build initial catalog |
|
|
61
61
|
| `septum ingest` | Full AST re-indexing into `.septum/septum.db` |
|
|
62
|
-
| `septum sync` |
|
|
62
|
+
| `septum sync` | Incremental synchronization of modified files for modified files |
|
|
63
63
|
| `septum check --strict` | CI/CD gate: verify boundary integrity & fail on violations |
|
|
64
64
|
| `septum query [domain]` | Inspect domain catalog, archetypes, and public signatures |
|
|
65
65
|
| `septum locate <query>` | Locate symbol, class, interface, method, or error source |
|
|
@@ -103,3 +103,23 @@ bun run build
|
|
|
103
103
|
## License
|
|
104
104
|
|
|
105
105
|
[MIT](LICENSE) © shironim
|
|
106
|
+
|
|
107
|
+
## Reliability and Scope
|
|
108
|
+
|
|
109
|
+
`septum check --strict` enables strict dependency rules before evaluating the codebase. A full check synchronizes the index first. CLI and MCP staged checks read Git index blobs, so unstaged edits cannot hide a violation in the proposed commit. Staged checks also evaluate dependency cycles using indexed dependencies with staged imports overlaid.
|
|
110
|
+
|
|
111
|
+
Domain ownership uses the most specific normalized root in both full and single-file ingestion. Feature touchpoints are normalized against the workspace; `/*` permits immediate children and `/**` permits descendants.
|
|
112
|
+
|
|
113
|
+
Each active MCP workspace has a watcher. Changes to multiple files are batched without dropping earlier paths; deletion and directory moves reconcile the catalog. If a native watcher is unavailable, Septum falls back to polling and synchronizes before answering queries. Catalog responses expose `index_status.watcher_mode` and `index_status.unsupported_files`. Single-file updates invalidate cached execution slices; the next trace rebuilds them.
|
|
114
|
+
|
|
115
|
+
Tree-sitter grammars are runtime dependencies and are embedded in compiled executables. The active extractors cover TypeScript/JavaScript, PHP/Blade, Python, Go, and script extraction from Vue/Svelte/Astro. Rust, Java, C#, and Ruby do not yet have active extractors. Unsupported files are reported explicitly; an incomplete audit does not receive a clean approval. Unindexed symbols return `risk_level: "unknown"`.
|
|
116
|
+
|
|
117
|
+
Feature sessions have a single owner per workspace. The default owner is the MCP process; set `SEPTUM_SESSION_ID` when several commands intentionally share the same owner. Another owner cannot replace or release the active session. Use `septum feature clear --force` for explicit recovery after a process exits or for a legacy session without ownership metadata.
|
|
118
|
+
|
|
119
|
+
Run the real-server integration checks with Python 3:
|
|
120
|
+
|
|
121
|
+
```bash
|
|
122
|
+
bun run test:integration
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
These checks exercise live rule changes, Git staged blobs, multiple workspaces, rapid edits, directory moves, and unsupported-file diagnostics. The normal `bun test` suite includes a standalone-binary regression for forbidden multiline imports.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "septum",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.7",
|
|
4
4
|
"description": "Deterministic Bounded-Context, Structural File Catalog & Anti-Poisoning Partition for AI Agents",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.ts",
|
|
@@ -18,13 +18,15 @@
|
|
|
18
18
|
"check": "bun run bin/septum.ts check",
|
|
19
19
|
"test": "bun test",
|
|
20
20
|
"build": "bun build ./bin/septum.ts --compile --outfile dist/septum",
|
|
21
|
-
"typecheck": "tsc --noEmit"
|
|
21
|
+
"typecheck": "tsc --noEmit",
|
|
22
|
+
"test:integration": "python3 tests/integration/mcp-reliability.py"
|
|
22
23
|
},
|
|
23
24
|
"dependencies": {
|
|
24
25
|
"@modelcontextprotocol/sdk": "^1.5.0",
|
|
25
26
|
"web-tree-sitter": "^0.24.0",
|
|
26
27
|
"yaml": "^2.7.0",
|
|
27
|
-
"zod": "^3.24.2"
|
|
28
|
+
"zod": "^3.24.2",
|
|
29
|
+
"tree-sitter-wasms": "^0.1.13"
|
|
28
30
|
},
|
|
29
31
|
"license": "MIT",
|
|
30
32
|
"repository": {
|
|
@@ -56,8 +58,6 @@
|
|
|
56
58
|
"devDependencies": {
|
|
57
59
|
"@types/bun": "latest",
|
|
58
60
|
"@types/node": "^22.13.4",
|
|
59
|
-
"tree-sitter-wasms": "^0.1.13",
|
|
60
61
|
"typescript": "^5.7.3"
|
|
61
62
|
}
|
|
62
63
|
}
|
|
63
|
-
|
|
@@ -1,13 +1,10 @@
|
|
|
1
|
-
import { execSync } from "node:child_process";
|
|
2
|
-
import * as fs from "node:fs";
|
|
3
|
-
import * as path from "node:path";
|
|
4
1
|
import { BoundaryEvaluator } from "../../core/boundary/evaluator.ts";
|
|
5
|
-
import type { ValidatedSeptumConfig } from "../../core/config/schema.ts";
|
|
6
2
|
import { ConfigLoader } from "../../core/config/loader.ts";
|
|
7
3
|
import { SeptumDatabase } from "../../core/database/client.ts";
|
|
8
4
|
import { SeptumRepository } from "../../core/database/repository.ts";
|
|
9
|
-
import { ASTParserEngine } from "../../core/parser/tree-sitter.ts";
|
|
10
5
|
import type { BoundaryViolation } from "../../types/index.ts";
|
|
6
|
+
import { IngestionPipeline } from "../../core/ingestion/pipeline.ts";
|
|
7
|
+
import { auditStagedFiles } from "../../core/boundary/staged-auditor.ts";
|
|
11
8
|
|
|
12
9
|
export interface CheckCommandOptions {
|
|
13
10
|
strict?: boolean;
|
|
@@ -28,134 +25,47 @@ export async function handleCheckCommand(
|
|
|
28
25
|
);
|
|
29
26
|
|
|
30
27
|
const config = ConfigLoader.load();
|
|
28
|
+
if (options.strict) config.settings.enforcement = "strict";
|
|
31
29
|
const db = new SeptumDatabase(config.settings.db_path);
|
|
32
30
|
const repo = new SeptumRepository(db.raw);
|
|
33
31
|
const evaluator = new BoundaryEvaluator(repo);
|
|
34
32
|
|
|
35
|
-
let violations: BoundaryViolation[] = [];
|
|
36
|
-
|
|
37
|
-
if (options.staged) {
|
|
38
|
-
violations = await auditStagedFiles(evaluator, config, options.feature);
|
|
39
|
-
} else {
|
|
40
|
-
violations = evaluator.auditCodebase(config);
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
if (violations.length === 0) {
|
|
44
|
-
console.log("✓ All domain boundaries are clean. Zero cross-domain leaks detected.");
|
|
45
|
-
db.close();
|
|
46
|
-
return;
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
console.error(`\n[Septum Boundary Violation] Found ${violations.length} boundary violation(s):\n`);
|
|
50
|
-
|
|
51
|
-
for (const v of violations) {
|
|
52
|
-
console.error(` ✕ [${v.rule.toUpperCase()}] at ${v.file}:${v.line}`);
|
|
53
|
-
console.error(` Source Domain: ${v.source_domain}`);
|
|
54
|
-
console.error(` Target Domain: ${v.target_domain}`);
|
|
55
|
-
console.error(` Import Target: ${v.imported_target}`);
|
|
56
|
-
console.error(` Message: ${v.message}\n`);
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
db.close();
|
|
60
|
-
|
|
61
|
-
const isStrict = options.strict || config.settings.enforcement === "strict";
|
|
62
|
-
if (isStrict) {
|
|
63
|
-
console.error("[Septum Failure] Process terminated with exit code 1 due to boundary violations.");
|
|
64
|
-
process.exit(1);
|
|
65
|
-
}
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
async function auditStagedFiles(
|
|
69
|
-
evaluator: BoundaryEvaluator,
|
|
70
|
-
config: ValidatedSeptumConfig,
|
|
71
|
-
featureKey?: string
|
|
72
|
-
): Promise<BoundaryViolation[]> {
|
|
73
|
-
const violations: BoundaryViolation[] = [];
|
|
74
|
-
let stagedFiles: string[] = [];
|
|
75
|
-
|
|
76
33
|
try {
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
.
|
|
84
|
-
.
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
console.warn(`[Septum Warning] Could not retrieve git staged files: ${msg}`);
|
|
89
|
-
return violations;
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
if (stagedFiles.length === 0) {
|
|
93
|
-
console.log(" Notice: No staged files found in git repository.");
|
|
94
|
-
return violations;
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
const astEngine = new ASTParserEngine();
|
|
98
|
-
|
|
99
|
-
for (const relPath of stagedFiles) {
|
|
100
|
-
const fullPath = path.resolve(process.cwd(), relPath);
|
|
101
|
-
if (!fs.existsSync(fullPath)) continue;
|
|
102
|
-
|
|
103
|
-
// 1. Feature touchpoint check if featureKey provided
|
|
104
|
-
if (featureKey) {
|
|
105
|
-
const tpViolations = evaluator.checkFeatureTouchpoints(featureKey, relPath, config);
|
|
106
|
-
violations.push(...tpViolations);
|
|
34
|
+
let violations: BoundaryViolation[] = [];
|
|
35
|
+
|
|
36
|
+
if (options.staged) {
|
|
37
|
+
violations = (await auditStagedFiles(evaluator, config, process.cwd(), options.feature, repo)).violations;
|
|
38
|
+
} else {
|
|
39
|
+
await new IngestionPipeline(repo).run(config, { verifyContent: true });
|
|
40
|
+
const unsupported = JSON.parse(repo.getMeta("unsupported_files") || "[]") as string[];
|
|
41
|
+
if (unsupported.length > 0) {
|
|
42
|
+
throw new Error(`Cannot complete boundary audit: unsupported source files: ${unsupported.join(", ")}`);
|
|
43
|
+
}
|
|
44
|
+
violations = evaluator.auditCodebase(config);
|
|
107
45
|
}
|
|
108
46
|
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
if (!sourceConfig) continue;
|
|
114
|
-
|
|
115
|
-
const forbidden = sourceConfig.forbidden_dependencies ?? [];
|
|
116
|
-
const allowed = sourceConfig.allowed_dependencies ?? [];
|
|
117
|
-
|
|
118
|
-
try {
|
|
119
|
-
const content = fs.readFileSync(fullPath, "utf-8");
|
|
120
|
-
const parsed = await astEngine.parseFile(fullPath, content);
|
|
47
|
+
if (violations.length === 0) {
|
|
48
|
+
console.log("✓ All domain boundaries are clean. Zero cross-domain leaks detected.");
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
121
51
|
|
|
122
|
-
|
|
123
|
-
if (dep.is_external) continue;
|
|
52
|
+
console.error(`\n[Septum Boundary Violation] Found ${violations.length} boundary violation(s):\n`);
|
|
124
53
|
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
54
|
+
for (const v of violations) {
|
|
55
|
+
console.error(` ✕ [${v.rule.toUpperCase()}] at ${v.file}:${v.line}`);
|
|
56
|
+
console.error(` Source Domain: ${v.source_domain}`);
|
|
57
|
+
console.error(` Target Domain: ${v.target_domain}`);
|
|
58
|
+
console.error(` Import Target: ${v.imported_target}`);
|
|
59
|
+
console.error(` Message: ${v.message}\n`);
|
|
60
|
+
}
|
|
130
61
|
|
|
131
|
-
if (!targetDomain || targetDomain === sourceDomain) continue;
|
|
132
62
|
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
source_domain: sourceDomain,
|
|
138
|
-
target_domain: targetDomain,
|
|
139
|
-
imported_target: dep.target,
|
|
140
|
-
rule: "forbidden_dependency",
|
|
141
|
-
message: `Illegal import: Domain '${sourceDomain}' is strictly prohibited from importing domain '${targetDomain}'.`,
|
|
142
|
-
});
|
|
143
|
-
} else if (config.settings.enforcement === "strict" && !allowed.includes(targetDomain)) {
|
|
144
|
-
violations.push({
|
|
145
|
-
file: relPath,
|
|
146
|
-
line: dep.line_number,
|
|
147
|
-
source_domain: sourceDomain,
|
|
148
|
-
target_domain: targetDomain,
|
|
149
|
-
imported_target: dep.target,
|
|
150
|
-
rule: "disallowed_dependency",
|
|
151
|
-
message: `Disallowed dependency: '${targetDomain}' is not declared in allowed_dependencies for domain '${sourceDomain}'.`,
|
|
152
|
-
});
|
|
153
|
-
}
|
|
154
|
-
}
|
|
155
|
-
} catch {
|
|
156
|
-
// Ignore unparseable files
|
|
63
|
+
const isStrict = options.strict || config.settings.enforcement === "strict";
|
|
64
|
+
if (isStrict) {
|
|
65
|
+
console.error("[Septum Failure] Process terminated with exit code 1 due to boundary violations.");
|
|
66
|
+
process.exitCode = 1;
|
|
157
67
|
}
|
|
68
|
+
} finally {
|
|
69
|
+
db.close();
|
|
158
70
|
}
|
|
159
|
-
|
|
160
|
-
return violations;
|
|
161
71
|
}
|
package/src/cli/commands/hook.ts
CHANGED
|
@@ -130,12 +130,14 @@ async function runPreWriteHook(): Promise<void> {
|
|
|
130
130
|
// -------------------------------------------------------------
|
|
131
131
|
// GATE 1: Hard Touchpoint Lock (if features are defined)
|
|
132
132
|
// -------------------------------------------------------------
|
|
133
|
-
const features = config.features ?? {};
|
|
134
133
|
const session = SessionManager.getActiveSession(process.cwd());
|
|
135
|
-
const
|
|
136
|
-
|
|
134
|
+
const requestedFeatureKey = toolInput.feature_key || process.env.SEPTUM_ACTIVE_FEATURE;
|
|
135
|
+
if (session && requestedFeatureKey && requestedFeatureKey !== session.feature_key) {
|
|
136
|
+
throw new Error(`Requested feature '${requestedFeatureKey}' does not own the active scope '${session.feature_key}'.`);
|
|
137
|
+
}
|
|
138
|
+
const activeFeatureKey = session?.feature_key || requestedFeatureKey;
|
|
137
139
|
|
|
138
|
-
if (activeFeatureKey
|
|
140
|
+
if (activeFeatureKey) {
|
|
139
141
|
const touchpointViolations = evaluator.checkFeatureTouchpoints(
|
|
140
142
|
activeFeatureKey,
|
|
141
143
|
targetFile,
|
|
@@ -161,6 +163,9 @@ async function runPreWriteHook(): Promise<void> {
|
|
|
161
163
|
if (content && sourceDomain) {
|
|
162
164
|
const astEngine = new ASTParserEngine();
|
|
163
165
|
const parsed = await astEngine.parseFile(targetFile, content);
|
|
166
|
+
if (ASTParserEngine.isSourceFile(targetFile) && (parsed.status === "unsupported" || parsed.status === "degraded")) {
|
|
167
|
+
throw new Error(`Cannot reliably check imports in '${targetFile}': parser status is ${parsed.status}.`);
|
|
168
|
+
}
|
|
164
169
|
|
|
165
170
|
const proposedImports = parsed.dependencies.map((d) => d.target);
|
|
166
171
|
const boundaryViolations = evaluator.checkProposedChanges(
|
package/src/cli/index.ts
CHANGED
|
@@ -132,7 +132,7 @@ export async function runCLI(argv: string[]): Promise<void> {
|
|
|
132
132
|
case "feature": {
|
|
133
133
|
const sub = positionals[1];
|
|
134
134
|
if (sub === "clear") {
|
|
135
|
-
const cleared = SessionManager.clearActiveSession(process.cwd());
|
|
135
|
+
const cleared = SessionManager.clearActiveSession(process.cwd(), undefined, Boolean(flags.force));
|
|
136
136
|
if (cleared) {
|
|
137
137
|
console.log("✓ Active feature session successfully cleared.");
|
|
138
138
|
} else {
|
|
@@ -203,6 +203,7 @@ COMMANDS:
|
|
|
203
203
|
check Audit codebase for cross-domain boundary violations
|
|
204
204
|
Flags: --strict, --staged, --feature <name>
|
|
205
205
|
feature [status|clear] View or release active feature context session lock
|
|
206
|
+
Flags: --force (explicit recovery of another owner's session)
|
|
206
207
|
hook [subhook] Internal pre-tool guardrail hook (e.g. pre-write)
|
|
207
208
|
|
|
208
209
|
Environment & Topology:
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { posix } from "node:path";
|
|
1
|
+
import { posix, relative, resolve, isAbsolute } from "node:path";
|
|
2
2
|
import type { BoundaryViolation } from "../../types/index.ts";
|
|
3
3
|
import type { ValidatedSeptumConfig } from "../config/schema.ts";
|
|
4
4
|
import { ConfigLoader } from "../config/loader.ts";
|
|
@@ -105,7 +105,8 @@ export class BoundaryEvaluator {
|
|
|
105
105
|
target: string;
|
|
106
106
|
statement: string;
|
|
107
107
|
is_external: boolean;
|
|
108
|
-
}
|
|
108
|
+
}>,
|
|
109
|
+
relevantFiles?: ReadonlySet<string>
|
|
109
110
|
): BoundaryViolation[] {
|
|
110
111
|
const violations: BoundaryViolation[] = [];
|
|
111
112
|
const allDependencies = preloadedDeps ?? this.repo.getAllDependenciesWithDomains();
|
|
@@ -158,6 +159,7 @@ export class BoundaryEvaluator {
|
|
|
158
159
|
};
|
|
159
160
|
|
|
160
161
|
type EdgeMeta = {
|
|
162
|
+
files: Set<string>;
|
|
161
163
|
file: string;
|
|
162
164
|
line: number;
|
|
163
165
|
source_domain: string;
|
|
@@ -182,6 +184,7 @@ export class BoundaryEvaluator {
|
|
|
182
184
|
}
|
|
183
185
|
if (!outMap.has(targetDomain)) {
|
|
184
186
|
outMap.set(targetDomain, {
|
|
187
|
+
files: new Set([dep.source_file]),
|
|
185
188
|
file: dep.source_file,
|
|
186
189
|
line: dep.line_number,
|
|
187
190
|
source_domain: dep.source_domain,
|
|
@@ -189,6 +192,7 @@ export class BoundaryEvaluator {
|
|
|
189
192
|
target: dep.target,
|
|
190
193
|
});
|
|
191
194
|
}
|
|
195
|
+
outMap.get(targetDomain)!.files.add(dep.source_file);
|
|
192
196
|
}
|
|
193
197
|
|
|
194
198
|
// 2. File-to-file edge
|
|
@@ -201,6 +205,7 @@ export class BoundaryEvaluator {
|
|
|
201
205
|
}
|
|
202
206
|
if (!outMap.has(targetFile)) {
|
|
203
207
|
outMap.set(targetFile, {
|
|
208
|
+
files: new Set([dep.source_file]),
|
|
204
209
|
file: dep.source_file,
|
|
205
210
|
line: dep.line_number,
|
|
206
211
|
source_domain: dep.source_domain,
|
|
@@ -245,6 +250,8 @@ export class BoundaryEvaluator {
|
|
|
245
250
|
if (cycleStartIndex !== -1) {
|
|
246
251
|
const cycleNodes = stack.slice(cycleStartIndex);
|
|
247
252
|
const sig = canonicalizeCycle(cycleNodes);
|
|
253
|
+
const cycleEdges = cycleNodes.map((source, index) => graph.get(source)?.get(cycleNodes[(index + 1) % cycleNodes.length]));
|
|
254
|
+
if (relevantFiles && !cycleEdges.some(edge => edge && [...edge.files].some(file => relevantFiles.has(file)))) continue;
|
|
248
255
|
if (!reportedCycles.has(sig)) {
|
|
249
256
|
reportedCycles.add(sig);
|
|
250
257
|
const firstNode = cycleNodes[0];
|
|
@@ -378,39 +385,28 @@ export class BoundaryEvaluator {
|
|
|
378
385
|
return violations;
|
|
379
386
|
}
|
|
380
387
|
|
|
381
|
-
const
|
|
388
|
+
const root = this.resolver.getProjectRoot();
|
|
389
|
+
const normalizedTarget = relative(root, resolve(root, targetFilePath.replace(/\\/g, "/"))).replace(/\\/g, "/");
|
|
382
390
|
const allowedTouchpoints = featureConfig.allowed_touchpoints.map((tp) =>
|
|
383
|
-
tp.replace(/\\/g, "/").replace(
|
|
391
|
+
isAbsolute(tp) ? relative(root, tp).replace(/\\/g, "/") : posix.normalize(tp.replace(/\\/g, "/"))
|
|
384
392
|
);
|
|
385
393
|
|
|
386
394
|
const domainCfg = config.domains[featureConfig.domain];
|
|
387
395
|
const domainRoot = domainCfg?.root ? domainCfg.root.replace(/\\/g, "/").replace(/^\.\//, "") : "";
|
|
388
396
|
|
|
389
|
-
const
|
|
397
|
+
const matches = (pattern: string): boolean => {
|
|
390
398
|
if (pattern.endsWith("/**")) {
|
|
391
|
-
|
|
392
|
-
return normalizedTarget.startsWith(prefix);
|
|
399
|
+
return normalizedTarget.startsWith(pattern.slice(0, -3) + "/");
|
|
393
400
|
}
|
|
394
401
|
if (pattern.endsWith("/*")) {
|
|
395
|
-
const prefix = pattern.slice(0, -2);
|
|
396
|
-
return normalizedTarget.startsWith(prefix);
|
|
402
|
+
const prefix = pattern.slice(0, -2) + "/";
|
|
403
|
+
return normalizedTarget.startsWith(prefix) && !normalizedTarget.slice(prefix.length).includes("/");
|
|
397
404
|
}
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
normalizedTarget.startsWith(`${domainRoot}/${pattern}`))
|
|
404
|
-
) {
|
|
405
|
-
return true;
|
|
406
|
-
}
|
|
407
|
-
|
|
408
|
-
if (pattern.includes("/") && normalizedTarget.endsWith("/" + pattern)) {
|
|
409
|
-
return true;
|
|
410
|
-
}
|
|
411
|
-
|
|
412
|
-
return false;
|
|
413
|
-
});
|
|
405
|
+
return normalizedTarget === pattern;
|
|
406
|
+
};
|
|
407
|
+
const isAllowed = allowedTouchpoints.some(pattern =>
|
|
408
|
+
matches(pattern) || Boolean(domainRoot && matches(posix.normalize(`${domainRoot}/${pattern}`)))
|
|
409
|
+
);
|
|
414
410
|
|
|
415
411
|
if (!isAllowed) {
|
|
416
412
|
violations.push({
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { execFileSync } from "node:child_process";
|
|
2
|
+
import { resolve } from "node:path";
|
|
3
|
+
import type { BoundaryViolation } from "../../types/index.ts";
|
|
4
|
+
import type { ValidatedSeptumConfig } from "../config/schema.ts";
|
|
5
|
+
import { ASTParserEngine } from "../parser/tree-sitter.ts";
|
|
6
|
+
import type { SeptumRepository } from "../database/repository.ts";
|
|
7
|
+
import type { BoundaryEvaluator } from "./evaluator.ts";
|
|
8
|
+
|
|
9
|
+
export async function auditStagedFiles(
|
|
10
|
+
evaluator: BoundaryEvaluator,
|
|
11
|
+
config: ValidatedSeptumConfig,
|
|
12
|
+
workspaceRoot: string,
|
|
13
|
+
featureKey?: string,
|
|
14
|
+
repo?: SeptumRepository
|
|
15
|
+
): Promise<{ files: string[]; violations: BoundaryViolation[] }> {
|
|
16
|
+
const output = execFileSync("git", ["diff", "--cached", "--name-only", "--diff-filter=ACMR", "-z"], {
|
|
17
|
+
cwd: workspaceRoot, encoding: "utf-8", timeout: 5000, maxBuffer: 10 * 1024 * 1024,
|
|
18
|
+
});
|
|
19
|
+
const files = output.split("\0").filter(Boolean);
|
|
20
|
+
const deletedOutput = execFileSync("git", ["diff", "--cached", "--name-only", "--diff-filter=D", "-z"], {
|
|
21
|
+
cwd: workspaceRoot, encoding: "utf-8", timeout: 5000, maxBuffer: 10 * 1024 * 1024,
|
|
22
|
+
});
|
|
23
|
+
const deleted = deletedOutput.split("\0").filter(Boolean);
|
|
24
|
+
const violations: BoundaryViolation[] = [];
|
|
25
|
+
if (featureKey) for (const file of deleted) violations.push(...evaluator.checkFeatureTouchpoints(featureKey, file, config));
|
|
26
|
+
const stagedDependencies: ReturnType<SeptumRepository["getAllDependenciesWithDomains"]> = [];
|
|
27
|
+
const parser = new ASTParserEngine();
|
|
28
|
+
for (const file of files) {
|
|
29
|
+
if (featureKey) violations.push(...evaluator.checkFeatureTouchpoints(featureKey, file, config));
|
|
30
|
+
if (!ASTParserEngine.isSourceFile(file) || !evaluator.resolveSourceDomain(file, config)) continue;
|
|
31
|
+
const content = execFileSync("git", ["show", `:${file}`], {
|
|
32
|
+
cwd: workspaceRoot, encoding: "utf-8", timeout: 5000, maxBuffer: 10 * 1024 * 1024,
|
|
33
|
+
});
|
|
34
|
+
const parsed = await parser.parseFile(resolve(workspaceRoot, file), content);
|
|
35
|
+
if (parsed.status === "unsupported" || parsed.status === "degraded") {
|
|
36
|
+
throw new Error(`Cannot reliably parse staged file '${file}': ${parsed.status}.`);
|
|
37
|
+
}
|
|
38
|
+
for (const dependency of parsed.dependencies) {
|
|
39
|
+
stagedDependencies.push({ source_file: file, source_domain: evaluator.resolveSourceDomain(file, config)!, target: dependency.target, statement: dependency.statement, line_number: dependency.line_number, is_external: dependency.is_external });
|
|
40
|
+
violations.push(...evaluator.checkProposedChanges(file, [dependency.target], config).map(violation => ({ ...violation, line: dependency.line_number })));
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
const staged = new Set([...files, ...deleted]);
|
|
44
|
+
const baseline = (repo?.getAllDependenciesWithDomains() || []).filter(dependency => !staged.has(dependency.source_file));
|
|
45
|
+
violations.push(...evaluator.detectCircularDependencies(config, [...baseline, ...stagedDependencies], staged));
|
|
46
|
+
return { files, violations };
|
|
47
|
+
}
|
|
@@ -65,20 +65,23 @@ export class ConfigLoader {
|
|
|
65
65
|
public static loadFromRepository(repo: SeptumRepository): ValidatedSeptumConfig {
|
|
66
66
|
const config = ConfigLoader.getDefaultConfig();
|
|
67
67
|
if (repo && typeof repo.getAllDomains === "function") {
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
};
|
|
77
|
-
}
|
|
78
|
-
} catch {
|
|
79
|
-
// Fallback gracefully to default config
|
|
68
|
+
const rows = repo.getAllDomains();
|
|
69
|
+
for (const row of rows) {
|
|
70
|
+
config.domains[row.name] = {
|
|
71
|
+
root: row.root_path,
|
|
72
|
+
allowed_dependencies: JSON.parse(row.allowed_deps_json || "[]"),
|
|
73
|
+
forbidden_dependencies: JSON.parse(row.forbidden_deps_json || "[]"),
|
|
74
|
+
archetypes: JSON.parse(row.archetypes_json || "{}"),
|
|
75
|
+
};
|
|
80
76
|
}
|
|
81
77
|
}
|
|
78
|
+
|
|
79
|
+
if (typeof repo?.getMeta === "function") {
|
|
80
|
+
const features = repo.getMeta("features_json");
|
|
81
|
+
if (features) config.features = JSON.parse(features);
|
|
82
|
+
const settings = repo.getMeta("settings_json");
|
|
83
|
+
if (settings) Object.assign(config.settings, JSON.parse(settings));
|
|
84
|
+
}
|
|
82
85
|
return config;
|
|
83
86
|
}
|
|
84
87
|
|
|
@@ -95,20 +98,14 @@ export class ConfigLoader {
|
|
|
95
98
|
)
|
|
96
99
|
.get("domains");
|
|
97
100
|
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
allowed_dependencies: JSON.parse(r.allowed_deps_json || "[]"),
|
|
104
|
-
forbidden_dependencies: JSON.parse(r.forbidden_deps_json || "[]"),
|
|
105
|
-
archetypes: JSON.parse(r.archetypes_json || "{}"),
|
|
106
|
-
};
|
|
107
|
-
}
|
|
101
|
+
try {
|
|
102
|
+
if (tables) Object.assign(config, ConfigLoader.loadFromRepository(new SeptumRepository(db)));
|
|
103
|
+
config.settings.db_path = dbPath;
|
|
104
|
+
} finally {
|
|
105
|
+
db.close();
|
|
108
106
|
}
|
|
109
|
-
db.close();
|
|
110
107
|
} catch {
|
|
111
|
-
|
|
108
|
+
throw new Error(`Cannot load Septum configuration from '${dbPath}'.`);
|
|
112
109
|
}
|
|
113
110
|
}
|
|
114
111
|
|
|
@@ -258,7 +258,7 @@ export class DependencyRepository {
|
|
|
258
258
|
warning: "Empty symbol query provided.",
|
|
259
259
|
impact_summary: {
|
|
260
260
|
total_dependents: 0,
|
|
261
|
-
risk_level: "
|
|
261
|
+
risk_level: "unknown",
|
|
262
262
|
affected_files_count: 0,
|
|
263
263
|
},
|
|
264
264
|
inbound_callers: [],
|
|
@@ -463,7 +463,7 @@ export class DependencyRepository {
|
|
|
463
463
|
const foundInIndex = targetSymRows.length > 0 || depRows.length > 0;
|
|
464
464
|
const warning = foundInIndex
|
|
465
465
|
? undefined
|
|
466
|
-
: `Symbol '${trimmed}' was not found in Septum index (neither in declared symbols nor tracked dependencies). Risk
|
|
466
|
+
: `Symbol '${trimmed}' was not found in Septum index (neither in declared symbols nor tracked dependencies). Risk cannot be assessed without index evidence.`;
|
|
467
467
|
|
|
468
468
|
return {
|
|
469
469
|
target_symbol: trimmed,
|
|
@@ -471,7 +471,7 @@ export class DependencyRepository {
|
|
|
471
471
|
warning,
|
|
472
472
|
impact_summary: {
|
|
473
473
|
total_dependents: inboundCallers.length,
|
|
474
|
-
risk_level: riskLevel,
|
|
474
|
+
risk_level: foundInIndex ? riskLevel : "unknown",
|
|
475
475
|
affected_files_count: affectedFiles.size,
|
|
476
476
|
},
|
|
477
477
|
inbound_callers: inboundCallers,
|
|
@@ -5,6 +5,8 @@ import type {
|
|
|
5
5
|
ExtractedSymbol,
|
|
6
6
|
FileRecord,
|
|
7
7
|
GetSymbolDetailsResponse,
|
|
8
|
+
ResolvedOutboundCall,
|
|
9
|
+
SymbolComplexitySmell,
|
|
8
10
|
SymbolKind,
|
|
9
11
|
SymbolRecord,
|
|
10
12
|
} from "../../../types/index.ts";
|
|
@@ -460,6 +462,73 @@ export class SymbolRepository {
|
|
|
460
462
|
const depRepo = new DependencyRepository(this.db);
|
|
461
463
|
const connectedFiles = depRepo.getFileConnections(row.file_path);
|
|
462
464
|
|
|
465
|
+
// 4. Resolve Outbound Calls to target symbol line ranges
|
|
466
|
+
const resolvedOutboundCalls: ResolvedOutboundCall[] = [];
|
|
467
|
+
const lookupStmt = this.db.query<
|
|
468
|
+
{
|
|
469
|
+
name: string;
|
|
470
|
+
line_start: number;
|
|
471
|
+
line_end: number;
|
|
472
|
+
line_count: number;
|
|
473
|
+
file_path: string;
|
|
474
|
+
domain_name: string;
|
|
475
|
+
},
|
|
476
|
+
[string, string, string, string]
|
|
477
|
+
>(`
|
|
478
|
+
SELECT
|
|
479
|
+
s.name,
|
|
480
|
+
s.line_start,
|
|
481
|
+
s.line_end,
|
|
482
|
+
s.line_count,
|
|
483
|
+
f.path AS file_path,
|
|
484
|
+
d.name AS domain_name
|
|
485
|
+
FROM symbols s
|
|
486
|
+
JOIN files f ON s.file_id = f.id
|
|
487
|
+
JOIN domains d ON f.domain_id = d.id
|
|
488
|
+
WHERE (s.name = ? OR s.name LIKE ? OR s.name LIKE ?)
|
|
489
|
+
ORDER BY CASE WHEN s.name = ? THEN 1 ELSE 2 END, s.line_start ASC
|
|
490
|
+
LIMIT 1
|
|
491
|
+
`);
|
|
492
|
+
|
|
493
|
+
for (const call of outboundCalls) {
|
|
494
|
+
try {
|
|
495
|
+
const target = lookupStmt.get(call, `%::${call}`, `${call}::%`, call);
|
|
496
|
+
if (target) {
|
|
497
|
+
resolvedOutboundCalls.push({
|
|
498
|
+
symbol: call,
|
|
499
|
+
target_file: target.file_path,
|
|
500
|
+
target_range: {
|
|
501
|
+
start_line: target.line_start,
|
|
502
|
+
end_line: target.line_end,
|
|
503
|
+
total_lines: target.line_count,
|
|
504
|
+
},
|
|
505
|
+
domain: target.domain_name,
|
|
506
|
+
is_resolved: true,
|
|
507
|
+
});
|
|
508
|
+
} else {
|
|
509
|
+
resolvedOutboundCalls.push({
|
|
510
|
+
symbol: call,
|
|
511
|
+
is_resolved: false,
|
|
512
|
+
});
|
|
513
|
+
}
|
|
514
|
+
} catch {
|
|
515
|
+
resolvedOutboundCalls.push({
|
|
516
|
+
symbol: call,
|
|
517
|
+
is_resolved: false,
|
|
518
|
+
});
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
const complexitySmell: SymbolComplexitySmell | undefined =
|
|
523
|
+
row.line_count > 300
|
|
524
|
+
? {
|
|
525
|
+
is_high_complexity: true,
|
|
526
|
+
threshold: 300,
|
|
527
|
+
total_lines: row.line_count,
|
|
528
|
+
warning: `Symbol '${row.name}' spans ${row.line_count} lines (exceeds 300-line threshold). Candidate for decomposition/refactoring.`,
|
|
529
|
+
}
|
|
530
|
+
: undefined;
|
|
531
|
+
|
|
463
532
|
return {
|
|
464
533
|
symbol: row.name,
|
|
465
534
|
kind: row.kind,
|
|
@@ -474,6 +543,8 @@ export class SymbolRepository {
|
|
|
474
543
|
visibility: row.visibility,
|
|
475
544
|
dependencies_injected: Array.from(dependenciesInjected),
|
|
476
545
|
outbound_calls: Array.from(outboundCalls),
|
|
546
|
+
resolved_outbound_calls: resolvedOutboundCalls.length > 0 ? resolvedOutboundCalls : undefined,
|
|
547
|
+
complexity_smell: complexitySmell,
|
|
477
548
|
sibling_methods: siblingMethods.length > 0 ? siblingMethods : undefined,
|
|
478
549
|
connected_files: connectedFiles,
|
|
479
550
|
};
|