septum 0.1.3 → 0.1.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. package/README.md +5 -2
  2. package/package.json +1 -1
  3. package/src/cli/commands/slice.ts +4 -46
  4. package/src/cli/commands/topology.ts +119 -0
  5. package/src/cli/index.ts +11 -0
  6. package/src/core/database/repositories/environment.repository.ts +207 -0
  7. package/src/core/database/repositories/symbol.repository.ts +28 -9
  8. package/src/core/database/repository.ts +6 -1
  9. package/src/core/database/schema.ts +153 -120
  10. package/src/core/parser/behavioral-analyzer.ts +119 -0
  11. package/src/core/parser/boundary-tracker.ts +160 -0
  12. package/src/core/parser/extractors/base.ts +1 -0
  13. package/src/core/parser/extractors/blade.ts +158 -158
  14. package/src/core/parser/extractors/go.ts +5 -1
  15. package/src/core/parser/extractors/laravel-semantic.ts +320 -4
  16. package/src/core/parser/extractors/php.ts +5 -1
  17. package/src/core/parser/extractors/python.ts +3 -1
  18. package/src/core/parser/extractors/typescript.ts +5 -1
  19. package/src/core/parser/schema/providers/laravel-migration-provider.ts +215 -0
  20. package/src/core/parser/schema/providers/prisma-schema-provider.ts +89 -0
  21. package/src/core/parser/schema/schema-provider-registry.ts +43 -0
  22. package/src/core/parser/schema/schema-provider.interface.ts +33 -0
  23. package/src/core/resolver/path-resolver.ts +147 -147
  24. package/src/core/resolver/vertical-slice-tracer.ts +34 -1
  25. package/src/core/topology/environment-detector.ts +860 -0
  26. package/src/core/topology/types.ts +83 -0
  27. package/src/index.ts +3 -0
  28. package/src/mcp/schemas.ts +48 -0
  29. package/src/mcp/server.ts +62 -2
  30. package/src/mcp/tools/get-environment-topology.ts +141 -0
  31. package/src/mcp/tools/get-symbol-hotspots.ts +13 -3
  32. package/src/mcp/tools/register-domain.ts +1 -1
  33. package/src/mcp/tools/trace-vertical-slice.ts +9 -64
  34. package/src/types/index.ts +16 -0
  35. package/src/version.ts +3 -3
@@ -1,6 +1,8 @@
1
1
  import * as fs from "node:fs";
2
2
  import * as path from "node:path";
