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:
Digital Production Factory
2025-12-10 11:05:27 -03:00
parent 842cce133c
commit d53b61008c
15 changed files with 952 additions and 171 deletions

92
dss-mvp1/dss-cli.py Executable file
View 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
View 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);
}

View 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>")

View File

@@ -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",

View File

@@ -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",

View 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

View 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."