@pennyfarthing/core 10.0.3 → 10.1.0
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.
- package/README.md +9 -7
- package/package.json +7 -1
- package/packages/core/dist/cli/commands/cyclist.d.ts +5 -1
- package/packages/core/dist/cli/commands/cyclist.d.ts.map +1 -1
- package/packages/core/dist/cli/commands/cyclist.js +4 -4
- package/packages/core/dist/cli/commands/cyclist.js.map +1 -1
- package/packages/core/dist/cli/commands/cyclist.test.js +2 -2
- package/packages/core/dist/cli/commands/cyclist.test.js.map +1 -1
- package/packages/core/dist/cli/commands/doctor-legacy.test.js +17 -16
- package/packages/core/dist/cli/commands/doctor-legacy.test.js.map +1 -1
- package/packages/core/dist/cli/commands/doctor.d.ts.map +1 -1
- package/packages/core/dist/cli/commands/doctor.js +251 -4
- package/packages/core/dist/cli/commands/doctor.js.map +1 -1
- package/packages/core/dist/cli/commands/init.d.ts +7 -0
- package/packages/core/dist/cli/commands/init.d.ts.map +1 -1
- package/packages/core/dist/cli/commands/init.js +43 -7
- package/packages/core/dist/cli/commands/init.js.map +1 -1
- package/packages/core/dist/cli/commands/update.d.ts.map +1 -1
- package/packages/core/dist/cli/commands/update.js +26 -0
- package/packages/core/dist/cli/commands/update.js.map +1 -1
- package/packages/core/dist/cli/index.js +1 -1
- package/packages/core/dist/cli/index.js.map +1 -1
- package/packages/core/dist/cli/ocean-profiles.test.js +1 -1
- package/packages/core/dist/cli/ocean-profiles.test.js.map +1 -1
- package/packages/core/dist/cli/utils/files.d.ts +10 -0
- package/packages/core/dist/cli/utils/files.d.ts.map +1 -1
- package/packages/core/dist/cli/utils/files.js +35 -0
- package/packages/core/dist/cli/utils/files.js.map +1 -1
- package/packages/core/dist/cli/utils/python.d.ts +22 -0
- package/packages/core/dist/cli/utils/python.d.ts.map +1 -0
- package/packages/core/dist/cli/utils/python.js +102 -0
- package/packages/core/dist/cli/utils/python.js.map +1 -0
- package/packages/core/dist/cli/utils/settings.d.ts.map +1 -1
- package/packages/core/dist/cli/utils/settings.js +10 -0
- package/packages/core/dist/cli/utils/settings.js.map +1 -1
- package/packages/core/dist/scripts/generate-report.d.ts.map +1 -1
- package/packages/core/dist/scripts/generate-report.js +11 -7
- package/packages/core/dist/scripts/generate-report.js.map +1 -1
- package/packages/core/dist/scripts/generate-spider-report.d.ts.map +1 -1
- package/packages/core/dist/scripts/generate-spider-report.js +12 -8
- package/packages/core/dist/scripts/generate-spider-report.js.map +1 -1
- package/packages/core/dist/scripts/generate-spider.d.ts.map +1 -1
- package/packages/core/dist/scripts/generate-spider.js +6 -4
- package/packages/core/dist/scripts/generate-spider.js.map +1 -1
- package/packages/core/dist/scripts/generate-spider.test.js +2 -2
- package/packages/core/dist/scripts/generate-spider.test.js.map +1 -1
- package/pennyfarthing-dist/agents/README.md +1 -3
- package/pennyfarthing-dist/agents/architect.md +0 -6
- package/pennyfarthing-dist/agents/devops.md +0 -6
- package/pennyfarthing-dist/agents/orchestrator.md +0 -6
- package/pennyfarthing-dist/agents/pm.md +1 -7
- package/pennyfarthing-dist/agents/sm-finish.md +1 -1
- package/pennyfarthing-dist/agents/sm-setup.md +2 -2
- package/pennyfarthing-dist/agents/sm.md +4 -11
- package/pennyfarthing-dist/commands/architect.md +11 -3
- package/pennyfarthing-dist/commands/close-epic.md +24 -131
- package/pennyfarthing-dist/commands/create-theme.md +14 -24
- package/pennyfarthing-dist/commands/dev.md +11 -3
- package/pennyfarthing-dist/commands/devops.md +11 -3
- package/pennyfarthing-dist/commands/health-check.md +1 -3
- package/pennyfarthing-dist/commands/help.md +8 -12
- package/pennyfarthing-dist/commands/list-themes.md +14 -16
- package/pennyfarthing-dist/commands/orchestrator.md +11 -3
- package/pennyfarthing-dist/commands/parallel-work.md +1 -3
- package/pennyfarthing-dist/commands/pm.md +11 -3
- package/pennyfarthing-dist/commands/prime.md +6 -6
- package/pennyfarthing-dist/commands/repo-status.md +2 -2
- package/pennyfarthing-dist/commands/reviewer.md +11 -3
- package/pennyfarthing-dist/commands/run-ci.md +1 -1
- package/pennyfarthing-dist/commands/set-theme.md +14 -51
- package/pennyfarthing-dist/commands/setup.md +1 -1
- package/pennyfarthing-dist/commands/show-theme.md +14 -16
- package/pennyfarthing-dist/commands/sm.md +11 -3
- package/pennyfarthing-dist/commands/sprint.md +8 -8
- package/pennyfarthing-dist/commands/tea.md +11 -3
- package/pennyfarthing-dist/commands/tech-writer.md +11 -3
- package/pennyfarthing-dist/commands/theme-maker.md +14 -671
- package/pennyfarthing-dist/commands/theme.md +95 -0
- package/pennyfarthing-dist/commands/ux-designer.md +11 -3
- package/pennyfarthing-dist/commands/work.md +3 -5
- package/pennyfarthing-dist/guides/agent-coordination.md +11 -13
- package/pennyfarthing-dist/guides/agent-template-tactical.md +2 -3
- package/pennyfarthing-dist/guides/command-tag-taxonomy.md +212 -0
- package/pennyfarthing-dist/guides/hooks.md +5 -5
- package/pennyfarthing-dist/guides/patterns/fan-out-fan-in-pattern.md +3 -3
- package/pennyfarthing-dist/guides/patterns/helper-delegation-pattern.md +9 -59
- package/pennyfarthing-dist/guides/patterns/tdd-flow-pattern.md +4 -5
- package/pennyfarthing-dist/guides/prime.md +2 -2
- package/pennyfarthing-dist/guides/skill-schema.md +25 -26
- package/pennyfarthing-dist/guides/xml-tags.md +2 -2
- package/pennyfarthing-dist/scripts/README.md +2 -2
- package/pennyfarthing-dist/scripts/core/agent-session.sh +6 -2
- package/pennyfarthing-dist/scripts/core/prime.sh +8 -10
- package/pennyfarthing-dist/scripts/git/git-status-all.sh +1 -1
- package/pennyfarthing-dist/scripts/git/install-git-hooks.sh +8 -6
- package/pennyfarthing-dist/scripts/git/worktree-manager.sh +3 -3
- package/pennyfarthing-dist/scripts/hooks/post-merge.sh +14 -12
- package/pennyfarthing-dist/scripts/hooks/pre-commit.sh +4 -3
- package/pennyfarthing-dist/scripts/hooks/pre-push.sh +11 -5
- package/pennyfarthing-dist/scripts/hooks/sprint-yaml-validation.sh +1 -1
- package/pennyfarthing-dist/scripts/misc/README.md +1 -1
- package/pennyfarthing-dist/scripts/misc/repo-utils.sh +3 -3
- package/pennyfarthing-dist/scripts/misc/validate-subagent-frontmatter.sh +1 -2
- package/pennyfarthing-dist/scripts/sprint/README.md +32 -17
- package/pennyfarthing-dist/scripts/story/README.md +1 -1
- package/pennyfarthing-dist/scripts/test/test-setup.sh +1 -1
- package/pennyfarthing-dist/scripts/tests/handoff-phase-update.test.sh +5 -5
- package/pennyfarthing-dist/scripts/tests/test-drift-detection.sh +3 -79
- package/pennyfarthing-dist/scripts/theme/README.md +1 -1
- package/pennyfarthing-dist/scripts/validation/validate-agent-schema.sh +0 -1
- package/pennyfarthing-dist/scripts/workflow/finish-story.sh +62 -17
- package/pennyfarthing-dist/skills/dev-patterns/SKILL.md +2 -2
- package/pennyfarthing-dist/skills/skill-registry.yaml +41 -28
- package/pennyfarthing-dist/skills/sprint/skill.md +386 -68
- package/pennyfarthing-dist/skills/story/skill.md +14 -206
- package/pennyfarthing-dist/skills/theme/skill.md +290 -75
- package/pennyfarthing-dist/skills/theme-creation/SKILL.md +23 -166
- package/pennyfarthing-dist/skills/workflow/skill.md +4 -4
- package/pennyfarthing-dist/templates/agent-scopes.yaml.template +0 -11
- package/pennyfarthing-dist/templates/auto-load-sm.sh.template +14 -0
- package/pennyfarthing-dist/templates/settings.local.json.template +9 -0
- package/pennyfarthing-dist/workflows/2party-tdd.yaml +399 -0
- package/pennyfarthing-dist/workflows/epics-and-stories/steps/step-05-import-to-future.md +42 -25
- package/pennyfarthing-dist/workflows/git-cleanup.yaml +1 -1
- package/pennyfarthing-dist/workflows/project-setup/steps/step-10-complete.md +1 -1
- package/pennyfarthing_scripts/__pycache__/cli.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/__pycache__/hooks.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/__pycache__/schema_validation_hook.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/__pycache__/workflow.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/cli.py +15 -0
- package/pennyfarthing_scripts/codemarkers/__init__.py +19 -0
- package/pennyfarthing_scripts/codemarkers/__main__.py +6 -0
- package/pennyfarthing_scripts/codemarkers/__pycache__/__init__.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/codemarkers/__pycache__/__main__.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/codemarkers/__pycache__/analyze.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/codemarkers/__pycache__/cli.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/codemarkers/__pycache__/formatters.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/codemarkers/__pycache__/models.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/codemarkers/analyze.py +326 -0
- package/pennyfarthing_scripts/codemarkers/cli.py +129 -0
- package/pennyfarthing_scripts/codemarkers/formatters.py +89 -0
- package/pennyfarthing_scripts/codemarkers/models.py +45 -0
- package/pennyfarthing_scripts/common/__pycache__/config.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/common/__pycache__/themes.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/common/config.py +2 -1
- package/pennyfarthing_scripts/complexity/__init__.py +15 -0
- package/pennyfarthing_scripts/complexity/__main__.py +6 -0
- package/pennyfarthing_scripts/complexity/__pycache__/__init__.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/complexity/__pycache__/__main__.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/complexity/__pycache__/analyze.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/complexity/__pycache__/cli.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/complexity/__pycache__/formatters.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/complexity/__pycache__/models.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/complexity/analyze.py +207 -0
- package/pennyfarthing_scripts/complexity/cli.py +78 -0
- package/pennyfarthing_scripts/complexity/formatters.py +64 -0
- package/pennyfarthing_scripts/complexity/models.py +32 -0
- package/pennyfarthing_scripts/deadcode/__init__.py +6 -0
- package/pennyfarthing_scripts/deadcode/__main__.py +6 -0
- package/pennyfarthing_scripts/deadcode/__pycache__/__init__.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/deadcode/__pycache__/__main__.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/deadcode/__pycache__/analyze.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/deadcode/__pycache__/cli.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/deadcode/__pycache__/formatters.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/deadcode/__pycache__/models.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/deadcode/analyze.py +323 -0
- package/pennyfarthing_scripts/deadcode/cli.py +163 -0
- package/pennyfarthing_scripts/deadcode/formatters.py +106 -0
- package/pennyfarthing_scripts/deadcode/models.py +54 -0
- package/pennyfarthing_scripts/dependencies/__init__.py +20 -0
- package/pennyfarthing_scripts/dependencies/__main__.py +5 -0
- package/pennyfarthing_scripts/dependencies/__pycache__/__init__.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/dependencies/__pycache__/__main__.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/dependencies/__pycache__/analyze.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/dependencies/__pycache__/cli.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/dependencies/__pycache__/formatters.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/dependencies/__pycache__/models.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/dependencies/analyze.py +155 -0
- package/pennyfarthing_scripts/dependencies/cli.py +72 -0
- package/pennyfarthing_scripts/dependencies/formatters.py +63 -0
- package/pennyfarthing_scripts/dependencies/models.py +39 -0
- package/pennyfarthing_scripts/healthscore/__init__.py +21 -0
- package/pennyfarthing_scripts/healthscore/__main__.py +6 -0
- package/pennyfarthing_scripts/healthscore/__pycache__/__init__.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/healthscore/__pycache__/__main__.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/healthscore/__pycache__/analyze.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/healthscore/__pycache__/cli.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/healthscore/__pycache__/formatters.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/healthscore/__pycache__/models.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/healthscore/analyze.py +161 -0
- package/pennyfarthing_scripts/healthscore/cli.py +76 -0
- package/pennyfarthing_scripts/healthscore/formatters.py +46 -0
- package/pennyfarthing_scripts/healthscore/models.py +44 -0
- package/pennyfarthing_scripts/hotspots/__pycache__/__init__.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/hotspots/__pycache__/__main__.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/hotspots/__pycache__/analyze.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/hotspots/__pycache__/cli.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/hotspots/__pycache__/formatters.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/hotspots/__pycache__/models.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/hotspots/analyze.py +28 -1
- package/pennyfarthing_scripts/hotspots/cli.py +11 -9
- package/pennyfarthing_scripts/jira/__pycache__/__init__.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/jira/__pycache__/bidirectional.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/jira/__pycache__/cli.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/jira/__pycache__/client.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/jira/__pycache__/create.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/jira/__pycache__/operations.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/jira/__pycache__/reconcile.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/jira/bidirectional.py +42 -15
- package/pennyfarthing_scripts/jira/cli.py +78 -1
- package/pennyfarthing_scripts/jira/client.py +28 -0
- package/pennyfarthing_scripts/prime/__pycache__/cli.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/prime/__pycache__/models.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/prime/__pycache__/persona.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/prime/__pycache__/tiers.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/prime/__pycache__/workflow.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/prime/workflow.py +5 -3
- package/pennyfarthing_scripts/sprint/__pycache__/archive.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/sprint/__pycache__/archive_epic.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/sprint/__pycache__/cli.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/sprint/__pycache__/epic_add.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/sprint/__pycache__/loader.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/sprint/__pycache__/story_add.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/sprint/__pycache__/story_finish.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/sprint/__pycache__/story_update.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/sprint/__pycache__/validate_cmd.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/sprint/__pycache__/validator.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/sprint/__pycache__/work.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/sprint/__pycache__/yaml_io.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/sprint/archive.py +63 -6
- package/pennyfarthing_scripts/sprint/archive_epic.py +198 -85
- package/pennyfarthing_scripts/sprint/cli.py +1565 -65
- package/pennyfarthing_scripts/sprint/epic_add.py +173 -0
- package/pennyfarthing_scripts/sprint/loader.py +46 -2
- package/pennyfarthing_scripts/sprint/story_add.py +202 -27
- package/pennyfarthing_scripts/sprint/story_finish.py +211 -0
- package/pennyfarthing_scripts/sprint/validate_cmd.py +44 -5
- package/pennyfarthing_scripts/sprint/validator.py +13 -3
- package/pennyfarthing_scripts/sprint/work.py +43 -3
- package/pennyfarthing_scripts/sprint/yaml_io.py +124 -15
- package/pennyfarthing_scripts/tests/__pycache__/test_codemarkers.cpython-314-pytest-9.0.2.pyc +0 -0
- package/pennyfarthing_scripts/tests/__pycache__/test_healthscore.cpython-314-pytest-9.0.2.pyc +0 -0
- package/pennyfarthing_scripts/tests/__pycache__/test_sprint_package.cpython-314-pytest-9.0.2.pyc +0 -0
- package/pennyfarthing_scripts/tests/__pycache__/test_sprint_validator.cpython-314-pytest-9.0.2.pyc +0 -0
- package/pennyfarthing_scripts/tests/__pycache__/test_story_add.cpython-314-pytest-9.0.2.pyc +0 -0
- package/pennyfarthing_scripts/tests/__pycache__/test_story_update.cpython-314-pytest-9.0.2.pyc +0 -0
- package/pennyfarthing_scripts/tests/__pycache__/test_validate_cmd.cpython-314-pytest-9.0.2.pyc +0 -0
- package/pennyfarthing_scripts/tests/__pycache__/test_yaml_io.cpython-314-pytest-9.0.2.pyc +0 -0
- package/pennyfarthing_scripts/tests/test_codemarkers.py +682 -0
- package/pennyfarthing_scripts/tests/test_healthscore.py +524 -0
- package/pennyfarthing_scripts/tests/test_sprint_package.py +166 -0
- package/pennyfarthing_scripts/tests/test_yaml_io.py +117 -0
- package/pennyfarthing_scripts/theme/__init__.py +5 -0
- package/pennyfarthing_scripts/theme/__main__.py +6 -0
- package/pennyfarthing_scripts/theme/__pycache__/__init__.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/theme/__pycache__/cli.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/theme/cli.py +286 -0
- package/scripts/README.md +53 -0
- package/scripts/postinstall.cjs +34 -0
- package/pennyfarthing-dist/agents/workflow-status-check.md +0 -96
- package/pennyfarthing-dist/scripts/sprint/archive-story.sh +0 -133
- package/pennyfarthing-dist/scripts/sprint/available-stories.sh +0 -91
- package/pennyfarthing-dist/scripts/sprint/check-story.sh +0 -158
- package/pennyfarthing-dist/scripts/sprint/get-epic-field.sh +0 -52
- package/pennyfarthing-dist/scripts/sprint/get-story-field.sh +0 -63
- package/pennyfarthing-dist/scripts/sprint/list-future.sh +0 -145
- package/pennyfarthing-dist/scripts/sprint/new-sprint.sh +0 -110
- package/pennyfarthing-dist/scripts/sprint/promote-epic.sh +0 -148
- package/pennyfarthing-dist/scripts/sprint/sprint-common.sh +0 -415
- package/pennyfarthing-dist/scripts/sprint/sprint-info.sh +0 -33
- package/pennyfarthing-dist/scripts/sprint/sprint-metrics.sh +0 -230
- package/pennyfarthing-dist/scripts/sprint/sprint-status.sh +0 -134
- package/pennyfarthing-dist/scripts/sprint/validate-sprint-yaml.sh +0 -139
- package/pennyfarthing-dist/skills/sprint/scripts/archive-story.sh +0 -101
- package/pennyfarthing-dist/skills/sprint/scripts/available-stories.sh +0 -97
- package/pennyfarthing-dist/skills/sprint/scripts/check-story.sh +0 -164
- package/pennyfarthing-dist/skills/sprint/scripts/create-jira-epic.sh +0 -23
- package/pennyfarthing-dist/skills/sprint/scripts/new-sprint.sh +0 -116
- package/pennyfarthing-dist/skills/sprint/scripts/promote-epic.sh +0 -164
- package/pennyfarthing-dist/skills/sprint/scripts/sprint-info.sh +0 -39
- package/pennyfarthing-dist/skills/sprint/scripts/sprint-status.sh +0 -147
- package/pennyfarthing-dist/skills/sprint/scripts/sync-epic-jira.sh +0 -23
|
@@ -0,0 +1,524 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Tests for the healthscore module.
|
|
3
|
+
|
|
4
|
+
Covers all acceptance criteria for MSSCI-14470:
|
|
5
|
+
AC1: Module structure (cli.py, models.py, analyze.py, formatters.py)
|
|
6
|
+
AC2: Weighted scoring algorithm with 8 configurable dimensions
|
|
7
|
+
AC3: Each dimension 0-100, composite is weighted average 0-100
|
|
8
|
+
AC4: CLI command: pf healthscore analyze [--format table|json|csv] [--path DIR]
|
|
9
|
+
AC5: Result caching within 5-minute window
|
|
10
|
+
AC6: Cache stored in .pennyfarthing/.cache/healthscore/
|
|
11
|
+
AC7: ADR-0008 result pattern
|
|
12
|
+
AC8: Registered in main CLI
|
|
13
|
+
AC9: Tests cover scoring algorithm, caching, and CLI output
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import asyncio
|
|
19
|
+
import json
|
|
20
|
+
import time
|
|
21
|
+
from dataclasses import asdict
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
from unittest.mock import patch, MagicMock
|
|
24
|
+
|
|
25
|
+
import pytest
|
|
26
|
+
from click.testing import CliRunner
|
|
27
|
+
|
|
28
|
+
from pennyfarthing_scripts.healthscore.models import (
|
|
29
|
+
DimensionScore,
|
|
30
|
+
HealthscoreResult,
|
|
31
|
+
DEFAULT_WEIGHTS,
|
|
32
|
+
)
|
|
33
|
+
from pennyfarthing_scripts.healthscore.analyze import (
|
|
34
|
+
analyze_healthscore,
|
|
35
|
+
compute_composite_score,
|
|
36
|
+
get_cache_path,
|
|
37
|
+
read_cached_score,
|
|
38
|
+
write_cached_score,
|
|
39
|
+
)
|
|
40
|
+
from pennyfarthing_scripts.healthscore.formatters import (
|
|
41
|
+
format_table,
|
|
42
|
+
export_json,
|
|
43
|
+
export_csv,
|
|
44
|
+
)
|
|
45
|
+
from pennyfarthing_scripts.healthscore.cli import healthscore
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
# ---------------------------------------------------------------------------
|
|
49
|
+
# AC1: Module structure
|
|
50
|
+
# ---------------------------------------------------------------------------
|
|
51
|
+
|
|
52
|
+
class TestModuleStructure:
|
|
53
|
+
"""AC1: New module at pennyfarthing_scripts/healthscore/ with standard files."""
|
|
54
|
+
|
|
55
|
+
def test_package_has_init(self):
|
|
56
|
+
"""Module must be importable as a package."""
|
|
57
|
+
import pennyfarthing_scripts.healthscore
|
|
58
|
+
assert hasattr(pennyfarthing_scripts.healthscore, "HealthscoreResult")
|
|
59
|
+
assert hasattr(pennyfarthing_scripts.healthscore, "DimensionScore")
|
|
60
|
+
assert hasattr(pennyfarthing_scripts.healthscore, "DEFAULT_WEIGHTS")
|
|
61
|
+
assert hasattr(pennyfarthing_scripts.healthscore, "analyze_healthscore")
|
|
62
|
+
|
|
63
|
+
def test_models_module_exists(self):
|
|
64
|
+
"""models.py must define DimensionScore and HealthscoreResult."""
|
|
65
|
+
from pennyfarthing_scripts.healthscore import models
|
|
66
|
+
assert hasattr(models, "DimensionScore")
|
|
67
|
+
assert hasattr(models, "HealthscoreResult")
|
|
68
|
+
|
|
69
|
+
def test_analyze_module_exists(self):
|
|
70
|
+
"""analyze.py must define analyze_healthscore."""
|
|
71
|
+
from pennyfarthing_scripts.healthscore import analyze
|
|
72
|
+
assert hasattr(analyze, "analyze_healthscore")
|
|
73
|
+
|
|
74
|
+
def test_cli_module_exists(self):
|
|
75
|
+
"""cli.py must define the click group."""
|
|
76
|
+
from pennyfarthing_scripts.healthscore import cli
|
|
77
|
+
assert hasattr(cli, "healthscore")
|
|
78
|
+
|
|
79
|
+
def test_formatters_module_exists(self):
|
|
80
|
+
"""formatters.py must define table/json/csv formatters."""
|
|
81
|
+
from pennyfarthing_scripts.healthscore import formatters
|
|
82
|
+
assert hasattr(formatters, "format_table")
|
|
83
|
+
assert hasattr(formatters, "export_json")
|
|
84
|
+
assert hasattr(formatters, "export_csv")
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
# ---------------------------------------------------------------------------
|
|
88
|
+
# AC2: Weighted scoring algorithm with 8 configurable dimensions
|
|
89
|
+
# ---------------------------------------------------------------------------
|
|
90
|
+
|
|
91
|
+
class TestWeightedScoring:
|
|
92
|
+
"""AC2: 8 dimensions with configurable weights."""
|
|
93
|
+
|
|
94
|
+
def test_default_weights_has_8_dimensions(self):
|
|
95
|
+
"""Must define exactly 8 dimensions."""
|
|
96
|
+
assert len(DEFAULT_WEIGHTS) == 8
|
|
97
|
+
|
|
98
|
+
def test_default_weights_sum_to_one(self):
|
|
99
|
+
"""Default weights must sum to 1.0."""
|
|
100
|
+
total = sum(DEFAULT_WEIGHTS.values())
|
|
101
|
+
assert abs(total - 1.0) < 1e-9, f"Weights sum to {total}, expected 1.0"
|
|
102
|
+
|
|
103
|
+
def test_default_weight_keys(self):
|
|
104
|
+
"""Must include all 8 named dimensions."""
|
|
105
|
+
expected = {
|
|
106
|
+
"churn", "todo_density", "complexity", "test_gaps",
|
|
107
|
+
"dead_code", "deprecation_debt", "dependency_freshness",
|
|
108
|
+
"agent_context_efficiency",
|
|
109
|
+
}
|
|
110
|
+
assert set(DEFAULT_WEIGHTS.keys()) == expected
|
|
111
|
+
|
|
112
|
+
def test_default_weight_values(self):
|
|
113
|
+
"""Default weights must match spec."""
|
|
114
|
+
assert DEFAULT_WEIGHTS["churn"] == 0.15
|
|
115
|
+
assert DEFAULT_WEIGHTS["todo_density"] == 0.15
|
|
116
|
+
assert DEFAULT_WEIGHTS["complexity"] == 0.15
|
|
117
|
+
assert DEFAULT_WEIGHTS["test_gaps"] == 0.15
|
|
118
|
+
assert DEFAULT_WEIGHTS["dead_code"] == 0.10
|
|
119
|
+
assert DEFAULT_WEIGHTS["deprecation_debt"] == 0.10
|
|
120
|
+
assert DEFAULT_WEIGHTS["dependency_freshness"] == 0.10
|
|
121
|
+
assert DEFAULT_WEIGHTS["agent_context_efficiency"] == 0.10
|
|
122
|
+
|
|
123
|
+
def test_custom_weights_override_defaults(self):
|
|
124
|
+
"""compute_composite_score must accept custom weights."""
|
|
125
|
+
custom = {k: 1.0 / 8 for k in DEFAULT_WEIGHTS}
|
|
126
|
+
scores = {k: 80.0 for k in DEFAULT_WEIGHTS}
|
|
127
|
+
result = compute_composite_score(scores, custom)
|
|
128
|
+
assert abs(result - 80.0) < 1e-9
|
|
129
|
+
|
|
130
|
+
def test_unequal_custom_weights(self):
|
|
131
|
+
"""Asymmetric weights should shift composite score."""
|
|
132
|
+
weights = {k: 0.0 for k in DEFAULT_WEIGHTS}
|
|
133
|
+
weights["churn"] = 1.0 # All weight on churn
|
|
134
|
+
|
|
135
|
+
scores = {k: 50.0 for k in DEFAULT_WEIGHTS}
|
|
136
|
+
scores["churn"] = 100.0
|
|
137
|
+
|
|
138
|
+
result = compute_composite_score(scores, weights)
|
|
139
|
+
assert abs(result - 100.0) < 1e-9
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
# ---------------------------------------------------------------------------
|
|
143
|
+
# AC3: Each dimension 0-100, composite is weighted average 0-100
|
|
144
|
+
# ---------------------------------------------------------------------------
|
|
145
|
+
|
|
146
|
+
class TestScoreRanges:
|
|
147
|
+
"""AC3: Dimension scores 0-100, composite 0-100."""
|
|
148
|
+
|
|
149
|
+
def test_all_zeros_yields_zero(self):
|
|
150
|
+
"""All dimensions at 0 → composite 0."""
|
|
151
|
+
scores = {k: 0.0 for k in DEFAULT_WEIGHTS}
|
|
152
|
+
result = compute_composite_score(scores, DEFAULT_WEIGHTS)
|
|
153
|
+
assert result == 0.0
|
|
154
|
+
|
|
155
|
+
def test_all_hundreds_yields_hundred(self):
|
|
156
|
+
"""All dimensions at 100 → composite 100."""
|
|
157
|
+
scores = {k: 100.0 for k in DEFAULT_WEIGHTS}
|
|
158
|
+
result = compute_composite_score(scores, DEFAULT_WEIGHTS)
|
|
159
|
+
assert abs(result - 100.0) < 1e-9
|
|
160
|
+
|
|
161
|
+
def test_mixed_scores_weighted_average(self):
|
|
162
|
+
"""Mixed scores should produce correct weighted average."""
|
|
163
|
+
scores = {
|
|
164
|
+
"churn": 80.0,
|
|
165
|
+
"todo_density": 60.0,
|
|
166
|
+
"complexity": 70.0,
|
|
167
|
+
"test_gaps": 50.0,
|
|
168
|
+
"dead_code": 90.0,
|
|
169
|
+
"deprecation_debt": 40.0,
|
|
170
|
+
"dependency_freshness": 85.0,
|
|
171
|
+
"agent_context_efficiency": 75.0,
|
|
172
|
+
}
|
|
173
|
+
expected = sum(scores[k] * DEFAULT_WEIGHTS[k] for k in DEFAULT_WEIGHTS)
|
|
174
|
+
result = compute_composite_score(scores, DEFAULT_WEIGHTS)
|
|
175
|
+
assert abs(result - expected) < 1e-9
|
|
176
|
+
|
|
177
|
+
def test_none_dimensions_excluded_and_renormalized(self):
|
|
178
|
+
"""Unavailable dimensions (None) are excluded; remaining weights renormalize."""
|
|
179
|
+
scores = {k: None for k in DEFAULT_WEIGHTS}
|
|
180
|
+
scores["churn"] = 80.0
|
|
181
|
+
scores["complexity"] = 60.0
|
|
182
|
+
# Only churn (0.15) and complexity (0.15) available → renorm to 0.5 each
|
|
183
|
+
expected = (80.0 * 0.5) + (60.0 * 0.5)
|
|
184
|
+
result = compute_composite_score(scores, DEFAULT_WEIGHTS)
|
|
185
|
+
assert abs(result - expected) < 1e-9
|
|
186
|
+
|
|
187
|
+
def test_all_none_dimensions_returns_zero(self):
|
|
188
|
+
"""All dimensions unavailable → composite 0."""
|
|
189
|
+
scores = {k: None for k in DEFAULT_WEIGHTS}
|
|
190
|
+
result = compute_composite_score(scores, DEFAULT_WEIGHTS)
|
|
191
|
+
assert result == 0.0
|
|
192
|
+
|
|
193
|
+
def test_composite_score_in_result_object(self):
|
|
194
|
+
"""HealthscoreResult.composite_score should reflect computed value."""
|
|
195
|
+
result = HealthscoreResult(
|
|
196
|
+
success=True,
|
|
197
|
+
composite_score=73.5,
|
|
198
|
+
target_path="/tmp/project",
|
|
199
|
+
dimensions=[
|
|
200
|
+
DimensionScore(name="churn", score=80.0, weight=0.15),
|
|
201
|
+
DimensionScore(name="complexity", score=67.0, weight=0.15),
|
|
202
|
+
],
|
|
203
|
+
)
|
|
204
|
+
assert 0.0 <= result.composite_score <= 100.0
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
# ---------------------------------------------------------------------------
|
|
208
|
+
# AC4: CLI command
|
|
209
|
+
# ---------------------------------------------------------------------------
|
|
210
|
+
|
|
211
|
+
class TestCLI:
|
|
212
|
+
"""AC4: pf healthscore analyze [--format table|json|csv] [--path DIR]."""
|
|
213
|
+
|
|
214
|
+
def test_help_shows_analyze_command(self):
|
|
215
|
+
"""CLI group must expose the analyze subcommand."""
|
|
216
|
+
runner = CliRunner()
|
|
217
|
+
result = runner.invoke(healthscore, ["--help"])
|
|
218
|
+
assert result.exit_code == 0
|
|
219
|
+
assert "analyze" in result.output
|
|
220
|
+
|
|
221
|
+
def test_analyze_help_shows_options(self):
|
|
222
|
+
"""analyze command must show --format, --path, --output, --no-cache."""
|
|
223
|
+
runner = CliRunner()
|
|
224
|
+
result = runner.invoke(healthscore, ["analyze", "--help"])
|
|
225
|
+
assert result.exit_code == 0
|
|
226
|
+
assert "--format" in result.output
|
|
227
|
+
assert "--path" in result.output
|
|
228
|
+
assert "--output" in result.output
|
|
229
|
+
assert "--no-cache" in result.output
|
|
230
|
+
|
|
231
|
+
def test_analyze_format_choices(self):
|
|
232
|
+
"""--format must accept table, json, csv."""
|
|
233
|
+
runner = CliRunner()
|
|
234
|
+
result = runner.invoke(healthscore, ["analyze", "--help"])
|
|
235
|
+
assert "table" in result.output
|
|
236
|
+
assert "json" in result.output
|
|
237
|
+
assert "csv" in result.output
|
|
238
|
+
|
|
239
|
+
def test_analyze_json_output_is_valid_json(self):
|
|
240
|
+
"""--format json must produce parseable JSON."""
|
|
241
|
+
mock_result = HealthscoreResult(
|
|
242
|
+
success=True,
|
|
243
|
+
composite_score=72.5,
|
|
244
|
+
target_path="/tmp/project",
|
|
245
|
+
dimensions=[
|
|
246
|
+
DimensionScore(name="churn", score=80.0, weight=0.15),
|
|
247
|
+
],
|
|
248
|
+
)
|
|
249
|
+
with patch(
|
|
250
|
+
"pennyfarthing_scripts.healthscore.cli._run_analysis",
|
|
251
|
+
return_value=mock_result,
|
|
252
|
+
):
|
|
253
|
+
runner = CliRunner()
|
|
254
|
+
result = runner.invoke(healthscore, ["analyze", "--format", "json"])
|
|
255
|
+
assert result.exit_code == 0
|
|
256
|
+
data = json.loads(result.output)
|
|
257
|
+
assert data["success"] is True
|
|
258
|
+
assert data["composite_score"] == 72.5
|
|
259
|
+
|
|
260
|
+
def test_analyze_table_output_has_score_header(self):
|
|
261
|
+
"""Table output must include score and dimension labels."""
|
|
262
|
+
mock_result = HealthscoreResult(
|
|
263
|
+
success=True,
|
|
264
|
+
composite_score=72.5,
|
|
265
|
+
target_path="/tmp/project",
|
|
266
|
+
dimensions=[
|
|
267
|
+
DimensionScore(name="churn", score=80.0, weight=0.15),
|
|
268
|
+
DimensionScore(name="complexity", score=65.0, weight=0.15),
|
|
269
|
+
],
|
|
270
|
+
)
|
|
271
|
+
with patch(
|
|
272
|
+
"pennyfarthing_scripts.healthscore.cli._run_analysis",
|
|
273
|
+
return_value=mock_result,
|
|
274
|
+
):
|
|
275
|
+
runner = CliRunner()
|
|
276
|
+
result = runner.invoke(healthscore, ["analyze", "--format", "table"])
|
|
277
|
+
assert result.exit_code == 0
|
|
278
|
+
assert "churn" in result.output.lower() or "Churn" in result.output
|
|
279
|
+
|
|
280
|
+
def test_analyze_csv_output_has_header_row(self):
|
|
281
|
+
"""CSV output must include header row with dimension names."""
|
|
282
|
+
mock_result = HealthscoreResult(
|
|
283
|
+
success=True,
|
|
284
|
+
composite_score=72.5,
|
|
285
|
+
target_path="/tmp/project",
|
|
286
|
+
dimensions=[
|
|
287
|
+
DimensionScore(name="churn", score=80.0, weight=0.15),
|
|
288
|
+
],
|
|
289
|
+
)
|
|
290
|
+
with patch(
|
|
291
|
+
"pennyfarthing_scripts.healthscore.cli._run_analysis",
|
|
292
|
+
return_value=mock_result,
|
|
293
|
+
):
|
|
294
|
+
runner = CliRunner()
|
|
295
|
+
result = runner.invoke(healthscore, ["analyze", "--format", "csv"])
|
|
296
|
+
assert result.exit_code == 0
|
|
297
|
+
lines = result.output.strip().split("\n")
|
|
298
|
+
assert len(lines) >= 2 # header + at least one data row
|
|
299
|
+
|
|
300
|
+
def test_analyze_output_to_file(self, tmp_path):
|
|
301
|
+
"""--output must write result to file."""
|
|
302
|
+
mock_result = HealthscoreResult(
|
|
303
|
+
success=True,
|
|
304
|
+
composite_score=72.5,
|
|
305
|
+
target_path="/tmp/project",
|
|
306
|
+
dimensions=[],
|
|
307
|
+
)
|
|
308
|
+
output_file = tmp_path / "result.json"
|
|
309
|
+
with patch(
|
|
310
|
+
"pennyfarthing_scripts.healthscore.cli._run_analysis",
|
|
311
|
+
return_value=mock_result,
|
|
312
|
+
):
|
|
313
|
+
runner = CliRunner()
|
|
314
|
+
result = runner.invoke(healthscore, [
|
|
315
|
+
"analyze", "--format", "json", "--output", str(output_file)
|
|
316
|
+
])
|
|
317
|
+
assert result.exit_code == 0
|
|
318
|
+
assert output_file.exists()
|
|
319
|
+
data = json.loads(output_file.read_text())
|
|
320
|
+
assert data["success"] is True
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
# ---------------------------------------------------------------------------
|
|
324
|
+
# AC5: Result caching within 5-minute window
|
|
325
|
+
# ---------------------------------------------------------------------------
|
|
326
|
+
|
|
327
|
+
class TestCaching:
|
|
328
|
+
"""AC5: Component scores cached, reused within 5-minute window."""
|
|
329
|
+
|
|
330
|
+
def test_write_then_read_cached_score(self, tmp_path):
|
|
331
|
+
"""Written score must be readable back."""
|
|
332
|
+
write_cached_score(tmp_path, "churn", 85.0)
|
|
333
|
+
result = read_cached_score(tmp_path, "churn", ttl=300)
|
|
334
|
+
assert result == 85.0
|
|
335
|
+
|
|
336
|
+
def test_cache_miss_returns_none(self, tmp_path):
|
|
337
|
+
"""Reading a non-existent cache entry returns None."""
|
|
338
|
+
result = read_cached_score(tmp_path, "nonexistent", ttl=300)
|
|
339
|
+
assert result is None
|
|
340
|
+
|
|
341
|
+
def test_expired_cache_returns_none(self, tmp_path):
|
|
342
|
+
"""Cache entry older than TTL returns None."""
|
|
343
|
+
write_cached_score(tmp_path, "churn", 85.0)
|
|
344
|
+
# Read with 0 TTL → always expired
|
|
345
|
+
result = read_cached_score(tmp_path, "churn", ttl=0)
|
|
346
|
+
assert result is None
|
|
347
|
+
|
|
348
|
+
def test_cache_ttl_boundary(self, tmp_path):
|
|
349
|
+
"""Cache at exactly TTL boundary should still be valid."""
|
|
350
|
+
write_cached_score(tmp_path, "churn", 85.0)
|
|
351
|
+
# Read within generous TTL
|
|
352
|
+
result = read_cached_score(tmp_path, "churn", ttl=300)
|
|
353
|
+
assert result == 85.0
|
|
354
|
+
|
|
355
|
+
def test_multiple_dimensions_cached_independently(self, tmp_path):
|
|
356
|
+
"""Each dimension has its own cache entry."""
|
|
357
|
+
write_cached_score(tmp_path, "churn", 85.0)
|
|
358
|
+
write_cached_score(tmp_path, "complexity", 60.0)
|
|
359
|
+
assert read_cached_score(tmp_path, "churn", ttl=300) == 85.0
|
|
360
|
+
assert read_cached_score(tmp_path, "complexity", ttl=300) == 60.0
|
|
361
|
+
|
|
362
|
+
def test_overwrite_cached_score(self, tmp_path):
|
|
363
|
+
"""Writing a new score overwrites the previous value."""
|
|
364
|
+
write_cached_score(tmp_path, "churn", 85.0)
|
|
365
|
+
write_cached_score(tmp_path, "churn", 42.0)
|
|
366
|
+
result = read_cached_score(tmp_path, "churn", ttl=300)
|
|
367
|
+
assert result == 42.0
|
|
368
|
+
|
|
369
|
+
def test_no_cache_flag_bypasses_cache(self):
|
|
370
|
+
"""analyze_healthscore with cache_ttl=0 must not use cached results."""
|
|
371
|
+
# This tests that the analyze function respects cache_ttl=0
|
|
372
|
+
# (will fail until implementation — that's the point)
|
|
373
|
+
with patch(
|
|
374
|
+
"pennyfarthing_scripts.healthscore.analyze.read_cached_score",
|
|
375
|
+
return_value=99.0,
|
|
376
|
+
) as mock_read:
|
|
377
|
+
result = asyncio.run(
|
|
378
|
+
analyze_healthscore(Path("/tmp/project"), cache_ttl=0)
|
|
379
|
+
)
|
|
380
|
+
# With ttl=0, cached values should not be used
|
|
381
|
+
mock_read.assert_not_called()
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
# ---------------------------------------------------------------------------
|
|
385
|
+
# AC6: Cache stored in .pennyfarthing/.cache/healthscore/
|
|
386
|
+
# ---------------------------------------------------------------------------
|
|
387
|
+
|
|
388
|
+
class TestCacheLocation:
|
|
389
|
+
"""AC6: Cache files stored in .pennyfarthing/.cache/healthscore/."""
|
|
390
|
+
|
|
391
|
+
def test_cache_path_under_pennyfarthing(self, tmp_path):
|
|
392
|
+
"""get_cache_path must return a path under .pennyfarthing/.cache/healthscore/."""
|
|
393
|
+
cache_dir = get_cache_path(tmp_path)
|
|
394
|
+
path_str = str(cache_dir)
|
|
395
|
+
assert ".pennyfarthing" in path_str
|
|
396
|
+
assert ".cache" in path_str
|
|
397
|
+
assert "healthscore" in path_str
|
|
398
|
+
|
|
399
|
+
def test_cache_path_includes_target_directory(self, tmp_path):
|
|
400
|
+
"""Cache path should be specific to the target directory."""
|
|
401
|
+
path_a = get_cache_path(tmp_path / "project-a")
|
|
402
|
+
path_b = get_cache_path(tmp_path / "project-b")
|
|
403
|
+
# Different projects should get different cache dirs (or at least different keys)
|
|
404
|
+
assert path_a != path_b
|
|
405
|
+
|
|
406
|
+
|
|
407
|
+
# ---------------------------------------------------------------------------
|
|
408
|
+
# AC7: ADR-0008 result pattern
|
|
409
|
+
# ---------------------------------------------------------------------------
|
|
410
|
+
|
|
411
|
+
class TestADR0008Pattern:
|
|
412
|
+
"""AC7: HealthscoreResult follows ADR-0008 pattern."""
|
|
413
|
+
|
|
414
|
+
def test_result_has_success_field(self):
|
|
415
|
+
"""Result must have a success boolean."""
|
|
416
|
+
result = HealthscoreResult(success=True)
|
|
417
|
+
assert result.success is True
|
|
418
|
+
|
|
419
|
+
def test_result_has_error_field(self):
|
|
420
|
+
"""Result must have an optional error field."""
|
|
421
|
+
result = HealthscoreResult(success=False, error="something broke")
|
|
422
|
+
assert result.error == "something broke"
|
|
423
|
+
|
|
424
|
+
def test_result_serializable_with_asdict(self):
|
|
425
|
+
"""Result must be serializable via dataclasses.asdict."""
|
|
426
|
+
result = HealthscoreResult(
|
|
427
|
+
success=True,
|
|
428
|
+
composite_score=72.5,
|
|
429
|
+
target_path="/tmp/project",
|
|
430
|
+
dimensions=[
|
|
431
|
+
DimensionScore(name="churn", score=80.0, weight=0.15),
|
|
432
|
+
],
|
|
433
|
+
)
|
|
434
|
+
d = asdict(result)
|
|
435
|
+
assert d["success"] is True
|
|
436
|
+
assert d["composite_score"] == 72.5
|
|
437
|
+
assert len(d["dimensions"]) == 1
|
|
438
|
+
|
|
439
|
+
def test_result_json_roundtrip(self):
|
|
440
|
+
"""Result must survive JSON serialization."""
|
|
441
|
+
result = HealthscoreResult(
|
|
442
|
+
success=True,
|
|
443
|
+
composite_score=72.5,
|
|
444
|
+
target_path="/tmp/project",
|
|
445
|
+
dimensions=[
|
|
446
|
+
DimensionScore(name="churn", score=80.0, weight=0.15),
|
|
447
|
+
],
|
|
448
|
+
)
|
|
449
|
+
d = asdict(result)
|
|
450
|
+
text = json.dumps(d, default=str)
|
|
451
|
+
loaded = json.loads(text)
|
|
452
|
+
assert loaded["success"] is True
|
|
453
|
+
assert loaded["composite_score"] == 72.5
|
|
454
|
+
|
|
455
|
+
def test_dimension_score_has_name_score_weight(self):
|
|
456
|
+
"""DimensionScore must have name, score, weight, error fields."""
|
|
457
|
+
ds = DimensionScore(name="churn", score=80.0, weight=0.15)
|
|
458
|
+
assert ds.name == "churn"
|
|
459
|
+
assert ds.score == 80.0
|
|
460
|
+
assert ds.weight == 0.15
|
|
461
|
+
assert ds.error is None
|
|
462
|
+
|
|
463
|
+
def test_dimension_score_none_means_unavailable(self):
|
|
464
|
+
"""DimensionScore with score=None means dimension unavailable."""
|
|
465
|
+
ds = DimensionScore(name="churn", score=None, weight=0.15, error="no data")
|
|
466
|
+
assert ds.score is None
|
|
467
|
+
assert ds.error == "no data"
|
|
468
|
+
|
|
469
|
+
def test_error_result(self):
|
|
470
|
+
"""Failed result has success=False and error message."""
|
|
471
|
+
result = HealthscoreResult(success=False, error="target not found")
|
|
472
|
+
assert result.success is False
|
|
473
|
+
assert result.error == "target not found"
|
|
474
|
+
assert result.composite_score == 0.0
|
|
475
|
+
|
|
476
|
+
|
|
477
|
+
# ---------------------------------------------------------------------------
|
|
478
|
+
# AC8: Registered in main CLI
|
|
479
|
+
# ---------------------------------------------------------------------------
|
|
480
|
+
|
|
481
|
+
class TestMainCLIRegistration:
|
|
482
|
+
"""AC8: healthscore command registered in pennyfarthing_scripts/cli.py."""
|
|
483
|
+
|
|
484
|
+
def test_healthscore_registered_in_main_cli(self):
|
|
485
|
+
"""Main CLI must have a 'healthscore' command group."""
|
|
486
|
+
from pennyfarthing_scripts.cli import cli
|
|
487
|
+
command_names = [cmd for cmd in cli.commands]
|
|
488
|
+
assert "healthscore" in command_names
|
|
489
|
+
|
|
490
|
+
|
|
491
|
+
# ---------------------------------------------------------------------------
|
|
492
|
+
# AC9: Full integration — analyze_healthscore returns real result
|
|
493
|
+
# ---------------------------------------------------------------------------
|
|
494
|
+
|
|
495
|
+
class TestAnalyzeIntegration:
|
|
496
|
+
"""AC9: End-to-end analysis returns HealthscoreResult."""
|
|
497
|
+
|
|
498
|
+
def test_analyze_returns_healthscore_result(self):
|
|
499
|
+
"""analyze_healthscore must return a HealthscoreResult."""
|
|
500
|
+
result = asyncio.run(analyze_healthscore(Path("/tmp/nonexistent")))
|
|
501
|
+
assert isinstance(result, HealthscoreResult)
|
|
502
|
+
|
|
503
|
+
def test_analyze_result_has_dimensions(self):
|
|
504
|
+
"""Result must include dimension scores list."""
|
|
505
|
+
result = asyncio.run(analyze_healthscore(Path("/tmp/nonexistent")))
|
|
506
|
+
assert isinstance(result.dimensions, list)
|
|
507
|
+
|
|
508
|
+
def test_analyze_with_custom_weights(self):
|
|
509
|
+
"""Custom weights must be accepted and applied."""
|
|
510
|
+
custom = {k: 1.0 / 8 for k in DEFAULT_WEIGHTS}
|
|
511
|
+
result = asyncio.run(analyze_healthscore(Path("/tmp/nonexistent"), weights=custom))
|
|
512
|
+
assert isinstance(result, HealthscoreResult)
|
|
513
|
+
|
|
514
|
+
def test_analyze_result_cached_flag(self):
|
|
515
|
+
"""Result must indicate whether values came from cache."""
|
|
516
|
+
result = asyncio.run(analyze_healthscore(Path("/tmp/nonexistent")))
|
|
517
|
+
assert isinstance(result.cached, bool)
|
|
518
|
+
|
|
519
|
+
def test_analyze_graceful_on_missing_path(self):
|
|
520
|
+
"""Missing target path should return error result, not raise."""
|
|
521
|
+
result = asyncio.run(analyze_healthscore(Path("/tmp/definitely-not-a-real-path-xyz123")))
|
|
522
|
+
assert isinstance(result, HealthscoreResult)
|
|
523
|
+
# Should either succeed with degraded scores or fail gracefully
|
|
524
|
+
assert isinstance(result.success, bool)
|
|
@@ -222,6 +222,172 @@ class TestSprintWork:
|
|
|
222
222
|
assert result.get("success") is False or "error" in result
|
|
223
223
|
|
|
224
224
|
|
|
225
|
+
class TestShardedSprint:
|
|
226
|
+
"""Tests for sharded per-epic sprint format."""
|
|
227
|
+
|
|
228
|
+
def _create_sharded_sprint(self, tmp_path: Path) -> Path:
|
|
229
|
+
"""Create a sharded sprint structure in tmp_path."""
|
|
230
|
+
sprint_dir = tmp_path / "sprint"
|
|
231
|
+
sprint_dir.mkdir()
|
|
232
|
+
|
|
233
|
+
# Index file with string refs
|
|
234
|
+
index = """\
|
|
235
|
+
sprint:
|
|
236
|
+
name: TO Sprint 2606
|
|
237
|
+
jira_sprint_id: 309
|
|
238
|
+
jira_sprint_name: TO Sprint 2606
|
|
239
|
+
goal: Test sharding
|
|
240
|
+
start_date: 2026-02-02
|
|
241
|
+
end_date: 2026-02-15
|
|
242
|
+
status: active
|
|
243
|
+
number: 2606
|
|
244
|
+
epics:
|
|
245
|
+
- MSSCI-14298
|
|
246
|
+
- epic-40
|
|
247
|
+
stories: []
|
|
248
|
+
"""
|
|
249
|
+
(sprint_dir / "current-sprint.yaml").write_text(index)
|
|
250
|
+
|
|
251
|
+
# Shard file 1: Jira-style ID
|
|
252
|
+
(sprint_dir / "epic-MSSCI-14298.yaml").write_text("""\
|
|
253
|
+
id: MSSCI-14298
|
|
254
|
+
type: epic
|
|
255
|
+
title: 'Epic: Stepped Workflow'
|
|
256
|
+
priority: P1
|
|
257
|
+
status: in_progress
|
|
258
|
+
stories:
|
|
259
|
+
- id: MSSCI-14299
|
|
260
|
+
title: Wire up stepped workflow
|
|
261
|
+
points: 5
|
|
262
|
+
priority: P0
|
|
263
|
+
status: done
|
|
264
|
+
""")
|
|
265
|
+
|
|
266
|
+
# Shard file 2: internal ID
|
|
267
|
+
(sprint_dir / "epic-epic-40.yaml").write_text("""\
|
|
268
|
+
id: epic-40
|
|
269
|
+
type: epic
|
|
270
|
+
title: 'Epic: Scale Adaptation'
|
|
271
|
+
priority: P2
|
|
272
|
+
status: backlog
|
|
273
|
+
stories:
|
|
274
|
+
- id: 40-1
|
|
275
|
+
title: First story
|
|
276
|
+
points: 3
|
|
277
|
+
priority: P1
|
|
278
|
+
status: backlog
|
|
279
|
+
- id: 40-2
|
|
280
|
+
title: Second story
|
|
281
|
+
points: 2
|
|
282
|
+
priority: P1
|
|
283
|
+
status: ready
|
|
284
|
+
""")
|
|
285
|
+
return tmp_path
|
|
286
|
+
|
|
287
|
+
def test_load_sprint_merges_shards(self, tmp_path: Path) -> None:
|
|
288
|
+
"""load_sprint should merge sharded epic files into full dicts."""
|
|
289
|
+
from pennyfarthing_scripts.sprint.loader import load_sprint
|
|
290
|
+
|
|
291
|
+
root = self._create_sharded_sprint(tmp_path)
|
|
292
|
+
data = load_sprint(project_root=root)
|
|
293
|
+
|
|
294
|
+
assert data is not None
|
|
295
|
+
assert len(data["epics"]) == 2
|
|
296
|
+
assert isinstance(data["epics"][0], dict)
|
|
297
|
+
assert data["epics"][0]["id"] == "MSSCI-14298"
|
|
298
|
+
assert data["epics"][1]["id"] == "epic-40"
|
|
299
|
+
|
|
300
|
+
def test_load_sprint_merges_stories(self, tmp_path: Path) -> None:
|
|
301
|
+
"""Merged epics should contain their stories."""
|
|
302
|
+
from pennyfarthing_scripts.sprint.loader import load_sprint
|
|
303
|
+
|
|
304
|
+
root = self._create_sharded_sprint(tmp_path)
|
|
305
|
+
data = load_sprint(project_root=root)
|
|
306
|
+
|
|
307
|
+
assert len(data["epics"][0]["stories"]) == 1
|
|
308
|
+
assert len(data["epics"][1]["stories"]) == 2
|
|
309
|
+
assert data["epics"][1]["stories"][0]["id"] == "40-1"
|
|
310
|
+
|
|
311
|
+
def test_load_sprint_non_sharded_unchanged(self, tmp_path: Path) -> None:
|
|
312
|
+
"""load_sprint should pass through non-sharded data unchanged."""
|
|
313
|
+
from pennyfarthing_scripts.sprint.loader import load_sprint
|
|
314
|
+
|
|
315
|
+
sprint_dir = tmp_path / "sprint"
|
|
316
|
+
sprint_dir.mkdir()
|
|
317
|
+
(sprint_dir / "current-sprint.yaml").write_text("""\
|
|
318
|
+
sprint:
|
|
319
|
+
name: Test
|
|
320
|
+
jira_sprint_id: 1
|
|
321
|
+
jira_sprint_name: Test
|
|
322
|
+
goal: Test
|
|
323
|
+
start_date: 2026-01-01
|
|
324
|
+
end_date: 2026-01-15
|
|
325
|
+
status: active
|
|
326
|
+
number: 1
|
|
327
|
+
epics:
|
|
328
|
+
- id: epic-1
|
|
329
|
+
title: Inline Epic
|
|
330
|
+
stories:
|
|
331
|
+
- id: 1-1
|
|
332
|
+
title: Story
|
|
333
|
+
points: 1
|
|
334
|
+
status: backlog
|
|
335
|
+
""")
|
|
336
|
+
data = load_sprint(project_root=tmp_path)
|
|
337
|
+
|
|
338
|
+
assert data is not None
|
|
339
|
+
assert isinstance(data["epics"][0], dict)
|
|
340
|
+
assert data["epics"][0]["id"] == "epic-1"
|
|
341
|
+
|
|
342
|
+
def test_get_all_stories_with_shards(self, tmp_path: Path) -> None:
|
|
343
|
+
"""get_all_stories should return stories from merged shards."""
|
|
344
|
+
from pennyfarthing_scripts.sprint.loader import get_all_stories, load_sprint
|
|
345
|
+
from unittest.mock import patch
|
|
346
|
+
|
|
347
|
+
root = self._create_sharded_sprint(tmp_path)
|
|
348
|
+
|
|
349
|
+
with patch("pennyfarthing_scripts.sprint.loader.get_project_root", return_value=root):
|
|
350
|
+
stories = get_all_stories()
|
|
351
|
+
|
|
352
|
+
assert len(stories) == 3
|
|
353
|
+
ids = {s["id"] for s in stories}
|
|
354
|
+
assert "MSSCI-14299" in ids
|
|
355
|
+
assert "40-1" in ids
|
|
356
|
+
assert "40-2" in ids
|
|
357
|
+
|
|
358
|
+
def test_find_epic_with_jira_id(self, tmp_path: Path) -> None:
|
|
359
|
+
"""find_epic should work with Jira-style epic IDs after merge."""
|
|
360
|
+
from pennyfarthing_scripts.sprint.loader import find_epic, load_sprint
|
|
361
|
+
|
|
362
|
+
root = self._create_sharded_sprint(tmp_path)
|
|
363
|
+
data = load_sprint(project_root=root)
|
|
364
|
+
|
|
365
|
+
epic = find_epic(data, "MSSCI-14298")
|
|
366
|
+
assert epic is not None
|
|
367
|
+
assert epic["title"] == "Epic: Stepped Workflow"
|
|
368
|
+
|
|
369
|
+
def test_backlog_count_with_shards(self, tmp_path: Path) -> None:
|
|
370
|
+
"""get_backlog_count should count stories from merged shards."""
|
|
371
|
+
from pennyfarthing_scripts.prime.workflow import get_backlog_count
|
|
372
|
+
|
|
373
|
+
root = self._create_sharded_sprint(tmp_path)
|
|
374
|
+
count = get_backlog_count(root)
|
|
375
|
+
|
|
376
|
+
# 40-1 is backlog, 40-2 is ready -> 2 stories
|
|
377
|
+
assert count == 2
|
|
378
|
+
|
|
379
|
+
def test_backlog_count_defensive_on_strings(self) -> None:
|
|
380
|
+
"""get_backlog_count should not crash on string epics."""
|
|
381
|
+
from pennyfarthing_scripts.prime.workflow import get_backlog_count
|
|
382
|
+
from unittest.mock import patch
|
|
383
|
+
|
|
384
|
+
fake_data = {"epics": ["MSSCI-14298", "MSSCI-14317"]}
|
|
385
|
+
with patch("pennyfarthing_scripts.sprint.loader.load_sprint", return_value=fake_data):
|
|
386
|
+
count = get_backlog_count(Path("/fake"))
|
|
387
|
+
|
|
388
|
+
assert count == 0
|
|
389
|
+
|
|
390
|
+
|
|
225
391
|
class TestSprintArchive:
|
|
226
392
|
"""Tests for sprint/archive.py module."""
|
|
227
393
|
|