@howlil/ez-agents 3.5.0 → 4.0.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 +735 -537
- package/agents/ez-architect-agent.md +267 -0
- package/agents/ez-backend-agent.md +303 -0
- package/agents/ez-chief-strategist.md +271 -0
- package/agents/ez-codebase-mapper.md +770 -770
- package/agents/ez-context-manager.md +319 -0
- package/agents/ez-debugger.md +1255 -1255
- package/agents/ez-design-expert.md +347 -0
- package/agents/ez-devops-agent.md +331 -0
- package/agents/ez-executor.md +487 -487
- package/agents/ez-frontend-agent.md +322 -0
- package/agents/ez-phase-researcher.md +553 -553
- package/agents/ez-planner.md +1307 -1307
- package/agents/ez-product-engineer.md +435 -0
- package/agents/ez-project-researcher.md +629 -629
- package/agents/ez-qa-agent.md +320 -0
- package/agents/ez-release-agent.md +333 -333
- package/agents/ez-requirements-agent.md +377 -377
- package/agents/ez-roadmapper.md +650 -650
- package/agents/ez-technical-writer.md +551 -0
- package/agents/ez-ux-expert.md +393 -0
- package/agents/ez-verifier.md +579 -579
- package/bin/guards/autonomy-guard.cjs +346 -0
- package/bin/guards/context-budget-guard.cjs +278 -0
- package/bin/guards/hallucination-guard.cjs +380 -0
- package/bin/guards/hidden-state-guard.cjs +182 -0
- package/bin/guards/team-overhead-guard.cjs +266 -0
- package/bin/guards/tool-sprawl-guard.cjs +271 -0
- package/bin/lib/analytics/analytics-collector.cjs +86 -0
- package/bin/lib/analytics/analytics-reporter.cjs +130 -0
- package/bin/lib/analytics/cohort-analyzer.cjs +138 -0
- package/bin/lib/analytics/funnel-analyzer.cjs +147 -0
- package/bin/lib/analytics/nps-tracker.cjs +147 -0
- package/bin/lib/archetype-detector.cjs +289 -0
- package/bin/lib/assistant-adapter.cjs +361 -0
- package/bin/lib/audit-exec.cjs +175 -0
- package/bin/lib/auth.cjs +176 -0
- package/bin/lib/backup-service.cjs +422 -0
- package/bin/lib/bdd-validator.cjs +622 -0
- package/bin/lib/business-flow-mapper.cjs +429 -0
- package/bin/lib/circuit-breaker.cjs +276 -0
- package/bin/lib/code-complexity-analyzer.cjs +360 -0
- package/bin/lib/codebase-analyzer.cjs +241 -0
- package/bin/lib/commands.cjs +691 -0
- package/bin/lib/config.cjs +236 -0
- package/bin/lib/constraint-extractor.cjs +526 -0
- package/bin/lib/content-scanner.cjs +238 -0
- package/bin/lib/context-cache.cjs +154 -0
- package/bin/lib/context-compressor.cjs +102 -0
- package/bin/lib/context-deduplicator.cjs +105 -0
- package/bin/lib/context-errors.cjs +78 -0
- package/bin/lib/context-manager.cjs +338 -0
- package/bin/lib/context-metadata-tracker.cjs +140 -0
- package/bin/lib/context-relevance-scorer.cjs +99 -0
- package/bin/lib/core.cjs +507 -0
- package/bin/lib/cost-alerts.cjs +174 -0
- package/bin/lib/cost-tracker.cjs +275 -0
- package/bin/lib/crash-recovery.cjs +220 -0
- package/bin/lib/dependency-graph.cjs +319 -0
- package/bin/lib/deploy/deploy-audit-log.cjs +76 -0
- package/bin/lib/deploy/deploy-detector.cjs +69 -0
- package/bin/lib/deploy/deploy-env-manager.cjs +109 -0
- package/bin/lib/deploy/deploy-health-check.cjs +88 -0
- package/bin/lib/deploy/deploy-pre-flight.cjs +57 -0
- package/bin/lib/deploy/deploy-rollback.cjs +72 -0
- package/bin/lib/deploy/deploy-runner.cjs +97 -0
- package/bin/lib/deploy/deploy-status.cjs +74 -0
- package/bin/lib/discussion-synthesizer.cjs +439 -0
- package/bin/lib/error-cache.cjs +114 -0
- package/bin/lib/error-registry.cjs +177 -0
- package/bin/lib/file-access.cjs +207 -0
- package/bin/lib/file-lock.cjs +236 -0
- package/bin/lib/finops/budget-enforcer.cjs +126 -0
- package/bin/lib/finops/cost-reporter.cjs +132 -0
- package/bin/lib/finops/finops-analyzer.cjs +112 -0
- package/bin/lib/finops/spot-manager.cjs +118 -0
- package/bin/lib/framework-detector.cjs +396 -0
- package/bin/lib/frontmatter.cjs +313 -0
- package/bin/lib/fs-utils.cjs +153 -0
- package/bin/lib/gate-executor.cjs +272 -0
- package/bin/lib/gates/README.md +374 -0
- package/bin/lib/gates/gate-01-requirement.cjs +303 -0
- package/bin/lib/gates/gate-02-architecture.cjs +555 -0
- package/bin/lib/gates/gate-03-code.cjs +635 -0
- package/bin/lib/gates/gate-04-security.cjs +829 -0
- package/bin/lib/git-errors.cjs +83 -0
- package/bin/lib/git-utils.cjs +321 -0
- package/bin/lib/git-workflow-engine.cjs +1157 -0
- package/bin/lib/health-check.cjs +227 -0
- package/bin/lib/index.cjs +279 -0
- package/bin/lib/init.cjs +725 -0
- package/bin/lib/lock-logger.cjs +194 -0
- package/bin/lib/lock-state.cjs +263 -0
- package/bin/lib/lockfile-validator.cjs +227 -0
- package/bin/lib/log-rotation.cjs +71 -0
- package/bin/lib/logger.cjs +125 -0
- package/bin/lib/memory-compression.cjs +256 -0
- package/bin/lib/milestone.cjs +247 -0
- package/bin/lib/model-provider.cjs +241 -0
- package/bin/lib/package-manager-detector.cjs +203 -0
- package/bin/lib/package-manager-executor.cjs +385 -0
- package/bin/lib/package-manager-service.cjs +216 -0
- package/bin/lib/perf/api-monitor.cjs +88 -0
- package/bin/lib/perf/db-optimizer.cjs +78 -0
- package/bin/lib/perf/frontend-performance.cjs +56 -0
- package/bin/lib/perf/perf-analyzer.cjs +77 -0
- package/bin/lib/perf/perf-baseline.cjs +102 -0
- package/bin/lib/perf/perf-reporter.cjs +117 -0
- package/bin/lib/perf/regression-detector.cjs +92 -0
- package/bin/lib/phase.cjs +963 -0
- package/bin/lib/planning-write.cjs +123 -0
- package/bin/lib/project-reporter.cjs +565 -0
- package/bin/lib/quality-gate.cjs +332 -0
- package/bin/lib/quality-metrics.cjs +324 -0
- package/bin/lib/recovery-manager.cjs +98 -0
- package/bin/lib/release-validator.cjs +617 -0
- package/bin/lib/retry.cjs +119 -0
- package/bin/lib/roadmap.cjs +309 -0
- package/bin/lib/safe-exec.cjs +173 -0
- package/bin/lib/safe-path.cjs +130 -0
- package/bin/lib/security-errors.cjs +62 -0
- package/bin/lib/session-chain.cjs +304 -0
- package/bin/lib/session-errors.cjs +81 -0
- package/bin/lib/session-export.cjs +251 -0
- package/bin/lib/session-import.cjs +262 -0
- package/bin/lib/session-manager.cjs +280 -0
- package/bin/lib/skill-context.cjs +148 -0
- package/bin/lib/skill-matcher.cjs +236 -0
- package/bin/lib/skill-registry.cjs +360 -0
- package/bin/lib/skill-resolver.cjs +449 -0
- package/bin/lib/skill-triggers.cjs +90 -0
- package/bin/lib/skill-validator.cjs +270 -0
- package/bin/lib/skill-versioning.cjs +355 -0
- package/bin/lib/stack-detector.cjs +399 -0
- package/bin/lib/state.cjs +736 -0
- package/bin/lib/tech-debt-analyzer.cjs +309 -0
- package/bin/lib/temp-file.cjs +239 -0
- package/bin/lib/template.cjs +223 -0
- package/bin/lib/test-file-lock.cjs +112 -0
- package/bin/lib/test-graceful.cjs +93 -0
- package/bin/lib/test-logger.cjs +60 -0
- package/bin/lib/test-safe-exec.cjs +38 -0
- package/bin/lib/test-safe-path.cjs +33 -0
- package/bin/lib/test-temp-file.cjs +125 -0
- package/bin/lib/tier-manager.cjs +428 -0
- package/bin/lib/timeout-exec.cjs +63 -0
- package/bin/lib/tradeoff-analyzer.cjs +284 -0
- package/bin/lib/url-fetch.cjs +170 -0
- package/bin/lib/verify.cjs +863 -0
- package/bin/update.js +217 -214
- package/commands/deploy.cjs +53 -0
- package/commands/ez/add-tests.md +41 -41
- package/commands/ez/audit-milestone.md +36 -36
- package/commands/ez/complete-milestone.md +136 -136
- package/commands/ez/discuss-phase.md +90 -90
- package/commands/ez/execute-phase.md +52 -52
- package/commands/ez/help.md +22 -22
- package/commands/ez/map-codebase.md +71 -71
- package/commands/ez/new-milestone.md +44 -44
- package/commands/ez/new-project.md +51 -42
- package/commands/ez/plan-phase.md +53 -53
- package/commands/ez/progress.md +36 -36
- package/commands/ez/quick.md +45 -45
- package/commands/ez/resume-work.md +40 -40
- package/commands/ez/run-phase.md +580 -0
- package/commands/ez/settings.md +36 -36
- package/commands/ez/update.md +37 -37
- package/commands/ez/verify-work.md +402 -38
- package/commands/health-check.cjs +44 -0
- package/commands/rollback.cjs +47 -0
- package/ez-agents/bin/ez-tools.cjs +599 -2
- package/ez-agents/bin/guards/autonomy-guard.cjs +346 -0
- package/ez-agents/bin/guards/context-budget-guard.cjs +247 -0
- package/ez-agents/bin/guards/hallucination-guard.cjs +271 -0
- package/ez-agents/bin/guards/hidden-state-guard.cjs +182 -0
- package/ez-agents/bin/guards/team-overhead-guard.cjs +266 -0
- package/ez-agents/bin/guards/tool-sprawl-guard.cjs +271 -0
- package/ez-agents/bin/lib/analytics/analytics-collector.cjs +86 -0
- package/ez-agents/bin/lib/analytics/analytics-reporter.cjs +130 -0
- package/ez-agents/bin/lib/analytics/cohort-analyzer.cjs +138 -0
- package/ez-agents/bin/lib/analytics/funnel-analyzer.cjs +147 -0
- package/ez-agents/bin/lib/analytics/nps-tracker.cjs +147 -0
- package/ez-agents/bin/lib/archetype-detector.cjs +289 -0
- package/ez-agents/bin/lib/audit-exec.cjs +166 -167
- package/ez-agents/bin/lib/auth.cjs +176 -176
- package/ez-agents/bin/lib/backup-service.cjs +422 -0
- package/ez-agents/bin/lib/bdd-validator.cjs +622 -622
- package/ez-agents/bin/lib/business-flow-mapper.cjs +429 -0
- package/ez-agents/bin/lib/code-complexity-analyzer.cjs +360 -0
- package/ez-agents/bin/lib/codebase-analyzer.cjs +241 -0
- package/ez-agents/bin/lib/commands.cjs +685 -685
- package/ez-agents/bin/lib/config.cjs +41 -1
- package/ez-agents/bin/lib/constraint-extractor.cjs +526 -0
- package/ez-agents/bin/lib/content-scanner.cjs +238 -238
- package/ez-agents/bin/lib/context-cache.cjs +154 -154
- package/ez-agents/bin/lib/context-errors.cjs +71 -71
- package/ez-agents/bin/lib/context-manager.cjs +220 -220
- package/ez-agents/bin/lib/core.cjs +507 -512
- package/ez-agents/bin/lib/cost-tracker.cjs +243 -0
- package/ez-agents/bin/lib/crash-recovery.cjs +172 -0
- package/ez-agents/bin/lib/dependency-graph.cjs +319 -0
- package/ez-agents/bin/lib/deploy/deploy-audit-log.cjs +76 -0
- package/ez-agents/bin/lib/deploy/deploy-detector.cjs +69 -0
- package/ez-agents/bin/lib/deploy/deploy-env-manager.cjs +109 -0
- package/ez-agents/bin/lib/deploy/deploy-health-check.cjs +88 -0
- package/ez-agents/bin/lib/deploy/deploy-pre-flight.cjs +57 -0
- package/ez-agents/bin/lib/deploy/deploy-rollback.cjs +72 -0
- package/ez-agents/bin/lib/deploy/deploy-runner.cjs +97 -0
- package/ez-agents/bin/lib/deploy/deploy-status.cjs +74 -0
- package/ez-agents/bin/lib/file-access.cjs +207 -207
- package/ez-agents/bin/lib/finops/budget-enforcer.cjs +126 -0
- package/ez-agents/bin/lib/finops/cost-reporter.cjs +132 -0
- package/ez-agents/bin/lib/finops/finops-analyzer.cjs +112 -0
- package/ez-agents/bin/lib/finops/spot-manager.cjs +118 -0
- package/ez-agents/bin/lib/framework-detector.cjs +396 -0
- package/ez-agents/bin/lib/frontmatter.cjs +3 -1
- package/ez-agents/bin/lib/gates/README.md +374 -0
- package/ez-agents/bin/lib/gates/gate-01-requirement.cjs +303 -0
- package/ez-agents/bin/lib/gates/gate-02-architecture.cjs +555 -0
- package/ez-agents/bin/lib/gates/gate-03-code.cjs +635 -0
- package/ez-agents/bin/lib/gates/gate-04-security.cjs +829 -0
- package/ez-agents/bin/lib/git-errors.cjs +83 -83
- package/ez-agents/bin/lib/git-utils.cjs +321 -321
- package/ez-agents/bin/lib/git-workflow-engine.cjs +1157 -1157
- package/ez-agents/bin/lib/health-check.cjs +162 -162
- package/ez-agents/bin/lib/index.cjs +2 -8
- package/ez-agents/bin/lib/init.cjs +0 -2
- package/ez-agents/bin/lib/lockfile-validator.cjs +227 -227
- package/ez-agents/bin/lib/log-rotation.cjs +71 -0
- package/ez-agents/bin/lib/logger.cjs +22 -47
- package/ez-agents/bin/lib/memory-compression.cjs +256 -256
- package/ez-agents/bin/lib/package-manager-detector.cjs +203 -203
- package/ez-agents/bin/lib/package-manager-executor.cjs +385 -385
- package/ez-agents/bin/lib/package-manager-service.cjs +216 -216
- package/ez-agents/bin/lib/perf/api-monitor.cjs +88 -0
- package/ez-agents/bin/lib/perf/db-optimizer.cjs +78 -0
- package/ez-agents/bin/lib/perf/frontend-performance.cjs +56 -0
- package/ez-agents/bin/lib/perf/perf-analyzer.cjs +77 -0
- package/ez-agents/bin/lib/perf/perf-baseline.cjs +102 -0
- package/ez-agents/bin/lib/perf/perf-reporter.cjs +117 -0
- package/ez-agents/bin/lib/perf/regression-detector.cjs +92 -0
- package/ez-agents/bin/lib/project-reporter.cjs +502 -0
- package/ez-agents/bin/lib/quality-gate.cjs +332 -0
- package/ez-agents/bin/lib/recovery-manager.cjs +98 -0
- package/ez-agents/bin/lib/release-validator.cjs +617 -614
- package/ez-agents/bin/lib/security-errors.cjs +62 -0
- package/ez-agents/bin/lib/session-chain.cjs +304 -304
- package/ez-agents/bin/lib/session-errors.cjs +81 -81
- package/ez-agents/bin/lib/session-export.cjs +251 -251
- package/ez-agents/bin/lib/session-import.cjs +262 -262
- package/ez-agents/bin/lib/session-manager.cjs +280 -280
- package/ez-agents/bin/lib/skill-context.cjs +148 -0
- package/ez-agents/bin/lib/skill-matcher.cjs +236 -0
- package/ez-agents/bin/lib/skill-registry.cjs +341 -0
- package/ez-agents/bin/lib/skill-resolver.cjs +449 -0
- package/ez-agents/bin/lib/skill-triggers.cjs +90 -0
- package/ez-agents/bin/lib/skill-validator.cjs +270 -0
- package/ez-agents/bin/lib/skill-versioning.cjs +355 -0
- package/ez-agents/bin/lib/stack-detector.cjs +399 -0
- package/ez-agents/bin/lib/tech-debt-analyzer.cjs +309 -0
- package/ez-agents/bin/lib/tier-manager.cjs +428 -428
- package/ez-agents/bin/lib/tradeoff-analyzer.cjs +284 -0
- package/ez-agents/bin/lib/url-fetch.cjs +170 -170
- package/ez-agents/bin/lib/verify.cjs +863 -863
- package/ez-agents/references/decimal-phase-calculation.md +65 -65
- package/ez-agents/references/git-integration.md +248 -248
- package/ez-agents/references/git-planning-commit.md +38 -38
- package/ez-agents/references/metrics-schema.md +118 -118
- package/ez-agents/references/model-profile-resolution.md +34 -34
- package/ez-agents/references/model-profiles.md +93 -93
- package/ez-agents/references/phase-argument-parsing.md +61 -61
- package/ez-agents/references/planning-config.md +340 -340
- package/ez-agents/references/tier-strategy.md +103 -103
- package/ez-agents/references/ui-brand.md +160 -160
- package/ez-agents/references/verification-patterns.md +612 -612
- package/ez-agents/templates/DEBUG.md +164 -164
- package/ez-agents/templates/UAT.md +247 -247
- package/ez-agents/templates/agent-output-format.md +404 -0
- package/ez-agents/templates/bdd-feature.md +173 -173
- package/ez-agents/templates/codebase/architecture.md +255 -255
- package/ez-agents/templates/codebase/structure.md +285 -285
- package/ez-agents/templates/copilot-instructions.md +7 -7
- package/ez-agents/templates/debug-subagent-prompt.md +91 -91
- package/ez-agents/templates/discovery.md +146 -146
- package/ez-agents/templates/discussion.md +68 -68
- package/ez-agents/templates/handoff-protocol.md +294 -0
- package/ez-agents/templates/incident-runbook.md +205 -205
- package/ez-agents/templates/mode-workflow-templates.md +301 -0
- package/ez-agents/templates/phase-prompt.md +610 -610
- package/ez-agents/templates/planner-subagent-prompt.md +117 -117
- package/ez-agents/templates/project.md +184 -184
- package/ez-agents/templates/release-checklist.md +136 -133
- package/ez-agents/templates/research.md +552 -552
- package/ez-agents/templates/rollback-plan.md +201 -201
- package/ez-agents/templates/security-user-setup.md +244 -0
- package/ez-agents/templates/skill-validation-rules.md +476 -0
- package/ez-agents/templates/state.md +180 -176
- package/ez-agents/templates/summary-complex.md +59 -59
- package/ez-agents/tests/gates/gate-01-02.test.cjs +812 -0
- package/ez-agents/tests/gates/gate-03-04.test.cjs +762 -0
- package/ez-agents/tests/gates/gate-05-validator.test.cjs +145 -0
- package/ez-agents/tests/gates/gate-06-docs-validator.test.cjs +244 -0
- package/ez-agents/tests/gates/gate-07-release-validator.test.cjs +219 -0
- package/ez-agents/tests/guards/context-budget-guard.test.cjs +145 -0
- package/ez-agents/tests/guards/edge-case-guards.test.cjs +238 -0
- package/ez-agents/tests/guards/hallucination-guard.test.cjs +124 -0
- package/ez-agents/workflows/audit-milestone.md +1 -1
- package/ez-agents/workflows/autonomous.md +844 -844
- package/ez-agents/workflows/complete-milestone.md +1 -1
- package/ez-agents/workflows/discuss-phase.md +1 -1
- package/ez-agents/workflows/execute-phase.md +124 -3
- package/ez-agents/workflows/help.md +42 -181
- package/ez-agents/workflows/hotfix.md +291 -291
- package/ez-agents/workflows/new-milestone.md +713 -713
- package/ez-agents/workflows/new-project.md +1089 -1107
- package/ez-agents/workflows/plan-phase.md +0 -40
- package/ez-agents/workflows/release.md +253 -253
- package/ez-agents/workflows/resume-session.md +215 -215
- package/ez-agents/workflows/run-phase.md +531 -0
- package/ez-agents/workflows/settings.md +2 -35
- package/hooks/dist/ez-check-update.js +81 -81
- package/hooks/dist/ez-context-monitor.js +148 -141
- package/hooks/dist/ez-statusline.js +115 -115
- package/package.json +78 -71
- package/scripts/fix-qwen-installation.js +144 -144
- package/agents/ez-integration-checker.md +0 -443
- package/agents/ez-nyquist-auditor.md +0 -176
- package/agents/ez-observer-agent.md +0 -260
- package/agents/ez-plan-checker.md +0 -706
- package/agents/ez-research-synthesizer.md +0 -247
- package/agents/ez-scrum-master-agent.md +0 -242
- package/agents/ez-tech-lead-agent.md +0 -267
- package/agents/ez-ui-auditor.md +0 -439
- package/agents/ez-ui-checker.md +0 -300
- package/agents/ez-ui-researcher.md +0 -353
- package/commands/ez/add-phase.md +0 -43
- package/commands/ez/add-todo.md +0 -47
- package/commands/ez/arch-review.md +0 -102
- package/commands/ez/auth.md +0 -87
- package/commands/ez/autonomous.md +0 -41
- package/commands/ez/check-todos.md +0 -45
- package/commands/ez/cleanup.md +0 -18
- package/commands/ez/debug.md +0 -168
- package/commands/ez/export-session.md +0 -79
- package/commands/ez/gather-requirements.md +0 -117
- package/commands/ez/git-workflow.md +0 -72
- package/commands/ez/health.md +0 -22
- package/commands/ez/hotfix.md +0 -120
- package/commands/ez/import-session.md +0 -82
- package/commands/ez/insert-phase.md +0 -32
- package/commands/ez/join-discord.md +0 -18
- package/commands/ez/list-phase-assumptions.md +0 -46
- package/commands/ez/list-sessions.md +0 -96
- package/commands/ez/package-manager.md +0 -316
- package/commands/ez/pause-work.md +0 -38
- package/commands/ez/plan-milestone-gaps.md +0 -34
- package/commands/ez/preflight.md +0 -79
- package/commands/ez/reapply-patches.md +0 -124
- package/commands/ez/release.md +0 -153
- package/commands/ez/remove-phase.md +0 -31
- package/commands/ez/research-phase.md +0 -190
- package/commands/ez/resume.md +0 -107
- package/commands/ez/set-profile.md +0 -34
- package/commands/ez/standup.md +0 -85
- package/commands/ez/stats.md +0 -18
- package/commands/ez/ui-phase.md +0 -34
- package/commands/ez/ui-review.md +0 -32
- package/commands/ez/validate-phase.md +0 -35
- package/ez-agents/bin/lib/metrics-tracker.cjs +0 -406
- package/ez-agents/templates/UI-SPEC.md +0 -100
- package/ez-agents/templates/VALIDATION.md +0 -76
- package/ez-agents/templates/context.md +0 -352
- package/ez-agents/templates/verification-report.md +0 -322
- package/ez-agents/workflows/arch-review.md +0 -54
- package/ez-agents/workflows/export-session.md +0 -255
- package/ez-agents/workflows/gather-requirements.md +0 -206
- package/ez-agents/workflows/import-session.md +0 -303
- package/ez-agents/workflows/research-phase.md +0 -74
- package/ez-agents/workflows/standup.md +0 -64
- package/ez-agents/workflows/ui-phase.md +0 -290
- package/ez-agents/workflows/ui-review.md +0 -157
- package/ez-agents/workflows/validate-phase.md +0 -167
|
@@ -1,622 +1,622 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* BDD Validator — INVEST criteria checker and MoSCoW tagging utilities
|
|
5
|
-
*
|
|
6
|
-
* Validates Gherkin .feature files against:
|
|
7
|
-
* - INVEST criteria (Independent, Negotiable, Valuable, Estimable, Small, Testable)
|
|
8
|
-
* - MoSCoW priority tagging (@must/@should/@could/@wont)
|
|
9
|
-
* - Tier tagging (@mvp/@medium/@enterprise)
|
|
10
|
-
* - Structural correctness (Given/When/Then format)
|
|
11
|
-
*/
|
|
12
|
-
|
|
13
|
-
'use strict';
|
|
14
|
-
|
|
15
|
-
const fs = require('fs');
|
|
16
|
-
const path = require('path');
|
|
17
|
-
|
|
18
|
-
// ─────────────────────────────────────────────
|
|
19
|
-
// Traceability
|
|
20
|
-
// ─────────────────────────────────────────────
|
|
21
|
-
|
|
22
|
-
/**
|
|
23
|
-
* Generate a deterministic scenario ID using FNV-1a hash
|
|
24
|
-
* @param {string} featureName
|
|
25
|
-
* @param {string} scenarioName
|
|
26
|
-
* @returns {string} ID like "SC-A1B2C3"
|
|
27
|
-
*/
|
|
28
|
-
function generateScenarioId(featureName, scenarioName) {
|
|
29
|
-
const input = `${featureName}::${scenarioName}`.toLowerCase().replace(/\s+/g, '-');
|
|
30
|
-
// Simple non-crypto hash (FNV-1a)
|
|
31
|
-
let hash = 0x811c9dc5;
|
|
32
|
-
for (const char of input) {
|
|
33
|
-
hash ^= char.charCodeAt(0);
|
|
34
|
-
hash = (hash * 0x01000193) >>> 0;
|
|
35
|
-
}
|
|
36
|
-
return `SC-${hash.toString(16).toUpperCase().slice(0, 6)}`;
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
// ─────────────────────────────────────────────
|
|
40
|
-
// Parser
|
|
41
|
-
// ─────────────────────────────────────────────
|
|
42
|
-
|
|
43
|
-
/**
|
|
44
|
-
* Parse a .feature file into structured object
|
|
45
|
-
* @param {string} content - Raw file content
|
|
46
|
-
* @returns {{ feature: object, scenarios: object[], errors: string[] }}
|
|
47
|
-
*/
|
|
48
|
-
function parseFeatureFile(content) {
|
|
49
|
-
const lines = content.split('\n');
|
|
50
|
-
const result = { feature: null, scenarios: [], errors: [] };
|
|
51
|
-
|
|
52
|
-
let currentScenario = null;
|
|
53
|
-
let currentStep = null;
|
|
54
|
-
let pendingTags = [];
|
|
55
|
-
|
|
56
|
-
for (let i = 0; i < lines.length; i++) {
|
|
57
|
-
const line = lines[i].trim();
|
|
58
|
-
|
|
59
|
-
// Skip blank lines and comments
|
|
60
|
-
if (!line || line.startsWith('#')) continue;
|
|
61
|
-
|
|
62
|
-
// Tags
|
|
63
|
-
if (line.startsWith('@')) {
|
|
64
|
-
const tags = line.split(/\s+/).filter(t => t.startsWith('@'));
|
|
65
|
-
pendingTags.push(...tags);
|
|
66
|
-
continue;
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
// Feature declaration
|
|
70
|
-
if (line.startsWith('Feature:')) {
|
|
71
|
-
result.feature = {
|
|
72
|
-
name: line.replace('Feature:', '').trim(),
|
|
73
|
-
tags: pendingTags.slice(),
|
|
74
|
-
lineNumber: i + 1
|
|
75
|
-
};
|
|
76
|
-
pendingTags = [];
|
|
77
|
-
continue;
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
// Background
|
|
81
|
-
if (line.startsWith('Background:')) {
|
|
82
|
-
currentScenario = { type: 'background', steps: [] };
|
|
83
|
-
pendingTags = [];
|
|
84
|
-
continue;
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
// Scenario
|
|
88
|
-
if (line.startsWith('Scenario:') || line.startsWith('Scenario Outline:')) {
|
|
89
|
-
if (currentScenario && currentScenario.type !== 'background') {
|
|
90
|
-
result.scenarios.push(currentScenario);
|
|
91
|
-
}
|
|
92
|
-
const scenarioName = line.replace(/^Scenario(?: Outline)?:/, '').trim();
|
|
93
|
-
const featureName = result.feature ? result.feature.name : 'unknown';
|
|
94
|
-
currentScenario = {
|
|
95
|
-
type: line.startsWith('Scenario Outline:') ? 'outline' : 'scenario',
|
|
96
|
-
name: scenarioName,
|
|
97
|
-
id: generateScenarioId(featureName, scenarioName),
|
|
98
|
-
tags: pendingTags.slice(),
|
|
99
|
-
steps: [],
|
|
100
|
-
lineNumber: i + 1
|
|
101
|
-
};
|
|
102
|
-
pendingTags = [];
|
|
103
|
-
currentStep = null;
|
|
104
|
-
continue;
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
// Steps
|
|
108
|
-
const stepMatch = line.match(/^(Given|When|Then|And|But)\s+(.+)$/);
|
|
109
|
-
if (stepMatch && currentScenario) {
|
|
110
|
-
const stepType = stepMatch[1];
|
|
111
|
-
// Resolve And/But to actual type based on previous step
|
|
112
|
-
let resolvedType = stepType;
|
|
113
|
-
if ((stepType === 'And' || stepType === 'But') && currentStep) {
|
|
114
|
-
resolvedType = currentStep.resolvedType;
|
|
115
|
-
} else if (stepType === 'And' || stepType === 'But') {
|
|
116
|
-
resolvedType = 'Given'; // fallback
|
|
117
|
-
}
|
|
118
|
-
currentStep = {
|
|
119
|
-
keyword: stepType,
|
|
120
|
-
resolvedType,
|
|
121
|
-
text: stepMatch[2],
|
|
122
|
-
lineNumber: i + 1
|
|
123
|
-
};
|
|
124
|
-
currentScenario.steps.push(currentStep);
|
|
125
|
-
continue;
|
|
126
|
-
}
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
// Push last scenario
|
|
130
|
-
if (currentScenario && currentScenario.type !== 'background') {
|
|
131
|
-
result.scenarios.push(currentScenario);
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
if (!result.feature) {
|
|
135
|
-
result.errors.push('No Feature: declaration found');
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
return result;
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
// ─────────────────────────────────────────────
|
|
142
|
-
// MoSCoW Validation
|
|
143
|
-
// ─────────────────────────────────────────────
|
|
144
|
-
|
|
145
|
-
const MOSCOW_TAGS = ['@must', '@should', '@could', '@wont'];
|
|
146
|
-
const TIER_TAGS = ['@mvp', '@medium', '@enterprise'];
|
|
147
|
-
|
|
148
|
-
/**
|
|
149
|
-
* Validate MoSCoW tags on a scenario
|
|
150
|
-
* @param {object} scenario
|
|
151
|
-
* @returns {{ valid: boolean, moscow: string|null, tier: string|null, issues: string[] }}
|
|
152
|
-
*/
|
|
153
|
-
function validateMosCowTags(scenario) {
|
|
154
|
-
const issues = [];
|
|
155
|
-
const tags = scenario.tags || [];
|
|
156
|
-
|
|
157
|
-
const moscowTag = tags.find(t => MOSCOW_TAGS.includes(t));
|
|
158
|
-
const tierTag = tags.find(t => TIER_TAGS.includes(t));
|
|
159
|
-
|
|
160
|
-
if (!moscowTag) {
|
|
161
|
-
issues.push(`Scenario "${scenario.name}" missing MoSCoW tag (@must/@should/@could/@wont)`);
|
|
162
|
-
}
|
|
163
|
-
|
|
164
|
-
if (!tierTag && moscowTag !== '@wont') {
|
|
165
|
-
issues.push(`Scenario "${scenario.name}" missing tier tag (@mvp/@medium/@enterprise)`);
|
|
166
|
-
}
|
|
167
|
-
|
|
168
|
-
// Check consistency
|
|
169
|
-
if (moscowTag === '@must' && tierTag && tierTag !== '@mvp') {
|
|
170
|
-
issues.push(`Scenario "${scenario.name}": @must scenarios should be tagged @mvp (found ${tierTag})`);
|
|
171
|
-
}
|
|
172
|
-
if (moscowTag === '@could' && tierTag === '@mvp') {
|
|
173
|
-
issues.push(`Scenario "${scenario.name}": @could scenarios should not be tagged @mvp`);
|
|
174
|
-
}
|
|
175
|
-
|
|
176
|
-
return {
|
|
177
|
-
valid: issues.length === 0,
|
|
178
|
-
moscow: moscowTag || null,
|
|
179
|
-
tier: tierTag || null,
|
|
180
|
-
issues
|
|
181
|
-
};
|
|
182
|
-
}
|
|
183
|
-
|
|
184
|
-
/**
|
|
185
|
-
* Count scenarios by MoSCoW priority
|
|
186
|
-
* @param {object[]} scenarios
|
|
187
|
-
* @returns {{ must: number, should: number, could: number, wont: number, untagged: number }}
|
|
188
|
-
*/
|
|
189
|
-
function countByMosCow(scenarios) {
|
|
190
|
-
const counts = { must: 0, should: 0, could: 0, wont: 0, untagged: 0 };
|
|
191
|
-
for (const s of scenarios) {
|
|
192
|
-
const tag = (s.tags || []).find(t => MOSCOW_TAGS.includes(t));
|
|
193
|
-
if (!tag) counts.untagged++;
|
|
194
|
-
else counts[tag.replace('@', '')]++;
|
|
195
|
-
}
|
|
196
|
-
return counts;
|
|
197
|
-
}
|
|
198
|
-
|
|
199
|
-
// ─────────────────────────────────────────────
|
|
200
|
-
// INVEST Validation
|
|
201
|
-
// ─────────────────────────────────────────────
|
|
202
|
-
|
|
203
|
-
/**
|
|
204
|
-
* Validate INVEST criteria for a Feature + its scenarios
|
|
205
|
-
* @param {object} parsed - Result from parseFeatureFile
|
|
206
|
-
* @returns {{ score: number, max: number, dimensions: object[], passed: boolean }}
|
|
207
|
-
*/
|
|
208
|
-
function validateINVEST(parsed) {
|
|
209
|
-
const dimensions = [];
|
|
210
|
-
|
|
211
|
-
// Independent — check for explicit dependency language
|
|
212
|
-
const dependencyWords = ['requires', 'depends on', 'after', 'before completing'];
|
|
213
|
-
const featureName = parsed.feature ? parsed.feature.name.toLowerCase() : '';
|
|
214
|
-
const hasDependencyLanguage = dependencyWords.some(w => featureName.includes(w));
|
|
215
|
-
dimensions.push({
|
|
216
|
-
dimension: 'Independent',
|
|
217
|
-
letter: 'I',
|
|
218
|
-
passed: !hasDependencyLanguage,
|
|
219
|
-
note: hasDependencyLanguage
|
|
220
|
-
? 'Feature name suggests hard dependency — split or remove dependency language'
|
|
221
|
-
: 'No hard dependency language detected in Feature name'
|
|
222
|
-
});
|
|
223
|
-
|
|
224
|
-
// Negotiable — check that Then clauses don't over-specify implementation
|
|
225
|
-
const implementationWords = ['using react', 'via postgres', 'with redis', 'using jwt', 'via sendgrid'];
|
|
226
|
-
let thenClauses = [];
|
|
227
|
-
for (const s of parsed.scenarios) {
|
|
228
|
-
thenClauses.push(...s.steps.filter(st => st.resolvedType === 'Then').map(st => st.text.toLowerCase()));
|
|
229
|
-
}
|
|
230
|
-
const overSpecified = thenClauses.some(t => implementationWords.some(w => t.includes(w)));
|
|
231
|
-
dimensions.push({
|
|
232
|
-
dimension: 'Negotiable',
|
|
233
|
-
letter: 'N',
|
|
234
|
-
passed: !overSpecified,
|
|
235
|
-
note: overSpecified
|
|
236
|
-
? 'Then clauses reference specific implementation technology — keep outcomes technology-agnostic'
|
|
237
|
-
: 'Then clauses describe outcomes, not implementation'
|
|
238
|
-
});
|
|
239
|
-
|
|
240
|
-
// Valuable — check Feature has "As a... I want... So that..." structure
|
|
241
|
-
const hasAsA = parsed.feature && /as a/i.test(parsed.feature.name);
|
|
242
|
-
dimensions.push({
|
|
243
|
-
dimension: 'Valuable',
|
|
244
|
-
letter: 'V',
|
|
245
|
-
passed: !!parsed.feature, // Feature declaration exists
|
|
246
|
-
note: parsed.feature
|
|
247
|
-
? (hasAsA ? 'Feature has user-value statement' : 'Feature exists but consider adding "As a... I want... So that..."')
|
|
248
|
-
: 'No Feature declaration found'
|
|
249
|
-
});
|
|
250
|
-
|
|
251
|
-
// Estimable — check sufficient detail in steps
|
|
252
|
-
const avgStepsPerScenario = parsed.scenarios.length > 0
|
|
253
|
-
? parsed.scenarios.reduce((sum, s) => sum + s.steps.length, 0) / parsed.scenarios.length
|
|
254
|
-
: 0;
|
|
255
|
-
const estimable = avgStepsPerScenario >= 2 && avgStepsPerScenario <= 10;
|
|
256
|
-
dimensions.push({
|
|
257
|
-
dimension: 'Estimable',
|
|
258
|
-
letter: 'E',
|
|
259
|
-
passed: estimable,
|
|
260
|
-
note: avgStepsPerScenario < 2
|
|
261
|
-
? 'Scenarios have too few steps — add more detail for estimability'
|
|
262
|
-
: avgStepsPerScenario > 10
|
|
263
|
-
? 'Scenarios are overly complex — split into smaller scenarios'
|
|
264
|
-
: `Average ${avgStepsPerScenario.toFixed(1)} steps per scenario — good estimability`
|
|
265
|
-
});
|
|
266
|
-
|
|
267
|
-
// Small — count @must scenarios (should be <= 8 for one phase)
|
|
268
|
-
const mustCount = parsed.scenarios.filter(s => (s.tags || []).includes('@must')).length;
|
|
269
|
-
const small = mustCount <= 8;
|
|
270
|
-
dimensions.push({
|
|
271
|
-
dimension: 'Small',
|
|
272
|
-
letter: 'S',
|
|
273
|
-
passed: small,
|
|
274
|
-
note: small
|
|
275
|
-
? `${mustCount} @must scenarios — fits in one phase`
|
|
276
|
-
: `${mustCount} @must scenarios — consider splitting Feature across phases (max 8 recommended)`
|
|
277
|
-
});
|
|
278
|
-
|
|
279
|
-
// Testable — check all Then clauses have specific assertions
|
|
280
|
-
const vagueWords = ['should work', 'is correct', 'looks good', 'is happy', 'functions properly'];
|
|
281
|
-
const vagueThens = thenClauses.filter(t => vagueWords.some(w => t.includes(w)));
|
|
282
|
-
dimensions.push({
|
|
283
|
-
dimension: 'Testable',
|
|
284
|
-
letter: 'T',
|
|
285
|
-
passed: vagueThens.length === 0,
|
|
286
|
-
note: vagueThens.length === 0
|
|
287
|
-
? 'All Then clauses have specific, testable assertions'
|
|
288
|
-
: `${vagueThens.length} vague Then clause(s) found — replace with specific assertions`
|
|
289
|
-
});
|
|
290
|
-
|
|
291
|
-
const score = dimensions.filter(d => d.passed).length;
|
|
292
|
-
return {
|
|
293
|
-
score,
|
|
294
|
-
max: dimensions.length,
|
|
295
|
-
dimensions,
|
|
296
|
-
passed: score === dimensions.length
|
|
297
|
-
};
|
|
298
|
-
}
|
|
299
|
-
|
|
300
|
-
// ─────────────────────────────────────────────
|
|
301
|
-
// Structural Validation
|
|
302
|
-
// ─────────────────────────────────────────────
|
|
303
|
-
|
|
304
|
-
/**
|
|
305
|
-
* Validate Given/When/Then structure of scenarios
|
|
306
|
-
* @param {object[]} scenarios
|
|
307
|
-
* @returns {{ valid: boolean, issues: string[] }}
|
|
308
|
-
*/
|
|
309
|
-
function validateStructure(scenarios) {
|
|
310
|
-
const issues = [];
|
|
311
|
-
|
|
312
|
-
for (const scenario of scenarios) {
|
|
313
|
-
if (scenario.type === 'background') continue;
|
|
314
|
-
|
|
315
|
-
const steps = scenario.steps;
|
|
316
|
-
if (steps.length === 0) {
|
|
317
|
-
issues.push(`Scenario "${scenario.name}" has no steps`);
|
|
318
|
-
continue;
|
|
319
|
-
}
|
|
320
|
-
|
|
321
|
-
const hasGiven = steps.some(s => s.resolvedType === 'Given');
|
|
322
|
-
const hasWhen = steps.some(s => s.resolvedType === 'When');
|
|
323
|
-
const hasThen = steps.some(s => s.resolvedType === 'Then');
|
|
324
|
-
|
|
325
|
-
if (!hasWhen) {
|
|
326
|
-
issues.push(`Scenario "${scenario.name}": missing When step (the action being tested)`);
|
|
327
|
-
}
|
|
328
|
-
if (!hasThen) {
|
|
329
|
-
issues.push(`Scenario "${scenario.name}": missing Then step (the expected outcome)`);
|
|
330
|
-
}
|
|
331
|
-
|
|
332
|
-
// Check order: Given before When, When before Then
|
|
333
|
-
let givenIndex = steps.findLastIndex(s => s.resolvedType === 'Given');
|
|
334
|
-
let whenIndex = steps.findIndex(s => s.resolvedType === 'When');
|
|
335
|
-
let thenIndex = steps.findIndex(s => s.resolvedType === 'Then');
|
|
336
|
-
|
|
337
|
-
if (hasWhen && hasThen && whenIndex > thenIndex) {
|
|
338
|
-
issues.push(`Scenario "${scenario.name}": When step appears after Then step`);
|
|
339
|
-
}
|
|
340
|
-
if (hasGiven && hasWhen && givenIndex > whenIndex) {
|
|
341
|
-
// Only warn if Given appears much later
|
|
342
|
-
}
|
|
343
|
-
}
|
|
344
|
-
|
|
345
|
-
return { valid: issues.length === 0, issues };
|
|
346
|
-
}
|
|
347
|
-
|
|
348
|
-
// ─────────────────────────────────────────────
|
|
349
|
-
// Main Validation Entry Point
|
|
350
|
-
// ─────────────────────────────────────────────
|
|
351
|
-
|
|
352
|
-
/**
|
|
353
|
-
* Validate a single .feature file
|
|
354
|
-
* @param {string} filePath - Path to .feature file
|
|
355
|
-
* @returns {{ file: string, valid: boolean, invest: object, moscow: object, structure: object, summary: string }}
|
|
356
|
-
*/
|
|
357
|
-
function validateFeatureFile(filePath) {
|
|
358
|
-
if (!fs.existsSync(filePath)) {
|
|
359
|
-
return {
|
|
360
|
-
file: filePath,
|
|
361
|
-
valid: false,
|
|
362
|
-
error: `File not found: ${filePath}`,
|
|
363
|
-
invest: null,
|
|
364
|
-
moscow: null,
|
|
365
|
-
structure: null,
|
|
366
|
-
summary: 'FILE_NOT_FOUND'
|
|
367
|
-
};
|
|
368
|
-
}
|
|
369
|
-
|
|
370
|
-
const content = fs.readFileSync(filePath, 'utf8');
|
|
371
|
-
const parsed = parseFeatureFile(content);
|
|
372
|
-
|
|
373
|
-
// Structural validation
|
|
374
|
-
const structure = validateStructure(parsed.scenarios);
|
|
375
|
-
|
|
376
|
-
// INVEST validation
|
|
377
|
-
const invest = validateINVEST(parsed);
|
|
378
|
-
|
|
379
|
-
// MoSCoW validation (per scenario)
|
|
380
|
-
const moscowResults = parsed.scenarios.map(s => validateMosCowTags(s));
|
|
381
|
-
const moscowIssues = moscowResults.flatMap(r => r.issues);
|
|
382
|
-
const moscow = {
|
|
383
|
-
valid: moscowIssues.length === 0,
|
|
384
|
-
issues: moscowIssues,
|
|
385
|
-
counts: countByMosCow(parsed.scenarios),
|
|
386
|
-
scenarios: moscowResults
|
|
387
|
-
};
|
|
388
|
-
|
|
389
|
-
const allIssues = [
|
|
390
|
-
...parsed.errors,
|
|
391
|
-
...structure.issues,
|
|
392
|
-
...moscowIssues
|
|
393
|
-
];
|
|
394
|
-
|
|
395
|
-
const valid = allIssues.length === 0 && invest.score >= 5; // Must pass at least 5/6 INVEST
|
|
396
|
-
|
|
397
|
-
return {
|
|
398
|
-
file: filePath,
|
|
399
|
-
valid,
|
|
400
|
-
invest,
|
|
401
|
-
moscow,
|
|
402
|
-
structure,
|
|
403
|
-
parseErrors: parsed.errors,
|
|
404
|
-
scenarioCount: parsed.scenarios.length,
|
|
405
|
-
summary: valid ? 'PASS' : `FAIL (${allIssues.length} issues, INVEST ${invest.score}/${invest.max})`
|
|
406
|
-
};
|
|
407
|
-
}
|
|
408
|
-
|
|
409
|
-
/**
|
|
410
|
-
* Validate all .feature files in a directory
|
|
411
|
-
* @param {string} dirPath - Directory to scan
|
|
412
|
-
* @param {object} options - { outputTraceability: boolean, cwd: string }
|
|
413
|
-
* @returns {{ valid: boolean, files: object[], totalScenarios: number, moscowCounts: object }}
|
|
414
|
-
*/
|
|
415
|
-
function validateFeatureDirectory(dirPath, options = {}) {
|
|
416
|
-
const featureFiles = findFeatureFiles(dirPath);
|
|
417
|
-
|
|
418
|
-
if (featureFiles.length === 0) {
|
|
419
|
-
return {
|
|
420
|
-
valid: false,
|
|
421
|
-
files: [],
|
|
422
|
-
totalScenarios: 0,
|
|
423
|
-
moscowCounts: { must: 0, should: 0, could: 0, wont: 0, untagged: 0 },
|
|
424
|
-
error: `No .feature files found in ${dirPath}`
|
|
425
|
-
};
|
|
426
|
-
}
|
|
427
|
-
|
|
428
|
-
const results = featureFiles.map(f => validateFeatureFile(f));
|
|
429
|
-
const totalMoscow = { must: 0, should: 0, could: 0, wont: 0, untagged: 0 };
|
|
430
|
-
|
|
431
|
-
let totalScenarios = 0;
|
|
432
|
-
const allScenarios = []; // for traceability matrix
|
|
433
|
-
|
|
434
|
-
for (const r of results) {
|
|
435
|
-
totalScenarios += r.scenarioCount || 0;
|
|
436
|
-
if (r.moscow && r.moscow.counts) {
|
|
437
|
-
for (const [key, val] of Object.entries(r.moscow.counts)) {
|
|
438
|
-
totalMoscow[key] = (totalMoscow[key] || 0) + val;
|
|
439
|
-
}
|
|
440
|
-
}
|
|
441
|
-
// Collect scenario data for traceability
|
|
442
|
-
if (r.moscow && r.moscow.scenarios) {
|
|
443
|
-
const featureBase = path.basename(r.file);
|
|
444
|
-
r.moscow.scenarios.forEach((s, idx) => {
|
|
445
|
-
allScenarios.push({
|
|
446
|
-
id: s.id || `SC-${idx}`,
|
|
447
|
-
name: s.scenario ? s.scenario.name : `Scenario ${idx + 1}`,
|
|
448
|
-
moscow: s.moscow || 'untagged',
|
|
449
|
-
file: featureBase,
|
|
450
|
-
status: 'not-run'
|
|
451
|
-
});
|
|
452
|
-
});
|
|
453
|
-
}
|
|
454
|
-
}
|
|
455
|
-
|
|
456
|
-
// Write traceability matrix if requested or by default when .planning/ exists
|
|
457
|
-
const cwd = options.cwd || process.cwd();
|
|
458
|
-
const planningDir = path.join(cwd, '.planning');
|
|
459
|
-
if (allScenarios.length > 0 && fs.existsSync(planningDir)) {
|
|
460
|
-
try {
|
|
461
|
-
const matrixLines = [
|
|
462
|
-
'# BDD Traceability Matrix',
|
|
463
|
-
'',
|
|
464
|
-
'| ID | Scenario | MoSCoW | Feature File | Status |',
|
|
465
|
-
'|-----|----------|--------|-------------|--------|'
|
|
466
|
-
];
|
|
467
|
-
for (const s of allScenarios) {
|
|
468
|
-
matrixLines.push(`| ${s.id} | ${s.name} | ${s.moscow} | ${s.file} | ⬜ ${s.status} |`);
|
|
469
|
-
}
|
|
470
|
-
matrixLines.push('');
|
|
471
|
-
matrixLines.push(`*Generated: ${new Date().toISOString()}*`);
|
|
472
|
-
fs.writeFileSync(
|
|
473
|
-
path.join(planningDir, 'bdd-traceability.md'),
|
|
474
|
-
matrixLines.join('\n'),
|
|
475
|
-
'utf8'
|
|
476
|
-
);
|
|
477
|
-
} catch {
|
|
478
|
-
// Non-fatal — traceability matrix is best-effort
|
|
479
|
-
}
|
|
480
|
-
}
|
|
481
|
-
|
|
482
|
-
return {
|
|
483
|
-
valid: results.every(r => r.valid),
|
|
484
|
-
files: results,
|
|
485
|
-
totalScenarios,
|
|
486
|
-
moscowCounts: totalMoscow
|
|
487
|
-
};
|
|
488
|
-
}
|
|
489
|
-
|
|
490
|
-
/**
|
|
491
|
-
* Recursively find all .feature files
|
|
492
|
-
* @param {string} dirPath
|
|
493
|
-
* @returns {string[]}
|
|
494
|
-
*/
|
|
495
|
-
function findFeatureFiles(dirPath) {
|
|
496
|
-
if (!fs.existsSync(dirPath)) return [];
|
|
497
|
-
|
|
498
|
-
const entries = fs.readdirSync(dirPath, { withFileTypes: true });
|
|
499
|
-
const files = [];
|
|
500
|
-
|
|
501
|
-
for (const entry of entries) {
|
|
502
|
-
const fullPath = path.join(dirPath, entry.name);
|
|
503
|
-
if (entry.isDirectory()) {
|
|
504
|
-
files.push(...findFeatureFiles(fullPath));
|
|
505
|
-
} else if (entry.name.endsWith('.feature')) {
|
|
506
|
-
files.push(fullPath);
|
|
507
|
-
}
|
|
508
|
-
}
|
|
509
|
-
|
|
510
|
-
return files;
|
|
511
|
-
}
|
|
512
|
-
|
|
513
|
-
/**
|
|
514
|
-
* Format validation report as human-readable markdown
|
|
515
|
-
* @param {object} result - From validateFeatureDirectory or validateFeatureFile
|
|
516
|
-
* @returns {string}
|
|
517
|
-
*/
|
|
518
|
-
function formatReport(result) {
|
|
519
|
-
const lines = [];
|
|
520
|
-
|
|
521
|
-
if (result.files) {
|
|
522
|
-
// Directory report
|
|
523
|
-
lines.push(`## BDD Validation Report`);
|
|
524
|
-
lines.push(`**Status:** ${result.valid ? '✓ PASS' : '✗ FAIL'}`);
|
|
525
|
-
lines.push(`**Files:** ${result.files.length} | **Scenarios:** ${result.totalScenarios}`);
|
|
526
|
-
lines.push('');
|
|
527
|
-
lines.push('### MoSCoW Distribution');
|
|
528
|
-
lines.push(`| Priority | Count |`);
|
|
529
|
-
lines.push(`|----------|-------|`);
|
|
530
|
-
for (const [key, val] of Object.entries(result.moscowCounts)) {
|
|
531
|
-
lines.push(`| @${key} | ${val} |`);
|
|
532
|
-
}
|
|
533
|
-
lines.push('');
|
|
534
|
-
lines.push('### File Results');
|
|
535
|
-
for (const f of result.files) {
|
|
536
|
-
const icon = f.valid ? '✓' : '✗';
|
|
537
|
-
lines.push(`- ${icon} \`${path.basename(f.file)}\` — ${f.summary}`);
|
|
538
|
-
if (!f.valid && f.parseErrors) {
|
|
539
|
-
for (const e of f.parseErrors) lines.push(` - **Parse Error:** ${e}`);
|
|
540
|
-
}
|
|
541
|
-
if (f.structure && f.structure.issues.length > 0) {
|
|
542
|
-
for (const e of f.structure.issues) lines.push(` - **Structure:** ${e}`);
|
|
543
|
-
}
|
|
544
|
-
if (f.moscow && f.moscow.issues.length > 0) {
|
|
545
|
-
for (const e of f.moscow.issues.slice(0, 3)) lines.push(` - **MoSCoW:** ${e}`);
|
|
546
|
-
}
|
|
547
|
-
}
|
|
548
|
-
} else {
|
|
549
|
-
// Single file report
|
|
550
|
-
lines.push(`## BDD Validation: ${path.basename(result.file)}`);
|
|
551
|
-
lines.push(`**Status:** ${result.valid ? '✓ PASS' : '✗ FAIL'}`);
|
|
552
|
-
lines.push(`**Scenarios:** ${result.scenarioCount}`);
|
|
553
|
-
|
|
554
|
-
if (result.invest) {
|
|
555
|
-
lines.push('');
|
|
556
|
-
lines.push('### INVEST Score');
|
|
557
|
-
lines.push(`**${result.invest.score}/${result.invest.max}** dimensions pass`);
|
|
558
|
-
for (const d of result.invest.dimensions) {
|
|
559
|
-
lines.push(`- ${d.passed ? '✓' : '✗'} **${d.letter}** — ${d.dimension}: ${d.note}`);
|
|
560
|
-
}
|
|
561
|
-
}
|
|
562
|
-
}
|
|
563
|
-
|
|
564
|
-
return lines.join('\n');
|
|
565
|
-
}
|
|
566
|
-
|
|
567
|
-
// ─────────────────────────────────────────────
|
|
568
|
-
// CLI Interface
|
|
569
|
-
// ─────────────────────────────────────────────
|
|
570
|
-
|
|
571
|
-
if (require.main === module) {
|
|
572
|
-
const args = process.argv.slice(2);
|
|
573
|
-
const cmd = args[0];
|
|
574
|
-
const target = args[1];
|
|
575
|
-
|
|
576
|
-
if (!cmd || !target) {
|
|
577
|
-
console.error('Usage: bdd-validator.cjs <validate-file|validate-dir|count-moscow> <path>');
|
|
578
|
-
process.exit(1);
|
|
579
|
-
}
|
|
580
|
-
|
|
581
|
-
try {
|
|
582
|
-
if (cmd === 'validate-file') {
|
|
583
|
-
const result = validateFeatureFile(target);
|
|
584
|
-
if (args.includes('--json')) {
|
|
585
|
-
console.log(JSON.stringify(result, null, 2));
|
|
586
|
-
} else {
|
|
587
|
-
console.log(formatReport(result));
|
|
588
|
-
process.exit(result.valid ? 0 : 1);
|
|
589
|
-
}
|
|
590
|
-
} else if (cmd === 'validate-dir') {
|
|
591
|
-
const result = validateFeatureDirectory(target);
|
|
592
|
-
if (args.includes('--json')) {
|
|
593
|
-
console.log(JSON.stringify(result, null, 2));
|
|
594
|
-
} else {
|
|
595
|
-
console.log(formatReport(result));
|
|
596
|
-
process.exit(result.valid ? 0 : 1);
|
|
597
|
-
}
|
|
598
|
-
} else if (cmd === 'count-moscow') {
|
|
599
|
-
const result = validateFeatureDirectory(target);
|
|
600
|
-
console.log(JSON.stringify(result.moscowCounts, null, 2));
|
|
601
|
-
} else {
|
|
602
|
-
console.error(`Unknown command: ${cmd}`);
|
|
603
|
-
process.exit(1);
|
|
604
|
-
}
|
|
605
|
-
} catch (err) {
|
|
606
|
-
console.error(`Error: ${err.message}`);
|
|
607
|
-
process.exit(1);
|
|
608
|
-
}
|
|
609
|
-
}
|
|
610
|
-
|
|
611
|
-
module.exports = {
|
|
612
|
-
parseFeatureFile,
|
|
613
|
-
validateFeatureFile,
|
|
614
|
-
validateFeatureDirectory,
|
|
615
|
-
validateMosCowTags,
|
|
616
|
-
validateINVEST,
|
|
617
|
-
validateStructure,
|
|
618
|
-
countByMosCow,
|
|
619
|
-
findFeatureFiles,
|
|
620
|
-
formatReport,
|
|
621
|
-
generateScenarioId
|
|
622
|
-
};
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* BDD Validator — INVEST criteria checker and MoSCoW tagging utilities
|
|
5
|
+
*
|
|
6
|
+
* Validates Gherkin .feature files against:
|
|
7
|
+
* - INVEST criteria (Independent, Negotiable, Valuable, Estimable, Small, Testable)
|
|
8
|
+
* - MoSCoW priority tagging (@must/@should/@could/@wont)
|
|
9
|
+
* - Tier tagging (@mvp/@medium/@enterprise)
|
|
10
|
+
* - Structural correctness (Given/When/Then format)
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
'use strict';
|
|
14
|
+
|
|
15
|
+
const fs = require('fs');
|
|
16
|
+
const path = require('path');
|
|
17
|
+
|
|
18
|
+
// ─────────────────────────────────────────────
|
|
19
|
+
// Traceability
|
|
20
|
+
// ─────────────────────────────────────────────
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Generate a deterministic scenario ID using FNV-1a hash
|
|
24
|
+
* @param {string} featureName
|
|
25
|
+
* @param {string} scenarioName
|
|
26
|
+
* @returns {string} ID like "SC-A1B2C3"
|
|
27
|
+
*/
|
|
28
|
+
function generateScenarioId(featureName, scenarioName) {
|
|
29
|
+
const input = `${featureName}::${scenarioName}`.toLowerCase().replace(/\s+/g, '-');
|
|
30
|
+
// Simple non-crypto hash (FNV-1a)
|
|
31
|
+
let hash = 0x811c9dc5;
|
|
32
|
+
for (const char of input) {
|
|
33
|
+
hash ^= char.charCodeAt(0);
|
|
34
|
+
hash = (hash * 0x01000193) >>> 0;
|
|
35
|
+
}
|
|
36
|
+
return `SC-${hash.toString(16).toUpperCase().slice(0, 6)}`;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// ─────────────────────────────────────────────
|
|
40
|
+
// Parser
|
|
41
|
+
// ─────────────────────────────────────────────
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Parse a .feature file into structured object
|
|
45
|
+
* @param {string} content - Raw file content
|
|
46
|
+
* @returns {{ feature: object, scenarios: object[], errors: string[] }}
|
|
47
|
+
*/
|
|
48
|
+
function parseFeatureFile(content) {
|
|
49
|
+
const lines = content.split('\n');
|
|
50
|
+
const result = { feature: null, scenarios: [], errors: [] };
|
|
51
|
+
|
|
52
|
+
let currentScenario = null;
|
|
53
|
+
let currentStep = null;
|
|
54
|
+
let pendingTags = [];
|
|
55
|
+
|
|
56
|
+
for (let i = 0; i < lines.length; i++) {
|
|
57
|
+
const line = lines[i].trim();
|
|
58
|
+
|
|
59
|
+
// Skip blank lines and comments
|
|
60
|
+
if (!line || line.startsWith('#')) continue;
|
|
61
|
+
|
|
62
|
+
// Tags
|
|
63
|
+
if (line.startsWith('@')) {
|
|
64
|
+
const tags = line.split(/\s+/).filter(t => t.startsWith('@'));
|
|
65
|
+
pendingTags.push(...tags);
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Feature declaration
|
|
70
|
+
if (line.startsWith('Feature:')) {
|
|
71
|
+
result.feature = {
|
|
72
|
+
name: line.replace('Feature:', '').trim(),
|
|
73
|
+
tags: pendingTags.slice(),
|
|
74
|
+
lineNumber: i + 1
|
|
75
|
+
};
|
|
76
|
+
pendingTags = [];
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Background
|
|
81
|
+
if (line.startsWith('Background:')) {
|
|
82
|
+
currentScenario = { type: 'background', steps: [] };
|
|
83
|
+
pendingTags = [];
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Scenario
|
|
88
|
+
if (line.startsWith('Scenario:') || line.startsWith('Scenario Outline:')) {
|
|
89
|
+
if (currentScenario && currentScenario.type !== 'background') {
|
|
90
|
+
result.scenarios.push(currentScenario);
|
|
91
|
+
}
|
|
92
|
+
const scenarioName = line.replace(/^Scenario(?: Outline)?:/, '').trim();
|
|
93
|
+
const featureName = result.feature ? result.feature.name : 'unknown';
|
|
94
|
+
currentScenario = {
|
|
95
|
+
type: line.startsWith('Scenario Outline:') ? 'outline' : 'scenario',
|
|
96
|
+
name: scenarioName,
|
|
97
|
+
id: generateScenarioId(featureName, scenarioName),
|
|
98
|
+
tags: pendingTags.slice(),
|
|
99
|
+
steps: [],
|
|
100
|
+
lineNumber: i + 1
|
|
101
|
+
};
|
|
102
|
+
pendingTags = [];
|
|
103
|
+
currentStep = null;
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Steps
|
|
108
|
+
const stepMatch = line.match(/^(Given|When|Then|And|But)\s+(.+)$/);
|
|
109
|
+
if (stepMatch && currentScenario) {
|
|
110
|
+
const stepType = stepMatch[1];
|
|
111
|
+
// Resolve And/But to actual type based on previous step
|
|
112
|
+
let resolvedType = stepType;
|
|
113
|
+
if ((stepType === 'And' || stepType === 'But') && currentStep) {
|
|
114
|
+
resolvedType = currentStep.resolvedType;
|
|
115
|
+
} else if (stepType === 'And' || stepType === 'But') {
|
|
116
|
+
resolvedType = 'Given'; // fallback
|
|
117
|
+
}
|
|
118
|
+
currentStep = {
|
|
119
|
+
keyword: stepType,
|
|
120
|
+
resolvedType,
|
|
121
|
+
text: stepMatch[2],
|
|
122
|
+
lineNumber: i + 1
|
|
123
|
+
};
|
|
124
|
+
currentScenario.steps.push(currentStep);
|
|
125
|
+
continue;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// Push last scenario
|
|
130
|
+
if (currentScenario && currentScenario.type !== 'background') {
|
|
131
|
+
result.scenarios.push(currentScenario);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
if (!result.feature) {
|
|
135
|
+
result.errors.push('No Feature: declaration found');
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
return result;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// ─────────────────────────────────────────────
|
|
142
|
+
// MoSCoW Validation
|
|
143
|
+
// ─────────────────────────────────────────────
|
|
144
|
+
|
|
145
|
+
const MOSCOW_TAGS = ['@must', '@should', '@could', '@wont'];
|
|
146
|
+
const TIER_TAGS = ['@mvp', '@medium', '@enterprise'];
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Validate MoSCoW tags on a scenario
|
|
150
|
+
* @param {object} scenario
|
|
151
|
+
* @returns {{ valid: boolean, moscow: string|null, tier: string|null, issues: string[] }}
|
|
152
|
+
*/
|
|
153
|
+
function validateMosCowTags(scenario) {
|
|
154
|
+
const issues = [];
|
|
155
|
+
const tags = scenario.tags || [];
|
|
156
|
+
|
|
157
|
+
const moscowTag = tags.find(t => MOSCOW_TAGS.includes(t));
|
|
158
|
+
const tierTag = tags.find(t => TIER_TAGS.includes(t));
|
|
159
|
+
|
|
160
|
+
if (!moscowTag) {
|
|
161
|
+
issues.push(`Scenario "${scenario.name}" missing MoSCoW tag (@must/@should/@could/@wont)`);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
if (!tierTag && moscowTag !== '@wont') {
|
|
165
|
+
issues.push(`Scenario "${scenario.name}" missing tier tag (@mvp/@medium/@enterprise)`);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// Check consistency
|
|
169
|
+
if (moscowTag === '@must' && tierTag && tierTag !== '@mvp') {
|
|
170
|
+
issues.push(`Scenario "${scenario.name}": @must scenarios should be tagged @mvp (found ${tierTag})`);
|
|
171
|
+
}
|
|
172
|
+
if (moscowTag === '@could' && tierTag === '@mvp') {
|
|
173
|
+
issues.push(`Scenario "${scenario.name}": @could scenarios should not be tagged @mvp`);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
return {
|
|
177
|
+
valid: issues.length === 0,
|
|
178
|
+
moscow: moscowTag || null,
|
|
179
|
+
tier: tierTag || null,
|
|
180
|
+
issues
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Count scenarios by MoSCoW priority
|
|
186
|
+
* @param {object[]} scenarios
|
|
187
|
+
* @returns {{ must: number, should: number, could: number, wont: number, untagged: number }}
|
|
188
|
+
*/
|
|
189
|
+
function countByMosCow(scenarios) {
|
|
190
|
+
const counts = { must: 0, should: 0, could: 0, wont: 0, untagged: 0 };
|
|
191
|
+
for (const s of scenarios) {
|
|
192
|
+
const tag = (s.tags || []).find(t => MOSCOW_TAGS.includes(t));
|
|
193
|
+
if (!tag) counts.untagged++;
|
|
194
|
+
else counts[tag.replace('@', '')]++;
|
|
195
|
+
}
|
|
196
|
+
return counts;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// ─────────────────────────────────────────────
|
|
200
|
+
// INVEST Validation
|
|
201
|
+
// ─────────────────────────────────────────────
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Validate INVEST criteria for a Feature + its scenarios
|
|
205
|
+
* @param {object} parsed - Result from parseFeatureFile
|
|
206
|
+
* @returns {{ score: number, max: number, dimensions: object[], passed: boolean }}
|
|
207
|
+
*/
|
|
208
|
+
function validateINVEST(parsed) {
|
|
209
|
+
const dimensions = [];
|
|
210
|
+
|
|
211
|
+
// Independent — check for explicit dependency language
|
|
212
|
+
const dependencyWords = ['requires', 'depends on', 'after', 'before completing'];
|
|
213
|
+
const featureName = parsed.feature ? parsed.feature.name.toLowerCase() : '';
|
|
214
|
+
const hasDependencyLanguage = dependencyWords.some(w => featureName.includes(w));
|
|
215
|
+
dimensions.push({
|
|
216
|
+
dimension: 'Independent',
|
|
217
|
+
letter: 'I',
|
|
218
|
+
passed: !hasDependencyLanguage,
|
|
219
|
+
note: hasDependencyLanguage
|
|
220
|
+
? 'Feature name suggests hard dependency — split or remove dependency language'
|
|
221
|
+
: 'No hard dependency language detected in Feature name'
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
// Negotiable — check that Then clauses don't over-specify implementation
|
|
225
|
+
const implementationWords = ['using react', 'via postgres', 'with redis', 'using jwt', 'via sendgrid'];
|
|
226
|
+
let thenClauses = [];
|
|
227
|
+
for (const s of parsed.scenarios) {
|
|
228
|
+
thenClauses.push(...s.steps.filter(st => st.resolvedType === 'Then').map(st => st.text.toLowerCase()));
|
|
229
|
+
}
|
|
230
|
+
const overSpecified = thenClauses.some(t => implementationWords.some(w => t.includes(w)));
|
|
231
|
+
dimensions.push({
|
|
232
|
+
dimension: 'Negotiable',
|
|
233
|
+
letter: 'N',
|
|
234
|
+
passed: !overSpecified,
|
|
235
|
+
note: overSpecified
|
|
236
|
+
? 'Then clauses reference specific implementation technology — keep outcomes technology-agnostic'
|
|
237
|
+
: 'Then clauses describe outcomes, not implementation'
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
// Valuable — check Feature has "As a... I want... So that..." structure
|
|
241
|
+
const hasAsA = parsed.feature && /as a/i.test(parsed.feature.name);
|
|
242
|
+
dimensions.push({
|
|
243
|
+
dimension: 'Valuable',
|
|
244
|
+
letter: 'V',
|
|
245
|
+
passed: !!parsed.feature, // Feature declaration exists
|
|
246
|
+
note: parsed.feature
|
|
247
|
+
? (hasAsA ? 'Feature has user-value statement' : 'Feature exists but consider adding "As a... I want... So that..."')
|
|
248
|
+
: 'No Feature declaration found'
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
// Estimable — check sufficient detail in steps
|
|
252
|
+
const avgStepsPerScenario = parsed.scenarios.length > 0
|
|
253
|
+
? parsed.scenarios.reduce((sum, s) => sum + s.steps.length, 0) / parsed.scenarios.length
|
|
254
|
+
: 0;
|
|
255
|
+
const estimable = avgStepsPerScenario >= 2 && avgStepsPerScenario <= 10;
|
|
256
|
+
dimensions.push({
|
|
257
|
+
dimension: 'Estimable',
|
|
258
|
+
letter: 'E',
|
|
259
|
+
passed: estimable,
|
|
260
|
+
note: avgStepsPerScenario < 2
|
|
261
|
+
? 'Scenarios have too few steps — add more detail for estimability'
|
|
262
|
+
: avgStepsPerScenario > 10
|
|
263
|
+
? 'Scenarios are overly complex — split into smaller scenarios'
|
|
264
|
+
: `Average ${avgStepsPerScenario.toFixed(1)} steps per scenario — good estimability`
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
// Small — count @must scenarios (should be <= 8 for one phase)
|
|
268
|
+
const mustCount = parsed.scenarios.filter(s => (s.tags || []).includes('@must')).length;
|
|
269
|
+
const small = mustCount <= 8;
|
|
270
|
+
dimensions.push({
|
|
271
|
+
dimension: 'Small',
|
|
272
|
+
letter: 'S',
|
|
273
|
+
passed: small,
|
|
274
|
+
note: small
|
|
275
|
+
? `${mustCount} @must scenarios — fits in one phase`
|
|
276
|
+
: `${mustCount} @must scenarios — consider splitting Feature across phases (max 8 recommended)`
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
// Testable — check all Then clauses have specific assertions
|
|
280
|
+
const vagueWords = ['should work', 'is correct', 'looks good', 'is happy', 'functions properly'];
|
|
281
|
+
const vagueThens = thenClauses.filter(t => vagueWords.some(w => t.includes(w)));
|
|
282
|
+
dimensions.push({
|
|
283
|
+
dimension: 'Testable',
|
|
284
|
+
letter: 'T',
|
|
285
|
+
passed: vagueThens.length === 0,
|
|
286
|
+
note: vagueThens.length === 0
|
|
287
|
+
? 'All Then clauses have specific, testable assertions'
|
|
288
|
+
: `${vagueThens.length} vague Then clause(s) found — replace with specific assertions`
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
const score = dimensions.filter(d => d.passed).length;
|
|
292
|
+
return {
|
|
293
|
+
score,
|
|
294
|
+
max: dimensions.length,
|
|
295
|
+
dimensions,
|
|
296
|
+
passed: score === dimensions.length
|
|
297
|
+
};
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
// ─────────────────────────────────────────────
|
|
301
|
+
// Structural Validation
|
|
302
|
+
// ─────────────────────────────────────────────
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* Validate Given/When/Then structure of scenarios
|
|
306
|
+
* @param {object[]} scenarios
|
|
307
|
+
* @returns {{ valid: boolean, issues: string[] }}
|
|
308
|
+
*/
|
|
309
|
+
function validateStructure(scenarios) {
|
|
310
|
+
const issues = [];
|
|
311
|
+
|
|
312
|
+
for (const scenario of scenarios) {
|
|
313
|
+
if (scenario.type === 'background') continue;
|
|
314
|
+
|
|
315
|
+
const steps = scenario.steps;
|
|
316
|
+
if (steps.length === 0) {
|
|
317
|
+
issues.push(`Scenario "${scenario.name}" has no steps`);
|
|
318
|
+
continue;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
const hasGiven = steps.some(s => s.resolvedType === 'Given');
|
|
322
|
+
const hasWhen = steps.some(s => s.resolvedType === 'When');
|
|
323
|
+
const hasThen = steps.some(s => s.resolvedType === 'Then');
|
|
324
|
+
|
|
325
|
+
if (!hasWhen) {
|
|
326
|
+
issues.push(`Scenario "${scenario.name}": missing When step (the action being tested)`);
|
|
327
|
+
}
|
|
328
|
+
if (!hasThen) {
|
|
329
|
+
issues.push(`Scenario "${scenario.name}": missing Then step (the expected outcome)`);
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
// Check order: Given before When, When before Then
|
|
333
|
+
let givenIndex = steps.findLastIndex(s => s.resolvedType === 'Given');
|
|
334
|
+
let whenIndex = steps.findIndex(s => s.resolvedType === 'When');
|
|
335
|
+
let thenIndex = steps.findIndex(s => s.resolvedType === 'Then');
|
|
336
|
+
|
|
337
|
+
if (hasWhen && hasThen && whenIndex > thenIndex) {
|
|
338
|
+
issues.push(`Scenario "${scenario.name}": When step appears after Then step`);
|
|
339
|
+
}
|
|
340
|
+
if (hasGiven && hasWhen && givenIndex > whenIndex) {
|
|
341
|
+
// Only warn if Given appears much later
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
return { valid: issues.length === 0, issues };
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
// ─────────────────────────────────────────────
|
|
349
|
+
// Main Validation Entry Point
|
|
350
|
+
// ─────────────────────────────────────────────
|
|
351
|
+
|
|
352
|
+
/**
|
|
353
|
+
* Validate a single .feature file
|
|
354
|
+
* @param {string} filePath - Path to .feature file
|
|
355
|
+
* @returns {{ file: string, valid: boolean, invest: object, moscow: object, structure: object, summary: string }}
|
|
356
|
+
*/
|
|
357
|
+
function validateFeatureFile(filePath) {
|
|
358
|
+
if (!fs.existsSync(filePath)) {
|
|
359
|
+
return {
|
|
360
|
+
file: filePath,
|
|
361
|
+
valid: false,
|
|
362
|
+
error: `File not found: ${filePath}`,
|
|
363
|
+
invest: null,
|
|
364
|
+
moscow: null,
|
|
365
|
+
structure: null,
|
|
366
|
+
summary: 'FILE_NOT_FOUND'
|
|
367
|
+
};
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
const content = fs.readFileSync(filePath, 'utf8');
|
|
371
|
+
const parsed = parseFeatureFile(content);
|
|
372
|
+
|
|
373
|
+
// Structural validation
|
|
374
|
+
const structure = validateStructure(parsed.scenarios);
|
|
375
|
+
|
|
376
|
+
// INVEST validation
|
|
377
|
+
const invest = validateINVEST(parsed);
|
|
378
|
+
|
|
379
|
+
// MoSCoW validation (per scenario)
|
|
380
|
+
const moscowResults = parsed.scenarios.map(s => validateMosCowTags(s));
|
|
381
|
+
const moscowIssues = moscowResults.flatMap(r => r.issues);
|
|
382
|
+
const moscow = {
|
|
383
|
+
valid: moscowIssues.length === 0,
|
|
384
|
+
issues: moscowIssues,
|
|
385
|
+
counts: countByMosCow(parsed.scenarios),
|
|
386
|
+
scenarios: moscowResults
|
|
387
|
+
};
|
|
388
|
+
|
|
389
|
+
const allIssues = [
|
|
390
|
+
...parsed.errors,
|
|
391
|
+
...structure.issues,
|
|
392
|
+
...moscowIssues
|
|
393
|
+
];
|
|
394
|
+
|
|
395
|
+
const valid = allIssues.length === 0 && invest.score >= 5; // Must pass at least 5/6 INVEST
|
|
396
|
+
|
|
397
|
+
return {
|
|
398
|
+
file: filePath,
|
|
399
|
+
valid,
|
|
400
|
+
invest,
|
|
401
|
+
moscow,
|
|
402
|
+
structure,
|
|
403
|
+
parseErrors: parsed.errors,
|
|
404
|
+
scenarioCount: parsed.scenarios.length,
|
|
405
|
+
summary: valid ? 'PASS' : `FAIL (${allIssues.length} issues, INVEST ${invest.score}/${invest.max})`
|
|
406
|
+
};
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
/**
|
|
410
|
+
* Validate all .feature files in a directory
|
|
411
|
+
* @param {string} dirPath - Directory to scan
|
|
412
|
+
* @param {object} options - { outputTraceability: boolean, cwd: string }
|
|
413
|
+
* @returns {{ valid: boolean, files: object[], totalScenarios: number, moscowCounts: object }}
|
|
414
|
+
*/
|
|
415
|
+
function validateFeatureDirectory(dirPath, options = {}) {
|
|
416
|
+
const featureFiles = findFeatureFiles(dirPath);
|
|
417
|
+
|
|
418
|
+
if (featureFiles.length === 0) {
|
|
419
|
+
return {
|
|
420
|
+
valid: false,
|
|
421
|
+
files: [],
|
|
422
|
+
totalScenarios: 0,
|
|
423
|
+
moscowCounts: { must: 0, should: 0, could: 0, wont: 0, untagged: 0 },
|
|
424
|
+
error: `No .feature files found in ${dirPath}`
|
|
425
|
+
};
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
const results = featureFiles.map(f => validateFeatureFile(f));
|
|
429
|
+
const totalMoscow = { must: 0, should: 0, could: 0, wont: 0, untagged: 0 };
|
|
430
|
+
|
|
431
|
+
let totalScenarios = 0;
|
|
432
|
+
const allScenarios = []; // for traceability matrix
|
|
433
|
+
|
|
434
|
+
for (const r of results) {
|
|
435
|
+
totalScenarios += r.scenarioCount || 0;
|
|
436
|
+
if (r.moscow && r.moscow.counts) {
|
|
437
|
+
for (const [key, val] of Object.entries(r.moscow.counts)) {
|
|
438
|
+
totalMoscow[key] = (totalMoscow[key] || 0) + val;
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
// Collect scenario data for traceability
|
|
442
|
+
if (r.moscow && r.moscow.scenarios) {
|
|
443
|
+
const featureBase = path.basename(r.file);
|
|
444
|
+
r.moscow.scenarios.forEach((s, idx) => {
|
|
445
|
+
allScenarios.push({
|
|
446
|
+
id: s.id || `SC-${idx}`,
|
|
447
|
+
name: s.scenario ? s.scenario.name : `Scenario ${idx + 1}`,
|
|
448
|
+
moscow: s.moscow || 'untagged',
|
|
449
|
+
file: featureBase,
|
|
450
|
+
status: 'not-run'
|
|
451
|
+
});
|
|
452
|
+
});
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
// Write traceability matrix if requested or by default when .planning/ exists
|
|
457
|
+
const cwd = options.cwd || process.cwd();
|
|
458
|
+
const planningDir = path.join(cwd, '.planning');
|
|
459
|
+
if (allScenarios.length > 0 && fs.existsSync(planningDir)) {
|
|
460
|
+
try {
|
|
461
|
+
const matrixLines = [
|
|
462
|
+
'# BDD Traceability Matrix',
|
|
463
|
+
'',
|
|
464
|
+
'| ID | Scenario | MoSCoW | Feature File | Status |',
|
|
465
|
+
'|-----|----------|--------|-------------|--------|'
|
|
466
|
+
];
|
|
467
|
+
for (const s of allScenarios) {
|
|
468
|
+
matrixLines.push(`| ${s.id} | ${s.name} | ${s.moscow} | ${s.file} | ⬜ ${s.status} |`);
|
|
469
|
+
}
|
|
470
|
+
matrixLines.push('');
|
|
471
|
+
matrixLines.push(`*Generated: ${new Date().toISOString()}*`);
|
|
472
|
+
fs.writeFileSync(
|
|
473
|
+
path.join(planningDir, 'bdd-traceability.md'),
|
|
474
|
+
matrixLines.join('\n'),
|
|
475
|
+
'utf8'
|
|
476
|
+
);
|
|
477
|
+
} catch {
|
|
478
|
+
// Non-fatal — traceability matrix is best-effort
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
return {
|
|
483
|
+
valid: results.every(r => r.valid),
|
|
484
|
+
files: results,
|
|
485
|
+
totalScenarios,
|
|
486
|
+
moscowCounts: totalMoscow
|
|
487
|
+
};
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
/**
|
|
491
|
+
* Recursively find all .feature files
|
|
492
|
+
* @param {string} dirPath
|
|
493
|
+
* @returns {string[]}
|
|
494
|
+
*/
|
|
495
|
+
function findFeatureFiles(dirPath) {
|
|
496
|
+
if (!fs.existsSync(dirPath)) return [];
|
|
497
|
+
|
|
498
|
+
const entries = fs.readdirSync(dirPath, { withFileTypes: true });
|
|
499
|
+
const files = [];
|
|
500
|
+
|
|
501
|
+
for (const entry of entries) {
|
|
502
|
+
const fullPath = path.join(dirPath, entry.name);
|
|
503
|
+
if (entry.isDirectory()) {
|
|
504
|
+
files.push(...findFeatureFiles(fullPath));
|
|
505
|
+
} else if (entry.name.endsWith('.feature')) {
|
|
506
|
+
files.push(fullPath);
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
return files;
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
/**
|
|
514
|
+
* Format validation report as human-readable markdown
|
|
515
|
+
* @param {object} result - From validateFeatureDirectory or validateFeatureFile
|
|
516
|
+
* @returns {string}
|
|
517
|
+
*/
|
|
518
|
+
function formatReport(result) {
|
|
519
|
+
const lines = [];
|
|
520
|
+
|
|
521
|
+
if (result.files) {
|
|
522
|
+
// Directory report
|
|
523
|
+
lines.push(`## BDD Validation Report`);
|
|
524
|
+
lines.push(`**Status:** ${result.valid ? '✓ PASS' : '✗ FAIL'}`);
|
|
525
|
+
lines.push(`**Files:** ${result.files.length} | **Scenarios:** ${result.totalScenarios}`);
|
|
526
|
+
lines.push('');
|
|
527
|
+
lines.push('### MoSCoW Distribution');
|
|
528
|
+
lines.push(`| Priority | Count |`);
|
|
529
|
+
lines.push(`|----------|-------|`);
|
|
530
|
+
for (const [key, val] of Object.entries(result.moscowCounts)) {
|
|
531
|
+
lines.push(`| @${key} | ${val} |`);
|
|
532
|
+
}
|
|
533
|
+
lines.push('');
|
|
534
|
+
lines.push('### File Results');
|
|
535
|
+
for (const f of result.files) {
|
|
536
|
+
const icon = f.valid ? '✓' : '✗';
|
|
537
|
+
lines.push(`- ${icon} \`${path.basename(f.file)}\` — ${f.summary}`);
|
|
538
|
+
if (!f.valid && f.parseErrors) {
|
|
539
|
+
for (const e of f.parseErrors) lines.push(` - **Parse Error:** ${e}`);
|
|
540
|
+
}
|
|
541
|
+
if (f.structure && f.structure.issues.length > 0) {
|
|
542
|
+
for (const e of f.structure.issues) lines.push(` - **Structure:** ${e}`);
|
|
543
|
+
}
|
|
544
|
+
if (f.moscow && f.moscow.issues.length > 0) {
|
|
545
|
+
for (const e of f.moscow.issues.slice(0, 3)) lines.push(` - **MoSCoW:** ${e}`);
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
} else {
|
|
549
|
+
// Single file report
|
|
550
|
+
lines.push(`## BDD Validation: ${path.basename(result.file)}`);
|
|
551
|
+
lines.push(`**Status:** ${result.valid ? '✓ PASS' : '✗ FAIL'}`);
|
|
552
|
+
lines.push(`**Scenarios:** ${result.scenarioCount}`);
|
|
553
|
+
|
|
554
|
+
if (result.invest) {
|
|
555
|
+
lines.push('');
|
|
556
|
+
lines.push('### INVEST Score');
|
|
557
|
+
lines.push(`**${result.invest.score}/${result.invest.max}** dimensions pass`);
|
|
558
|
+
for (const d of result.invest.dimensions) {
|
|
559
|
+
lines.push(`- ${d.passed ? '✓' : '✗'} **${d.letter}** — ${d.dimension}: ${d.note}`);
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
return lines.join('\n');
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
// ─────────────────────────────────────────────
|
|
568
|
+
// CLI Interface
|
|
569
|
+
// ─────────────────────────────────────────────
|
|
570
|
+
|
|
571
|
+
if (require.main === module) {
|
|
572
|
+
const args = process.argv.slice(2);
|
|
573
|
+
const cmd = args[0];
|
|
574
|
+
const target = args[1];
|
|
575
|
+
|
|
576
|
+
if (!cmd || !target) {
|
|
577
|
+
console.error('Usage: bdd-validator.cjs <validate-file|validate-dir|count-moscow> <path>');
|
|
578
|
+
process.exit(1);
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
try {
|
|
582
|
+
if (cmd === 'validate-file') {
|
|
583
|
+
const result = validateFeatureFile(target);
|
|
584
|
+
if (args.includes('--json')) {
|
|
585
|
+
console.log(JSON.stringify(result, null, 2));
|
|
586
|
+
} else {
|
|
587
|
+
console.log(formatReport(result));
|
|
588
|
+
process.exit(result.valid ? 0 : 1);
|
|
589
|
+
}
|
|
590
|
+
} else if (cmd === 'validate-dir') {
|
|
591
|
+
const result = validateFeatureDirectory(target);
|
|
592
|
+
if (args.includes('--json')) {
|
|
593
|
+
console.log(JSON.stringify(result, null, 2));
|
|
594
|
+
} else {
|
|
595
|
+
console.log(formatReport(result));
|
|
596
|
+
process.exit(result.valid ? 0 : 1);
|
|
597
|
+
}
|
|
598
|
+
} else if (cmd === 'count-moscow') {
|
|
599
|
+
const result = validateFeatureDirectory(target);
|
|
600
|
+
console.log(JSON.stringify(result.moscowCounts, null, 2));
|
|
601
|
+
} else {
|
|
602
|
+
console.error(`Unknown command: ${cmd}`);
|
|
603
|
+
process.exit(1);
|
|
604
|
+
}
|
|
605
|
+
} catch (err) {
|
|
606
|
+
console.error(`Error: ${err.message}`);
|
|
607
|
+
process.exit(1);
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
module.exports = {
|
|
612
|
+
parseFeatureFile,
|
|
613
|
+
validateFeatureFile,
|
|
614
|
+
validateFeatureDirectory,
|
|
615
|
+
validateMosCowTags,
|
|
616
|
+
validateINVEST,
|
|
617
|
+
validateStructure,
|
|
618
|
+
countByMosCow,
|
|
619
|
+
findFeatureFiles,
|
|
620
|
+
formatReport,
|
|
621
|
+
generateScenarioId
|
|
622
|
+
};
|