3
- import type { VerticalSliceRecord } from "../../../types/index.ts";
3
+ import type { ExecutionChainNode, VerticalSliceRecord } from "../../../types/index.ts";
4
+ import { BehavioralAnalyzer } from "../behavioral-analyzer.ts";
5
+ import { SchemaProviderRegistry } from "../schema/schema-provider-registry.ts";
4
6
  import type {
5
7
  SemanticSliceExtractor,
6
8
  VerticalSliceCandidate,
@@ -12,6 +14,7 @@ export interface ParsedRoute {
12
14
  routeName?: string;
13
15
  controllerClass: string;
14
16
  actionName: string;
17
+ file?: string;
15
18
  }
16
19
 
17
20
  export type LaravelSemanticExtractionResult = VerticalSliceCandidate[] & {
@@ -85,9 +88,11 @@ export class LaravelSemanticExtractor implements SemanticSliceExtractor {
85
88
  props: string[];
86
89
  } | null = null;
87
90
 
91
+ let behavioralInfo: import("../behavioral-analyzer.ts").MethodBehavioralSummary | null = null;
88
92
  if (controllerInfo.filePath && fs.existsSync(controllerInfo.filePath)) {
89
93
  const controllerCode = fs.readFileSync(controllerInfo.filePath, "utf-8");
90
94
  const actionBody = this.extractActionBody(controllerCode, route.actionName);
95
+ behavioralInfo = BehavioralAnalyzer.analyzeMethod(actionBody);
91
96
 
92
97
  // 1. Detect FormRequest in method signature
93
98
  const requestClassName = this.detectFormRequestClass(controllerCode, route.actionName);
@@ -118,7 +123,7 @@ export class LaravelSemanticExtractor implements SemanticSliceExtractor {
118
123
  frontendInfo = this.detectFrontendTarget(actionBody);
119
124
  }
120
125
 
121
- const chain: Array<{ stage: string; symbol: string; file?: string; line?: number; description?: string }> = [
126
+ const chain: ExecutionChainNode[] = [
122
127
  {
123
128
  stage: "ingress",
124
129
  symbol: `${route.httpMethod.toUpperCase()} ${route.uri.startsWith("/") ? route.uri : `/${route.uri}`}`,
@@ -127,6 +132,15 @@ export class LaravelSemanticExtractor implements SemanticSliceExtractor {
127
132
  },
128
133
  ];
129
134
 
135
+ if (behavioralInfo && behavioralInfo.guards.length > 0) {
136
+ chain.push({
137
+ stage: "guard",
138
+ symbol: behavioralInfo.guards.join(", "),
139
+ description: "Authorization / Security Guard",
140
+ guards: behavioralInfo.guards,
141
+ });
142
+ }
143
+
130
144
  if (formRequestInfo) {
131
145
  chain.push({
132
146
  stage: "validation",
@@ -142,17 +156,70 @@ export class LaravelSemanticExtractor implements SemanticSliceExtractor {
142
156
  file: controllerInfo.filePath ? path.relative(this.projectRoot, controllerInfo.filePath) : undefined,
143
157
  line: controllerInfo.line,
144
158
  description: `Controller Action`,
159
+ guards: behavioralInfo && behavioralInfo.guards.length > 0 ? behavioralInfo.guards : undefined,
160
+ mutations: behavioralInfo && behavioralInfo.mutations.length > 0 ? behavioralInfo.mutations : undefined,
161
+ transactions: behavioralInfo?.hasTransaction,
145
162
  });
146
163
 
164
+ if (behavioralInfo && behavioralInfo.hasTransaction) {
165
+ chain.push({
166
+ stage: "transaction",
167
+ symbol: "DB::transaction",
168
+ description: "Database Transaction Scope",
169
+ transactions: true,
170
+ });
171
+ }
172
+
147
173
  if (modelInfo) {
174
+ const schema = SchemaProviderRegistry.resolveSchema(
175
+ root,
176
+ modelInfo.className,
177
+ modelInfo.filePath
178
+ );
179
+
180
+ const columnsSummary =
181
+ schema && schema.fields.length > 0
182
+ ? schema.fields.map((f) => `${f.name}:${f.type}${f.nullable ? "?" : ""}`).join(", ")
183
+ : undefined;
184
+
148
185
  chain.push({
149
186
  stage: "entity",
150
187
  symbol: modelInfo.className,
151
188
  file: modelInfo.filePath,
152
- description: "Eloquent Model",
189
+ description: schema?.tableName ? `Eloquent Model (${schema.tableName})` : "Eloquent Model",
190
+ table: schema?.tableName,
191
+ schema_file: schema?.schemaSourceFile,
192
+ columns_summary: columnsSummary,
193
+ mutations: behavioralInfo && behavioralInfo.mutations.length > 0 ? behavioralInfo.mutations : undefined,
153
194
  });
154
195
  }
155
196
 
197
+ if (behavioralInfo && behavioralInfo.dispatchedJobs.length > 0) {
198
+ for (const job of behavioralInfo.dispatchedJobs) {
199
+ const jobFile = this.findClassFile("Jobs", job);
200
+ chain.push({
201
+ stage: "job",
202
+ symbol: job,
203
+ file: jobFile ? path.relative(this.projectRoot, jobFile) : undefined,
204
+ description: "Background Queue Job Dispatched",
205
+ dispatches: [job],
206
+ });
207
+ }
208
+ }
209
+
210
+ if (behavioralInfo && behavioralInfo.emittedEvents.length > 0) {
211
+ for (const evt of behavioralInfo.emittedEvents) {
212
+ const evtFile = this.findClassFile("Events", evt);
213
+ chain.push({
214
+ stage: "event",
215
+ symbol: evt,
216
+ file: evtFile ? path.relative(this.projectRoot, evtFile) : undefined,
217
+ description: "Domain Event Emitted",
218
+ emits: [evt],
219
+ });
220
+ }
221
+ }
222
+
156
223
  if (frontendInfo) {
157
224
  chain.push({
158
225
  stage: "egress",
@@ -177,6 +244,11 @@ export class LaravelSemanticExtractor implements SemanticSliceExtractor {
177
244
  });
178
245
  }
179
246
 
247
+ // Also discover Omni-Trigger slices (Jobs, Console Commands, Event Listeners)
248
+ slices.push(...this.extractJobSlices(root, domainId));
249
+ slices.push(...this.extractCommandSlices(root, domainId));
250
+ slices.push(...this.extractListenerSlices(root, domainId));
251
+
180
252
  return Object.assign(slices, { slices });
181
253
  }
182
254
 
@@ -524,7 +596,10 @@ export class LaravelSemanticExtractor implements SemanticSliceExtractor {
524
596
  return null;
525
597
  }
526
598
 
527
- private findClassFile(subDir: "Requests" | "Models", className: string): string | null {
599
+ private findClassFile(
600
+ subDir: "Requests" | "Models" | "Jobs" | "Events" | "Listeners" | "Console/Commands",
601
+ className: string
602
+ ): string | null {
528
603
  const directPath = path.join(this.projectRoot, `app/${subDir}/${className}.php`);
529
604
  if (fs.existsSync(directPath)) return directPath;
530
605
 
@@ -582,4 +657,245 @@ export class LaravelSemanticExtractor implements SemanticSliceExtractor {
582
657
 
583
658
  return { fillable, casts };
584
659
  }
660
+
661
+ private extractJobSlices(
662
+ root: string,
663
+ domainId?: number
664
+ ): Array<Omit<VerticalSliceRecord, "id" | "created_at">> {
665
+ const jobsDir = path.join(root, "app/Jobs");
666
+ if (!fs.existsSync(jobsDir)) return [];
667
+
668
+ const jobFiles = fs.readdirSync(jobsDir).filter((f) => f.endsWith(".php"));
669
+ const slices: Array<Omit<VerticalSliceRecord, "id" | "created_at">> = [];
670
+
671
+ for (const file of jobFiles) {
672
+ const fullPath = path.join(jobsDir, file);
673
+ const content = fs.readFileSync(fullPath, "utf-8");
674
+ const classMatch = content.match(/class\s+([A-Za-z0-9_]+)/);
675
+ if (!classMatch) continue;
676
+
677
+ const className = classMatch[1];
678
+ const actionBody = this.extractActionBody(content, "handle");
679
+ const behavior = BehavioralAnalyzer.analyzeMethod(actionBody);
680
+
681
+ const chain: ExecutionChainNode[] = [
682
+ {
683
+ stage: "ingress",
684
+ symbol: `JOB ${className}`,
685
+ file: path.relative(this.projectRoot, fullPath),
686
+ description: `Queue Worker Trigger: ${className}`,
687
+ },
688
+ {
689
+ stage: "controller",
690
+ symbol: `${className}::handle`,
691
+ file: path.relative(this.projectRoot, fullPath),
692
+ description: "Queue Job Handler",
693
+ mutations: behavior.mutations.length ? behavior.mutations : undefined,
694
+ transactions: behavior.hasTransaction,
695
+ },
696
+ ];
697
+
698
+ if (behavior.hasTransaction) {
699
+ chain.push({
700
+ stage: "transaction",
701
+ symbol: "DB::transaction",
702
+ description: "Database Transaction Scope",
703
+ transactions: true,
704
+ });
705
+ }
706
+
707
+ for (const evt of behavior.emittedEvents) {
708
+ const evtFile = this.findClassFile("Events", evt);
709
+ chain.push({
710
+ stage: "event",
711
+ symbol: evt,
712
+ file: evtFile ? path.relative(this.projectRoot, evtFile) : undefined,
713
+ description: "Domain Event Emitted",
714
+ emits: [evt],
715
+ });
716
+ }
717
+
718
+ slices.push({
719
+ domain_id: domainId ?? null,
720
+ feature_key: `job:${className}`,
721
+ http_method: "JOB",
722
+ route_uri: `job:${className}`,
723
+ route_name: `queue.${this.toSnakeCase(className)}`,
724
+ controller_class: className,
725
+ action_name: "handle",
726
+ controller_file: path.relative(this.projectRoot, fullPath),
727
+ controller_line: 1,
728
+ architecture_style: "clean",
729
+ entry_kind: "queue_job",
730
+ execution_chain_json: JSON.stringify(chain),
731
+ });
732
+ }
733
+
734
+ return slices;
735
+ }
736
+
737
+ private extractCommandSlices(
738
+ root: string,
739
+ domainId?: number
740
+ ): Array<Omit<VerticalSliceRecord, "id" | "created_at">> {
741
+ const commandsDir = path.join(root, "app/Console/Commands");
742
+ if (!fs.existsSync(commandsDir)) return [];
743
+
744
+ const files = fs.readdirSync(commandsDir).filter((f) => f.endsWith(".php"));
745
+ const slices: Array<Omit<VerticalSliceRecord, "id" | "created_at">> = [];
746
+
747
+ for (const file of files) {
748
+ const fullPath = path.join(commandsDir, file);
749
+ const content = fs.readFileSync(fullPath, "utf-8");
750
+ const classMatch = content.match(/class\s+([A-Za-z0-9_]+)/);
751
+ if (!classMatch) continue;
752
+
753
+ const className = classMatch[1];
754
+ const sigMatch = content.match(/protected\s+\$signature\s*=\s*['"]([^'"]+)['"]/);
755
+ const commandSignature = sigMatch ? sigMatch[1].split(/\s+/)[0] : this.toSnakeCase(className);
756
+
757
+ const actionBody = this.extractActionBody(content, "handle");
758
+ const behavior = BehavioralAnalyzer.analyzeMethod(actionBody);
759
+
760
+ const chain: ExecutionChainNode[] = [
761
+ {
762
+ stage: "ingress",
763
+ symbol: `CLI ${commandSignature}`,
764
+ file: path.relative(this.projectRoot, fullPath),
765
+ description: `Console Command: ${commandSignature}`,
766
+ },
767
+ {
768
+ stage: "controller",
769
+ symbol: `${className}::handle`,
770
+ file: path.relative(this.projectRoot, fullPath),
771
+ description: "Command Execution Handler",
772
+ mutations: behavior.mutations.length ? behavior.mutations : undefined,
773
+ transactions: behavior.hasTransaction,
774
+ },
775
+ ];
776
+
777
+ if (behavior.hasTransaction) {
778
+ chain.push({
779
+ stage: "transaction",
780
+ symbol: "DB::transaction",
781
+ description: "Database Transaction Scope",
782
+ transactions: true,
783
+ });
784
+ }
785
+
786
+ for (const job of behavior.dispatchedJobs) {
787
+ const jobFile = this.findClassFile("Jobs", job);
788
+ chain.push({
789
+ stage: "job",
790
+ symbol: job,
791
+ file: jobFile ? path.relative(this.projectRoot, jobFile) : undefined,
792
+ description: "Dispatched Background Worker",
793
+ dispatches: [job],
794
+ });
795
+ }
796
+
797
+ slices.push({
798
+ domain_id: domainId ?? null,
799
+ feature_key: `command:${commandSignature}`,
800
+ http_method: "CLI",
801
+ route_uri: `command:${commandSignature}`,
802
+ route_name: `cli.${commandSignature}`,
803
+ controller_class: className,
804
+ action_name: "handle",
805
+ controller_file: path.relative(this.projectRoot, fullPath),
806
+ controller_line: 1,
807
+ architecture_style: "clean",
808
+ entry_kind: "scheduled_command",
809
+ execution_chain_json: JSON.stringify(chain),
810
+ });
811
+ }
812
+
813
+ return slices;
814
+ }
815
+
816
+ private extractListenerSlices(
817
+ root: string,
818
+ domainId?: number
819
+ ): Array<Omit<VerticalSliceRecord, "id" | "created_at">> {
820
+ const listenersDir = path.join(root, "app/Listeners");
821
+ if (!fs.existsSync(listenersDir)) return [];
822
+
823
+ const files = fs.readdirSync(listenersDir).filter((f) => f.endsWith(".php"));
824
+ const slices: Array<Omit<VerticalSliceRecord, "id" | "created_at">> = [];
825
+
826
+ for (const file of files) {
827
+ const fullPath = path.join(listenersDir, file);
828
+ const content = fs.readFileSync(fullPath, "utf-8");
829
+ const classMatch = content.match(/class\s+([A-Za-z0-9_]+)/);
830
+ if (!classMatch) continue;
831
+
832
+ const className = classMatch[1];
833
+ const actionBody = this.extractActionBody(content, "handle");
834
+ const behavior = BehavioralAnalyzer.analyzeMethod(actionBody);
835
+
836
+ const eventParamMatch = content.match(/public\s+function\s+handle\s*\(\s*([A-Za-z0-9_]+)\s+\$event\s*\)/i);
837
+ const listenedEvent = eventParamMatch ? eventParamMatch[1] : undefined;
838
+
839
+ const chain: ExecutionChainNode[] = [
840
+ {
841
+ stage: "ingress",
842
+ symbol: `EVENT ${listenedEvent || className}`,
843
+ file: path.relative(this.projectRoot, fullPath),
844
+ description: `Event Listener Trigger${listenedEvent ? ` for [${listenedEvent}]` : ""}`,
845
+ },
846
+ {
847
+ stage: "controller",
848
+ symbol: `${className}::handle`,
849
+ file: path.relative(this.projectRoot, fullPath),
850
+ description: "Event Listener Handler",
851
+ mutations: behavior.mutations.length ? behavior.mutations : undefined,
852
+ transactions: behavior.hasTransaction,
853
+ },
854
+ ];
855
+
856
+ if (behavior.hasTransaction) {
857
+ chain.push({
858
+ stage: "transaction",
859
+ symbol: "DB::transaction",
860
+ description: "Database Transaction Scope",
861
+ transactions: true,
862
+ });
863
+ }
864
+
865
+ for (const job of behavior.dispatchedJobs) {
866
+ const jobFile = this.findClassFile("Jobs", job);
867
+ chain.push({
868
+ stage: "job",
869
+ symbol: job,
870
+ file: jobFile ? path.relative(this.projectRoot, jobFile) : undefined,
871
+ description: "Dispatched Background Worker",
872
+ dispatches: [job],
873
+ });
874
+ }
875
+
876
+ slices.push({
877
+ domain_id: domainId ?? null,
878
+ feature_key: `listener:${className}`,
879
+ http_method: "EVENT",
880
+ route_uri: `event:${className}`,
881
+ route_name: `event.${this.toSnakeCase(className)}`,
882
+ controller_class: className,
883
+ action_name: "handle",
884
+ controller_file: path.relative(this.projectRoot, fullPath),
885
+ controller_line: 1,
886
+ architecture_style: "clean",
887
+ entry_kind: "event_listener",
888
+ execution_chain_json: JSON.stringify(chain),
889
+ });
890
+ }
891
+
892
+ return slices;
893
+ }
894
+
895
+ private toSnakeCase(str: string): string {
896
+ return str
897
+ .replace(/([a-z0-9])([A-Z])/g, "$1_$2")
898
+ .replace(/([A-Z]+)([A-Z][a-z])/g, "$1_$2")
899
+ .toLowerCase();
900
+ }
585
901
  }
@@ -1,5 +1,5 @@
1
1
  import type { ExtractedDependency, ExtractedSymbol, ParsedFileAST, Visibility } from "../../../types/index.ts";
2
- import { findBraceBlockEnd } from "../boundary-tracker.ts";
2
+ import { findBraceBlockEnd, calculateControlFlowNesting } from "../boundary-tracker.ts";
3
3
  import type { CodeExtractor } from "./base.ts";
4
4
 
5
5
  export class PHPExtractor implements CodeExtractor {
@@ -112,6 +112,7 @@ export class PHPExtractor implements CodeExtractor {
112
112
  const params = methodMatch[3].trim();
113
113
  const retType = methodMatch[4] ? `: ${methodMatch[4].trim()}` : "";
114
114
  const endLine = findBraceBlockEnd(lines, i);
115
+ const nestingDepth = calculateControlFlowNesting(lines, i, endLine - 1);
115
116
  symbols.push({
116
117
  name: `${currentContainer}::${name}`,
117
118
  kind: "method",
@@ -120,6 +121,7 @@ export class PHPExtractor implements CodeExtractor {
120
121
  line_start: lineNum,
121
122
  line_end: endLine,
122
123
  line_count: Math.max(1, endLine - lineNum + 1),
124
+ nesting_depth: nestingDepth,
123
125
  });
124
126
  continue;
125
127
  }
@@ -132,6 +134,7 @@ export class PHPExtractor implements CodeExtractor {
132
134
  const params = funcMatch[2].trim();
133
135
  const retType = funcMatch[3] ? `: ${funcMatch[3].trim()}` : "";
134
136
  const endLine = findBraceBlockEnd(lines, i);
137
+ const nestingDepth = calculateControlFlowNesting(lines, i, endLine - 1);
135
138
  symbols.push({
136
139
  name,
137
140
  kind: "function",
@@ -140,6 +143,7 @@ export class PHPExtractor implements CodeExtractor {
140
143
  line_start: lineNum,
141
144
  line_end: endLine,
142
145
  line_count: Math.max(1, endLine - lineNum + 1),
146
+ nesting_depth: nestingDepth,
143
147
  });
144
148
  }
145
149
  }
@@ -1,5 +1,5 @@
1
1
  import type { ExtractedDependency, ExtractedSymbol, ParsedFileAST, Visibility } from "../../../types/index.ts";
2
- import { findPythonBlockEnd } from "../boundary-tracker.ts";
2
+ import { findPythonBlockEnd, calculatePythonNesting } from "../boundary-tracker.ts";
3
3
  import type { CodeExtractor } from "./base.ts";
4
4
 
5
5
  const PYTHON_STDLIB_AND_EXTERNALS = new Set([
@@ -206,6 +206,7 @@ export class PythonExtractor implements CodeExtractor {
206
206
  : "public";
207
207
 
208
208
  const endLine = findPythonBlockEnd(lines, i);
209
+ const nestingDepth = calculatePythonNesting(lines, i, endLine - 1);
209
210
  symbols.push({
210
211
  name: isMethod ? `${currentClass}::${funcName}` : funcName,
211
212
  kind: isMethod ? "method" : "function",
@@ -214,6 +215,7 @@ export class PythonExtractor implements CodeExtractor {
214
215
  line_start: lineNum,
215
216
  line_end: endLine,
216
217
  line_count: Math.max(1, endLine - lineNum + 1),
218
+ nesting_depth: nestingDepth,
217
219
  });
218
220
  }
219
221
  }
@@ -1,5 +1,5 @@
1
1
  import type { ExtractedDependency, ExtractedSymbol, ParsedFileAST, Visibility } from "../../../types/index.ts";
2
- import { findBraceBlockEnd } from "../boundary-tracker.ts";
2
+ import { findBraceBlockEnd, calculateControlFlowNesting } from "../boundary-tracker.ts";
3
3
  import type { CodeExtractor } from "./base.ts";
4
4
 
5
5
  export class TypeScriptExtractor implements CodeExtractor {
@@ -103,6 +103,7 @@ export class TypeScriptExtractor implements CodeExtractor {
103
103
  const params = fnMatch[2];
104
104
  const retType = fnMatch[3] ? `: ${fnMatch[3].trim()}` : "";
105
105
  const endLine = findBraceBlockEnd(lines, i);
106
+ const nestingDepth = calculateControlFlowNesting(lines, i, endLine - 1);
106
107
  symbols.push({
107
108
  name,
108
109
  kind: "function",
@@ -111,6 +112,7 @@ export class TypeScriptExtractor implements CodeExtractor {
111
112
  line_start: lineNum,
112
113
  line_end: endLine,
113
114
  line_count: Math.max(1, endLine - lineNum + 1),
115
+ nesting_depth: nestingDepth,
114
116
  });
115
117
  continue;
116
118
  }
@@ -159,6 +161,7 @@ export class TypeScriptExtractor implements CodeExtractor {
159
161
  const params = methodMatch[3];
160
162
  const retType = methodMatch[4] ? `: ${methodMatch[4].trim()}` : "";
161
163
  const endLine = findBraceBlockEnd(lines, i);
164
+ const nestingDepth = calculateControlFlowNesting(lines, i, endLine - 1);
162
165
  symbols.push({
163
166
  name: `${currentClass}::${name}`,
164
167
  kind: "method",
@@ -167,6 +170,7 @@ export class TypeScriptExtractor implements CodeExtractor {
167
170
  line_start: lineNum,
168
171
  line_end: endLine,
169
172
  line_count: Math.max(1, endLine - lineNum + 1),
173
+ nesting_depth: nestingDepth,
170
174
  });
171
175
  }
172
176
  }