code-analyser 0.1.0__py3-none-any.whl
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.
- code_analyser-0.1.0.dist-info/METADATA +283 -0
- code_analyser-0.1.0.dist-info/RECORD +34 -0
- code_analyser-0.1.0.dist-info/WHEEL +4 -0
- code_analyser-0.1.0.dist-info/licenses/LICENSE +21 -0
- codelens/__init__.py +7 -0
- codelens/__main__.py +19 -0
- codelens/analyzers/__init__.py +30 -0
- codelens/analyzers/base.py +139 -0
- codelens/analyzers/manager.py +207 -0
- codelens/analyzers/python_analyzer.py +344 -0
- codelens/analyzers/similarity_analyzer.py +512 -0
- codelens/api/__init__.py +1 -0
- codelens/api/routes/__init__.py +1 -0
- codelens/api/routes/analysis.py +441 -0
- codelens/api/routes/reports.py +438 -0
- codelens/api/routes/rubrics.py +349 -0
- codelens/api/schemas.py +305 -0
- codelens/cli.py +297 -0
- codelens/core/__init__.py +1 -0
- codelens/core/config.py +91 -0
- codelens/db/__init__.py +1 -0
- codelens/db/database.py +57 -0
- codelens/main.py +111 -0
- codelens/models/__init__.py +14 -0
- codelens/models/assignments.py +105 -0
- codelens/models/reports.py +172 -0
- codelens/models/rubrics.py +76 -0
- codelens/services/__init__.py +37 -0
- codelens/services/batch_processor.py +508 -0
- codelens/services/code_executor.py +310 -0
- codelens/services/sandbox.py +375 -0
- codelens/services/similarity_service.py +449 -0
- codelens/utils/__init__.py +29 -0
- codelens/utils/helpers.py +217 -0
|
@@ -0,0 +1,441 @@
|
|
|
1
|
+
"""
|
|
2
|
+
API endpoints for code analysis
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import uuid
|
|
6
|
+
from datetime import datetime
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
import structlog
|
|
10
|
+
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, status
|
|
11
|
+
from sqlalchemy.ext.asyncio import AsyncSession
|
|
12
|
+
|
|
13
|
+
from codelens.analyzers import AnalysisResult, analyzer_manager
|
|
14
|
+
from codelens.api.schemas import (
|
|
15
|
+
AnalysisIssueSchema,
|
|
16
|
+
AnalysisRequest,
|
|
17
|
+
AnalysisResponse,
|
|
18
|
+
BatchAnalysisRequest,
|
|
19
|
+
BatchAnalysisResponse,
|
|
20
|
+
CodeMetricsSchema,
|
|
21
|
+
ExecutionResultSchema,
|
|
22
|
+
FeedbackSchema,
|
|
23
|
+
GradeBreakdownSchema,
|
|
24
|
+
SimilarityResultSchema,
|
|
25
|
+
TestResultSchema,
|
|
26
|
+
)
|
|
27
|
+
from codelens.core.config import settings
|
|
28
|
+
from codelens.db.database import get_db
|
|
29
|
+
from codelens.models import AnalysisReport
|
|
30
|
+
from codelens.services import CodeExecutionRequest, code_executor
|
|
31
|
+
|
|
32
|
+
logger = structlog.get_logger()
|
|
33
|
+
router = APIRouter()
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def convert_analysis_issues(issues: list[Any]) -> list[AnalysisIssueSchema]:
|
|
37
|
+
"""Convert analyzer issues to schema format"""
|
|
38
|
+
return [
|
|
39
|
+
AnalysisIssueSchema(
|
|
40
|
+
line=issue.line,
|
|
41
|
+
column=issue.column,
|
|
42
|
+
severity=issue.severity,
|
|
43
|
+
code=issue.code,
|
|
44
|
+
message=issue.message,
|
|
45
|
+
category=issue.category,
|
|
46
|
+
suggestion=issue.suggestion
|
|
47
|
+
)
|
|
48
|
+
for issue in issues
|
|
49
|
+
]
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def convert_metrics(metrics: Any) -> CodeMetricsSchema:
|
|
53
|
+
"""Convert analyzer metrics to schema format"""
|
|
54
|
+
return CodeMetricsSchema(
|
|
55
|
+
lines_of_code=metrics.lines_of_code,
|
|
56
|
+
lines_of_comments=metrics.lines_of_comments,
|
|
57
|
+
blank_lines=metrics.blank_lines,
|
|
58
|
+
cyclomatic_complexity=metrics.cyclomatic_complexity,
|
|
59
|
+
cognitive_complexity=metrics.cognitive_complexity,
|
|
60
|
+
function_count=metrics.function_count,
|
|
61
|
+
class_count=metrics.class_count,
|
|
62
|
+
max_nesting_depth=metrics.max_nesting_depth,
|
|
63
|
+
maintainability_index=metrics.maintainability_index
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
async def store_analysis_report(
|
|
68
|
+
analysis_result: AnalysisResult,
|
|
69
|
+
request: AnalysisRequest,
|
|
70
|
+
submission_id: str,
|
|
71
|
+
db: AsyncSession,
|
|
72
|
+
grade_breakdown: dict[str, float] | None = None,
|
|
73
|
+
total_score: float | None = None,
|
|
74
|
+
test_result: Any = None,
|
|
75
|
+
similarity_result: Any = None
|
|
76
|
+
) -> None:
|
|
77
|
+
"""Store analysis report in database"""
|
|
78
|
+
try:
|
|
79
|
+
# Create analysis report
|
|
80
|
+
report = AnalysisReport(
|
|
81
|
+
assignment_id=request.assignment_id,
|
|
82
|
+
student_id=request.student_id,
|
|
83
|
+
student_name=request.student_name,
|
|
84
|
+
submission_id=submission_id,
|
|
85
|
+
file_name="submission.py", # Default filename
|
|
86
|
+
file_size=len(request.code.encode('utf-8')),
|
|
87
|
+
file_hash=analysis_result.analyzer_version, # Use analyzer version as temp hash
|
|
88
|
+
language=request.language.value,
|
|
89
|
+
analysis_version=analysis_result.analyzer_version,
|
|
90
|
+
syntax_valid=analysis_result.success and len([i for i in analysis_result.issues if i.category == "syntax"]) == 0,
|
|
91
|
+
syntax_errors={
|
|
92
|
+
"errors": [
|
|
93
|
+
{"line": i.line, "message": i.message}
|
|
94
|
+
for i in analysis_result.issues if i.category == "syntax"
|
|
95
|
+
]
|
|
96
|
+
},
|
|
97
|
+
quality_metrics={
|
|
98
|
+
"complexity": {
|
|
99
|
+
"cyclomatic": analysis_result.metrics.cyclomatic_complexity,
|
|
100
|
+
"cognitive": analysis_result.metrics.cognitive_complexity
|
|
101
|
+
},
|
|
102
|
+
"lines_of_code": analysis_result.metrics.lines_of_code,
|
|
103
|
+
"style_issues": [
|
|
104
|
+
{"line": i.line, "issue": i.message, "severity": i.severity.value}
|
|
105
|
+
for i in analysis_result.issues if i.category == "style"
|
|
106
|
+
],
|
|
107
|
+
"type_issues": [
|
|
108
|
+
{"line": i.line, "issue": i.message}
|
|
109
|
+
for i in analysis_result.issues if i.category == "types"
|
|
110
|
+
]
|
|
111
|
+
},
|
|
112
|
+
test_results={
|
|
113
|
+
"total_tests": test_result.total_tests if test_result else 0,
|
|
114
|
+
"passed_tests": test_result.passed_tests if test_result else 0,
|
|
115
|
+
"failed_tests": test_result.failed_tests if test_result else [],
|
|
116
|
+
"execution_time": test_result.execution_result.execution_time if test_result and test_result.execution_result else 0
|
|
117
|
+
} if test_result else None,
|
|
118
|
+
grade_breakdown=grade_breakdown or {},
|
|
119
|
+
total_score=total_score or 0.0,
|
|
120
|
+
similarity_results=similarity_result.__dict__ if similarity_result else None,
|
|
121
|
+
feedback={
|
|
122
|
+
"strengths": ["Code structure looks good"] if analysis_result.success else [],
|
|
123
|
+
"improvements": [i.message for i in analysis_result.issues[:3]], # Top 3 issues
|
|
124
|
+
"resources": []
|
|
125
|
+
},
|
|
126
|
+
processing_time=analysis_result.execution_time,
|
|
127
|
+
tools_used={
|
|
128
|
+
"analyzer": analysis_result.analyzer_version,
|
|
129
|
+
"execution": test_result is not None
|
|
130
|
+
},
|
|
131
|
+
status="completed"
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
db.add(report)
|
|
135
|
+
await db.commit()
|
|
136
|
+
await db.refresh(report)
|
|
137
|
+
|
|
138
|
+
logger.info("Analysis report stored", report_id=report.id, submission_id=submission_id)
|
|
139
|
+
|
|
140
|
+
except Exception as e:
|
|
141
|
+
logger.error("Failed to store analysis report", error=str(e), submission_id=submission_id)
|
|
142
|
+
await db.rollback()
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
@router.post("/python", response_model=AnalysisResponse)
|
|
146
|
+
async def analyze_python_code(
|
|
147
|
+
request: AnalysisRequest,
|
|
148
|
+
background_tasks: BackgroundTasks,
|
|
149
|
+
db: AsyncSession = Depends(get_db)
|
|
150
|
+
) -> AnalysisResponse:
|
|
151
|
+
"""Analyze Python code submission"""
|
|
152
|
+
start_time = datetime.utcnow()
|
|
153
|
+
submission_id = str(uuid.uuid4())
|
|
154
|
+
|
|
155
|
+
try:
|
|
156
|
+
logger.info("Starting Python code analysis", submission_id=submission_id)
|
|
157
|
+
|
|
158
|
+
# Run static analysis
|
|
159
|
+
analysis_result = await analyzer_manager.analyze_code(
|
|
160
|
+
code=request.code,
|
|
161
|
+
language=request.language.value,
|
|
162
|
+
file_path="submission.py",
|
|
163
|
+
analyzer_config=request.analyzer_config
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
if not analysis_result.success:
|
|
167
|
+
logger.warning("Analysis failed", submission_id=submission_id)
|
|
168
|
+
return AnalysisResponse(
|
|
169
|
+
success=False,
|
|
170
|
+
submission_id=submission_id,
|
|
171
|
+
error_message="Static analysis failed",
|
|
172
|
+
issues=convert_analysis_issues(analysis_result.issues),
|
|
173
|
+
processing_time=(datetime.utcnow() - start_time).total_seconds(),
|
|
174
|
+
total_score=0.0,
|
|
175
|
+
max_score=100.0
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
# Code execution (if requested)
|
|
179
|
+
execution_result = None
|
|
180
|
+
test_result = None
|
|
181
|
+
if request.execute_code or request.run_tests:
|
|
182
|
+
if code_executor.is_available():
|
|
183
|
+
exec_request = CodeExecutionRequest(
|
|
184
|
+
code=request.code,
|
|
185
|
+
language=request.language.value,
|
|
186
|
+
input_data=request.input_data,
|
|
187
|
+
run_tests=request.run_tests,
|
|
188
|
+
test_cases=[tc.dict() for tc in request.test_cases] if request.test_cases else None
|
|
189
|
+
)
|
|
190
|
+
|
|
191
|
+
exec_response = await code_executor.execute_code(exec_request)
|
|
192
|
+
if exec_response.execution_result:
|
|
193
|
+
execution_result = ExecutionResultSchema(**exec_response.execution_result.__dict__)
|
|
194
|
+
if exec_response.test_result:
|
|
195
|
+
test_result = TestResultSchema(**exec_response.test_result.__dict__)
|
|
196
|
+
else:
|
|
197
|
+
logger.warning("Code execution requested but sandbox not available")
|
|
198
|
+
|
|
199
|
+
# Similarity checking (placeholder - will implement later)
|
|
200
|
+
similarity_result = None
|
|
201
|
+
if request.check_similarity:
|
|
202
|
+
similarity_result = SimilarityResultSchema(
|
|
203
|
+
highest_similarity=0.0,
|
|
204
|
+
flagged_submissions=[],
|
|
205
|
+
ai_baseline_similarity=0.0,
|
|
206
|
+
methods_used=["ast_similarity"]
|
|
207
|
+
)
|
|
208
|
+
|
|
209
|
+
# Grading (if rubric provided)
|
|
210
|
+
grade_breakdown = None
|
|
211
|
+
total_score = None
|
|
212
|
+
if request.rubric_id:
|
|
213
|
+
# Calculate grade based on rubric (simplified implementation)
|
|
214
|
+
grade_breakdown = GradeBreakdownSchema(
|
|
215
|
+
functionality=85.0 if not test_result or test_result.passed_tests == test_result.total_tests else 70.0,
|
|
216
|
+
style=90.0 if len([i for i in analysis_result.issues if i.category == "style"]) < 5 else 75.0,
|
|
217
|
+
documentation=80.0, # Placeholder
|
|
218
|
+
testing=95.0 if test_result and test_result.passed_tests > 0 else 60.0,
|
|
219
|
+
total=82.5 # Average
|
|
220
|
+
)
|
|
221
|
+
total_score = grade_breakdown.total
|
|
222
|
+
|
|
223
|
+
# Generate feedback
|
|
224
|
+
feedback = FeedbackSchema(
|
|
225
|
+
strengths=["Code compiles successfully"] if analysis_result.success else [],
|
|
226
|
+
improvements=[
|
|
227
|
+
f"Fix {i.category} issue: {i.message}"
|
|
228
|
+
for i in analysis_result.issues[:3]
|
|
229
|
+
],
|
|
230
|
+
resources=[]
|
|
231
|
+
)
|
|
232
|
+
# Add style resource if needed
|
|
233
|
+
if any(i.category == "style" for i in analysis_result.issues):
|
|
234
|
+
feedback.resources.append("https://pep8.org/")
|
|
235
|
+
|
|
236
|
+
# Create response
|
|
237
|
+
response = AnalysisResponse(
|
|
238
|
+
success=True,
|
|
239
|
+
submission_id=submission_id,
|
|
240
|
+
syntax_valid=analysis_result.success,
|
|
241
|
+
syntax_errors=[i for i in convert_analysis_issues(analysis_result.issues) if i.category == "syntax"],
|
|
242
|
+
issues=convert_analysis_issues(analysis_result.issues),
|
|
243
|
+
metrics=convert_metrics(analysis_result.metrics),
|
|
244
|
+
execution_result=execution_result,
|
|
245
|
+
test_result=test_result,
|
|
246
|
+
similarity_result=similarity_result,
|
|
247
|
+
grade_breakdown=grade_breakdown,
|
|
248
|
+
total_score=total_score or 0.0,
|
|
249
|
+
max_score=100.0,
|
|
250
|
+
feedback=feedback,
|
|
251
|
+
analysis_version=analysis_result.analyzer_version,
|
|
252
|
+
processing_time=analysis_result.execution_time,
|
|
253
|
+
tools_used={"analyzer": analysis_result.analyzer_version},
|
|
254
|
+
analyzed_at=start_time
|
|
255
|
+
)
|
|
256
|
+
|
|
257
|
+
# Store report in background
|
|
258
|
+
if request.assignment_id:
|
|
259
|
+
background_tasks.add_task(
|
|
260
|
+
store_analysis_report,
|
|
261
|
+
analysis_result,
|
|
262
|
+
request,
|
|
263
|
+
submission_id,
|
|
264
|
+
db,
|
|
265
|
+
grade_breakdown.dict() if grade_breakdown else None,
|
|
266
|
+
total_score or 0.0,
|
|
267
|
+
test_result,
|
|
268
|
+
similarity_result
|
|
269
|
+
)
|
|
270
|
+
|
|
271
|
+
logger.info("Python code analysis completed",
|
|
272
|
+
submission_id=submission_id,
|
|
273
|
+
processing_time=response.processing_time,
|
|
274
|
+
issue_count=len(response.issues))
|
|
275
|
+
|
|
276
|
+
return response
|
|
277
|
+
|
|
278
|
+
except Exception as e:
|
|
279
|
+
logger.error("Python code analysis failed", error=str(e), submission_id=submission_id)
|
|
280
|
+
raise HTTPException(
|
|
281
|
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
282
|
+
detail=f"Analysis failed: {str(e)}"
|
|
283
|
+
) from None
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
@router.post("/batch", response_model=BatchAnalysisResponse)
|
|
287
|
+
async def analyze_batch(
|
|
288
|
+
request: BatchAnalysisRequest,
|
|
289
|
+
background_tasks: BackgroundTasks,
|
|
290
|
+
db: AsyncSession = Depends(get_db)
|
|
291
|
+
) -> BatchAnalysisResponse:
|
|
292
|
+
"""Analyze multiple code files in batch"""
|
|
293
|
+
datetime.utcnow()
|
|
294
|
+
batch_id = str(uuid.uuid4())
|
|
295
|
+
|
|
296
|
+
try:
|
|
297
|
+
logger.info("Starting batch analysis",
|
|
298
|
+
batch_id=batch_id,
|
|
299
|
+
file_count=len(request.files))
|
|
300
|
+
|
|
301
|
+
results = []
|
|
302
|
+
total_processing_time = 0.0
|
|
303
|
+
failed_files = 0
|
|
304
|
+
|
|
305
|
+
# Process files (could be parallelized further)
|
|
306
|
+
for i, file_info in enumerate(request.files):
|
|
307
|
+
try:
|
|
308
|
+
# Create individual analysis request
|
|
309
|
+
analysis_request = AnalysisRequest(
|
|
310
|
+
code=file_info["code"],
|
|
311
|
+
language=request.language,
|
|
312
|
+
rubric_id=request.rubric_id,
|
|
313
|
+
assignment_id=request.assignment_id,
|
|
314
|
+
student_id=file_info.get("student_id"),
|
|
315
|
+
student_name=file_info.get("student_name"),
|
|
316
|
+
check_similarity=request.check_similarity,
|
|
317
|
+
run_tests=False,
|
|
318
|
+
test_cases=[],
|
|
319
|
+
execute_code=False,
|
|
320
|
+
input_data=None,
|
|
321
|
+
analyzer_config=None
|
|
322
|
+
)
|
|
323
|
+
|
|
324
|
+
# Analyze individual file
|
|
325
|
+
file_result = await analyze_python_code(analysis_request, background_tasks, db)
|
|
326
|
+
results.append(file_result)
|
|
327
|
+
total_processing_time += file_result.processing_time
|
|
328
|
+
|
|
329
|
+
logger.info("Processed file in batch",
|
|
330
|
+
batch_id=batch_id,
|
|
331
|
+
file_index=i,
|
|
332
|
+
success=file_result.success)
|
|
333
|
+
|
|
334
|
+
except Exception as e:
|
|
335
|
+
logger.error("Failed to process file in batch",
|
|
336
|
+
batch_id=batch_id,
|
|
337
|
+
file_index=i,
|
|
338
|
+
error=str(e))
|
|
339
|
+
failed_files += 1
|
|
340
|
+
|
|
341
|
+
# Add error result
|
|
342
|
+
error_result = AnalysisResponse(
|
|
343
|
+
success=False,
|
|
344
|
+
submission_id=str(uuid.uuid4()),
|
|
345
|
+
error_message=f"Processing failed: {str(e)}",
|
|
346
|
+
processing_time=0.0,
|
|
347
|
+
total_score=0.0,
|
|
348
|
+
max_score=100.0
|
|
349
|
+
)
|
|
350
|
+
results.append(error_result)
|
|
351
|
+
|
|
352
|
+
# Cross-file similarity analysis (placeholder)
|
|
353
|
+
cross_similarity_results = []
|
|
354
|
+
if request.check_similarity and len(results) > 1:
|
|
355
|
+
# This would implement cross-submission similarity checking
|
|
356
|
+
cross_similarity_results = [
|
|
357
|
+
{
|
|
358
|
+
"submission_1": results[0].submission_id,
|
|
359
|
+
"submission_2": results[1].submission_id,
|
|
360
|
+
"similarity_score": 0.1,
|
|
361
|
+
"method": "ast_similarity"
|
|
362
|
+
}
|
|
363
|
+
] if len(results) >= 2 else []
|
|
364
|
+
|
|
365
|
+
# Calculate batch statistics
|
|
366
|
+
processed_files = len(results) - failed_files
|
|
367
|
+
average_processing_time = total_processing_time / len(results) if results else 0.0
|
|
368
|
+
|
|
369
|
+
response = BatchAnalysisResponse(
|
|
370
|
+
success=failed_files == 0,
|
|
371
|
+
batch_id=batch_id,
|
|
372
|
+
total_files=len(request.files),
|
|
373
|
+
processed_files=processed_files,
|
|
374
|
+
failed_files=failed_files,
|
|
375
|
+
results=results,
|
|
376
|
+
cross_similarity_results=cross_similarity_results,
|
|
377
|
+
total_processing_time=total_processing_time,
|
|
378
|
+
average_processing_time=average_processing_time,
|
|
379
|
+
completed_at=datetime.utcnow()
|
|
380
|
+
)
|
|
381
|
+
|
|
382
|
+
logger.info("Batch analysis completed",
|
|
383
|
+
batch_id=batch_id,
|
|
384
|
+
processed=processed_files,
|
|
385
|
+
failed=failed_files,
|
|
386
|
+
total_time=total_processing_time)
|
|
387
|
+
|
|
388
|
+
return response
|
|
389
|
+
|
|
390
|
+
except Exception as e:
|
|
391
|
+
logger.error("Batch analysis failed", error=str(e), batch_id=batch_id)
|
|
392
|
+
raise HTTPException(
|
|
393
|
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
394
|
+
detail=f"Batch analysis failed: {str(e)}"
|
|
395
|
+
) from None
|
|
396
|
+
|
|
397
|
+
|
|
398
|
+
@router.get("/status")
|
|
399
|
+
async def get_analysis_status() -> dict[str, Any]:
|
|
400
|
+
"""Get analyzer status and configuration"""
|
|
401
|
+
return {
|
|
402
|
+
"status": "healthy",
|
|
403
|
+
"supported_languages": analyzer_manager.get_supported_languages(),
|
|
404
|
+
"analyzers": analyzer_manager.get_all_analyzer_info(),
|
|
405
|
+
"sandbox_available": code_executor.is_available(),
|
|
406
|
+
"configuration": {
|
|
407
|
+
"max_file_size": settings.max_file_size,
|
|
408
|
+
"max_files_per_batch": settings.max_files_per_batch,
|
|
409
|
+
"execution_timeout": settings.analyzer.execution_timeout,
|
|
410
|
+
"memory_limit": settings.analyzer.memory_limit
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
|
|
415
|
+
@router.get("/tools")
|
|
416
|
+
async def get_available_tools() -> dict[str, Any]:
|
|
417
|
+
"""Get information about available analysis tools"""
|
|
418
|
+
return {
|
|
419
|
+
"python": {
|
|
420
|
+
"ruff": {
|
|
421
|
+
"enabled": settings.analyzer.ruff_enabled,
|
|
422
|
+
"description": "Fast Python linter and formatter"
|
|
423
|
+
},
|
|
424
|
+
"mypy": {
|
|
425
|
+
"enabled": settings.analyzer.mypy_enabled,
|
|
426
|
+
"description": "Static type checker for Python"
|
|
427
|
+
}
|
|
428
|
+
},
|
|
429
|
+
"execution": {
|
|
430
|
+
"docker": {
|
|
431
|
+
"enabled": settings.docker_enabled,
|
|
432
|
+
"description": "Docker-based code execution sandbox"
|
|
433
|
+
}
|
|
434
|
+
},
|
|
435
|
+
"similarity": {
|
|
436
|
+
"ast_similarity": {
|
|
437
|
+
"enabled": settings.similarity.enabled,
|
|
438
|
+
"description": "AST-based structural similarity detection"
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
}
|