solokit 0.1.1__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.
- solokit/__init__.py +10 -0
- solokit/__version__.py +3 -0
- solokit/cli.py +374 -0
- solokit/core/__init__.py +1 -0
- solokit/core/cache.py +102 -0
- solokit/core/command_runner.py +278 -0
- solokit/core/config.py +453 -0
- solokit/core/config_validator.py +204 -0
- solokit/core/constants.py +291 -0
- solokit/core/error_formatter.py +279 -0
- solokit/core/error_handlers.py +346 -0
- solokit/core/exceptions.py +1567 -0
- solokit/core/file_ops.py +309 -0
- solokit/core/logging_config.py +166 -0
- solokit/core/output.py +99 -0
- solokit/core/performance.py +57 -0
- solokit/core/protocols.py +141 -0
- solokit/core/types.py +312 -0
- solokit/deployment/__init__.py +1 -0
- solokit/deployment/executor.py +411 -0
- solokit/git/__init__.py +1 -0
- solokit/git/integration.py +619 -0
- solokit/init/__init__.py +41 -0
- solokit/init/claude_commands_installer.py +87 -0
- solokit/init/dependency_installer.py +313 -0
- solokit/init/docs_structure.py +90 -0
- solokit/init/env_generator.py +160 -0
- solokit/init/environment_validator.py +334 -0
- solokit/init/git_hooks_installer.py +71 -0
- solokit/init/git_setup.py +188 -0
- solokit/init/gitignore_updater.py +195 -0
- solokit/init/initial_commit.py +145 -0
- solokit/init/initial_scans.py +109 -0
- solokit/init/orchestrator.py +246 -0
- solokit/init/readme_generator.py +207 -0
- solokit/init/session_structure.py +239 -0
- solokit/init/template_installer.py +424 -0
- solokit/learning/__init__.py +1 -0
- solokit/learning/archiver.py +115 -0
- solokit/learning/categorizer.py +126 -0
- solokit/learning/curator.py +428 -0
- solokit/learning/extractor.py +352 -0
- solokit/learning/reporter.py +351 -0
- solokit/learning/repository.py +254 -0
- solokit/learning/similarity.py +342 -0
- solokit/learning/validator.py +144 -0
- solokit/project/__init__.py +1 -0
- solokit/project/init.py +1162 -0
- solokit/project/stack.py +436 -0
- solokit/project/sync_plugin.py +438 -0
- solokit/project/tree.py +375 -0
- solokit/quality/__init__.py +1 -0
- solokit/quality/api_validator.py +424 -0
- solokit/quality/checkers/__init__.py +25 -0
- solokit/quality/checkers/base.py +114 -0
- solokit/quality/checkers/context7.py +221 -0
- solokit/quality/checkers/custom.py +162 -0
- solokit/quality/checkers/deployment.py +323 -0
- solokit/quality/checkers/documentation.py +179 -0
- solokit/quality/checkers/formatting.py +161 -0
- solokit/quality/checkers/integration.py +394 -0
- solokit/quality/checkers/linting.py +159 -0
- solokit/quality/checkers/security.py +261 -0
- solokit/quality/checkers/spec_completeness.py +127 -0
- solokit/quality/checkers/tests.py +184 -0
- solokit/quality/env_validator.py +306 -0
- solokit/quality/gates.py +655 -0
- solokit/quality/reporters/__init__.py +10 -0
- solokit/quality/reporters/base.py +25 -0
- solokit/quality/reporters/console.py +98 -0
- solokit/quality/reporters/json_reporter.py +34 -0
- solokit/quality/results.py +98 -0
- solokit/session/__init__.py +1 -0
- solokit/session/briefing/__init__.py +245 -0
- solokit/session/briefing/documentation_loader.py +53 -0
- solokit/session/briefing/formatter.py +476 -0
- solokit/session/briefing/git_context.py +282 -0
- solokit/session/briefing/learning_loader.py +212 -0
- solokit/session/briefing/milestone_builder.py +78 -0
- solokit/session/briefing/orchestrator.py +137 -0
- solokit/session/briefing/stack_detector.py +51 -0
- solokit/session/briefing/tree_generator.py +52 -0
- solokit/session/briefing/work_item_loader.py +209 -0
- solokit/session/briefing.py +353 -0
- solokit/session/complete.py +1188 -0
- solokit/session/status.py +246 -0
- solokit/session/validate.py +452 -0
- solokit/templates/.claude/commands/end.md +109 -0
- solokit/templates/.claude/commands/init.md +159 -0
- solokit/templates/.claude/commands/learn-curate.md +88 -0
- solokit/templates/.claude/commands/learn-search.md +62 -0
- solokit/templates/.claude/commands/learn-show.md +69 -0
- solokit/templates/.claude/commands/learn.md +136 -0
- solokit/templates/.claude/commands/start.md +114 -0
- solokit/templates/.claude/commands/status.md +22 -0
- solokit/templates/.claude/commands/validate.md +27 -0
- solokit/templates/.claude/commands/work-delete.md +119 -0
- solokit/templates/.claude/commands/work-graph.md +139 -0
- solokit/templates/.claude/commands/work-list.md +26 -0
- solokit/templates/.claude/commands/work-new.md +114 -0
- solokit/templates/.claude/commands/work-next.md +25 -0
- solokit/templates/.claude/commands/work-show.md +24 -0
- solokit/templates/.claude/commands/work-update.md +141 -0
- solokit/templates/CHANGELOG.md +17 -0
- solokit/templates/WORK_ITEM_TYPES.md +141 -0
- solokit/templates/__init__.py +1 -0
- solokit/templates/bug_spec.md +217 -0
- solokit/templates/config.schema.json +150 -0
- solokit/templates/dashboard_refine/base/.gitignore +36 -0
- solokit/templates/dashboard_refine/base/app/(dashboard)/layout.tsx +22 -0
- solokit/templates/dashboard_refine/base/app/(dashboard)/page.tsx +68 -0
- solokit/templates/dashboard_refine/base/app/(dashboard)/users/page.tsx +77 -0
- solokit/templates/dashboard_refine/base/app/globals.css +60 -0
- solokit/templates/dashboard_refine/base/app/layout.tsx +23 -0
- solokit/templates/dashboard_refine/base/app/page.tsx +9 -0
- solokit/templates/dashboard_refine/base/components/client-refine-wrapper.tsx +21 -0
- solokit/templates/dashboard_refine/base/components/layout/header.tsx +44 -0
- solokit/templates/dashboard_refine/base/components/layout/sidebar.tsx +82 -0
- solokit/templates/dashboard_refine/base/components/ui/button.tsx +53 -0
- solokit/templates/dashboard_refine/base/components/ui/card.tsx +78 -0
- solokit/templates/dashboard_refine/base/components/ui/table.tsx +116 -0
- solokit/templates/dashboard_refine/base/components.json +16 -0
- solokit/templates/dashboard_refine/base/lib/refine.tsx +65 -0
- solokit/templates/dashboard_refine/base/lib/utils.ts +13 -0
- solokit/templates/dashboard_refine/base/next.config.ts +10 -0
- solokit/templates/dashboard_refine/base/package.json.template +40 -0
- solokit/templates/dashboard_refine/base/postcss.config.mjs +8 -0
- solokit/templates/dashboard_refine/base/providers/refine-provider.tsx +26 -0
- solokit/templates/dashboard_refine/base/tailwind.config.ts +57 -0
- solokit/templates/dashboard_refine/base/tsconfig.json +27 -0
- solokit/templates/dashboard_refine/docker/Dockerfile +57 -0
- solokit/templates/dashboard_refine/docker/docker-compose.prod.yml +31 -0
- solokit/templates/dashboard_refine/docker/docker-compose.yml +21 -0
- solokit/templates/dashboard_refine/tier-1-essential/.eslintrc.json +7 -0
- solokit/templates/dashboard_refine/tier-1-essential/jest.config.ts +17 -0
- solokit/templates/dashboard_refine/tier-1-essential/jest.setup.ts +1 -0
- solokit/templates/dashboard_refine/tier-1-essential/package.json.tier1.template +57 -0
- solokit/templates/dashboard_refine/tier-1-essential/tests/setup.ts +26 -0
- solokit/templates/dashboard_refine/tier-1-essential/tests/unit/example.test.tsx +73 -0
- solokit/templates/dashboard_refine/tier-2-standard/package.json.tier2.template +62 -0
- solokit/templates/dashboard_refine/tier-3-comprehensive/eslint.config.mjs +22 -0
- solokit/templates/dashboard_refine/tier-3-comprehensive/package.json.tier3.template +79 -0
- solokit/templates/dashboard_refine/tier-3-comprehensive/playwright.config.ts +66 -0
- solokit/templates/dashboard_refine/tier-3-comprehensive/stryker.conf.json +38 -0
- solokit/templates/dashboard_refine/tier-3-comprehensive/tests/e2e/dashboard.spec.ts +88 -0
- solokit/templates/dashboard_refine/tier-3-comprehensive/tests/e2e/user-management.spec.ts +102 -0
- solokit/templates/dashboard_refine/tier-3-comprehensive/tests/integration/dashboard.test.tsx +90 -0
- solokit/templates/dashboard_refine/tier-3-comprehensive/type-coverage.json +16 -0
- solokit/templates/dashboard_refine/tier-4-production/instrumentation.ts +9 -0
- solokit/templates/dashboard_refine/tier-4-production/k6/dashboard-load-test.js +70 -0
- solokit/templates/dashboard_refine/tier-4-production/next.config.ts +46 -0
- solokit/templates/dashboard_refine/tier-4-production/package.json.tier4.template +89 -0
- solokit/templates/dashboard_refine/tier-4-production/sentry.client.config.ts +26 -0
- solokit/templates/dashboard_refine/tier-4-production/sentry.edge.config.ts +11 -0
- solokit/templates/dashboard_refine/tier-4-production/sentry.server.config.ts +11 -0
- solokit/templates/deployment_spec.md +500 -0
- solokit/templates/feature_spec.md +248 -0
- solokit/templates/fullstack_nextjs/base/.gitignore +36 -0
- solokit/templates/fullstack_nextjs/base/app/api/example/route.ts +65 -0
- solokit/templates/fullstack_nextjs/base/app/globals.css +27 -0
- solokit/templates/fullstack_nextjs/base/app/layout.tsx +20 -0
- solokit/templates/fullstack_nextjs/base/app/page.tsx +32 -0
- solokit/templates/fullstack_nextjs/base/components/example-component.tsx +20 -0
- solokit/templates/fullstack_nextjs/base/lib/prisma.ts +17 -0
- solokit/templates/fullstack_nextjs/base/lib/utils.ts +13 -0
- solokit/templates/fullstack_nextjs/base/lib/validations.ts +20 -0
- solokit/templates/fullstack_nextjs/base/next.config.ts +7 -0
- solokit/templates/fullstack_nextjs/base/package.json.template +32 -0
- solokit/templates/fullstack_nextjs/base/postcss.config.mjs +8 -0
- solokit/templates/fullstack_nextjs/base/prisma/schema.prisma +21 -0
- solokit/templates/fullstack_nextjs/base/tailwind.config.ts +19 -0
- solokit/templates/fullstack_nextjs/base/tsconfig.json +27 -0
- solokit/templates/fullstack_nextjs/docker/Dockerfile +60 -0
- solokit/templates/fullstack_nextjs/docker/docker-compose.prod.yml +57 -0
- solokit/templates/fullstack_nextjs/docker/docker-compose.yml +47 -0
- solokit/templates/fullstack_nextjs/tier-1-essential/.eslintrc.json +7 -0
- solokit/templates/fullstack_nextjs/tier-1-essential/jest.config.ts +17 -0
- solokit/templates/fullstack_nextjs/tier-1-essential/jest.setup.ts +1 -0
- solokit/templates/fullstack_nextjs/tier-1-essential/package.json.tier1.template +48 -0
- solokit/templates/fullstack_nextjs/tier-1-essential/tests/api/example.test.ts +88 -0
- solokit/templates/fullstack_nextjs/tier-1-essential/tests/setup.ts +22 -0
- solokit/templates/fullstack_nextjs/tier-1-essential/tests/unit/example.test.tsx +22 -0
- solokit/templates/fullstack_nextjs/tier-2-standard/package.json.tier2.template +52 -0
- solokit/templates/fullstack_nextjs/tier-3-comprehensive/eslint.config.mjs +39 -0
- solokit/templates/fullstack_nextjs/tier-3-comprehensive/package.json.tier3.template +68 -0
- solokit/templates/fullstack_nextjs/tier-3-comprehensive/playwright.config.ts +66 -0
- solokit/templates/fullstack_nextjs/tier-3-comprehensive/stryker.conf.json +33 -0
- solokit/templates/fullstack_nextjs/tier-3-comprehensive/tests/e2e/flow.spec.ts +59 -0
- solokit/templates/fullstack_nextjs/tier-3-comprehensive/tests/integration/api.test.ts +165 -0
- solokit/templates/fullstack_nextjs/tier-3-comprehensive/type-coverage.json +12 -0
- solokit/templates/fullstack_nextjs/tier-4-production/instrumentation.ts +9 -0
- solokit/templates/fullstack_nextjs/tier-4-production/k6/load-test.js +45 -0
- solokit/templates/fullstack_nextjs/tier-4-production/next.config.ts +46 -0
- solokit/templates/fullstack_nextjs/tier-4-production/package.json.tier4.template +77 -0
- solokit/templates/fullstack_nextjs/tier-4-production/sentry.client.config.ts +26 -0
- solokit/templates/fullstack_nextjs/tier-4-production/sentry.edge.config.ts +11 -0
- solokit/templates/fullstack_nextjs/tier-4-production/sentry.server.config.ts +11 -0
- solokit/templates/git-hooks/prepare-commit-msg +24 -0
- solokit/templates/integration_test_spec.md +363 -0
- solokit/templates/learnings.json +15 -0
- solokit/templates/ml_ai_fastapi/base/.gitignore +104 -0
- solokit/templates/ml_ai_fastapi/base/alembic/env.py +96 -0
- solokit/templates/ml_ai_fastapi/base/alembic.ini +114 -0
- solokit/templates/ml_ai_fastapi/base/pyproject.toml.template +91 -0
- solokit/templates/ml_ai_fastapi/base/requirements.txt.template +28 -0
- solokit/templates/ml_ai_fastapi/base/src/__init__.py +5 -0
- solokit/templates/ml_ai_fastapi/base/src/api/__init__.py +3 -0
- solokit/templates/ml_ai_fastapi/base/src/api/dependencies.py +20 -0
- solokit/templates/ml_ai_fastapi/base/src/api/routes/__init__.py +3 -0
- solokit/templates/ml_ai_fastapi/base/src/api/routes/example.py +134 -0
- solokit/templates/ml_ai_fastapi/base/src/api/routes/health.py +66 -0
- solokit/templates/ml_ai_fastapi/base/src/core/__init__.py +3 -0
- solokit/templates/ml_ai_fastapi/base/src/core/config.py +64 -0
- solokit/templates/ml_ai_fastapi/base/src/core/database.py +50 -0
- solokit/templates/ml_ai_fastapi/base/src/main.py +64 -0
- solokit/templates/ml_ai_fastapi/base/src/models/__init__.py +7 -0
- solokit/templates/ml_ai_fastapi/base/src/models/example.py +61 -0
- solokit/templates/ml_ai_fastapi/base/src/services/__init__.py +3 -0
- solokit/templates/ml_ai_fastapi/base/src/services/example.py +115 -0
- solokit/templates/ml_ai_fastapi/docker/Dockerfile +59 -0
- solokit/templates/ml_ai_fastapi/docker/docker-compose.prod.yml +112 -0
- solokit/templates/ml_ai_fastapi/docker/docker-compose.yml +77 -0
- solokit/templates/ml_ai_fastapi/tier-1-essential/pyproject.toml.tier1.template +112 -0
- solokit/templates/ml_ai_fastapi/tier-1-essential/pyrightconfig.json +41 -0
- solokit/templates/ml_ai_fastapi/tier-1-essential/pytest.ini +69 -0
- solokit/templates/ml_ai_fastapi/tier-1-essential/requirements-dev.txt +17 -0
- solokit/templates/ml_ai_fastapi/tier-1-essential/ruff.toml +81 -0
- solokit/templates/ml_ai_fastapi/tier-1-essential/tests/__init__.py +3 -0
- solokit/templates/ml_ai_fastapi/tier-1-essential/tests/conftest.py +72 -0
- solokit/templates/ml_ai_fastapi/tier-1-essential/tests/test_main.py +49 -0
- solokit/templates/ml_ai_fastapi/tier-1-essential/tests/unit/__init__.py +3 -0
- solokit/templates/ml_ai_fastapi/tier-1-essential/tests/unit/test_example.py +113 -0
- solokit/templates/ml_ai_fastapi/tier-2-standard/pyproject.toml.tier2.template +130 -0
- solokit/templates/ml_ai_fastapi/tier-3-comprehensive/locustfile.py +99 -0
- solokit/templates/ml_ai_fastapi/tier-3-comprehensive/mutmut_config.py +53 -0
- solokit/templates/ml_ai_fastapi/tier-3-comprehensive/pyproject.toml.tier3.template +150 -0
- solokit/templates/ml_ai_fastapi/tier-3-comprehensive/tests/integration/__init__.py +3 -0
- solokit/templates/ml_ai_fastapi/tier-3-comprehensive/tests/integration/conftest.py +74 -0
- solokit/templates/ml_ai_fastapi/tier-3-comprehensive/tests/integration/test_api.py +131 -0
- solokit/templates/ml_ai_fastapi/tier-4-production/pyproject.toml.tier4.template +162 -0
- solokit/templates/ml_ai_fastapi/tier-4-production/requirements-prod.txt +25 -0
- solokit/templates/ml_ai_fastapi/tier-4-production/src/api/routes/metrics.py +19 -0
- solokit/templates/ml_ai_fastapi/tier-4-production/src/core/logging.py +74 -0
- solokit/templates/ml_ai_fastapi/tier-4-production/src/core/monitoring.py +68 -0
- solokit/templates/ml_ai_fastapi/tier-4-production/src/core/sentry.py +66 -0
- solokit/templates/ml_ai_fastapi/tier-4-production/src/middleware/__init__.py +3 -0
- solokit/templates/ml_ai_fastapi/tier-4-production/src/middleware/logging.py +79 -0
- solokit/templates/ml_ai_fastapi/tier-4-production/src/middleware/tracing.py +60 -0
- solokit/templates/refactor_spec.md +287 -0
- solokit/templates/saas_t3/base/.gitignore +36 -0
- solokit/templates/saas_t3/base/app/api/trpc/[trpc]/route.ts +33 -0
- solokit/templates/saas_t3/base/app/globals.css +27 -0
- solokit/templates/saas_t3/base/app/layout.tsx +23 -0
- solokit/templates/saas_t3/base/app/page.tsx +31 -0
- solokit/templates/saas_t3/base/lib/api.tsx +77 -0
- solokit/templates/saas_t3/base/lib/utils.ts +13 -0
- solokit/templates/saas_t3/base/next.config.ts +7 -0
- solokit/templates/saas_t3/base/package.json.template +38 -0
- solokit/templates/saas_t3/base/postcss.config.mjs +8 -0
- solokit/templates/saas_t3/base/prisma/schema.prisma +20 -0
- solokit/templates/saas_t3/base/server/api/root.ts +19 -0
- solokit/templates/saas_t3/base/server/api/routers/example.ts +28 -0
- solokit/templates/saas_t3/base/server/api/trpc.ts +52 -0
- solokit/templates/saas_t3/base/server/db.ts +17 -0
- solokit/templates/saas_t3/base/tailwind.config.ts +19 -0
- solokit/templates/saas_t3/base/tsconfig.json +27 -0
- solokit/templates/saas_t3/docker/Dockerfile +60 -0
- solokit/templates/saas_t3/docker/docker-compose.prod.yml +59 -0
- solokit/templates/saas_t3/docker/docker-compose.yml +49 -0
- solokit/templates/saas_t3/tier-1-essential/.eslintrc.json +7 -0
- solokit/templates/saas_t3/tier-1-essential/jest.config.ts +17 -0
- solokit/templates/saas_t3/tier-1-essential/jest.setup.ts +1 -0
- solokit/templates/saas_t3/tier-1-essential/package.json.tier1.template +54 -0
- solokit/templates/saas_t3/tier-1-essential/tests/setup.ts +22 -0
- solokit/templates/saas_t3/tier-1-essential/tests/unit/example.test.tsx +24 -0
- solokit/templates/saas_t3/tier-2-standard/package.json.tier2.template +58 -0
- solokit/templates/saas_t3/tier-3-comprehensive/eslint.config.mjs +39 -0
- solokit/templates/saas_t3/tier-3-comprehensive/package.json.tier3.template +74 -0
- solokit/templates/saas_t3/tier-3-comprehensive/playwright.config.ts +66 -0
- solokit/templates/saas_t3/tier-3-comprehensive/stryker.conf.json +34 -0
- solokit/templates/saas_t3/tier-3-comprehensive/tests/e2e/home.spec.ts +41 -0
- solokit/templates/saas_t3/tier-3-comprehensive/tests/integration/api.test.ts +44 -0
- solokit/templates/saas_t3/tier-3-comprehensive/type-coverage.json +12 -0
- solokit/templates/saas_t3/tier-4-production/instrumentation.ts +9 -0
- solokit/templates/saas_t3/tier-4-production/k6/load-test.js +51 -0
- solokit/templates/saas_t3/tier-4-production/next.config.ts +46 -0
- solokit/templates/saas_t3/tier-4-production/package.json.tier4.template +83 -0
- solokit/templates/saas_t3/tier-4-production/sentry.client.config.ts +26 -0
- solokit/templates/saas_t3/tier-4-production/sentry.edge.config.ts +11 -0
- solokit/templates/saas_t3/tier-4-production/sentry.server.config.ts +11 -0
- solokit/templates/saas_t3/tier-4-production/vercel.json +37 -0
- solokit/templates/security_spec.md +287 -0
- solokit/templates/stack-versions.yaml +617 -0
- solokit/templates/status_update.json +6 -0
- solokit/templates/template-registry.json +257 -0
- solokit/templates/work_items.json +11 -0
- solokit/testing/__init__.py +1 -0
- solokit/testing/integration_runner.py +550 -0
- solokit/testing/performance.py +637 -0
- solokit/visualization/__init__.py +1 -0
- solokit/visualization/dependency_graph.py +788 -0
- solokit/work_items/__init__.py +1 -0
- solokit/work_items/creator.py +217 -0
- solokit/work_items/delete.py +264 -0
- solokit/work_items/get_dependencies.py +185 -0
- solokit/work_items/get_dependents.py +113 -0
- solokit/work_items/get_metadata.py +121 -0
- solokit/work_items/get_next_recommendations.py +133 -0
- solokit/work_items/manager.py +235 -0
- solokit/work_items/milestones.py +137 -0
- solokit/work_items/query.py +376 -0
- solokit/work_items/repository.py +267 -0
- solokit/work_items/scheduler.py +184 -0
- solokit/work_items/spec_parser.py +838 -0
- solokit/work_items/spec_validator.py +493 -0
- solokit/work_items/updater.py +157 -0
- solokit/work_items/validator.py +205 -0
- solokit-0.1.1.dist-info/METADATA +640 -0
- solokit-0.1.1.dist-info/RECORD +323 -0
- solokit-0.1.1.dist-info/WHEEL +5 -0
- solokit-0.1.1.dist-info/entry_points.txt +2 -0
- solokit-0.1.1.dist-info/licenses/LICENSE +21 -0
- solokit-0.1.1.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,788 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Dependency graph visualization for work items
|
|
4
|
+
|
|
5
|
+
Generates visual dependency graphs with critical path analysis and work item timeline projection.
|
|
6
|
+
Supports DOT format, SVG, and ASCII art output.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import argparse
|
|
12
|
+
import json
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
from solokit.core.command_runner import CommandRunner
|
|
17
|
+
from solokit.core.constants import DEPENDENCY_GRAPH_TIMEOUT
|
|
18
|
+
from solokit.core.error_handlers import convert_file_errors, log_errors
|
|
19
|
+
from solokit.core.exceptions import (
|
|
20
|
+
CircularDependencyError,
|
|
21
|
+
CommandExecutionError,
|
|
22
|
+
FileOperationError,
|
|
23
|
+
ValidationError,
|
|
24
|
+
)
|
|
25
|
+
from solokit.core.logging_config import get_logger
|
|
26
|
+
from solokit.core.output import get_output
|
|
27
|
+
from solokit.core.types import WorkItemStatus
|
|
28
|
+
|
|
29
|
+
logger = get_logger(__name__)
|
|
30
|
+
output = get_output()
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class DependencyGraphVisualizer:
|
|
34
|
+
"""Visualizes work item dependency graphs"""
|
|
35
|
+
|
|
36
|
+
def __init__(self, work_items_file: Path | None = None):
|
|
37
|
+
"""
|
|
38
|
+
Initialize visualizer.
|
|
39
|
+
|
|
40
|
+
Args:
|
|
41
|
+
work_items_file: Path to work_items.json (default: .session/tracking/work_items.json)
|
|
42
|
+
"""
|
|
43
|
+
if work_items_file is None:
|
|
44
|
+
work_items_file = Path(".session/tracking/work_items.json")
|
|
45
|
+
self.work_items_file = work_items_file
|
|
46
|
+
self.runner = CommandRunner(default_timeout=DEPENDENCY_GRAPH_TIMEOUT)
|
|
47
|
+
|
|
48
|
+
@convert_file_errors
|
|
49
|
+
@log_errors()
|
|
50
|
+
def load_work_items(
|
|
51
|
+
self,
|
|
52
|
+
status_filter: str | None = None,
|
|
53
|
+
milestone_filter: str | None = None,
|
|
54
|
+
type_filter: str | None = None,
|
|
55
|
+
include_completed: bool = False,
|
|
56
|
+
) -> list[dict]:
|
|
57
|
+
"""Load and filter work items from JSON file.
|
|
58
|
+
|
|
59
|
+
Args:
|
|
60
|
+
status_filter: Filter by status (not_started, in_progress, completed, blocked)
|
|
61
|
+
milestone_filter: Filter by milestone name
|
|
62
|
+
type_filter: Filter by work item type
|
|
63
|
+
include_completed: Include completed items (default: False)
|
|
64
|
+
|
|
65
|
+
Returns:
|
|
66
|
+
List of filtered work items
|
|
67
|
+
|
|
68
|
+
Raises:
|
|
69
|
+
FileNotFoundError: If work_items_file doesn't exist
|
|
70
|
+
FileOperationError: If JSON parsing fails
|
|
71
|
+
ValidationError: If work items data structure is invalid
|
|
72
|
+
"""
|
|
73
|
+
if not self.work_items_file.exists():
|
|
74
|
+
return []
|
|
75
|
+
|
|
76
|
+
try:
|
|
77
|
+
with open(self.work_items_file) as f:
|
|
78
|
+
data = json.load(f)
|
|
79
|
+
except json.JSONDecodeError as e:
|
|
80
|
+
raise FileOperationError(
|
|
81
|
+
operation="parse",
|
|
82
|
+
file_path=str(self.work_items_file),
|
|
83
|
+
details=f"Invalid JSON: {e}",
|
|
84
|
+
cause=e,
|
|
85
|
+
) from e
|
|
86
|
+
|
|
87
|
+
if not isinstance(data, dict):
|
|
88
|
+
raise ValidationError(
|
|
89
|
+
message="Work items file must contain a JSON object",
|
|
90
|
+
context={"file_path": str(self.work_items_file)},
|
|
91
|
+
remediation="Ensure the JSON file contains a top-level object with 'work_items' key",
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
work_items = list(data.get("work_items", {}).values())
|
|
95
|
+
|
|
96
|
+
# Apply filters
|
|
97
|
+
if not include_completed:
|
|
98
|
+
work_items = [
|
|
99
|
+
wi for wi in work_items if wi.get("status") != WorkItemStatus.COMPLETED.value
|
|
100
|
+
]
|
|
101
|
+
|
|
102
|
+
if status_filter:
|
|
103
|
+
work_items = [wi for wi in work_items if wi.get("status") == status_filter]
|
|
104
|
+
|
|
105
|
+
if milestone_filter:
|
|
106
|
+
work_items = [wi for wi in work_items if wi.get("milestone") == milestone_filter]
|
|
107
|
+
|
|
108
|
+
if type_filter:
|
|
109
|
+
work_items = [wi for wi in work_items if wi.get("type") == type_filter]
|
|
110
|
+
|
|
111
|
+
return work_items
|
|
112
|
+
|
|
113
|
+
@log_errors()
|
|
114
|
+
def generate_dot(self, work_items: list[dict]) -> str:
|
|
115
|
+
"""Generate DOT format graph
|
|
116
|
+
|
|
117
|
+
Args:
|
|
118
|
+
work_items: List of work items to include in graph
|
|
119
|
+
|
|
120
|
+
Returns:
|
|
121
|
+
DOT format string
|
|
122
|
+
|
|
123
|
+
Raises:
|
|
124
|
+
ValidationError: If work items have invalid structure
|
|
125
|
+
CircularDependencyError: If circular dependencies detected
|
|
126
|
+
"""
|
|
127
|
+
# Validate work items structure
|
|
128
|
+
for item in work_items:
|
|
129
|
+
if not isinstance(item, dict):
|
|
130
|
+
raise ValidationError(
|
|
131
|
+
message="Work item must be a dictionary",
|
|
132
|
+
context={"item": str(item)},
|
|
133
|
+
)
|
|
134
|
+
if "id" not in item:
|
|
135
|
+
raise ValidationError(
|
|
136
|
+
message="Work item missing required 'id' field",
|
|
137
|
+
context={"item": str(item)},
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
# Calculate critical path
|
|
141
|
+
critical_items = self._calculate_critical_path(work_items)
|
|
142
|
+
|
|
143
|
+
# Start DOT graph
|
|
144
|
+
lines = [
|
|
145
|
+
"digraph WorkItems {",
|
|
146
|
+
" rankdir=TB;",
|
|
147
|
+
" node [shape=box, style=rounded];",
|
|
148
|
+
"",
|
|
149
|
+
]
|
|
150
|
+
|
|
151
|
+
# Add nodes
|
|
152
|
+
for item in work_items:
|
|
153
|
+
# Determine node styling based on status and critical path
|
|
154
|
+
color = self._get_node_color(item, critical_items)
|
|
155
|
+
style = self._get_node_style(item)
|
|
156
|
+
|
|
157
|
+
label = self._format_node_label(item)
|
|
158
|
+
|
|
159
|
+
lines.append(f' "{item["id"]}" [label="{label}", color="{color}", style="{style}"];')
|
|
160
|
+
|
|
161
|
+
lines.append("")
|
|
162
|
+
|
|
163
|
+
# Add edges
|
|
164
|
+
for item in work_items:
|
|
165
|
+
for dep_id in item.get("dependencies", []):
|
|
166
|
+
# Check if dependency exists in filtered items
|
|
167
|
+
if any(wi["id"] == dep_id for wi in work_items):
|
|
168
|
+
edge_style = (
|
|
169
|
+
"bold, color=red"
|
|
170
|
+
if item["id"] in critical_items and dep_id in critical_items
|
|
171
|
+
else ""
|
|
172
|
+
)
|
|
173
|
+
if edge_style:
|
|
174
|
+
lines.append(f' "{dep_id}" -> "{item["id"]}" [{edge_style}];')
|
|
175
|
+
else:
|
|
176
|
+
lines.append(f' "{dep_id}" -> "{item["id"]}";')
|
|
177
|
+
|
|
178
|
+
lines.append("}")
|
|
179
|
+
return "\n".join(lines)
|
|
180
|
+
|
|
181
|
+
@log_errors()
|
|
182
|
+
def generate_ascii(self, work_items: list[dict]) -> str:
|
|
183
|
+
"""Generate ASCII art graph
|
|
184
|
+
|
|
185
|
+
Args:
|
|
186
|
+
work_items: List of work items to include in graph
|
|
187
|
+
|
|
188
|
+
Returns:
|
|
189
|
+
ASCII art string
|
|
190
|
+
|
|
191
|
+
Raises:
|
|
192
|
+
ValidationError: If work items have invalid structure
|
|
193
|
+
CircularDependencyError: If circular dependencies detected
|
|
194
|
+
"""
|
|
195
|
+
# Validate work items structure
|
|
196
|
+
for item in work_items:
|
|
197
|
+
if not isinstance(item, dict):
|
|
198
|
+
raise ValidationError(
|
|
199
|
+
message="Work item must be a dictionary",
|
|
200
|
+
context={"item": str(item)},
|
|
201
|
+
)
|
|
202
|
+
if "id" not in item:
|
|
203
|
+
raise ValidationError(
|
|
204
|
+
message="Work item missing required 'id' field",
|
|
205
|
+
context={"item": str(item)},
|
|
206
|
+
)
|
|
207
|
+
|
|
208
|
+
# Calculate critical path
|
|
209
|
+
critical_items = self._calculate_critical_path(work_items)
|
|
210
|
+
|
|
211
|
+
# Build dependency tree
|
|
212
|
+
lines = ["Work Item Dependency Graph", "=" * 50, ""]
|
|
213
|
+
|
|
214
|
+
# Group items by dependency level
|
|
215
|
+
levels = self._group_by_dependency_level(work_items)
|
|
216
|
+
|
|
217
|
+
for level_num, level_items in enumerate(levels):
|
|
218
|
+
lines.append(f"Level {level_num}:")
|
|
219
|
+
for item in level_items:
|
|
220
|
+
status_icon = self._get_status_icon(item)
|
|
221
|
+
critical_marker = " [CRITICAL PATH]" if item["id"] in critical_items else ""
|
|
222
|
+
lines.append(f" {status_icon} {item['id']}: {item['title']}{critical_marker}")
|
|
223
|
+
|
|
224
|
+
# Show dependencies
|
|
225
|
+
if item.get("dependencies"):
|
|
226
|
+
for dep_id in item["dependencies"]:
|
|
227
|
+
lines.append(f" └─ depends on: {dep_id}")
|
|
228
|
+
|
|
229
|
+
lines.append("")
|
|
230
|
+
|
|
231
|
+
# Add timeline projection
|
|
232
|
+
timeline = self._generate_timeline_projection(work_items)
|
|
233
|
+
if timeline:
|
|
234
|
+
lines.append("Timeline Projection:")
|
|
235
|
+
lines.append("-" * 50)
|
|
236
|
+
lines.extend(timeline)
|
|
237
|
+
|
|
238
|
+
return "\n".join(lines)
|
|
239
|
+
|
|
240
|
+
@log_errors()
|
|
241
|
+
def generate_svg(self, dot_content: str, output_file: Path) -> None:
|
|
242
|
+
"""Generate SVG from DOT using Graphviz.
|
|
243
|
+
|
|
244
|
+
Args:
|
|
245
|
+
dot_content: DOT format string
|
|
246
|
+
output_file: Path to save SVG file
|
|
247
|
+
|
|
248
|
+
Raises:
|
|
249
|
+
CommandExecutionError: If Graphviz dot command fails
|
|
250
|
+
FileOperationError: If temporary file operations fail
|
|
251
|
+
ValidationError: If dot_content is empty or invalid
|
|
252
|
+
"""
|
|
253
|
+
if not dot_content or not dot_content.strip():
|
|
254
|
+
raise ValidationError(
|
|
255
|
+
message="DOT content cannot be empty",
|
|
256
|
+
remediation="Provide valid DOT format graph data",
|
|
257
|
+
)
|
|
258
|
+
|
|
259
|
+
# Use stdin input via temporary file approach since CommandRunner doesn't support stdin input directly
|
|
260
|
+
import tempfile
|
|
261
|
+
|
|
262
|
+
temp_file = None
|
|
263
|
+
try:
|
|
264
|
+
with tempfile.NamedTemporaryFile(mode="w", suffix=".dot", delete=False) as f:
|
|
265
|
+
f.write(dot_content)
|
|
266
|
+
temp_file = f.name
|
|
267
|
+
except OSError as e:
|
|
268
|
+
raise FileOperationError(
|
|
269
|
+
operation="write",
|
|
270
|
+
file_path=temp_file or "temporary file",
|
|
271
|
+
details=f"Failed to create temporary DOT file: {e}",
|
|
272
|
+
cause=e,
|
|
273
|
+
) from e
|
|
274
|
+
|
|
275
|
+
try:
|
|
276
|
+
result = self.runner.run(["dot", "-Tsvg", temp_file, "-o", str(output_file)])
|
|
277
|
+
|
|
278
|
+
if not result.success:
|
|
279
|
+
raise CommandExecutionError(
|
|
280
|
+
command=f"dot -Tsvg {temp_file} -o {output_file}",
|
|
281
|
+
returncode=result.returncode,
|
|
282
|
+
stderr=result.stderr,
|
|
283
|
+
stdout=result.stdout,
|
|
284
|
+
)
|
|
285
|
+
finally:
|
|
286
|
+
# Clean up temp file
|
|
287
|
+
if temp_file:
|
|
288
|
+
try:
|
|
289
|
+
Path(temp_file).unlink()
|
|
290
|
+
except OSError:
|
|
291
|
+
pass # Ignore cleanup errors
|
|
292
|
+
|
|
293
|
+
@log_errors()
|
|
294
|
+
def get_bottlenecks(self, work_items: list[dict]) -> list[dict]:
|
|
295
|
+
"""Identify bottleneck work items (items that block many others).
|
|
296
|
+
|
|
297
|
+
Args:
|
|
298
|
+
work_items: List of work items to analyze
|
|
299
|
+
|
|
300
|
+
Returns:
|
|
301
|
+
List of bottleneck info dicts with id, blocks count, and item details
|
|
302
|
+
|
|
303
|
+
Raises:
|
|
304
|
+
ValidationError: If work items have invalid structure
|
|
305
|
+
"""
|
|
306
|
+
# Count how many items each work item blocks
|
|
307
|
+
blocking_count = {}
|
|
308
|
+
for wi in work_items:
|
|
309
|
+
blocking_count[wi["id"]] = 0
|
|
310
|
+
|
|
311
|
+
for wi in work_items:
|
|
312
|
+
for dep_id in wi.get("dependencies", []):
|
|
313
|
+
if dep_id in blocking_count:
|
|
314
|
+
blocking_count[dep_id] += 1
|
|
315
|
+
|
|
316
|
+
# Return items that block 2+ other items
|
|
317
|
+
bottlenecks = [
|
|
318
|
+
{
|
|
319
|
+
"id": wid,
|
|
320
|
+
"blocks": count,
|
|
321
|
+
"item": next(wi for wi in work_items if wi["id"] == wid),
|
|
322
|
+
}
|
|
323
|
+
for wid, count in blocking_count.items()
|
|
324
|
+
if count >= 2
|
|
325
|
+
]
|
|
326
|
+
|
|
327
|
+
return sorted(bottlenecks, key=lambda x: x["blocks"], reverse=True)
|
|
328
|
+
|
|
329
|
+
@log_errors()
|
|
330
|
+
def get_neighborhood(self, work_items: list[dict], focus_id: str) -> list[dict]:
|
|
331
|
+
"""Get work items in neighborhood of focus item (dependencies and dependents).
|
|
332
|
+
|
|
333
|
+
Args:
|
|
334
|
+
work_items: List of work items
|
|
335
|
+
focus_id: ID of focus work item
|
|
336
|
+
|
|
337
|
+
Returns:
|
|
338
|
+
List of work items in neighborhood (empty list if focus item not found)
|
|
339
|
+
|
|
340
|
+
Raises:
|
|
341
|
+
ValidationError: If focus_id is empty or work items have invalid structure
|
|
342
|
+
"""
|
|
343
|
+
if not focus_id or not focus_id.strip():
|
|
344
|
+
raise ValidationError(
|
|
345
|
+
message="Focus item ID cannot be empty",
|
|
346
|
+
remediation="Provide a valid work item ID",
|
|
347
|
+
)
|
|
348
|
+
|
|
349
|
+
# Find focus item
|
|
350
|
+
focus_item = next((wi for wi in work_items if wi["id"] == focus_id), None)
|
|
351
|
+
if not focus_item:
|
|
352
|
+
return []
|
|
353
|
+
|
|
354
|
+
# Get all dependencies (recursive)
|
|
355
|
+
neighborhood_ids = {focus_id}
|
|
356
|
+
to_check = set(focus_item.get("dependencies", []))
|
|
357
|
+
|
|
358
|
+
while to_check:
|
|
359
|
+
dep_id = to_check.pop()
|
|
360
|
+
if dep_id not in neighborhood_ids:
|
|
361
|
+
neighborhood_ids.add(dep_id)
|
|
362
|
+
dep_item = next((wi for wi in work_items if wi["id"] == dep_id), None)
|
|
363
|
+
if dep_item:
|
|
364
|
+
to_check.update(dep_item.get("dependencies", []))
|
|
365
|
+
|
|
366
|
+
# Get all dependents (items that depend on any item in neighborhood)
|
|
367
|
+
for wi in work_items:
|
|
368
|
+
if any(dep_id in neighborhood_ids for dep_id in wi.get("dependencies", [])):
|
|
369
|
+
neighborhood_ids.add(wi["id"])
|
|
370
|
+
|
|
371
|
+
return [wi for wi in work_items if wi["id"] in neighborhood_ids]
|
|
372
|
+
|
|
373
|
+
@log_errors()
|
|
374
|
+
def generate_stats(self, work_items: list[dict], critical_path: set[str]) -> dict:
|
|
375
|
+
"""Generate graph statistics.
|
|
376
|
+
|
|
377
|
+
Args:
|
|
378
|
+
work_items: List of work items
|
|
379
|
+
critical_path: Set of work item IDs on critical path
|
|
380
|
+
|
|
381
|
+
Returns:
|
|
382
|
+
Dictionary with statistics
|
|
383
|
+
|
|
384
|
+
Raises:
|
|
385
|
+
ValidationError: If work items have invalid structure
|
|
386
|
+
"""
|
|
387
|
+
total = len(work_items)
|
|
388
|
+
completed = len(
|
|
389
|
+
[wi for wi in work_items if wi.get("status") == WorkItemStatus.COMPLETED.value]
|
|
390
|
+
)
|
|
391
|
+
in_progress = len(
|
|
392
|
+
[wi for wi in work_items if wi.get("status") == WorkItemStatus.IN_PROGRESS.value]
|
|
393
|
+
)
|
|
394
|
+
not_started = len(
|
|
395
|
+
[wi for wi in work_items if wi.get("status") == WorkItemStatus.NOT_STARTED.value]
|
|
396
|
+
)
|
|
397
|
+
|
|
398
|
+
return {
|
|
399
|
+
"total_items": total,
|
|
400
|
+
"completed": completed,
|
|
401
|
+
"in_progress": in_progress,
|
|
402
|
+
"not_started": not_started,
|
|
403
|
+
"completion_pct": round(completed / total * 100, 1) if total > 0 else 0,
|
|
404
|
+
"critical_path_length": len(critical_path),
|
|
405
|
+
"critical_items": list(critical_path),
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
@log_errors()
|
|
409
|
+
def _calculate_critical_path(self, work_items: list[dict]) -> set[str]:
|
|
410
|
+
"""Calculate critical path through work items
|
|
411
|
+
|
|
412
|
+
The critical path is the longest chain of dependencies.
|
|
413
|
+
|
|
414
|
+
Args:
|
|
415
|
+
work_items: List of work items to analyze
|
|
416
|
+
|
|
417
|
+
Returns:
|
|
418
|
+
Set of work item IDs on the critical path
|
|
419
|
+
|
|
420
|
+
Raises:
|
|
421
|
+
CircularDependencyError: If circular dependencies detected (strict mode)
|
|
422
|
+
ValidationError: If work items have invalid structure
|
|
423
|
+
"""
|
|
424
|
+
# Build dependency graph
|
|
425
|
+
item_dict = {item["id"]: item for item in work_items}
|
|
426
|
+
|
|
427
|
+
# Calculate depth for each item
|
|
428
|
+
depths: dict[str, int] = {}
|
|
429
|
+
|
|
430
|
+
def calculate_depth(item_id: str, visited: set[str], path: list[str]) -> int:
|
|
431
|
+
if item_id in depths:
|
|
432
|
+
return depths[item_id]
|
|
433
|
+
|
|
434
|
+
if item_id in visited:
|
|
435
|
+
# Circular dependency detected
|
|
436
|
+
# For now, return 0 to handle gracefully (existing behavior)
|
|
437
|
+
# In strict mode, we could:
|
|
438
|
+
# cycle = path[path.index(item_id):] + [item_id]
|
|
439
|
+
# raise CircularDependencyError(cycle)
|
|
440
|
+
return 0
|
|
441
|
+
|
|
442
|
+
if item_id not in item_dict:
|
|
443
|
+
return 0
|
|
444
|
+
|
|
445
|
+
item = item_dict[item_id]
|
|
446
|
+
if not item.get("dependencies"):
|
|
447
|
+
depths[item_id] = 0
|
|
448
|
+
return 0
|
|
449
|
+
|
|
450
|
+
visited.add(item_id)
|
|
451
|
+
path.append(item_id)
|
|
452
|
+
max_depth = 0
|
|
453
|
+
|
|
454
|
+
for dep_id in item.get("dependencies", []):
|
|
455
|
+
dep_depth = calculate_depth(dep_id, visited.copy(), path.copy())
|
|
456
|
+
max_depth = max(max_depth, dep_depth + 1)
|
|
457
|
+
|
|
458
|
+
depths[item_id] = max_depth
|
|
459
|
+
return max_depth
|
|
460
|
+
|
|
461
|
+
# Calculate depths for all items
|
|
462
|
+
for item in work_items:
|
|
463
|
+
calculate_depth(item["id"], set(), [])
|
|
464
|
+
|
|
465
|
+
# Find maximum depth
|
|
466
|
+
if not depths:
|
|
467
|
+
return set()
|
|
468
|
+
|
|
469
|
+
max_depth = max(depths.values())
|
|
470
|
+
|
|
471
|
+
# Trace critical path
|
|
472
|
+
critical_items = set()
|
|
473
|
+
|
|
474
|
+
def trace_critical_path(item_id: str, current_depth: int) -> None:
|
|
475
|
+
if current_depth == 0:
|
|
476
|
+
critical_items.add(item_id)
|
|
477
|
+
return
|
|
478
|
+
|
|
479
|
+
if item_id not in item_dict:
|
|
480
|
+
return
|
|
481
|
+
|
|
482
|
+
item = item_dict[item_id]
|
|
483
|
+
critical_items.add(item_id)
|
|
484
|
+
|
|
485
|
+
for dep_id in item.get("dependencies", []):
|
|
486
|
+
if dep_id in depths and depths[dep_id] == current_depth - 1:
|
|
487
|
+
trace_critical_path(dep_id, current_depth - 1)
|
|
488
|
+
|
|
489
|
+
# Find items at max depth
|
|
490
|
+
for item_id, depth in depths.items():
|
|
491
|
+
if depth == max_depth:
|
|
492
|
+
trace_critical_path(item_id, max_depth)
|
|
493
|
+
|
|
494
|
+
return critical_items
|
|
495
|
+
|
|
496
|
+
def _get_node_color(self, item: dict, critical_items: set[str]) -> str:
|
|
497
|
+
"""Get node color based on status and critical path"""
|
|
498
|
+
if item["id"] in critical_items:
|
|
499
|
+
return "red"
|
|
500
|
+
elif item.get("status") == WorkItemStatus.COMPLETED.value:
|
|
501
|
+
return "green"
|
|
502
|
+
elif item.get("status") == WorkItemStatus.IN_PROGRESS.value:
|
|
503
|
+
return "blue"
|
|
504
|
+
elif item.get("status") == WorkItemStatus.BLOCKED.value:
|
|
505
|
+
return "orange"
|
|
506
|
+
else:
|
|
507
|
+
return "black"
|
|
508
|
+
|
|
509
|
+
def _get_node_style(self, item: dict) -> str:
|
|
510
|
+
"""Get node style based on status"""
|
|
511
|
+
if item.get("status") == WorkItemStatus.COMPLETED.value:
|
|
512
|
+
return "rounded,filled"
|
|
513
|
+
elif item.get("status") == WorkItemStatus.IN_PROGRESS.value:
|
|
514
|
+
return "rounded,bold"
|
|
515
|
+
else:
|
|
516
|
+
return "rounded"
|
|
517
|
+
|
|
518
|
+
def _format_node_label(self, item: dict) -> str:
|
|
519
|
+
"""Format node label with work item details"""
|
|
520
|
+
# Escape special characters for DOT
|
|
521
|
+
title = item["title"].replace('"', '\\"')
|
|
522
|
+
|
|
523
|
+
# Truncate long titles
|
|
524
|
+
if len(title) > 30:
|
|
525
|
+
title = title[:27] + "..."
|
|
526
|
+
|
|
527
|
+
status = item.get("status", WorkItemStatus.NOT_STARTED.value)
|
|
528
|
+
return f"{item['id']}\\n{title}\\n[{status}]"
|
|
529
|
+
|
|
530
|
+
def _get_status_icon(self, item: dict[str, Any]) -> str:
|
|
531
|
+
"""Get ASCII icon for work item status"""
|
|
532
|
+
icons: dict[str, str] = {
|
|
533
|
+
WorkItemStatus.NOT_STARTED.value: "○",
|
|
534
|
+
WorkItemStatus.IN_PROGRESS.value: "◐",
|
|
535
|
+
WorkItemStatus.COMPLETED.value: "●",
|
|
536
|
+
WorkItemStatus.BLOCKED.value: "✗",
|
|
537
|
+
}
|
|
538
|
+
status = item.get("status")
|
|
539
|
+
return icons.get(str(status) if status is not None else "", "○")
|
|
540
|
+
|
|
541
|
+
def _group_by_dependency_level(self, work_items: list[dict]) -> list[list[dict]]:
|
|
542
|
+
"""Group work items by dependency level
|
|
543
|
+
|
|
544
|
+
Level 0 = no dependencies
|
|
545
|
+
Level 1 = depends only on level 0
|
|
546
|
+
etc.
|
|
547
|
+
"""
|
|
548
|
+
item_dict = {item["id"]: item for item in work_items}
|
|
549
|
+
levels: list[list[dict]] = []
|
|
550
|
+
assigned = set()
|
|
551
|
+
|
|
552
|
+
def get_item_level(item: dict) -> int:
|
|
553
|
+
if not item.get("dependencies"):
|
|
554
|
+
return 0
|
|
555
|
+
|
|
556
|
+
max_dep_level = -1
|
|
557
|
+
for dep_id in item.get("dependencies", []):
|
|
558
|
+
if dep_id in item_dict:
|
|
559
|
+
dep_item = item_dict[dep_id]
|
|
560
|
+
dep_level = get_item_level(dep_item)
|
|
561
|
+
max_dep_level = max(max_dep_level, dep_level)
|
|
562
|
+
|
|
563
|
+
return max_dep_level + 1
|
|
564
|
+
|
|
565
|
+
# Assign items to levels
|
|
566
|
+
for item in work_items:
|
|
567
|
+
level = get_item_level(item)
|
|
568
|
+
|
|
569
|
+
# Ensure we have enough levels
|
|
570
|
+
while len(levels) <= level:
|
|
571
|
+
levels.append([])
|
|
572
|
+
|
|
573
|
+
levels[level].append(item)
|
|
574
|
+
assigned.add(item["id"])
|
|
575
|
+
|
|
576
|
+
return levels
|
|
577
|
+
|
|
578
|
+
def _generate_timeline_projection(self, work_items: list[dict]) -> list[str]:
|
|
579
|
+
"""Generate timeline projection based on work items
|
|
580
|
+
|
|
581
|
+
Note: This is a simplified projection assuming each item takes 1 time unit.
|
|
582
|
+
In practice, you'd use time_estimate from metadata.
|
|
583
|
+
"""
|
|
584
|
+
lines = []
|
|
585
|
+
|
|
586
|
+
# Group by dependency level
|
|
587
|
+
levels = self._group_by_dependency_level(work_items)
|
|
588
|
+
|
|
589
|
+
total_time = 0
|
|
590
|
+
for level_num, level_items in enumerate(levels):
|
|
591
|
+
# Assume each level can be done in parallel
|
|
592
|
+
# Time for this level is the max time of any item in the level
|
|
593
|
+
# For now, assume each item takes 1 unit
|
|
594
|
+
level_time = 1 if level_items else 0
|
|
595
|
+
|
|
596
|
+
if level_items:
|
|
597
|
+
completed_count = sum(
|
|
598
|
+
1
|
|
599
|
+
for item in level_items
|
|
600
|
+
if item.get("status") == WorkItemStatus.COMPLETED.value
|
|
601
|
+
)
|
|
602
|
+
in_progress_count = sum(
|
|
603
|
+
1
|
|
604
|
+
for item in level_items
|
|
605
|
+
if item.get("status") == WorkItemStatus.IN_PROGRESS.value
|
|
606
|
+
)
|
|
607
|
+
not_started_count = sum(
|
|
608
|
+
1
|
|
609
|
+
for item in level_items
|
|
610
|
+
if item.get("status") == WorkItemStatus.NOT_STARTED.value
|
|
611
|
+
)
|
|
612
|
+
|
|
613
|
+
lines.append(
|
|
614
|
+
f" Level {level_num}: {len(level_items)} items "
|
|
615
|
+
f"(✓{completed_count} ◐{in_progress_count} ○{not_started_count})"
|
|
616
|
+
)
|
|
617
|
+
|
|
618
|
+
total_time += level_time
|
|
619
|
+
|
|
620
|
+
lines.append("")
|
|
621
|
+
lines.append(f"Estimated remaining levels: {len(levels)}")
|
|
622
|
+
lines.append("Note: Timeline assumes items can be completed in parallel within each level")
|
|
623
|
+
|
|
624
|
+
return lines
|
|
625
|
+
|
|
626
|
+
|
|
627
|
+
def main() -> int:
|
|
628
|
+
"""CLI entry point for graph generation."""
|
|
629
|
+
parser = argparse.ArgumentParser(description="Generate work item dependency graphs")
|
|
630
|
+
|
|
631
|
+
# Output format
|
|
632
|
+
parser.add_argument(
|
|
633
|
+
"--format",
|
|
634
|
+
choices=["ascii", "dot", "svg"],
|
|
635
|
+
default="ascii",
|
|
636
|
+
help="Output format (default: ascii)",
|
|
637
|
+
)
|
|
638
|
+
parser.add_argument("--output", help="Output file (for dot/svg formats)")
|
|
639
|
+
|
|
640
|
+
# Filters
|
|
641
|
+
parser.add_argument(
|
|
642
|
+
"--status",
|
|
643
|
+
choices=WorkItemStatus.values(),
|
|
644
|
+
help="Filter by status",
|
|
645
|
+
)
|
|
646
|
+
parser.add_argument("--milestone", help="Filter by milestone")
|
|
647
|
+
parser.add_argument("--type", help="Filter by work item type")
|
|
648
|
+
parser.add_argument(
|
|
649
|
+
"--include-completed",
|
|
650
|
+
action="store_true",
|
|
651
|
+
help="Include completed items (default: hide)",
|
|
652
|
+
)
|
|
653
|
+
|
|
654
|
+
# Special views
|
|
655
|
+
parser.add_argument("--critical-path", action="store_true", help="Show only critical path")
|
|
656
|
+
parser.add_argument("--bottlenecks", action="store_true", help="Show bottleneck analysis")
|
|
657
|
+
parser.add_argument("--stats", action="store_true", help="Show graph statistics")
|
|
658
|
+
parser.add_argument("--focus", help="Focus on neighborhood of specific work item")
|
|
659
|
+
|
|
660
|
+
# Work items file
|
|
661
|
+
parser.add_argument(
|
|
662
|
+
"--work-items-file",
|
|
663
|
+
type=Path,
|
|
664
|
+
help="Path to work_items.json (default: .session/tracking/work_items.json)",
|
|
665
|
+
)
|
|
666
|
+
|
|
667
|
+
args = parser.parse_args()
|
|
668
|
+
|
|
669
|
+
# Initialize visualizer
|
|
670
|
+
viz = DependencyGraphVisualizer(args.work_items_file)
|
|
671
|
+
|
|
672
|
+
# Load work items with filters
|
|
673
|
+
work_items = viz.load_work_items(
|
|
674
|
+
status_filter=args.status,
|
|
675
|
+
milestone_filter=args.milestone,
|
|
676
|
+
type_filter=args.type,
|
|
677
|
+
include_completed=args.include_completed,
|
|
678
|
+
)
|
|
679
|
+
|
|
680
|
+
if not work_items:
|
|
681
|
+
output.error("No work items found matching criteria.")
|
|
682
|
+
return 1
|
|
683
|
+
|
|
684
|
+
# Apply special filters
|
|
685
|
+
if args.focus:
|
|
686
|
+
try:
|
|
687
|
+
work_items = viz.get_neighborhood(work_items, args.focus)
|
|
688
|
+
if not work_items:
|
|
689
|
+
output.error(f"Work item '{args.focus}' not found.")
|
|
690
|
+
return 1
|
|
691
|
+
except ValidationError as e:
|
|
692
|
+
output.error(f"Error: {e.message}")
|
|
693
|
+
if e.remediation:
|
|
694
|
+
output.error(f"Remediation: {e.remediation}")
|
|
695
|
+
return e.exit_code
|
|
696
|
+
|
|
697
|
+
critical_path = viz._calculate_critical_path(work_items)
|
|
698
|
+
|
|
699
|
+
if args.critical_path:
|
|
700
|
+
work_items = [wi for wi in work_items if wi["id"] in critical_path]
|
|
701
|
+
|
|
702
|
+
# Handle special views
|
|
703
|
+
if args.stats:
|
|
704
|
+
stats = viz.generate_stats(work_items, critical_path)
|
|
705
|
+
output.info("Graph Statistics:")
|
|
706
|
+
output.info("=" * 50)
|
|
707
|
+
output.info(f"Total work items: {stats['total_items']}")
|
|
708
|
+
output.info(f"Completed: {stats['completed']} ({stats['completion_pct']}%)")
|
|
709
|
+
output.info(f"In progress: {stats['in_progress']}")
|
|
710
|
+
output.info(f"Not started: {stats['not_started']}")
|
|
711
|
+
output.info(f"Critical path length: {stats['critical_path_length']}")
|
|
712
|
+
if stats["critical_items"]:
|
|
713
|
+
output.info(f"Critical items: {', '.join(stats['critical_items'])}")
|
|
714
|
+
return 0
|
|
715
|
+
|
|
716
|
+
if args.bottlenecks:
|
|
717
|
+
bottlenecks = viz.get_bottlenecks(work_items)
|
|
718
|
+
output.info("Bottleneck Analysis:")
|
|
719
|
+
output.info("=" * 50)
|
|
720
|
+
if bottlenecks:
|
|
721
|
+
for bn in bottlenecks:
|
|
722
|
+
item = bn["item"]
|
|
723
|
+
output.info(
|
|
724
|
+
f"{bn['id']} - {item.get('title', 'N/A')} (blocks {bn['blocks']} items)"
|
|
725
|
+
)
|
|
726
|
+
else:
|
|
727
|
+
output.info("No bottlenecks found (no items block 2+ other items).")
|
|
728
|
+
return 0
|
|
729
|
+
|
|
730
|
+
# Generate graph
|
|
731
|
+
try:
|
|
732
|
+
if args.format == "ascii":
|
|
733
|
+
graph_output = viz.generate_ascii(work_items)
|
|
734
|
+
output.info(graph_output)
|
|
735
|
+
|
|
736
|
+
elif args.format == "dot":
|
|
737
|
+
graph_output = viz.generate_dot(work_items)
|
|
738
|
+
if args.output:
|
|
739
|
+
try:
|
|
740
|
+
Path(args.output).write_text(graph_output)
|
|
741
|
+
output.info(f"DOT graph saved to {args.output}")
|
|
742
|
+
except OSError as e:
|
|
743
|
+
from solokit.core.exceptions import FileOperationError
|
|
744
|
+
|
|
745
|
+
raise FileOperationError(
|
|
746
|
+
operation="write",
|
|
747
|
+
file_path=args.output,
|
|
748
|
+
details=str(e),
|
|
749
|
+
cause=e,
|
|
750
|
+
) from e
|
|
751
|
+
else:
|
|
752
|
+
output.info(graph_output)
|
|
753
|
+
|
|
754
|
+
elif args.format == "svg":
|
|
755
|
+
dot_output = viz.generate_dot(work_items)
|
|
756
|
+
output_file = Path(args.output) if args.output else Path("dependency_graph.svg")
|
|
757
|
+
viz.generate_svg(dot_output, output_file)
|
|
758
|
+
output.info(f"SVG graph saved to {output_file}")
|
|
759
|
+
|
|
760
|
+
except ValidationError as e:
|
|
761
|
+
output.error(f"Validation Error: {e.message}")
|
|
762
|
+
if e.remediation:
|
|
763
|
+
output.error(f"Remediation: {e.remediation}")
|
|
764
|
+
return e.exit_code
|
|
765
|
+
except CommandExecutionError as e:
|
|
766
|
+
output.error(f"Command Error: {e.message}")
|
|
767
|
+
if e.context.get("stderr"):
|
|
768
|
+
output.error(f"Details: {e.context['stderr']}")
|
|
769
|
+
output.error(
|
|
770
|
+
"Hint: Ensure Graphviz is installed (apt-get install graphviz / brew install graphviz)"
|
|
771
|
+
)
|
|
772
|
+
return e.exit_code
|
|
773
|
+
except FileOperationError as e:
|
|
774
|
+
output.error(f"File Error: {e.message}")
|
|
775
|
+
if e.remediation:
|
|
776
|
+
output.error(f"Remediation: {e.remediation}")
|
|
777
|
+
return e.exit_code
|
|
778
|
+
except CircularDependencyError as e:
|
|
779
|
+
output.error(f"Dependency Error: {e.message}")
|
|
780
|
+
if e.remediation:
|
|
781
|
+
output.error(f"Remediation: {e.remediation}")
|
|
782
|
+
return e.exit_code
|
|
783
|
+
|
|
784
|
+
return 0
|
|
785
|
+
|
|
786
|
+
|
|
787
|
+
if __name__ == "__main__":
|
|
788
|
+
exit(main())
|