@bfra.me/workspace-analyzer 0.1.0
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 +402 -0
- package/lib/chunk-4LSFAAZW.js +1 -0
- package/lib/chunk-JDF7DQ4V.js +27 -0
- package/lib/chunk-WOJ4C7N7.js +7122 -0
- package/lib/cli.d.ts +1 -0
- package/lib/cli.js +318 -0
- package/lib/index.d.ts +3701 -0
- package/lib/index.js +1262 -0
- package/lib/types/index.d.ts +146 -0
- package/lib/types/index.js +28 -0
- package/package.json +89 -0
- package/src/analyzers/analyzer.ts +201 -0
- package/src/analyzers/architectural-analyzer.ts +304 -0
- package/src/analyzers/build-config-analyzer.ts +334 -0
- package/src/analyzers/circular-import-analyzer.ts +463 -0
- package/src/analyzers/config-consistency-analyzer.ts +335 -0
- package/src/analyzers/dead-code-analyzer.ts +565 -0
- package/src/analyzers/duplicate-code-analyzer.ts +626 -0
- package/src/analyzers/duplicate-dependency-analyzer.ts +381 -0
- package/src/analyzers/eslint-config-analyzer.ts +281 -0
- package/src/analyzers/exports-field-analyzer.ts +324 -0
- package/src/analyzers/index.ts +388 -0
- package/src/analyzers/large-dependency-analyzer.ts +535 -0
- package/src/analyzers/package-json-analyzer.ts +349 -0
- package/src/analyzers/peer-dependency-analyzer.ts +275 -0
- package/src/analyzers/tree-shaking-analyzer.ts +623 -0
- package/src/analyzers/tsconfig-analyzer.ts +382 -0
- package/src/analyzers/unused-dependency-analyzer.ts +356 -0
- package/src/analyzers/version-alignment-analyzer.ts +308 -0
- package/src/api/analyze-workspace.ts +245 -0
- package/src/api/index.ts +11 -0
- package/src/cache/cache-manager.ts +495 -0
- package/src/cache/cache-schema.ts +247 -0
- package/src/cache/change-detector.ts +169 -0
- package/src/cache/file-hasher.ts +65 -0
- package/src/cache/index.ts +47 -0
- package/src/cli/commands/analyze.ts +240 -0
- package/src/cli/commands/index.ts +5 -0
- package/src/cli/index.ts +61 -0
- package/src/cli/types.ts +65 -0
- package/src/cli/ui.ts +213 -0
- package/src/cli.ts +9 -0
- package/src/config/defaults.ts +183 -0
- package/src/config/index.ts +81 -0
- package/src/config/loader.ts +270 -0
- package/src/config/merger.ts +229 -0
- package/src/config/schema.ts +263 -0
- package/src/core/incremental-analyzer.ts +462 -0
- package/src/core/index.ts +34 -0
- package/src/core/orchestrator.ts +416 -0
- package/src/graph/dependency-graph.ts +408 -0
- package/src/graph/index.ts +19 -0
- package/src/index.ts +417 -0
- package/src/parser/config-parser.ts +491 -0
- package/src/parser/import-extractor.ts +340 -0
- package/src/parser/index.ts +54 -0
- package/src/parser/typescript-parser.ts +95 -0
- package/src/performance/bundle-estimator.ts +444 -0
- package/src/performance/index.ts +27 -0
- package/src/reporters/console-reporter.ts +355 -0
- package/src/reporters/index.ts +49 -0
- package/src/reporters/json-reporter.ts +273 -0
- package/src/reporters/markdown-reporter.ts +349 -0
- package/src/reporters/reporter.ts +399 -0
- package/src/rules/builtin-rules.ts +709 -0
- package/src/rules/index.ts +52 -0
- package/src/rules/rule-engine.ts +409 -0
- package/src/scanner/index.ts +18 -0
- package/src/scanner/workspace-scanner.ts +403 -0
- package/src/types/index.ts +176 -0
- package/src/types/result.ts +19 -0
- package/src/utils/index.ts +7 -0
- package/src/utils/pattern-matcher.ts +48 -0
|
@@ -0,0 +1,416 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Analysis orchestrator for workspace analysis.
|
|
3
|
+
*
|
|
4
|
+
* Adapts the doc-sync sync-orchestrator pattern for workspace analysis,
|
|
5
|
+
* coordinating scanner, analyzers, and reporters into a unified pipeline.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type {Analyzer, AnalyzerError} from '../analyzers/analyzer'
|
|
9
|
+
import type {MergedConfig} from '../config/merger'
|
|
10
|
+
import type {WorkspacePackage} from '../scanner/workspace-scanner'
|
|
11
|
+
import type {
|
|
12
|
+
AnalysisProgress,
|
|
13
|
+
AnalysisResult,
|
|
14
|
+
AnalysisSummary,
|
|
15
|
+
Issue,
|
|
16
|
+
Severity,
|
|
17
|
+
} from '../types/index'
|
|
18
|
+
import type {Result} from '../types/result'
|
|
19
|
+
|
|
20
|
+
import path from 'node:path'
|
|
21
|
+
|
|
22
|
+
import {pLimit} from '@bfra.me/es/async'
|
|
23
|
+
import {consola} from 'consola'
|
|
24
|
+
|
|
25
|
+
import {createDefaultRegistry} from '../analyzers/index'
|
|
26
|
+
import {getAnalyzerOptions} from '../config/merger'
|
|
27
|
+
import {createWorkspaceScanner} from '../scanner/workspace-scanner'
|
|
28
|
+
import {err, ok} from '../types/result'
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Extended analysis context shared between analyzers.
|
|
32
|
+
* Contains all information needed to perform analysis operations.
|
|
33
|
+
*/
|
|
34
|
+
export interface AnalysisContext {
|
|
35
|
+
/** Root path of the workspace being analyzed */
|
|
36
|
+
readonly workspacePath: string
|
|
37
|
+
/** All packages discovered in the workspace */
|
|
38
|
+
readonly packages: readonly WorkspacePackage[]
|
|
39
|
+
/** All source files in the workspace */
|
|
40
|
+
readonly sourceFiles: readonly string[]
|
|
41
|
+
/** Merged configuration */
|
|
42
|
+
readonly config: MergedConfig
|
|
43
|
+
/** Configuration hash for caching */
|
|
44
|
+
readonly configHash: string
|
|
45
|
+
/** Report progress during analysis */
|
|
46
|
+
readonly reportProgress: (message: string) => void
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Error codes for orchestration operations.
|
|
51
|
+
*/
|
|
52
|
+
export type OrchestratorErrorCode =
|
|
53
|
+
| 'SCAN_FAILED'
|
|
54
|
+
| 'ANALYSIS_FAILED'
|
|
55
|
+
| 'INVALID_CONFIG'
|
|
56
|
+
| 'NO_PACKAGES'
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Error that occurred during orchestration.
|
|
60
|
+
*/
|
|
61
|
+
export interface OrchestratorError {
|
|
62
|
+
readonly code: OrchestratorErrorCode
|
|
63
|
+
readonly message: string
|
|
64
|
+
readonly cause?: unknown
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Options for the analysis orchestrator.
|
|
69
|
+
*/
|
|
70
|
+
export interface OrchestratorOptions {
|
|
71
|
+
/** Root path of the workspace to analyze */
|
|
72
|
+
readonly workspacePath: string
|
|
73
|
+
/** Merged configuration */
|
|
74
|
+
readonly config: MergedConfig
|
|
75
|
+
/** Progress callback */
|
|
76
|
+
readonly onProgress?: (progress: AnalysisProgress) => void
|
|
77
|
+
/** Verbose logging */
|
|
78
|
+
readonly verbose?: boolean
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Analysis orchestrator interface.
|
|
83
|
+
*/
|
|
84
|
+
export interface AnalysisOrchestrator {
|
|
85
|
+
/** Run full analysis on the workspace */
|
|
86
|
+
readonly analyzeAll: () => Promise<Result<AnalysisResult, OrchestratorError>>
|
|
87
|
+
/** Run analysis on specific packages */
|
|
88
|
+
readonly analyzePackages: (
|
|
89
|
+
packageNames: readonly string[],
|
|
90
|
+
) => Promise<Result<AnalysisResult, OrchestratorError>>
|
|
91
|
+
/** Get the current analysis context */
|
|
92
|
+
readonly getContext: () => Promise<Result<AnalysisContext, OrchestratorError>>
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Severity ordering for filtering.
|
|
97
|
+
*/
|
|
98
|
+
const SEVERITY_ORDER: Record<Severity, number> = {
|
|
99
|
+
info: 0,
|
|
100
|
+
warning: 1,
|
|
101
|
+
error: 2,
|
|
102
|
+
critical: 3,
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Computes a simple hash from configuration for cache invalidation.
|
|
107
|
+
*/
|
|
108
|
+
function computeConfigHash(config: MergedConfig): string {
|
|
109
|
+
const configStr = JSON.stringify({
|
|
110
|
+
include: config.include,
|
|
111
|
+
exclude: config.exclude,
|
|
112
|
+
categories: config.categories,
|
|
113
|
+
rules: config.rules,
|
|
114
|
+
analyzers: config.analyzers,
|
|
115
|
+
architecture: config.architecture,
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
let hash = 0
|
|
119
|
+
for (let i = 0; i < configStr.length; i++) {
|
|
120
|
+
const char = configStr.charCodeAt(i)
|
|
121
|
+
hash = (hash << 5) - hash + char
|
|
122
|
+
hash = hash & hash
|
|
123
|
+
}
|
|
124
|
+
return Math.abs(hash).toString(16)
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Creates an analysis summary from issues.
|
|
129
|
+
*/
|
|
130
|
+
function createSummary(
|
|
131
|
+
issues: readonly Issue[],
|
|
132
|
+
packagesAnalyzed: number,
|
|
133
|
+
filesAnalyzed: number,
|
|
134
|
+
durationMs: number,
|
|
135
|
+
): AnalysisSummary {
|
|
136
|
+
const bySeverity: Record<Severity, number> = {
|
|
137
|
+
info: 0,
|
|
138
|
+
warning: 0,
|
|
139
|
+
error: 0,
|
|
140
|
+
critical: 0,
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const byCategory: Record<string, number> = {
|
|
144
|
+
configuration: 0,
|
|
145
|
+
dependency: 0,
|
|
146
|
+
architecture: 0,
|
|
147
|
+
performance: 0,
|
|
148
|
+
'circular-import': 0,
|
|
149
|
+
'unused-export': 0,
|
|
150
|
+
'type-safety': 0,
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
for (const issue of issues) {
|
|
154
|
+
bySeverity[issue.severity]++
|
|
155
|
+
byCategory[issue.category] = (byCategory[issue.category] ?? 0) + 1
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
return {
|
|
159
|
+
totalIssues: issues.length,
|
|
160
|
+
bySeverity,
|
|
161
|
+
byCategory: byCategory as AnalysisSummary['byCategory'],
|
|
162
|
+
packagesAnalyzed,
|
|
163
|
+
filesAnalyzed,
|
|
164
|
+
durationMs,
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Filters issues by minimum severity threshold.
|
|
170
|
+
*/
|
|
171
|
+
function filterBySeverity(issues: readonly Issue[], minSeverity: Severity): readonly Issue[] {
|
|
172
|
+
const minLevel = SEVERITY_ORDER[minSeverity]
|
|
173
|
+
return issues.filter(issue => SEVERITY_ORDER[issue.severity] >= minLevel)
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Creates an analysis orchestrator for coordinating workspace analysis.
|
|
178
|
+
*
|
|
179
|
+
* @example
|
|
180
|
+
* ```ts
|
|
181
|
+
* const orchestrator = createOrchestrator({
|
|
182
|
+
* config: mergedConfig,
|
|
183
|
+
* onProgress: (progress) => console.log(progress.phase),
|
|
184
|
+
* })
|
|
185
|
+
*
|
|
186
|
+
* const result = await orchestrator.analyzeAll()
|
|
187
|
+
* if (result.success) {
|
|
188
|
+
* console.log(`Found ${result.data.summary.totalIssues} issues`)
|
|
189
|
+
* }
|
|
190
|
+
* ```
|
|
191
|
+
*/
|
|
192
|
+
export function createOrchestrator(options: OrchestratorOptions): AnalysisOrchestrator {
|
|
193
|
+
const {workspacePath, config, onProgress, verbose = false} = options
|
|
194
|
+
|
|
195
|
+
const scanner = createWorkspaceScanner({
|
|
196
|
+
rootDir: workspacePath,
|
|
197
|
+
includePatterns: config.packagePatterns,
|
|
198
|
+
excludePackages: [],
|
|
199
|
+
sourceExtensions: ['.ts', '.tsx', '.js', '.jsx', '.mts', '.cts'],
|
|
200
|
+
excludeDirs: ['node_modules', 'dist', 'lib', 'build', '__tests__', '__mocks__'],
|
|
201
|
+
})
|
|
202
|
+
|
|
203
|
+
const registry = createDefaultRegistry()
|
|
204
|
+
const limit = pLimit(config.concurrency)
|
|
205
|
+
|
|
206
|
+
function log(message: string): void {
|
|
207
|
+
if (verbose) {
|
|
208
|
+
consola.info(message)
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function reportProgress(
|
|
213
|
+
phase: AnalysisProgress['phase'],
|
|
214
|
+
current: string,
|
|
215
|
+
processed: number,
|
|
216
|
+
total?: number,
|
|
217
|
+
): void {
|
|
218
|
+
onProgress?.({phase, current, processed, total})
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
async function buildContext(
|
|
222
|
+
workspacePath: string,
|
|
223
|
+
): Promise<Result<AnalysisContext, OrchestratorError>> {
|
|
224
|
+
log('Scanning workspace...')
|
|
225
|
+
reportProgress('scanning', workspacePath, 0)
|
|
226
|
+
|
|
227
|
+
const scanResult = await scanner.scan()
|
|
228
|
+
|
|
229
|
+
if (scanResult.errors.length > 0) {
|
|
230
|
+
const errorMessages = scanResult.errors.map(e => e.message).join('; ')
|
|
231
|
+
consola.warn(`Scan completed with ${scanResult.errors.length} errors: ${errorMessages}`)
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
if (scanResult.packages.length === 0) {
|
|
235
|
+
return err({
|
|
236
|
+
code: 'NO_PACKAGES',
|
|
237
|
+
message: 'No packages found in workspace',
|
|
238
|
+
})
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
log(`Found ${scanResult.packages.length} packages`)
|
|
242
|
+
reportProgress(
|
|
243
|
+
'scanning',
|
|
244
|
+
workspacePath,
|
|
245
|
+
scanResult.packages.length,
|
|
246
|
+
scanResult.packages.length,
|
|
247
|
+
)
|
|
248
|
+
|
|
249
|
+
const allSourceFiles = scanResult.packages.flatMap(pkg => [...pkg.sourceFiles])
|
|
250
|
+
|
|
251
|
+
const configHash = computeConfigHash(config)
|
|
252
|
+
|
|
253
|
+
const context: AnalysisContext = {
|
|
254
|
+
workspacePath: scanResult.workspacePath,
|
|
255
|
+
packages: scanResult.packages,
|
|
256
|
+
sourceFiles: allSourceFiles,
|
|
257
|
+
config,
|
|
258
|
+
configHash,
|
|
259
|
+
reportProgress: log,
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
return ok(context)
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
async function runAnalyzers(
|
|
266
|
+
context: AnalysisContext,
|
|
267
|
+
packages: readonly WorkspacePackage[],
|
|
268
|
+
): Promise<readonly Issue[]> {
|
|
269
|
+
const allIssues: Issue[] = []
|
|
270
|
+
const enabledAnalyzers: Analyzer[] = []
|
|
271
|
+
|
|
272
|
+
// Filter to enabled analyzers based on config
|
|
273
|
+
for (const analyzer of registry.getEnabled()) {
|
|
274
|
+
const analyzerOpts = getAnalyzerOptions(config, analyzer.metadata.id)
|
|
275
|
+
if (analyzerOpts.enabled) {
|
|
276
|
+
enabledAnalyzers.push(analyzer)
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
log(`Running ${enabledAnalyzers.length} analyzers...`)
|
|
281
|
+
reportProgress('analyzing', '', 0, enabledAnalyzers.length)
|
|
282
|
+
|
|
283
|
+
// Run analyzers in parallel with concurrency limit
|
|
284
|
+
const results = await Promise.all(
|
|
285
|
+
enabledAnalyzers.map(async (analyzer, index) =>
|
|
286
|
+
limit(async (): Promise<Result<readonly Issue[], AnalyzerError>> => {
|
|
287
|
+
const analyzerId = analyzer.metadata.id
|
|
288
|
+
reportProgress('analyzing', analyzerId, index + 1, enabledAnalyzers.length)
|
|
289
|
+
log(`Running analyzer: ${analyzerId}`)
|
|
290
|
+
|
|
291
|
+
try {
|
|
292
|
+
const analyzerContext = {
|
|
293
|
+
workspacePath: context.workspacePath,
|
|
294
|
+
packages,
|
|
295
|
+
config: {
|
|
296
|
+
minSeverity: context.config.minSeverity,
|
|
297
|
+
categories: context.config.categories,
|
|
298
|
+
include: context.config.include,
|
|
299
|
+
exclude: context.config.exclude,
|
|
300
|
+
rules: context.config.rules,
|
|
301
|
+
},
|
|
302
|
+
reportProgress: context.reportProgress,
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
const result = await analyzer.analyze(analyzerContext)
|
|
306
|
+
return result
|
|
307
|
+
} catch (error) {
|
|
308
|
+
consola.warn(`Analyzer ${analyzerId} failed: ${(error as Error).message}`)
|
|
309
|
+
return err({
|
|
310
|
+
code: 'ANALYZER_ERROR',
|
|
311
|
+
message: `Analyzer ${analyzerId} failed: ${(error as Error).message}`,
|
|
312
|
+
})
|
|
313
|
+
}
|
|
314
|
+
}),
|
|
315
|
+
),
|
|
316
|
+
)
|
|
317
|
+
|
|
318
|
+
// Collect all issues
|
|
319
|
+
for (const result of results) {
|
|
320
|
+
if (result.success) {
|
|
321
|
+
allIssues.push(...result.data)
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
// Filter by minimum severity
|
|
326
|
+
const filteredIssues = filterBySeverity(allIssues, config.minSeverity)
|
|
327
|
+
|
|
328
|
+
log(`Found ${filteredIssues.length} issues after filtering`)
|
|
329
|
+
|
|
330
|
+
return filteredIssues
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
return {
|
|
334
|
+
async analyzeAll(): Promise<Result<AnalysisResult, OrchestratorError>> {
|
|
335
|
+
const startTime = Date.now()
|
|
336
|
+
const workspacePath = path.resolve('.')
|
|
337
|
+
|
|
338
|
+
const contextResult = await buildContext(workspacePath)
|
|
339
|
+
if (!contextResult.success) {
|
|
340
|
+
return contextResult
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
const context = contextResult.data
|
|
344
|
+
const issues = await runAnalyzers(context, context.packages)
|
|
345
|
+
|
|
346
|
+
const durationMs = Date.now() - startTime
|
|
347
|
+
reportProgress('reporting', '', context.packages.length, context.packages.length)
|
|
348
|
+
|
|
349
|
+
const summary = createSummary(
|
|
350
|
+
issues,
|
|
351
|
+
context.packages.length,
|
|
352
|
+
context.sourceFiles.length,
|
|
353
|
+
durationMs,
|
|
354
|
+
)
|
|
355
|
+
|
|
356
|
+
log(`Analysis complete in ${durationMs}ms`)
|
|
357
|
+
|
|
358
|
+
return ok({
|
|
359
|
+
issues,
|
|
360
|
+
summary,
|
|
361
|
+
workspacePath: context.workspacePath,
|
|
362
|
+
startedAt: new Date(startTime),
|
|
363
|
+
completedAt: new Date(),
|
|
364
|
+
})
|
|
365
|
+
},
|
|
366
|
+
|
|
367
|
+
async analyzePackages(
|
|
368
|
+
packageNames: readonly string[],
|
|
369
|
+
): Promise<Result<AnalysisResult, OrchestratorError>> {
|
|
370
|
+
const startTime = Date.now()
|
|
371
|
+
|
|
372
|
+
const contextResult = await buildContext(workspacePath)
|
|
373
|
+
if (!contextResult.success) {
|
|
374
|
+
return contextResult
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
const context = contextResult.data
|
|
378
|
+
|
|
379
|
+
// Filter to requested packages
|
|
380
|
+
const targetPackages = context.packages.filter(pkg => packageNames.includes(pkg.name))
|
|
381
|
+
|
|
382
|
+
if (targetPackages.length === 0) {
|
|
383
|
+
return err({
|
|
384
|
+
code: 'NO_PACKAGES',
|
|
385
|
+
message: `No packages found matching: ${packageNames.join(', ')}`,
|
|
386
|
+
})
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
log(
|
|
390
|
+
`Analyzing ${targetPackages.length} packages: ${targetPackages.map(p => p.name).join(', ')}`,
|
|
391
|
+
)
|
|
392
|
+
|
|
393
|
+
const issues = await runAnalyzers(context, targetPackages)
|
|
394
|
+
|
|
395
|
+
const durationMs = Date.now() - startTime
|
|
396
|
+
reportProgress('reporting', '', targetPackages.length, targetPackages.length)
|
|
397
|
+
|
|
398
|
+
const filesAnalyzed = targetPackages.reduce((sum, pkg) => sum + pkg.sourceFiles.length, 0)
|
|
399
|
+
|
|
400
|
+
const summary = createSummary(issues, targetPackages.length, filesAnalyzed, durationMs)
|
|
401
|
+
|
|
402
|
+
return ok({
|
|
403
|
+
issues,
|
|
404
|
+
summary,
|
|
405
|
+
workspacePath: context.workspacePath,
|
|
406
|
+
startedAt: new Date(startTime),
|
|
407
|
+
completedAt: new Date(),
|
|
408
|
+
})
|
|
409
|
+
},
|
|
410
|
+
|
|
411
|
+
async getContext(): Promise<Result<AnalysisContext, OrchestratorError>> {
|
|
412
|
+
const workspacePath = path.resolve('.')
|
|
413
|
+
return buildContext(workspacePath)
|
|
414
|
+
},
|
|
415
|
+
}
|
|
416
|
+
}
|