feat: collect project source for code analysis
This commit is contained in:
@@ -0,0 +1,38 @@
|
||||
package com.ruoyi.generator.domain.front.dto;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
public class CodeAnalysisResponse
|
||||
{
|
||||
private String title;
|
||||
private String markdown;
|
||||
private List<Section> sections = new ArrayList<Section>();
|
||||
private CodeAnalysisSourceSummary sourceSnapshot;
|
||||
private List<String> warnings = new ArrayList<String>();
|
||||
|
||||
public String getTitle() { return title; }
|
||||
public void setTitle(String title) { this.title = title; }
|
||||
public String getMarkdown() { return markdown; }
|
||||
public void setMarkdown(String markdown) { this.markdown = markdown; }
|
||||
public List<Section> getSections() { return sections; }
|
||||
public void setSections(List<Section> sections) { this.sections = sections; }
|
||||
public CodeAnalysisSourceSummary getSourceSnapshot() { return sourceSnapshot; }
|
||||
public void setSourceSnapshot(CodeAnalysisSourceSummary sourceSnapshot) { this.sourceSnapshot = sourceSnapshot; }
|
||||
public List<String> getWarnings() { return warnings; }
|
||||
public void setWarnings(List<String> warnings) { this.warnings = warnings; }
|
||||
|
||||
public static class Section
|
||||
{
|
||||
private String key;
|
||||
private String title;
|
||||
private Boolean complete = Boolean.FALSE;
|
||||
|
||||
public String getKey() { return key; }
|
||||
public void setKey(String key) { this.key = key; }
|
||||
public String getTitle() { return title; }
|
||||
public void setTitle(String title) { this.title = title; }
|
||||
public Boolean getComplete() { return complete; }
|
||||
public void setComplete(Boolean complete) { this.complete = complete; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
package com.ruoyi.generator.domain.front.dto;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
public class CodeAnalysisSourceSummary
|
||||
{
|
||||
private Long projectId;
|
||||
private Integer fileCount = 0;
|
||||
private Integer analyzedFileCount = 0;
|
||||
private Integer skippedFileCount = 0;
|
||||
private Integer charCount = 0;
|
||||
private Integer estimatedTokens = 0;
|
||||
private List<SourceFile> includedFiles = new ArrayList<SourceFile>();
|
||||
private List<SkippedFile> skippedFiles = new ArrayList<SkippedFile>();
|
||||
private List<String> warnings = new ArrayList<String>();
|
||||
private String promptContext = "";
|
||||
|
||||
public Long getProjectId() { return projectId; }
|
||||
public void setProjectId(Long projectId) { this.projectId = projectId; }
|
||||
public Integer getFileCount() { return fileCount; }
|
||||
public void setFileCount(Integer fileCount) { this.fileCount = fileCount; }
|
||||
public Integer getAnalyzedFileCount() { return analyzedFileCount; }
|
||||
public void setAnalyzedFileCount(Integer analyzedFileCount) { this.analyzedFileCount = analyzedFileCount; }
|
||||
public Integer getSkippedFileCount() { return skippedFileCount; }
|
||||
public void setSkippedFileCount(Integer skippedFileCount) { this.skippedFileCount = skippedFileCount; }
|
||||
public Integer getCharCount() { return charCount; }
|
||||
public void setCharCount(Integer charCount) { this.charCount = charCount; }
|
||||
public Integer getEstimatedTokens() { return estimatedTokens; }
|
||||
public void setEstimatedTokens(Integer estimatedTokens) { this.estimatedTokens = estimatedTokens; }
|
||||
public List<SourceFile> getIncludedFiles() { return includedFiles; }
|
||||
public void setIncludedFiles(List<SourceFile> includedFiles) { this.includedFiles = includedFiles; }
|
||||
public List<SkippedFile> getSkippedFiles() { return skippedFiles; }
|
||||
public void setSkippedFiles(List<SkippedFile> skippedFiles) { this.skippedFiles = skippedFiles; }
|
||||
public List<String> getWarnings() { return warnings; }
|
||||
public void setWarnings(List<String> warnings) { this.warnings = warnings; }
|
||||
public String getPromptContext() { return promptContext; }
|
||||
public void setPromptContext(String promptContext) { this.promptContext = promptContext; }
|
||||
|
||||
public static class SourceFile
|
||||
{
|
||||
private String templateType;
|
||||
private String nodeId;
|
||||
private String path;
|
||||
private String name;
|
||||
private String content;
|
||||
private Integer charCount = 0;
|
||||
private Boolean truncated = Boolean.FALSE;
|
||||
|
||||
public String getTemplateType() { return templateType; }
|
||||
public void setTemplateType(String templateType) { this.templateType = templateType; }
|
||||
public String getNodeId() { return nodeId; }
|
||||
public void setNodeId(String nodeId) { this.nodeId = nodeId; }
|
||||
public String getPath() { return path; }
|
||||
public void setPath(String path) { this.path = path; }
|
||||
public String getName() { return name; }
|
||||
public void setName(String name) { this.name = name; }
|
||||
public String getContent() { return content; }
|
||||
public void setContent(String content) { this.content = content; }
|
||||
public Integer getCharCount() { return charCount; }
|
||||
public void setCharCount(Integer charCount) { this.charCount = charCount; }
|
||||
public Boolean getTruncated() { return truncated; }
|
||||
public void setTruncated(Boolean truncated) { this.truncated = truncated; }
|
||||
}
|
||||
|
||||
public static class SkippedFile
|
||||
{
|
||||
private String templateType;
|
||||
private String path;
|
||||
private String reason;
|
||||
|
||||
public String getTemplateType() { return templateType; }
|
||||
public void setTemplateType(String templateType) { this.templateType = templateType; }
|
||||
public String getPath() { return path; }
|
||||
public void setPath(String path) { this.path = path; }
|
||||
public String getReason() { return reason; }
|
||||
public void setReason(String reason) { this.reason = reason; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package com.ruoyi.generator.domain.front.dto;
|
||||
|
||||
public class GenerateCodeAnalysisRequest
|
||||
{
|
||||
private String mode;
|
||||
private Long previousGenerationId;
|
||||
private String previousMarkdown;
|
||||
private String extraRequirements;
|
||||
|
||||
public String getMode() { return mode; }
|
||||
public void setMode(String mode) { this.mode = mode; }
|
||||
public Long getPreviousGenerationId() { return previousGenerationId; }
|
||||
public void setPreviousGenerationId(Long previousGenerationId) { this.previousGenerationId = previousGenerationId; }
|
||||
public String getPreviousMarkdown() { return previousMarkdown; }
|
||||
public void setPreviousMarkdown(String previousMarkdown) { this.previousMarkdown = previousMarkdown; }
|
||||
public String getExtraRequirements() { return extraRequirements; }
|
||||
public void setExtraRequirements(String extraRequirements) { this.extraRequirements = extraRequirements; }
|
||||
}
|
||||
@@ -0,0 +1,247 @@
|
||||
package com.ruoyi.generator.service.front;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.stereotype.Service;
|
||||
import com.ruoyi.common.utils.StringUtils;
|
||||
import com.ruoyi.generator.domain.front.dto.CodeAnalysisSourceSummary;
|
||||
import com.ruoyi.generator.domain.front.dto.PreviewFileRequest;
|
||||
|
||||
@Service
|
||||
public class ProjectCodeAnalysisService
|
||||
{
|
||||
private static final int LARGE_FILE_LIMIT = 100 * 1024;
|
||||
private static final String[] TEMPLATE_TYPES = { "backend", "frontend", "admin_frontend", "sql" };
|
||||
private static final List<String> ALLOWED_SUFFIXES = Arrays.asList(
|
||||
".java", ".xml", ".yml", ".yaml", ".properties", ".vue", ".js", ".ts",
|
||||
".json", ".scss", ".css", ".sql", ".md");
|
||||
private static final List<String> EXACT_ALLOWED_NAMES = Arrays.asList("pom.xml", "package.json", "readme.md");
|
||||
private static final List<String> SKIPPED_SEGMENTS = Arrays.asList(
|
||||
"node_modules", "target", "dist", ".git", ".idea");
|
||||
|
||||
@Autowired
|
||||
private IFrontProjectPreviewService frontProjectPreviewService;
|
||||
|
||||
public CodeAnalysisSourceSummary buildSourceSummary(Long userId, Long projectId)
|
||||
{
|
||||
CodeAnalysisSourceSummary summary = new CodeAnalysisSourceSummary();
|
||||
summary.setProjectId(projectId);
|
||||
for (String templateType : TEMPLATE_TYPES)
|
||||
{
|
||||
collectTemplate(summary, userId, projectId, templateType);
|
||||
}
|
||||
finishSummary(summary);
|
||||
return summary;
|
||||
}
|
||||
|
||||
private void collectTemplate(CodeAnalysisSourceSummary summary, Long userId, Long projectId, String templateType)
|
||||
{
|
||||
List<Map<String, Object>> nodes;
|
||||
try
|
||||
{
|
||||
nodes = frontProjectPreviewService.getStructure(userId, projectId, templateType);
|
||||
}
|
||||
catch (RuntimeException e)
|
||||
{
|
||||
summary.getWarnings().add(templateType + " 源码结构读取失败:" + StringUtils.defaultString(e.getMessage()));
|
||||
return;
|
||||
}
|
||||
collectNodes(summary, userId, projectId, templateType, nodes);
|
||||
}
|
||||
|
||||
private void collectNodes(CodeAnalysisSourceSummary summary, Long userId, Long projectId, String templateType,
|
||||
List<Map<String, Object>> nodes)
|
||||
{
|
||||
if (nodes == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
for (Map<String, Object> node : nodes)
|
||||
{
|
||||
if (node == null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
@SuppressWarnings("unchecked")
|
||||
List<Map<String, Object>> children = (List<Map<String, Object>>) node.get("children");
|
||||
if (!isFile(node, children))
|
||||
{
|
||||
collectNodes(summary, userId, projectId, templateType, children);
|
||||
continue;
|
||||
}
|
||||
summary.setFileCount(summary.getFileCount() + 1);
|
||||
collectFile(summary, userId, projectId, templateType, node);
|
||||
}
|
||||
}
|
||||
|
||||
private void collectFile(CodeAnalysisSourceSummary summary, Long userId, Long projectId, String templateType,
|
||||
Map<String, Object> node)
|
||||
{
|
||||
String path = normalizePath(firstNonBlank(stringValue(node.get("path")), stringValue(node.get("label")),
|
||||
stringValue(node.get("id"))));
|
||||
String skipReason = skipReason(path);
|
||||
if (StringUtils.isNotEmpty(skipReason))
|
||||
{
|
||||
addSkipped(summary, templateType, path, skipReason);
|
||||
return;
|
||||
}
|
||||
if (!isAllowedCodeFile(path))
|
||||
{
|
||||
addSkipped(summary, templateType, path, "非代码或非文档文件");
|
||||
return;
|
||||
}
|
||||
|
||||
String nodeId = stringValue(node.get("id"));
|
||||
String content = readContent(userId, projectId, templateType, node, path);
|
||||
int originalLength = content.length();
|
||||
boolean truncated = false;
|
||||
if (content.length() > LARGE_FILE_LIMIT)
|
||||
{
|
||||
content = content.substring(0, LARGE_FILE_LIMIT)
|
||||
+ "\n\n[文件超过 100KB,代码解读仅保留前 100KB 内容]";
|
||||
truncated = true;
|
||||
summary.getWarnings().add(path + " 超过 100KB,已裁剪后参与分析");
|
||||
}
|
||||
|
||||
CodeAnalysisSourceSummary.SourceFile file = new CodeAnalysisSourceSummary.SourceFile();
|
||||
file.setTemplateType(templateType);
|
||||
file.setNodeId(nodeId);
|
||||
file.setPath(path);
|
||||
file.setName(fileName(path));
|
||||
file.setContent(content);
|
||||
file.setCharCount(originalLength);
|
||||
file.setTruncated(truncated);
|
||||
summary.getIncludedFiles().add(file);
|
||||
}
|
||||
|
||||
private String readContent(Long userId, Long projectId, String templateType, Map<String, Object> node, String path)
|
||||
{
|
||||
PreviewFileRequest request = new PreviewFileRequest();
|
||||
request.setTemplateType(templateType);
|
||||
request.setNodeId(stringValue(node.get("id")));
|
||||
request.setCategory(path);
|
||||
Object tableId = node.get("tableId");
|
||||
if (tableId instanceof Number)
|
||||
{
|
||||
request.setTableId(((Number) tableId).longValue());
|
||||
}
|
||||
Map<String, String> result = frontProjectPreviewService.getFileContent(userId, projectId, request);
|
||||
if (result == null || result.isEmpty())
|
||||
{
|
||||
return "";
|
||||
}
|
||||
if (result.containsKey("content"))
|
||||
{
|
||||
return StringUtils.defaultString(result.get("content"));
|
||||
}
|
||||
return StringUtils.defaultString(result.values().iterator().next());
|
||||
}
|
||||
|
||||
private void finishSummary(CodeAnalysisSourceSummary summary)
|
||||
{
|
||||
int charCount = 0;
|
||||
StringBuilder prompt = new StringBuilder();
|
||||
for (CodeAnalysisSourceSummary.SourceFile file : summary.getIncludedFiles())
|
||||
{
|
||||
charCount += file.getContent() == null ? 0 : file.getContent().length();
|
||||
prompt.append("\n\n## ").append(file.getTemplateType()).append(" / ").append(file.getPath()).append("\n");
|
||||
prompt.append(file.getContent() == null ? "" : file.getContent());
|
||||
}
|
||||
summary.setAnalyzedFileCount(summary.getIncludedFiles().size());
|
||||
summary.setSkippedFileCount(summary.getSkippedFiles().size());
|
||||
summary.setCharCount(charCount);
|
||||
summary.setEstimatedTokens(Math.max(0, (charCount + 3) / 4));
|
||||
summary.setPromptContext(prompt.toString().trim());
|
||||
}
|
||||
|
||||
private boolean isFile(Map<String, Object> node, List<Map<String, Object>> children)
|
||||
{
|
||||
String type = stringValue(node.get("type"));
|
||||
if ("folder".equalsIgnoreCase(type) || "directory".equalsIgnoreCase(type))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
if ("file".equalsIgnoreCase(type) || Boolean.TRUE.equals(node.get("isFile")))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
return children == null || children.isEmpty();
|
||||
}
|
||||
|
||||
private boolean isAllowedCodeFile(String path)
|
||||
{
|
||||
String lower = path.toLowerCase(Locale.ROOT);
|
||||
String name = fileName(lower);
|
||||
if (EXACT_ALLOWED_NAMES.contains(name))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
for (String suffix : ALLOWED_SUFFIXES)
|
||||
{
|
||||
if (lower.endsWith(suffix))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private String skipReason(String path)
|
||||
{
|
||||
String normalized = "/" + normalizePath(path).toLowerCase(Locale.ROOT) + "/";
|
||||
for (String segment : SKIPPED_SEGMENTS)
|
||||
{
|
||||
if (normalized.contains("/" + segment + "/"))
|
||||
{
|
||||
if ("node_modules".equals(segment))
|
||||
{
|
||||
return "依赖目录";
|
||||
}
|
||||
return "构建产物或本地工程目录";
|
||||
}
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
private void addSkipped(CodeAnalysisSourceSummary summary, String templateType, String path, String reason)
|
||||
{
|
||||
CodeAnalysisSourceSummary.SkippedFile file = new CodeAnalysisSourceSummary.SkippedFile();
|
||||
file.setTemplateType(templateType);
|
||||
file.setPath(path);
|
||||
file.setReason(reason);
|
||||
summary.getSkippedFiles().add(file);
|
||||
}
|
||||
|
||||
private String firstNonBlank(String... values)
|
||||
{
|
||||
for (String value : values)
|
||||
{
|
||||
if (StringUtils.isNotBlank(value))
|
||||
{
|
||||
return value;
|
||||
}
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
private String normalizePath(String path)
|
||||
{
|
||||
return StringUtils.defaultString(path).replace("\\", "/");
|
||||
}
|
||||
|
||||
private String fileName(String path)
|
||||
{
|
||||
String normalized = normalizePath(path);
|
||||
int index = normalized.lastIndexOf("/");
|
||||
return index >= 0 ? normalized.substring(index + 1) : normalized;
|
||||
}
|
||||
|
||||
private String stringValue(Object value)
|
||||
{
|
||||
return value == null ? "" : String.valueOf(value);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
package com.ruoyi.generator.service.front;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import java.lang.reflect.Field;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import org.junit.Before;
|
||||
import org.junit.Test;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.MockitoAnnotations;
|
||||
import com.ruoyi.generator.domain.front.dto.CodeAnalysisSourceSummary;
|
||||
import com.ruoyi.generator.domain.front.dto.PreviewFileRequest;
|
||||
|
||||
public class ProjectCodeAnalysisServiceTest
|
||||
{
|
||||
private ProjectCodeAnalysisService service;
|
||||
|
||||
@Mock
|
||||
private IFrontProjectPreviewService frontProjectPreviewService;
|
||||
|
||||
@Before
|
||||
public void setUp() throws Exception
|
||||
{
|
||||
MockitoAnnotations.initMocks(this);
|
||||
service = new ProjectCodeAnalysisService();
|
||||
setField("frontProjectPreviewService", frontProjectPreviewService);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void shouldCollectAllowedPreviewFilesAndSkipBuildArtifacts()
|
||||
{
|
||||
when(frontProjectPreviewService.getStructure(eq(1L), eq(10L), eq("backend")))
|
||||
.thenReturn(nodes(
|
||||
node("backend:src/main/java/com/example/UserController.java",
|
||||
"UserController.java", "src/main/java/com/example/UserController.java", "file"),
|
||||
node("backend:target/classes/App.class",
|
||||
"App.class", "target/classes/App.class", "file")));
|
||||
when(frontProjectPreviewService.getStructure(eq(1L), eq(10L), eq("frontend")))
|
||||
.thenReturn(nodes(node("frontend:node_modules/vue/index.js",
|
||||
"index.js", "node_modules/vue/index.js", "file")));
|
||||
when(frontProjectPreviewService.getStructure(eq(1L), eq(10L), eq("admin_frontend")))
|
||||
.thenReturn(Collections.<Map<String, Object>>emptyList());
|
||||
when(frontProjectPreviewService.getStructure(eq(1L), eq(10L), eq("sql")))
|
||||
.thenReturn(Collections.<Map<String, Object>>emptyList());
|
||||
when(frontProjectPreviewService.getFileContent(eq(1L), eq(10L), any(PreviewFileRequest.class)))
|
||||
.thenReturn(content("package com.example;\npublic class UserController {}\n"));
|
||||
|
||||
CodeAnalysisSourceSummary summary = service.buildSourceSummary(1L, 10L);
|
||||
|
||||
assertEquals(Integer.valueOf(1), summary.getAnalyzedFileCount());
|
||||
assertEquals(Integer.valueOf(3), summary.getFileCount());
|
||||
assertTrue(summary.getIncludedFiles().get(0).getPath().contains("UserController.java"));
|
||||
assertTrue(summary.getPromptContext().contains("UserController"));
|
||||
assertTrue(summary.getSkippedFiles().stream().anyMatch(file -> file.getReason().contains("构建产物")));
|
||||
assertTrue(summary.getSkippedFiles().stream().anyMatch(file -> file.getPath().contains("node_modules")));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void shouldTrimLargeFilesAndKeepSummaryUsable()
|
||||
{
|
||||
String large = repeat("public void method() {}\n", 7000);
|
||||
when(frontProjectPreviewService.getStructure(eq(1L), eq(10L), eq("backend")))
|
||||
.thenReturn(nodes(node("backend:src/main/java/App.java",
|
||||
"App.java", "src/main/java/App.java", "file")));
|
||||
when(frontProjectPreviewService.getStructure(eq(1L), eq(10L), eq("frontend")))
|
||||
.thenReturn(Collections.<Map<String, Object>>emptyList());
|
||||
when(frontProjectPreviewService.getStructure(eq(1L), eq(10L), eq("admin_frontend")))
|
||||
.thenReturn(Collections.<Map<String, Object>>emptyList());
|
||||
when(frontProjectPreviewService.getStructure(eq(1L), eq(10L), eq("sql")))
|
||||
.thenReturn(Collections.<Map<String, Object>>emptyList());
|
||||
when(frontProjectPreviewService.getFileContent(eq(1L), eq(10L), any(PreviewFileRequest.class)))
|
||||
.thenReturn(content(large));
|
||||
|
||||
CodeAnalysisSourceSummary summary = service.buildSourceSummary(1L, 10L);
|
||||
|
||||
assertEquals(Integer.valueOf(1), summary.getAnalyzedFileCount());
|
||||
assertTrue(summary.getIncludedFiles().get(0).getContent().length() < large.length());
|
||||
assertTrue(summary.getWarnings().stream().anyMatch(value -> value.contains("超过 100KB")));
|
||||
assertTrue(summary.getEstimatedTokens().intValue() > 0);
|
||||
}
|
||||
|
||||
private void setField(String name, Object value) throws Exception
|
||||
{
|
||||
Field field = ProjectCodeAnalysisService.class.getDeclaredField(name);
|
||||
field.setAccessible(true);
|
||||
field.set(service, value);
|
||||
}
|
||||
|
||||
private List<Map<String, Object>> nodes(Map<String, Object>... nodes)
|
||||
{
|
||||
List<Map<String, Object>> result = new ArrayList<Map<String, Object>>();
|
||||
Collections.addAll(result, nodes);
|
||||
return result;
|
||||
}
|
||||
|
||||
private Map<String, Object> node(String id, String label, String path, String type)
|
||||
{
|
||||
Map<String, Object> node = new HashMap<String, Object>();
|
||||
node.put("id", id);
|
||||
node.put("label", label);
|
||||
node.put("path", path);
|
||||
node.put("type", type);
|
||||
return node;
|
||||
}
|
||||
|
||||
private Map<String, String> content(String value)
|
||||
{
|
||||
Map<String, String> result = new HashMap<String, String>();
|
||||
result.put("content", value);
|
||||
return result;
|
||||
}
|
||||
|
||||
private String repeat(String value, int times)
|
||||
{
|
||||
StringBuilder builder = new StringBuilder();
|
||||
for (int i = 0; i < times; i++)
|
||||
{
|
||||
builder.append(value);
|
||||
}
|
||||
return builder.toString();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user