septum 0.1.5 → 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 +45 -12
- package/src/cli/commands/init.ts +6 -4
- package/src/cli/commands/query.ts +45 -5
- package/src/cli/commands/slice.ts +4 -2
- package/src/cli/index.ts +17 -6
- package/src/cli/templates/hooks.ts +6 -6
- package/src/cli/templates/rules.ts +35 -23
- package/src/cli/templates/skill.ts +47 -51
- package/src/core/boundary/evaluator.ts +217 -24
- package/src/core/boundary/staged-auditor.ts +47 -0
- package/src/core/config/loader.ts +29 -12
- package/src/core/database/repositories/dependency.repository.ts +252 -66
- package/src/core/database/repositories/domain.repository.ts +96 -39
- package/src/core/database/repositories/file.repository.ts +20 -19
- package/src/core/database/repositories/symbol.repository.ts +115 -5
- package/src/core/database/repository.ts +19 -2
- package/src/core/database/schema.ts +3 -2
- package/src/core/ingestion/pipeline.ts +96 -60
- package/src/core/parser/boundary-tracker.ts +145 -0
- package/src/core/parser/extractors/frontend.ts +44 -90
- package/src/core/parser/extractors/go.ts +200 -21
- package/src/core/parser/extractors/laravel-semantic.ts +18 -12
- package/src/core/parser/extractors/mcp-cli-semantic.ts +173 -44
- package/src/core/parser/extractors/nestjs-semantic.ts +20 -5
- package/src/core/parser/extractors/php.ts +293 -31
- package/src/core/parser/extractors/python.ts +242 -10
- package/src/core/parser/extractors/typescript.ts +604 -117
- 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 +274 -0
- package/src/core/resolver/call-graph-tracer.ts +49 -17
- package/src/core/resolver/module-resolver.ts +88 -26
- package/src/core/resolver/vertical-slice-tracer.ts +34 -11
- package/src/core/session/session-manager.ts +55 -39
- package/src/core/topology/environment-detector.ts +109 -6
- package/src/mcp/schemas.ts +146 -29
- package/src/mcp/server.ts +177 -114
- package/src/mcp/tools/check-boundary.ts +159 -41
- package/src/mcp/tools/get-domain-catalog.ts +30 -15
- package/src/mcp/tools/get-environment-topology.ts +3 -6
- package/src/mcp/tools/get-feature-context.ts +35 -20
- package/src/mcp/tools/get-symbol-hotspots.ts +4 -10
- package/src/mcp/tools/locate-symbol.ts +37 -16
- package/src/mcp/tools/register-domain.ts +3 -12
- package/src/mcp/tools/register-feature.ts +25 -32
- package/src/mcp/tools/trace-vertical-slice.ts +8 -8
- package/src/types/index.ts +56 -6
- package/src/types/wasm.d.ts +4 -0
|
@@ -1,38 +1,50 @@
|
|
|
1
|
-
export const SEPTUM_RULES_TEMPLATE = `# Septum Bounded-Context
|
|
1
|
+
export const SEPTUM_RULES_TEMPLATE = `# Septum Bounded-Context, Anti-Duplication & File Connectivity Rules
|
|
2
2
|
|
|
3
|
-
> **Prinsip Utama:** Proyek ini menggunakan **Septum**
|
|
3
|
+
> **Prinsip Utama:** Proyek ini menggunakan **Septum** (\`.septum/septum.db\`) sebagai Single Source of Truth (SSOT) untuk memetakan seluruh class, fungsi, method, struct/interface, dan variabel/konstanta beserta keterhubungan antar-file. Setiap AI Coding Agent **dilarang membuat simbol duplikat** dan wajib mematuhi batas domain.
|
|
4
4
|
|
|
5
5
|
---
|
|
6
6
|
|
|
7
|
-
## 1.
|
|
8
|
-
|
|
9
|
-
Sebelum
|
|
10
|
-
1. **
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
*
|
|
15
|
-
|
|
7
|
+
## 1. Anti-Duplikasi Simbol & Peta Keterhubungan File (Wajib Sebelum Coding)
|
|
8
|
+
|
|
9
|
+
Sebelum membuat class, fungsi, method, tipe, atau variabel/konstanta baru di file backend:
|
|
10
|
+
1. **Periksa Peta Domain & Koneksi File (Compact Mode < 400 Token):**
|
|
11
|
+
Panggil \`septum_get_domain_catalog({ domain: "<nama_domain>", detail_level: "compact" })\` (atau tanpa \`domain\` untuk *Macro Semantic Map*).
|
|
12
|
+
* Perhatikan daftar simbol eksisting (\`class\`, \`interface\`, \`struct\`, \`function\`, \`type\`, \`constant\`, \`variable\`) beserta \`line_span\`-nya.
|
|
13
|
+
* Perhatikan \`connections.imports_from\` (file mana saja yang diimpor oleh file tersebut) dan \`connections.imported_by_count\` (berapa file lain yang bergantung padanya).
|
|
14
|
+
* Jika membutuhkan daftar signature method lengkap pada satu file, panggil \`septum_get_symbol({ symbol: "<NamaSimbol>" })\` atau \`septum_get_domain_catalog({ domain: "<nama_domain>", detail_level: "full", file_filter: "<nama_file>" })\`.
|
|
15
|
+
2. **Cari Simbol Lintas-Domain Sebelum Membuat Baru:**
|
|
16
|
+
Panggil \`septum_locate_symbol({ query: "<kata_kunci>" })\` untuk memastikan fungsi/helper/class yang ingin dibuat belum ada di codebase.
|
|
17
|
+
3. **Kunci Scope pada Fitur Aktif:**
|
|
18
|
+
Jika mengerjakan fitur terdaftar, panggil \`septum_get_feature_context({ feature_key: "<nama_fitur>" })\`:
|
|
19
|
+
* **Patuhi \`allowed_touchpoints\`:** Jangan memodifikasi file di luar daftar touchpoint fitur aktif.
|
|
20
|
+
* **Gunakan ulang \`reuse_symbols\`:** Wajib mengimpor dan memakai kembali simbol yang terdaftar di \`reuse_symbols\`.
|
|
16
21
|
|
|
17
22
|
---
|
|
18
23
|
|
|
19
|
-
## 2.
|
|
24
|
+
## 2. Audit Blast Radius & Alur Eksekusi Sebelum Refactoring
|
|
20
25
|
|
|
21
|
-
|
|
22
|
-
|
|
26
|
+
1. **Cek Dampak Simbol (\`inbound_callers\`):**
|
|
27
|
+
Sebelum mengubah parameter/return signature atau menghapus class/fungsi/method publik, wajib panggil:
|
|
23
28
|
\`\`\`json
|
|
24
|
-
|
|
25
|
-
"file_path": "path/ke/file.ts",
|
|
26
|
-
"proposed_imports": ["target_import_statement"],
|
|
27
|
-
"feature_key": "nama_fitur_opsional"
|
|
28
|
-
})
|
|
29
|
+
septum_get_symbol_impact({ "symbol": "NamaClassAtauFungsi" })
|
|
29
30
|
\`\`\`
|
|
30
|
-
|
|
31
|
+
Periksa seluruh \`inbound_callers\` (termasuk *named imports* lintas file) agar tidak memutus kontrak file pemanggil.
|
|
32
|
+
2. **Telusuri Vertical Slice (Default Depth 5, Max Depth 8):**
|
|
33
|
+
Gunakan \`septum_trace_vertical_slice({ query: "<route|command|handler>", max_depth: 5 })\` (naikkan hingga \`max_depth: 8\` untuk alur Event/Job/Queue berlapis) guna memahami rantai eksekusi dari Ingress hingga Repository/Entity.
|
|
31
34
|
|
|
32
35
|
---
|
|
33
36
|
|
|
34
|
-
## 3.
|
|
37
|
+
## 3. Pre-Flight Boundary & Circular Dependency Check
|
|
35
38
|
|
|
36
|
-
|
|
37
|
-
|
|
39
|
+
1. **Sebelum Menulis Import Lintas-Domain (\`write_to_file\` / \`replace_file_content\`):**
|
|
40
|
+
\`\`\`json
|
|
41
|
+
septum_check_boundary({
|
|
42
|
+
"file_path": "path/ke/file.ts",
|
|
43
|
+
"proposed_imports": ["../other-domain/service"],
|
|
44
|
+
"feature_key": "opsional_feature_key"
|
|
45
|
+
})
|
|
46
|
+
\`\`\`
|
|
47
|
+
2. **Audit Menyeluruh & Deteksi Siklus (\`circular_dependency\`):**
|
|
48
|
+
Jalankan \`septum_check_boundary({ scope: "staged" })\` atau \`septum_check_boundary({ scope: "all" })\` setelah menyelesaikan perubahan multi-file. Jika status \`rejected\` (\`forbidden_dependency\`, \`disallowed_dependency\`, \`touchpoint_violation\`, atau \`circular_dependency\`), batalkan/perbaiki arsitektur import sebelum melakukan commit.
|
|
38
49
|
`;
|
|
50
|
+
|
|
@@ -1,69 +1,65 @@
|
|
|
1
1
|
export const SEPTUM_MAP_SKILL_TEMPLATE = `---
|
|
2
2
|
name: septum-map
|
|
3
|
-
description: Framework-agnostic codebase discovery,
|
|
3
|
+
description: Framework-agnostic codebase discovery, multi-language AST symbol & file-connection mapping, and bounded-context governance for Septum. Uses Tree-Sitter WASM (TS/JS/TSX, PHP, Python, Go, Rust, Java, C#, Ruby + Vue/Svelte/Astro/Blade) and SQLite SSOT (.septum/septum.db) to prevent symbol duplication, map file integrations, and enforce architectural boundaries.
|
|
4
4
|
---
|
|
5
5
|
|
|
6
|
-
# \`septum-map\` —
|
|
6
|
+
# \`septum-map\` — Multi-Language AST Discovery, File Connectivity & Bounded-Context Governance
|
|
7
7
|
|
|
8
|
-
> **Prinsip Utama:**
|
|
8
|
+
> **Prinsip Utama:** Septum memetakan setiap fungsi, class, method, struct/interface, dan variabel/konstanta di file backend beserta keterhubungan dua arah antar-file (\`imports_from\` & \`imported_by\`) ke dalam SQLite SSOT (\`.septum/septum.db\`). Dengan peta deterministik ini, AI Agent **tidak boleh menduplikasi simbol yang sudah ada** dan wajib menjaga integritas batas domain (*bounded-context*) serta mencegah *circular dependency*.
|
|
9
9
|
|
|
10
10
|
---
|
|
11
11
|
|
|
12
|
-
## 1. Landasan
|
|
12
|
+
## 1. Landasan Arsitektur & Pencegahan Duplikasi
|
|
13
13
|
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
Agen hanya diberikan kapabilitas membaca dan mengubah file dalam domain/fitur aktif, memangkas *blast radius* kesalahan hingga mendekati nol.
|
|
21
|
-
4. **Hybrid Defense (SynCode / NeMo Guardrails):**
|
|
22
|
-
Probabilistic AI digunakan untuk penalaran awal (*discovery & blueprinting*), sementara eksekusi dan penegakannya 100% deterministik (*Tree-sitter WASM, SQLite WAL, Pre-Commit Hooks*).
|
|
14
|
+
1. **Anti-Symbol Duplication (Peta Simbol & Keterhubungan File):**
|
|
15
|
+
Setiap file yang diindeks oleh mesin \`web-tree-sitter\` Septum memiliki daftar simbol terstruktur (\`class\`, \`interface\`, \`struct\`, \`trait\`, \`enum\`, \`function\`, \`method\`, \`type\`, \`constant\`, \`variable\`) serta graf koneksi dua arah (\`connections.imports_from\` dan \`connections.imported_by_count\`). Agen wajib memeriksa peta ini sebelum membuat fungsi, class, atau variabel baru.
|
|
16
|
+
2. **Software Reflexion Models & Boundary Enforcement:**
|
|
17
|
+
Septum memvalidasi rencana perubahan kode terhadap aturan \`allowed_dependencies\`, \`forbidden_dependencies\`, \`allowed_touchpoints\`, dan mendeteksi siklus dependensi (\`circular_dependency\`) baik per-file maupun lintas-domain.
|
|
18
|
+
3. **Token-Budgeted Context (< 400 Tokens Default):**
|
|
19
|
+
Katalog domain secara default disajikan dalam mode ringkas (\`detail_level: "compact"\`) agar agen memperoleh topologi file, rentang baris (\`line_span\`), jumlah method/property, dan koneksi antar-file tanpa membanjiri jendela konteks.
|
|
23
20
|
|
|
24
21
|
---
|
|
25
22
|
|
|
26
|
-
## 2.
|
|
23
|
+
## 2. Referensi Cepat 12 Tool MCP Septum (Standardized \`snake_case\` Contracts)
|
|
24
|
+
|
|
25
|
+
| Tool MCP | Parameter Utama (\`snake_case\`) | Fungsi & Kapan Digunakan |
|
|
26
|
+
|---|---|---|
|
|
27
|
+
| **\`septum_get_domain_catalog\`** | \`{ domain?, detail_level?: "compact"\\|"full", archetype_filter?, file_filter? }\` | Tanpa \`domain\`: menampilkan *Macro Semantic Map* seluruh proyek. Dengan \`domain\`: menampilkan daftar file, \`connections\` (\`imports_from\`, \`imported_by_count\`), dan simbol di domain tersebut (\`compact\` default < 400 token). |
|
|
28
|
+
| **\`septum_locate_symbol\`** | \`{ query, domain?, limit?: 10 }\` | Mencari lokasi file dan baris (\`line_start\`-\`line_end\`) dari class, fungsi, method (\`Class::method\`), struct, atau konstanta. |
|
|
29
|
+
| **\`septum_get_symbol\`** | \`{ symbol, domain?, include_dependencies?: true }\` | Mengekstrak detail kontrak simbol: signature, \`exact_range\`, \`dependencies_injected\`, \`outbound_calls\`, \`sibling_methods\`, dan \`connected_files\`. |
|
|
30
|
+
| **\`septum_get_symbol_impact\`** | \`{ symbol }\` | Menghitung *blast radius* (\`inbound_callers\`, \`total_dependents\`, \`risk_level\`) termasuk pelacakan *named imports* lintas file sebelum mengubah signature. |
|
|
31
|
+
| **\`septum_trace_vertical_slice\`** | \`{ query, max_depth?: 5 }\` | Menelusuri alur eksekusi end-to-end (\`Ingress ➔ Validation ➔ Controller ➔ Service ➔ Repository ➔ Entity\`). \`max_depth\` default \`5\`, rentang \`1..8\` (gunakan \`6..8\` untuk deep trace Event/Job/Queue). |
|
|
32
|
+
| **\`septum_check_boundary\`** | \`{ file_path?, file_paths?, proposed_imports?, feature_key?, scope?: "file"\\|"all"\\|"staged"\\|"domain", domain? }\` | **Pre-Write:** memvalidasi \`proposed_imports\` pada \`file_path\`. **Macro Audit:** panggil dengan \`{ scope: "all" }\` atau \`{ scope: "staged" }\` untuk mengaudit seluruh batas domain & *circular dependencies*. |
|
|
33
|
+
| **\`septum_get_symbol_hotspots\`** | \`{ domain?, min_lines?: 30, min_nesting?, kind?, limit?: 30, sort_by?: "risk_score" }\` | Mengidentifikasi fungsi/class paling kompleks berdasarkan \`risk_score\` (\`line_count\` $\\times$ \`nesting_depth\`). |
|
|
34
|
+
| **\`septum_get_feature_context\`** | \`{ feature_key? }\` | Mengambil konteks fitur aktif (\`allowed_touchpoints\`, \`reuse_symbols\`, input/output contract). |
|
|
35
|
+
| **\`septum_clear_feature_context\`** | \`{}\` | Melepaskan kunci sesi fitur aktif di \`.septum/session.json\`. |
|
|
36
|
+
| **\`septum_register_domain\`** | \`{ domain, root, description?, allowed_dependencies?, forbidden_dependencies?, archetypes?, ingest_now?: true }\` | Mendaftarkan atau memperbarui bounded-context domain langsung ke SQLite SSOT (\`.septum/septum.db\`). |
|
|
37
|
+
| **\`septum_register_feature\`** | \`{ feature_key, domain, description?, allowed_touchpoints?, reuse_symbols?, input_contract?, output_contract? }\` | Mendaftarkan kontrak fitur baru ke SQLite SSOT dan mengaktifkan sesi *touchpoint lock*. |
|
|
38
|
+
| **\`septum_get_environment_topology\`** | \`{ resolve?, resolveNode?, refresh? }\` | Memetakan 6-layer topologi infrastruktur (\`EDGE\`, \`GATEWAY\`, \`HOST\`, \`RUNTIME\`, \`STORAGE\`, \`TELEMETRY\`) beserta invarian lintas-layer. |
|
|
39
|
+
|
|
40
|
+
---
|
|
41
|
+
|
|
42
|
+
## 3. Alur Kerja Pemetaan & Inisialisasi Domain (The 4-Step Lifecycle)
|
|
27
43
|
|
|
28
44
|
> [!NOTE] Zero-Config Default (\`septum init\`)
|
|
29
|
-
>
|
|
45
|
+
> Menjalankan \`septum init\` di terminal secara otomatis mendeteksi bahasa & framework (TS/JS, PHP/Laravel, Python, Go, Rust, Java, C#, Ruby), mendaftarkan domain ke \`.septum/septum.db\`, mengindeks AST via Tree-Sitter WASM, dan memasang hook di \`.agents/\`.
|
|
30
46
|
|
|
31
|
-
Gunakan alur 4 langkah
|
|
32
|
-
1. Memetakan domain bisnis kustom / modular DDD enterprise yang kompleks di mana batasan dependency non-standar perlu disepakati bersama Tech Lead.
|
|
33
|
-
2. Memetakan ulang domain bisnis setelah terjadi refactoring arsitektur besar (*re-mapping*).
|
|
34
|
-
3. Mengonfigurasi sesi fitur baru (\`features\` session) untuk mengunci daftar file yang boleh dimodifikasi (*allowed_touchpoints*) dan simbol yang wajib dipakai ulang (*reuse_symbols*).
|
|
35
|
-
4. Pengguna secara eksplisit meminta konsultasi desain domain boundary interaktif.
|
|
47
|
+
Gunakan alur 4 langkah ini saat merancang domain baru, melakukan *re-mapping* arsitektur, atau mengunci kontrak fitur:
|
|
36
48
|
|
|
37
|
-
|
|
49
|
+
### Langkah 1: Topological & Connectivity Scan (Maksimal 2 Tool Call)
|
|
50
|
+
1. Panggil \`septum_get_domain_catalog({})\` untuk memeriksa *Telescope View* dan daftar domain yang terdaftar.
|
|
51
|
+
2. Panggil \`septum_get_domain_catalog({ domain: "<target>", detail_level: "compact" })\` untuk memetakan file, simbol yang sudah ada, dan koneksi \`imports_from\` / \`imported_by_count\`.
|
|
52
|
+
|
|
53
|
+
### Langkah 2: Perumusan Kontrak Domain & Anti-Duplikasi Simbol
|
|
54
|
+
* **\`domain\` & \`root\`:** Nama domain dan direktori kanonikalnya.
|
|
55
|
+
* **\`allowed_dependencies\` & \`forbidden_dependencies\`:** Whitelist dan blacklist dependensi lintas-domain.
|
|
56
|
+
* **\`reuse_symbols\`:** Daftar class, service, repository, atau konstanta eksisting yang **wajib digunakan kembali** agar agen tidak membuat kode duplikat.
|
|
38
57
|
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
* PHP: \`composer.json\` (baca \`autoload.psr-4\`, framework dependencies).
|
|
46
|
-
* Python: \`pyproject.toml\`, \`requirements.txt\`, \`setup.py\` (baca modules & framework).
|
|
47
|
-
* Go: \`go.mod\` (baca module path).
|
|
48
|
-
2. **Petakan Direktori Tingkat Atas:**
|
|
49
|
-
Identifikasi apakah proyek menggunakan pola:
|
|
50
|
-
* *Domain-Driven / Vertical Slice:* \`src/domains/\`, \`app/Domain/\`, \`features/\`.
|
|
51
|
-
* *Layered / Clean Architecture:* \`core/\`, \`infrastructure/\`, \`application/\`, \`interfaces/\`.
|
|
52
|
-
* *Framework Conventional:* NestJS modules, Laravel app folders, FastAPI routers, Next.js route groups.
|
|
53
|
-
|
|
54
|
-
### Langkah 2: Semantic Blueprint Drafting & Domain Boundary Formulation
|
|
55
|
-
Rumuskan spesifikasi domain dengan prinsip **Contract & Invariants (Bukan Prosedural)**:
|
|
56
|
-
* **\`root\`:** Direktori fisik domain (gunakan path kanonikal).
|
|
57
|
-
* **\`allowed_dependencies\`:** Domain mana saja yang sah diimpor (inbound whitelist).
|
|
58
|
-
* **\`forbidden_dependencies\`:** Domain yang dilarang keras diimpor (outbound blacklist).
|
|
59
|
-
* **\`archetypes\`:** Pola glob penamaan layer (\`service: "*Service.*"\`, \`model: "*Model.*"\`).
|
|
60
|
-
|
|
61
|
-
### Langkah 3: Human-in-the-Loop Approval Gate
|
|
62
|
-
Tampilkan ringkasan domain boundary yang dirumuskan kepada pengguna / Tech Lead untuk disetujui atau disesuaikan sebelum didaftarkan.
|
|
63
|
-
|
|
64
|
-
### Langkah 4: Deterministic Registration & Ingestion
|
|
65
|
-
Setelah disetujui:
|
|
66
|
-
1. Daftarkan domain secara langsung via tool MCP \`septum_register_domain(domain_name, root, ...)\` dengan opsi \`ingest_now: true\`.
|
|
67
|
-
2. Atau jalankan CLI \`septum ingest\` untuk mengekstrak seluruh simbol AST dan menyimpannya ke database SQLite \`.septum/septum.db\`.
|
|
68
|
-
3. Pastikan hook pre-commit terpasang aktif di \`.git/hooks/pre-commit\`.
|
|
58
|
+
### Langkah 3: Registrasi Deterministik ke SQLite SSOT
|
|
59
|
+
1. Gunakan \`septum_register_domain({ domain, root, allowed_dependencies, forbidden_dependencies, ingest_now: true })\` untuk menyimpan batas domain.
|
|
60
|
+
2. Gunakan \`septum_register_feature({ feature_key, domain, allowed_touchpoints, reuse_symbols })\` untuk mengunci fokus pengerjaan fitur.
|
|
61
|
+
|
|
62
|
+
### Langkah 4: Verifikasi Boundary & Siklus Dependensi
|
|
63
|
+
Jalankan \`septum_check_boundary({ scope: "all" })\` (atau CLI \`septum check --staged\`) untuk memastikan 0 pelanggaran batas domain dan 0 *circular dependency*.
|
|
69
64
|
`;
|
|
65
|
+
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { posix, relative, resolve, isAbsolute } from "node:path";
|
|
1
2
|
import type { BoundaryViolation } from "../../types/index.ts";
|
|
2
3
|
import type { ValidatedSeptumConfig } from "../config/schema.ts";
|
|
3
4
|
import { ConfigLoader } from "../config/loader.ts";
|
|
@@ -23,7 +24,14 @@ export class BoundaryEvaluator {
|
|
|
23
24
|
featureKey?: string,
|
|
24
25
|
config?: ValidatedSeptumConfig
|
|
25
26
|
): BoundaryViolation[] {
|
|
26
|
-
|
|
27
|
+
let activeConfig = config;
|
|
28
|
+
if (!activeConfig) {
|
|
29
|
+
const fromRepo = ConfigLoader.loadFromRepository(this.repo);
|
|
30
|
+
activeConfig =
|
|
31
|
+
Object.keys(fromRepo.domains).length > 0
|
|
32
|
+
? fromRepo
|
|
33
|
+
: ConfigLoader.loadFromDatabaseOrDefaults();
|
|
34
|
+
}
|
|
27
35
|
const violations: BoundaryViolation[] = [];
|
|
28
36
|
|
|
29
37
|
if (featureKey) {
|
|
@@ -83,6 +91,202 @@ export class BoundaryEvaluator {
|
|
|
83
91
|
}
|
|
84
92
|
}
|
|
85
93
|
|
|
94
|
+
violations.push(...this.detectCircularDependencies(config, allDependencies));
|
|
95
|
+
|
|
96
|
+
return violations;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
public detectCircularDependencies(
|
|
100
|
+
config: ValidatedSeptumConfig,
|
|
101
|
+
preloadedDeps?: Array<{
|
|
102
|
+
source_file: string;
|
|
103
|
+
source_domain: string;
|
|
104
|
+
line_number: number;
|
|
105
|
+
target: string;
|
|
106
|
+
statement: string;
|
|
107
|
+
is_external: boolean;
|
|
108
|
+
}>,
|
|
109
|
+
relevantFiles?: ReadonlySet<string>
|
|
110
|
+
): BoundaryViolation[] {
|
|
111
|
+
const violations: BoundaryViolation[] = [];
|
|
112
|
+
const allDependencies = preloadedDeps ?? this.repo.getAllDependenciesWithDomains();
|
|
113
|
+
|
|
114
|
+
// Prepare file lookup maps for internal file-to-file resolution
|
|
115
|
+
const fileByNormPath = new Map<string, string>();
|
|
116
|
+
const fileByNoExtPath = new Map<string, string>();
|
|
117
|
+
|
|
118
|
+
const registerKnownFile = (rawPath: string) => {
|
|
119
|
+
const norm = rawPath.replace(/\\/g, "/").replace(/^\.\//, "");
|
|
120
|
+
fileByNormPath.set(norm.toLowerCase(), rawPath);
|
|
121
|
+
const noExt = norm.replace(/\.[^/.]+$/, "").toLowerCase();
|
|
122
|
+
fileByNoExtPath.set(noExt, rawPath);
|
|
123
|
+
if (noExt.endsWith("/index")) {
|
|
124
|
+
fileByNoExtPath.set(noExt.slice(0, -"/index".length), rawPath);
|
|
125
|
+
}
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
if (typeof this.repo.getAllFiles === "function") {
|
|
129
|
+
for (const f of this.repo.getAllFiles()) {
|
|
130
|
+
registerKnownFile(f.path);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
for (const d of allDependencies) {
|
|
134
|
+
registerKnownFile(d.source_file);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const resolveTargetFile = (sourceFile: string, rawTarget: string): string | null => {
|
|
138
|
+
const cleaned = rawTarget.trim().replace(/^['"]|['"]$/g, "").replace(/\\/g, "/");
|
|
139
|
+
if (!cleaned) return null;
|
|
140
|
+
|
|
141
|
+
if (cleaned.startsWith(".")) {
|
|
142
|
+
const srcDir = posix.dirname(sourceFile.replace(/\\/g, "/"));
|
|
143
|
+
const joined = posix.normalize(posix.join(srcDir, cleaned)).replace(/^\.\//, "");
|
|
144
|
+
const lowerJoined = joined.toLowerCase();
|
|
145
|
+
const exact =
|
|
146
|
+
fileByNormPath.get(lowerJoined) ??
|
|
147
|
+
fileByNoExtPath.get(lowerJoined.replace(/\.[^/.]+$/, ""));
|
|
148
|
+
if (exact) return exact;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const aliasClean = cleaned.replace(/^@\//, "src/").replace(/^\.\//, "");
|
|
152
|
+
const lowerAlias = aliasClean.toLowerCase();
|
|
153
|
+
const byAlias =
|
|
154
|
+
fileByNormPath.get(lowerAlias) ??
|
|
155
|
+
fileByNoExtPath.get(lowerAlias.replace(/\.[^/.]+$/, ""));
|
|
156
|
+
if (byAlias) return byAlias;
|
|
157
|
+
|
|
158
|
+
return null;
|
|
159
|
+
};
|
|
160
|
+
|
|
161
|
+
type EdgeMeta = {
|
|
162
|
+
files: Set<string>;
|
|
163
|
+
file: string;
|
|
164
|
+
line: number;
|
|
165
|
+
source_domain: string;
|
|
166
|
+
target_domain: string;
|
|
167
|
+
target: string;
|
|
168
|
+
};
|
|
169
|
+
|
|
170
|
+
const fileGraph = new Map<string, Map<string, EdgeMeta>>();
|
|
171
|
+
const domainGraph = new Map<string, Map<string, EdgeMeta>>();
|
|
172
|
+
|
|
173
|
+
for (const dep of allDependencies) {
|
|
174
|
+
if (dep.is_external) continue;
|
|
175
|
+
|
|
176
|
+
const targetDomain = this.resolveTargetDomain(dep.target, config, dep.source_file);
|
|
177
|
+
|
|
178
|
+
// 1. Domain-to-domain edge
|
|
179
|
+
if (targetDomain && targetDomain !== dep.source_domain) {
|
|
180
|
+
let outMap = domainGraph.get(dep.source_domain);
|
|
181
|
+
if (!outMap) {
|
|
182
|
+
outMap = new Map();
|
|
183
|
+
domainGraph.set(dep.source_domain, outMap);
|
|
184
|
+
}
|
|
185
|
+
if (!outMap.has(targetDomain)) {
|
|
186
|
+
outMap.set(targetDomain, {
|
|
187
|
+
files: new Set([dep.source_file]),
|
|
188
|
+
file: dep.source_file,
|
|
189
|
+
line: dep.line_number,
|
|
190
|
+
source_domain: dep.source_domain,
|
|
191
|
+
target_domain: targetDomain,
|
|
192
|
+
target: dep.target,
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
outMap.get(targetDomain)!.files.add(dep.source_file);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// 2. File-to-file edge
|
|
199
|
+
const targetFile = resolveTargetFile(dep.source_file, dep.target);
|
|
200
|
+
if (targetFile && targetFile !== dep.source_file) {
|
|
201
|
+
let outMap = fileGraph.get(dep.source_file);
|
|
202
|
+
if (!outMap) {
|
|
203
|
+
outMap = new Map();
|
|
204
|
+
fileGraph.set(dep.source_file, outMap);
|
|
205
|
+
}
|
|
206
|
+
if (!outMap.has(targetFile)) {
|
|
207
|
+
outMap.set(targetFile, {
|
|
208
|
+
files: new Set([dep.source_file]),
|
|
209
|
+
file: dep.source_file,
|
|
210
|
+
line: dep.line_number,
|
|
211
|
+
source_domain: dep.source_domain,
|
|
212
|
+
target_domain: targetDomain ?? dep.source_domain,
|
|
213
|
+
target: dep.target,
|
|
214
|
+
});
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
const findCycles = (
|
|
220
|
+
graph: Map<string, Map<string, EdgeMeta>>,
|
|
221
|
+
kindLabel: "file" | "domain"
|
|
222
|
+
) => {
|
|
223
|
+
const visited = new Set<string>();
|
|
224
|
+
const inStack = new Set<string>();
|
|
225
|
+
const stack: string[] = [];
|
|
226
|
+
const reportedCycles = new Set<string>();
|
|
227
|
+
|
|
228
|
+
const canonicalizeCycle = (nodes: string[]): string => {
|
|
229
|
+
if (nodes.length === 0) return "";
|
|
230
|
+
let minIdx = 0;
|
|
231
|
+
for (let i = 1; i < nodes.length; i++) {
|
|
232
|
+
if (nodes[i] < nodes[minIdx]) minIdx = i;
|
|
233
|
+
}
|
|
234
|
+
const rotated = [...nodes.slice(minIdx), ...nodes.slice(0, minIdx)];
|
|
235
|
+
return rotated.join("->");
|
|
236
|
+
};
|
|
237
|
+
|
|
238
|
+
const dfs = (node: string) => {
|
|
239
|
+
visited.add(node);
|
|
240
|
+
inStack.add(node);
|
|
241
|
+
stack.push(node);
|
|
242
|
+
|
|
243
|
+
const neighbors = graph.get(node);
|
|
244
|
+
if (neighbors) {
|
|
245
|
+
for (const [nextNode, edgeMeta] of neighbors.entries()) {
|
|
246
|
+
if (!visited.has(nextNode)) {
|
|
247
|
+
dfs(nextNode);
|
|
248
|
+
} else if (inStack.has(nextNode)) {
|
|
249
|
+
const cycleStartIndex = stack.indexOf(nextNode);
|
|
250
|
+
if (cycleStartIndex !== -1) {
|
|
251
|
+
const cycleNodes = stack.slice(cycleStartIndex);
|
|
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;
|
|
255
|
+
if (!reportedCycles.has(sig)) {
|
|
256
|
+
reportedCycles.add(sig);
|
|
257
|
+
const firstNode = cycleNodes[0];
|
|
258
|
+
const secondNode = cycleNodes[1] ?? nextNode;
|
|
259
|
+
const firstEdge = graph.get(firstNode)?.get(secondNode) ?? edgeMeta;
|
|
260
|
+
const cycleDisplay = [...cycleNodes, cycleNodes[0]].join(" -> ");
|
|
261
|
+
violations.push({
|
|
262
|
+
file: firstEdge.file,
|
|
263
|
+
line: firstEdge.line,
|
|
264
|
+
source_domain: firstEdge.source_domain,
|
|
265
|
+
target_domain: firstEdge.target_domain,
|
|
266
|
+
imported_target: firstEdge.target,
|
|
267
|
+
rule: "circular_dependency",
|
|
268
|
+
message: `Circular ${kindLabel} dependency detected: ${cycleDisplay}.`,
|
|
269
|
+
});
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
stack.pop();
|
|
277
|
+
inStack.delete(node);
|
|
278
|
+
};
|
|
279
|
+
|
|
280
|
+
for (const startNode of graph.keys()) {
|
|
281
|
+
if (!visited.has(startNode)) {
|
|
282
|
+
dfs(startNode);
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
};
|
|
286
|
+
|
|
287
|
+
findCycles(fileGraph, "file");
|
|
288
|
+
findCycles(domainGraph, "domain");
|
|
289
|
+
|
|
86
290
|
return violations;
|
|
87
291
|
}
|
|
88
292
|
|
|
@@ -181,39 +385,28 @@ export class BoundaryEvaluator {
|
|
|
181
385
|
return violations;
|
|
182
386
|
}
|
|
183
387
|
|
|
184
|
-
const
|
|
388
|
+
const root = this.resolver.getProjectRoot();
|
|
389
|
+
const normalizedTarget = relative(root, resolve(root, targetFilePath.replace(/\\/g, "/"))).replace(/\\/g, "/");
|
|
185
390
|
const allowedTouchpoints = featureConfig.allowed_touchpoints.map((tp) =>
|
|
186
|
-
tp.replace(/\\/g, "/").replace(
|
|
391
|
+
isAbsolute(tp) ? relative(root, tp).replace(/\\/g, "/") : posix.normalize(tp.replace(/\\/g, "/"))
|
|
187
392
|
);
|
|
188
393
|
|
|
189
394
|
const domainCfg = config.domains[featureConfig.domain];
|
|
190
395
|
const domainRoot = domainCfg?.root ? domainCfg.root.replace(/\\/g, "/").replace(/^\.\//, "") : "";
|
|
191
396
|
|
|
192
|
-
const
|
|
397
|
+
const matches = (pattern: string): boolean => {
|
|
193
398
|
if (pattern.endsWith("/**")) {
|
|
194
|
-
|
|
195
|
-
return normalizedTarget.startsWith(prefix);
|
|
399
|
+
return normalizedTarget.startsWith(pattern.slice(0, -3) + "/");
|
|
196
400
|
}
|
|
197
401
|
if (pattern.endsWith("/*")) {
|
|
198
|
-
const prefix = pattern.slice(0, -2);
|
|
199
|
-
return normalizedTarget.startsWith(prefix);
|
|
200
|
-
}
|
|
201
|
-
if (normalizedTarget === pattern) return true;
|
|
202
|
-
|
|
203
|
-
if (
|
|
204
|
-
domainRoot &&
|
|
205
|
-
(normalizedTarget === `${domainRoot}/${pattern}` ||
|
|
206
|
-
normalizedTarget.startsWith(`${domainRoot}/${pattern}`))
|
|
207
|
-
) {
|
|
208
|
-
return true;
|
|
402
|
+
const prefix = pattern.slice(0, -2) + "/";
|
|
403
|
+
return normalizedTarget.startsWith(prefix) && !normalizedTarget.slice(prefix.length).includes("/");
|
|
209
404
|
}
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
}
|
|
214
|
-
|
|
215
|
-
return false;
|
|
216
|
-
});
|
|
405
|
+
return normalizedTarget === pattern;
|
|
406
|
+
};
|
|
407
|
+
const isAllowed = allowedTouchpoints.some(pattern =>
|
|
408
|
+
matches(pattern) || Boolean(domainRoot && matches(posix.normalize(`${domainRoot}/${pattern}`)))
|
|
409
|
+
);
|
|
217
410
|
|
|
218
411
|
if (!isAllowed) {
|
|
219
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
|
+
}
|
|
@@ -62,6 +62,29 @@ export class ConfigLoader {
|
|
|
62
62
|
};
|
|
63
63
|
}
|
|
64
64
|
|
|
65
|
+
public static loadFromRepository(repo: SeptumRepository): ValidatedSeptumConfig {
|
|
66
|
+
const config = ConfigLoader.getDefaultConfig();
|
|
67
|
+
if (repo && typeof repo.getAllDomains === "function") {
|
|
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
|
+
};
|
|
76
|
+
}
|
|
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
|
+
}
|
|
85
|
+
return config;
|
|
86
|
+
}
|
|
87
|
+
|
|
65
88
|
public static loadFromDatabaseOrDefaults(dbPath: string = ".septum/septum.db"): ValidatedSeptumConfig {
|
|
66
89
|
const config = ConfigLoader.getDefaultConfig();
|
|
67
90
|
config.settings.db_path = dbPath;
|
|
@@ -75,20 +98,14 @@ export class ConfigLoader {
|
|
|
75
98
|
)
|
|
76
99
|
.get("domains");
|
|
77
100
|
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
allowed_dependencies: JSON.parse(r.allowed_deps_json || "[]"),
|
|
84
|
-
forbidden_dependencies: JSON.parse(r.forbidden_deps_json || "[]"),
|
|
85
|
-
archetypes: JSON.parse(r.archetypes_json || "{}"),
|
|
86
|
-
};
|
|
87
|
-
}
|
|
101
|
+
try {
|
|
102
|
+
if (tables) Object.assign(config, ConfigLoader.loadFromRepository(new SeptumRepository(db)));
|
|
103
|
+
config.settings.db_path = dbPath;
|
|
104
|
+
} finally {
|
|
105
|
+
db.close();
|
|
88
106
|
}
|
|
89
|
-
db.close();
|
|
90
107
|
} catch {
|
|
91
|
-
|
|
108
|
+
throw new Error(`Cannot load Septum configuration from '${dbPath}'.`);
|
|
92
109
|
}
|
|
93
110
|
}
|
|
94
111
|
|