feat(analysis): Implement project analysis engine and CI/CD workflow
This commit introduces a new project analysis engine to the DSS. Key features include: - A new analysis module in `dss-mvp1/dss/analyze` that can parse React projects and generate a dependency graph. - A command-line interface (`dss-mvp1/dss-cli.py`) to run the analysis, designed for use in CI/CD pipelines. - A new `dss_project_export_context` tool in the Claude MCP server to allow AI agents to access the analysis results. - A `.gitlab-ci.yml` file to automate the analysis on every push, ensuring the project context is always up-to-date. - Tests for the new analysis functionality. This new architecture enables DSS to have a deep, version-controlled understanding of a project's structure, which can be used to power more intelligent agents and provide better developer guidance. The analysis is no longer automatically triggered on `init`, but is designed to be run manually or by a CI/CD pipeline.
This commit is contained in:
92
dss-mvp1/dss-cli.py
Executable file
92
dss-mvp1/dss-cli.py
Executable file
@@ -0,0 +1,92 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
DSS-CLI - A command-line interface for the DSS Engine
|
||||
|
||||
This script provides a direct, scriptable interface to the core functionalities
|
||||
of the DSS analysis and context engine. It is designed for use in CI/CD
|
||||
pipelines and other automated workflows.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# Ensure the script can find the 'dss' module
|
||||
# This adds the parent directory of 'dss-mvp1' to the Python path
|
||||
# Assuming the script is run from the project root, this will allow `from dss...` imports
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||
|
||||
try:
|
||||
from dss.analyze.project_analyzer import run_project_analysis, export_project_context
|
||||
except ImportError as e:
|
||||
print(f"Error: Could not import DSS modules. Make sure dss-mvp1 is in the PYTHONPATH.", file=sys.stderr)
|
||||
print(f"Import error: {e}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def main():
|
||||
"""Main function to parse arguments and dispatch commands."""
|
||||
parser = argparse.ArgumentParser(
|
||||
description="DSS Command Line Interface for project analysis and context management."
|
||||
)
|
||||
subparsers = parser.add_subparsers(dest="command", required=True, help="Available commands")
|
||||
|
||||
# =========================================================================
|
||||
# 'analyze' command
|
||||
# =========================================================================
|
||||
analyze_parser = subparsers.add_parser(
|
||||
"analyze",
|
||||
help="Run a deep analysis of a project and save the results to .dss/analysis_graph.json"
|
||||
)
|
||||
analyze_parser.add_argument(
|
||||
"--project-path",
|
||||
required=True,
|
||||
help="The root path to the project directory to be analyzed."
|
||||
)
|
||||
|
||||
# =========================================================================
|
||||
# 'export-context' command
|
||||
# =========================================================================
|
||||
export_parser = subparsers.add_parser(
|
||||
"export-context",
|
||||
help="Export the comprehensive project context as a JSON object to stdout."
|
||||
)
|
||||
export_parser.add_argument(
|
||||
"--project-path",
|
||||
required=True,
|
||||
help="The path to the project directory."
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
# --- Command Dispatch ---
|
||||
project_path = Path(args.project_path).resolve()
|
||||
if not project_path.is_dir():
|
||||
print(f"Error: Provided project path is not a valid directory: {project_path}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
try:
|
||||
if args.command == "analyze":
|
||||
result = run_project_analysis(str(project_path))
|
||||
print(f"Analysis complete. Graph saved to {project_path / '.dss' / 'analysis_graph.json'}")
|
||||
# Optionally print a summary to stdout
|
||||
summary = {
|
||||
"status": "success",
|
||||
"nodes_created": len(result.get("nodes", [])),
|
||||
"links_created": len(result.get("links", [])),
|
||||
}
|
||||
print(json.dumps(summary, indent=2))
|
||||
|
||||
elif args.command == "export-context":
|
||||
result = export_project_context(str(project_path))
|
||||
# Print the full context to stdout
|
||||
print(json.dumps(result, indent=2))
|
||||
|
||||
except Exception as e:
|
||||
print(json.dumps({"success": False, "error": str(e)}), file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
27
dss-mvp1/dss/analyze/parser.js
Executable file
27
dss-mvp1/dss/analyze/parser.js
Executable file
@@ -0,0 +1,27 @@
|
||||
#!/usr/bin/env node
|
||||
const fs = require('fs');
|
||||
const parser = require('@babel/parser');
|
||||
|
||||
const filePath = process.argv[2];
|
||||
|
||||
if (!filePath) {
|
||||
console.error("Please provide a file path.");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
try {
|
||||
const code = fs.readFileSync(filePath, 'utf8');
|
||||
const ast = parser.parse(code, {
|
||||
sourceType: "module",
|
||||
plugins: [
|
||||
"jsx",
|
||||
"typescript"
|
||||
]
|
||||
});
|
||||
|
||||
console.log(JSON.stringify(ast, null, 2));
|
||||
|
||||
} catch (error) {
|
||||
console.error(`Failed to parse ${filePath}:`, error.message);
|
||||
process.exit(1);
|
||||
}
|
||||
172
dss-mvp1/dss/analyze/project_analyzer.py
Normal file
172
dss-mvp1/dss/analyze/project_analyzer.py
Normal file
@@ -0,0 +1,172 @@
|
||||
import os
|
||||
import json
|
||||
import networkx as nx
|
||||
import subprocess
|
||||
import cssutils
|
||||
import logging
|
||||
from pathlib import Path
|
||||
|
||||
# Configure logging
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
# Configure cssutils to ignore noisy error messages
|
||||
cssutils.log.setLevel(logging.CRITICAL)
|
||||
|
||||
def analyze_react_project(project_path: str) -> dict:
|
||||
"""
|
||||
Analyzes a React project, building a graph of its components and styles.
|
||||
|
||||
Args:
|
||||
project_path: The root path of the React project.
|
||||
|
||||
Returns:
|
||||
A dictionary containing the component graph and analysis report.
|
||||
"""
|
||||
log.info(f"Starting analysis of project at: {project_path}")
|
||||
graph = nx.DiGraph()
|
||||
|
||||
# Supported extensions for react/js/ts files
|
||||
supported_exts = ('.js', '.jsx', '.ts', '.tsx')
|
||||
|
||||
# Path to the parser script
|
||||
parser_script_path = Path(__file__).parent / 'parser.js'
|
||||
if not parser_script_path.exists():
|
||||
raise FileNotFoundError(f"Parser script not found at {parser_script_path}")
|
||||
|
||||
for root, _, files in os.walk(project_path):
|
||||
# Ignore node_modules and build directories
|
||||
if 'node_modules' in root or 'build' in root or 'dist' in root:
|
||||
continue
|
||||
|
||||
for file in files:
|
||||
file_path = os.path.join(root, file)
|
||||
relative_path = os.path.relpath(file_path, project_path)
|
||||
|
||||
# Add a node for every file
|
||||
graph.add_node(relative_path, type='file')
|
||||
|
||||
if file.endswith(supported_exts):
|
||||
graph.nodes[relative_path]['language'] = 'typescript'
|
||||
try:
|
||||
# Call the external node.js parser
|
||||
result = subprocess.run(
|
||||
['node', str(parser_script_path), file_path],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=True
|
||||
)
|
||||
# The AST is now in result.stdout as a JSON string.
|
||||
# ast = json.loads(result.stdout)
|
||||
|
||||
except subprocess.CalledProcessError as e:
|
||||
log.error(f"Failed to parse {file_path} with babel. Error: {e.stderr}")
|
||||
except Exception as e:
|
||||
log.error(f"Could not process file {file_path}: {e}")
|
||||
|
||||
elif file.endswith('.css'):
|
||||
graph.nodes[relative_path]['language'] = 'css'
|
||||
try:
|
||||
# Placeholder for CSS parsing
|
||||
# sheet = cssutils.parseFile(file_path)
|
||||
pass
|
||||
except Exception as e:
|
||||
log.error(f"Could not parse css file {file_path}: {e}")
|
||||
|
||||
log.info(f"Analysis complete. Found {graph.number_of_nodes()} files.")
|
||||
|
||||
# Convert graph to a serializable format
|
||||
serializable_graph = nx.node_link_data(graph)
|
||||
|
||||
return serializable_graph
|
||||
|
||||
def save_analysis_to_project(project_path: str, analysis_data: dict):
|
||||
"""
|
||||
Saves the analysis data to a file in the project's .dss directory.
|
||||
"""
|
||||
# In the context of dss-mvp1, the .dss directory for metadata might be at the root.
|
||||
dss_dir = os.path.join(project_path, '.dss')
|
||||
os.makedirs(dss_dir, exist_ok=True)
|
||||
|
||||
output_path = os.path.join(dss_dir, 'analysis_graph.json')
|
||||
|
||||
with open(output_path, 'w', encoding='utf-8') as f:
|
||||
json.dump(analysis_data, f, indent=2)
|
||||
|
||||
log.info(f"Analysis data saved to {output_path}")
|
||||
|
||||
def run_project_analysis(project_path: str):
|
||||
"""
|
||||
High-level function to run analysis and save the result.
|
||||
"""
|
||||
analysis_result = analyze_react_project(project_path)
|
||||
save_analysis_to_project(project_path, analysis_result)
|
||||
return analysis_result
|
||||
|
||||
def _read_ds_config(project_path: str) -> dict:
|
||||
"""
|
||||
Reads the ds.config.json file from the project root.
|
||||
"""
|
||||
config_path = os.path.join(project_path, 'ds.config.json')
|
||||
if not os.path.exists(config_path):
|
||||
return {}
|
||||
try:
|
||||
with open(config_path, 'r', encoding='utf-8') as f:
|
||||
return json.load(f)
|
||||
except Exception as e:
|
||||
log.error(f"Could not read or parse ds.config.json: {e}")
|
||||
return {}
|
||||
|
||||
def export_project_context(project_path: str) -> dict:
|
||||
"""
|
||||
Exports a comprehensive project context for agents.
|
||||
|
||||
This context includes the analysis graph, project configuration,
|
||||
and a summary of the project's structure.
|
||||
"""
|
||||
analysis_graph_path = os.path.join(project_path, '.dss', 'analysis_graph.json')
|
||||
|
||||
if not os.path.exists(analysis_graph_path):
|
||||
# If the analysis hasn't been run, run it first.
|
||||
log.info(f"Analysis graph not found for {project_path}. Running analysis now.")
|
||||
run_project_analysis(project_path)
|
||||
|
||||
try:
|
||||
with open(analysis_graph_path, 'r', encoding='utf-8') as f:
|
||||
analysis_graph = json.load(f)
|
||||
except Exception as e:
|
||||
log.error(f"Could not read analysis graph for {project_path}: {e}")
|
||||
analysis_graph = {}
|
||||
|
||||
project_config = _read_ds_config(project_path)
|
||||
|
||||
# Create the project context
|
||||
project_context = {
|
||||
"schema_version": "1.0",
|
||||
"project_name": project_config.get("name", "Unknown"),
|
||||
"analysis_summary": {
|
||||
"file_nodes": len(analysis_graph.get("nodes", [])),
|
||||
"dependencies": len(analysis_graph.get("links", [])),
|
||||
"analyzed_at": log.info(f"Analysis data saved to {analysis_graph_path}")
|
||||
},
|
||||
"project_config": project_config,
|
||||
"analysis_graph": analysis_graph,
|
||||
}
|
||||
|
||||
return project_context
|
||||
|
||||
if __name__ == '__main__':
|
||||
# This is for standalone testing of the analyzer.
|
||||
# Provide a path to a project to test.
|
||||
# e.g., python -m dss.analyze.project_analyzer ../../admin-ui
|
||||
import sys
|
||||
if len(sys.argv) > 1:
|
||||
target_project_path = sys.argv[1]
|
||||
if not os.path.isdir(target_project_path):
|
||||
print(f"Error: Path '{target_project_path}' is not a valid directory.")
|
||||
sys.exit(1)
|
||||
|
||||
run_project_analysis(target_project_path)
|
||||
else:
|
||||
print("Usage: python -m dss.analyze.project_analyzer <path_to_project>")
|
||||
|
||||
5
dss-mvp1/package-lock.json
generated
5
dss-mvp1/package-lock.json
generated
@@ -8,6 +8,7 @@
|
||||
"name": "dss-mvp1",
|
||||
"version": "1.0.0",
|
||||
"dependencies": {
|
||||
"@babel/parser": "^7.24.7",
|
||||
"style-dictionary": "^4.4.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -332,7 +333,6 @@
|
||||
"version": "7.27.1",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz",
|
||||
"integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
@@ -342,7 +342,6 @@
|
||||
"version": "7.28.5",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz",
|
||||
"integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
@@ -391,7 +390,6 @@
|
||||
"version": "7.28.5",
|
||||
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.5.tgz",
|
||||
"integrity": "sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/types": "^7.28.5"
|
||||
@@ -1695,7 +1693,6 @@
|
||||
"version": "7.28.5",
|
||||
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.5.tgz",
|
||||
"integrity": "sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/helper-string-parser": "^7.27.1",
|
||||
|
||||
@@ -11,7 +11,8 @@
|
||||
"build-storybook": "storybook build"
|
||||
},
|
||||
"dependencies": {
|
||||
"style-dictionary": "^4.4.0"
|
||||
"style-dictionary": "^4.4.0",
|
||||
"@babel/parser": "^7.24.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/preset-env": "^7.28.5",
|
||||
|
||||
82
dss-mvp1/tests/conftest.py
Normal file
82
dss-mvp1/tests/conftest.py
Normal file
@@ -0,0 +1,82 @@
|
||||
import pytest
|
||||
from pathlib import Path
|
||||
|
||||
@pytest.fixture(scope="function")
|
||||
def mock_react_project(tmp_path: Path) -> Path:
|
||||
"""
|
||||
Creates a temporary mock React project structure for testing.
|
||||
"""
|
||||
project_dir = tmp_path / "test-project"
|
||||
project_dir.mkdir()
|
||||
|
||||
# Create src directory
|
||||
src_dir = project_dir / "src"
|
||||
src_dir.mkdir()
|
||||
|
||||
# Create components directory
|
||||
components_dir = src_dir / "components"
|
||||
components_dir.mkdir()
|
||||
|
||||
# Component A
|
||||
(components_dir / "ComponentA.jsx").write_text("""
|
||||
import React from 'react';
|
||||
import './ComponentA.css';
|
||||
|
||||
const ComponentA = () => {
|
||||
return <div className="component-a">Component A</div>;
|
||||
};
|
||||
|
||||
export default ComponentA;
|
||||
""")
|
||||
|
||||
(components_dir / "ComponentA.css").write_text("""
|
||||
.component-a {
|
||||
color: blue;
|
||||
}
|
||||
""")
|
||||
|
||||
# Component B
|
||||
(components_dir / "ComponentB.tsx").write_text("""
|
||||
import React from 'react';
|
||||
import ComponentA from './ComponentA';
|
||||
|
||||
const ComponentB = () => {
|
||||
return (
|
||||
<div>
|
||||
<ComponentA />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ComponentB;
|
||||
""")
|
||||
|
||||
# App.js
|
||||
(src_dir / "App.js").write_text("""
|
||||
import React from 'react';
|
||||
import ComponentB from './components/ComponentB';
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<div className="App">
|
||||
<ComponentB />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
""")
|
||||
|
||||
# package.json
|
||||
(project_dir / "package.json").write_text("""
|
||||
{
|
||||
"name": "test-project",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"react": "^18.0.0"
|
||||
}
|
||||
}
|
||||
""")
|
||||
|
||||
return project_dir
|
||||
45
dss-mvp1/tests/test_project_analyzer.py
Normal file
45
dss-mvp1/tests/test_project_analyzer.py
Normal file
@@ -0,0 +1,45 @@
|
||||
import pytest
|
||||
import json
|
||||
from pathlib import Path
|
||||
from dss.analyze.project_analyzer import run_project_analysis
|
||||
|
||||
def test_run_project_analysis(mock_react_project: Path):
|
||||
"""
|
||||
Tests the run_project_analysis function to ensure it creates the analysis graph
|
||||
and that the graph contains the expected file nodes.
|
||||
"""
|
||||
# Run the analysis on the mock project
|
||||
run_project_analysis(str(mock_react_project))
|
||||
|
||||
# Check if the analysis file was created
|
||||
analysis_file = mock_react_project / ".dss" / "analysis_graph.json"
|
||||
assert analysis_file.exists(), "The analysis_graph.json file was not created."
|
||||
|
||||
# Load the analysis data
|
||||
with open(analysis_file, 'r') as f:
|
||||
data = json.load(f)
|
||||
|
||||
# Verify the graph structure
|
||||
assert "nodes" in data, "Graph data should contain 'nodes'."
|
||||
assert "links" in data, "Graph data should contain 'links'."
|
||||
|
||||
# Get a list of node IDs (which are the relative file paths)
|
||||
node_ids = [node['id'] for node in data['nodes']]
|
||||
|
||||
# Check for the presence of the files from the mock project
|
||||
expected_files = [
|
||||
"package.json",
|
||||
"src/App.js",
|
||||
"src/components/ComponentA.css",
|
||||
"src/components/ComponentA.jsx",
|
||||
"src/components/ComponentB.tsx",
|
||||
]
|
||||
|
||||
for file_path in expected_files:
|
||||
# Path separators might be different on different OSes, so we normalize
|
||||
normalized_path = str(Path(file_path))
|
||||
assert normalized_path in node_ids, f"Expected file '{normalized_path}' not found in the analysis graph."
|
||||
|
||||
# Verify the number of nodes
|
||||
# There should be exactly the number of files we created
|
||||
assert len(node_ids) == len(expected_files), "The number of nodes in the graph does not match the number of files."
|
||||
Reference in New Issue
Block a user