cap-pro 1.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/.claude-plugin/README.md +26 -0
- package/.claude-plugin/marketplace.json +24 -0
- package/.claude-plugin/plugin.json +24 -0
- package/LICENSE +21 -0
- package/README.ja-JP.md +834 -0
- package/README.ko-KR.md +823 -0
- package/README.md +806 -0
- package/README.pt-BR.md +452 -0
- package/README.zh-CN.md +800 -0
- package/agents/cap-architect.md +269 -0
- package/agents/cap-brainstormer.md +207 -0
- package/agents/cap-curator.md +276 -0
- package/agents/cap-debugger.md +365 -0
- package/agents/cap-designer.md +246 -0
- package/agents/cap-historian.md +464 -0
- package/agents/cap-migrator.md +291 -0
- package/agents/cap-prototyper.md +197 -0
- package/agents/cap-validator.md +308 -0
- package/bin/install.js +5433 -0
- package/cap/bin/cap-tools.cjs +853 -0
- package/cap/bin/lib/arc-scanner.cjs +344 -0
- package/cap/bin/lib/cap-affinity-engine.cjs +862 -0
- package/cap/bin/lib/cap-anchor.cjs +228 -0
- package/cap/bin/lib/cap-annotation-writer.cjs +340 -0
- package/cap/bin/lib/cap-checkpoint.cjs +434 -0
- package/cap/bin/lib/cap-cluster-detect.cjs +945 -0
- package/cap/bin/lib/cap-cluster-display.cjs +52 -0
- package/cap/bin/lib/cap-cluster-format.cjs +245 -0
- package/cap/bin/lib/cap-cluster-helpers.cjs +295 -0
- package/cap/bin/lib/cap-cluster-io.cjs +212 -0
- package/cap/bin/lib/cap-completeness.cjs +540 -0
- package/cap/bin/lib/cap-deps.cjs +583 -0
- package/cap/bin/lib/cap-design-families.cjs +332 -0
- package/cap/bin/lib/cap-design.cjs +966 -0
- package/cap/bin/lib/cap-divergence-detector.cjs +400 -0
- package/cap/bin/lib/cap-doctor.cjs +752 -0
- package/cap/bin/lib/cap-feature-map-internals.cjs +19 -0
- package/cap/bin/lib/cap-feature-map-migrate.cjs +335 -0
- package/cap/bin/lib/cap-feature-map-monorepo.cjs +885 -0
- package/cap/bin/lib/cap-feature-map-shard.cjs +315 -0
- package/cap/bin/lib/cap-feature-map.cjs +1943 -0
- package/cap/bin/lib/cap-fitness-score.cjs +1075 -0
- package/cap/bin/lib/cap-impact-analysis.cjs +652 -0
- package/cap/bin/lib/cap-learn-review.cjs +1072 -0
- package/cap/bin/lib/cap-learning-signals.cjs +627 -0
- package/cap/bin/lib/cap-loader.cjs +227 -0
- package/cap/bin/lib/cap-logger.cjs +57 -0
- package/cap/bin/lib/cap-memory-bridge.cjs +764 -0
- package/cap/bin/lib/cap-memory-confidence.cjs +452 -0
- package/cap/bin/lib/cap-memory-dir.cjs +987 -0
- package/cap/bin/lib/cap-memory-engine.cjs +698 -0
- package/cap/bin/lib/cap-memory-extends.cjs +398 -0
- package/cap/bin/lib/cap-memory-graph.cjs +790 -0
- package/cap/bin/lib/cap-memory-migrate.cjs +2015 -0
- package/cap/bin/lib/cap-memory-pin.cjs +183 -0
- package/cap/bin/lib/cap-memory-platform.cjs +490 -0
- package/cap/bin/lib/cap-memory-prune.cjs +707 -0
- package/cap/bin/lib/cap-memory-schema.cjs +812 -0
- package/cap/bin/lib/cap-migrate-tags.cjs +309 -0
- package/cap/bin/lib/cap-migrate.cjs +540 -0
- package/cap/bin/lib/cap-pattern-apply.cjs +1203 -0
- package/cap/bin/lib/cap-pattern-pipeline.cjs +1034 -0
- package/cap/bin/lib/cap-plugin-manifest.cjs +80 -0
- package/cap/bin/lib/cap-realtime-affinity.cjs +399 -0
- package/cap/bin/lib/cap-reconcile.cjs +570 -0
- package/cap/bin/lib/cap-research-gate.cjs +218 -0
- package/cap/bin/lib/cap-scope-filter.cjs +402 -0
- package/cap/bin/lib/cap-semantic-pipeline.cjs +1038 -0
- package/cap/bin/lib/cap-session-extract.cjs +987 -0
- package/cap/bin/lib/cap-session.cjs +445 -0
- package/cap/bin/lib/cap-snapshot-linkage.cjs +963 -0
- package/cap/bin/lib/cap-stack-docs.cjs +646 -0
- package/cap/bin/lib/cap-tag-observer.cjs +371 -0
- package/cap/bin/lib/cap-tag-scanner.cjs +1766 -0
- package/cap/bin/lib/cap-telemetry.cjs +466 -0
- package/cap/bin/lib/cap-test-audit.cjs +1438 -0
- package/cap/bin/lib/cap-thread-migrator.cjs +307 -0
- package/cap/bin/lib/cap-thread-synthesis.cjs +545 -0
- package/cap/bin/lib/cap-thread-tracker.cjs +519 -0
- package/cap/bin/lib/cap-trace.cjs +399 -0
- package/cap/bin/lib/cap-trust-mode.cjs +336 -0
- package/cap/bin/lib/cap-ui-design-editor.cjs +642 -0
- package/cap/bin/lib/cap-ui-mind-map.cjs +712 -0
- package/cap/bin/lib/cap-ui-thread-nav.cjs +693 -0
- package/cap/bin/lib/cap-ui.cjs +1245 -0
- package/cap/bin/lib/cap-upgrade.cjs +1028 -0
- package/cap/bin/lib/cli/arg-helpers.cjs +49 -0
- package/cap/bin/lib/cli/frontmatter-router.cjs +31 -0
- package/cap/bin/lib/cli/init-router.cjs +68 -0
- package/cap/bin/lib/cli/phase-router.cjs +102 -0
- package/cap/bin/lib/cli/state-router.cjs +61 -0
- package/cap/bin/lib/cli/template-router.cjs +37 -0
- package/cap/bin/lib/cli/uat-router.cjs +29 -0
- package/cap/bin/lib/cli/validation-router.cjs +26 -0
- package/cap/bin/lib/cli/verification-router.cjs +31 -0
- package/cap/bin/lib/cli/workstream-router.cjs +39 -0
- package/cap/bin/lib/commands.cjs +961 -0
- package/cap/bin/lib/config.cjs +467 -0
- package/cap/bin/lib/convention-reader.cjs +258 -0
- package/cap/bin/lib/core.cjs +1241 -0
- package/cap/bin/lib/feature-aggregator.cjs +423 -0
- package/cap/bin/lib/frontmatter.cjs +337 -0
- package/cap/bin/lib/init.cjs +1443 -0
- package/cap/bin/lib/manifest-generator.cjs +383 -0
- package/cap/bin/lib/milestone.cjs +253 -0
- package/cap/bin/lib/model-profiles.cjs +69 -0
- package/cap/bin/lib/monorepo-context.cjs +226 -0
- package/cap/bin/lib/monorepo-migrator.cjs +509 -0
- package/cap/bin/lib/phase.cjs +889 -0
- package/cap/bin/lib/profile-output.cjs +989 -0
- package/cap/bin/lib/profile-pipeline.cjs +540 -0
- package/cap/bin/lib/roadmap.cjs +330 -0
- package/cap/bin/lib/security.cjs +394 -0
- package/cap/bin/lib/session-manager.cjs +292 -0
- package/cap/bin/lib/skeleton-generator.cjs +179 -0
- package/cap/bin/lib/state.cjs +1032 -0
- package/cap/bin/lib/template.cjs +231 -0
- package/cap/bin/lib/test-detector.cjs +62 -0
- package/cap/bin/lib/uat.cjs +283 -0
- package/cap/bin/lib/verify.cjs +889 -0
- package/cap/bin/lib/workspace-detector.cjs +371 -0
- package/cap/bin/lib/workstream.cjs +492 -0
- package/cap/commands/gsd/workstreams.md +63 -0
- package/cap/references/arc-standard.md +315 -0
- package/cap/references/cap-agent-architecture.md +101 -0
- package/cap/references/cap-gitignore-template +9 -0
- package/cap/references/cap-zero-deps.md +158 -0
- package/cap/references/checkpoints.md +778 -0
- package/cap/references/continuation-format.md +249 -0
- package/cap/references/contract-test-templates.md +312 -0
- package/cap/references/feature-map-template.md +25 -0
- package/cap/references/git-integration.md +295 -0
- package/cap/references/git-planning-commit.md +38 -0
- package/cap/references/model-profiles.md +174 -0
- package/cap/references/phase-numbering.md +126 -0
- package/cap/references/planning-config.md +202 -0
- package/cap/references/property-test-templates.md +316 -0
- package/cap/references/security-test-templates.md +347 -0
- package/cap/references/session-template.json +8 -0
- package/cap/references/tdd.md +263 -0
- package/cap/references/user-profiling.md +681 -0
- package/cap/references/verification-patterns.md +612 -0
- package/cap/templates/UAT.md +265 -0
- package/cap/templates/claude-md.md +175 -0
- package/cap/templates/codebase/architecture.md +255 -0
- package/cap/templates/codebase/concerns.md +310 -0
- package/cap/templates/codebase/conventions.md +307 -0
- package/cap/templates/codebase/integrations.md +280 -0
- package/cap/templates/codebase/stack.md +186 -0
- package/cap/templates/codebase/structure.md +285 -0
- package/cap/templates/codebase/testing.md +480 -0
- package/cap/templates/config.json +44 -0
- package/cap/templates/context.md +352 -0
- package/cap/templates/continue-here.md +78 -0
- package/cap/templates/copilot-instructions.md +7 -0
- package/cap/templates/debug-subagent-prompt.md +91 -0
- package/cap/templates/discussion-log.md +63 -0
- package/cap/templates/milestone-archive.md +123 -0
- package/cap/templates/milestone.md +115 -0
- package/cap/templates/phase-prompt.md +610 -0
- package/cap/templates/planner-subagent-prompt.md +117 -0
- package/cap/templates/project.md +186 -0
- package/cap/templates/requirements.md +231 -0
- package/cap/templates/research-project/ARCHITECTURE.md +204 -0
- package/cap/templates/research-project/FEATURES.md +147 -0
- package/cap/templates/research-project/PITFALLS.md +200 -0
- package/cap/templates/research-project/STACK.md +120 -0
- package/cap/templates/research-project/SUMMARY.md +170 -0
- package/cap/templates/research.md +552 -0
- package/cap/templates/roadmap.md +202 -0
- package/cap/templates/state.md +176 -0
- package/cap/templates/summary.md +364 -0
- package/cap/templates/user-preferences.md +498 -0
- package/cap/templates/verification-report.md +322 -0
- package/cap/workflows/add-phase.md +112 -0
- package/cap/workflows/add-tests.md +351 -0
- package/cap/workflows/add-todo.md +158 -0
- package/cap/workflows/audit-milestone.md +340 -0
- package/cap/workflows/audit-uat.md +109 -0
- package/cap/workflows/autonomous.md +891 -0
- package/cap/workflows/check-todos.md +177 -0
- package/cap/workflows/cleanup.md +152 -0
- package/cap/workflows/complete-milestone.md +767 -0
- package/cap/workflows/diagnose-issues.md +231 -0
- package/cap/workflows/discovery-phase.md +289 -0
- package/cap/workflows/discuss-phase-assumptions.md +653 -0
- package/cap/workflows/discuss-phase.md +1049 -0
- package/cap/workflows/do.md +104 -0
- package/cap/workflows/execute-phase.md +846 -0
- package/cap/workflows/execute-plan.md +514 -0
- package/cap/workflows/fast.md +105 -0
- package/cap/workflows/forensics.md +265 -0
- package/cap/workflows/health.md +181 -0
- package/cap/workflows/help.md +660 -0
- package/cap/workflows/insert-phase.md +130 -0
- package/cap/workflows/list-phase-assumptions.md +178 -0
- package/cap/workflows/list-workspaces.md +56 -0
- package/cap/workflows/manager.md +362 -0
- package/cap/workflows/map-codebase.md +377 -0
- package/cap/workflows/milestone-summary.md +223 -0
- package/cap/workflows/new-milestone.md +486 -0
- package/cap/workflows/new-project.md +1250 -0
- package/cap/workflows/new-workspace.md +237 -0
- package/cap/workflows/next.md +97 -0
- package/cap/workflows/node-repair.md +92 -0
- package/cap/workflows/note.md +156 -0
- package/cap/workflows/pause-work.md +176 -0
- package/cap/workflows/plan-milestone-gaps.md +273 -0
- package/cap/workflows/plan-phase.md +857 -0
- package/cap/workflows/plant-seed.md +169 -0
- package/cap/workflows/pr-branch.md +129 -0
- package/cap/workflows/profile-user.md +449 -0
- package/cap/workflows/progress.md +507 -0
- package/cap/workflows/quick.md +757 -0
- package/cap/workflows/remove-phase.md +155 -0
- package/cap/workflows/remove-workspace.md +90 -0
- package/cap/workflows/research-phase.md +82 -0
- package/cap/workflows/resume-project.md +326 -0
- package/cap/workflows/review.md +228 -0
- package/cap/workflows/session-report.md +146 -0
- package/cap/workflows/settings.md +283 -0
- package/cap/workflows/ship.md +228 -0
- package/cap/workflows/stats.md +60 -0
- package/cap/workflows/transition.md +671 -0
- package/cap/workflows/ui-phase.md +298 -0
- package/cap/workflows/ui-review.md +161 -0
- package/cap/workflows/update.md +323 -0
- package/cap/workflows/validate-phase.md +170 -0
- package/cap/workflows/verify-phase.md +254 -0
- package/cap/workflows/verify-work.md +637 -0
- package/commands/cap/annotate.md +165 -0
- package/commands/cap/brainstorm.md +393 -0
- package/commands/cap/checkpoint.md +106 -0
- package/commands/cap/completeness.md +94 -0
- package/commands/cap/continue.md +72 -0
- package/commands/cap/debug.md +588 -0
- package/commands/cap/deps.md +169 -0
- package/commands/cap/design.md +479 -0
- package/commands/cap/init.md +354 -0
- package/commands/cap/iterate.md +249 -0
- package/commands/cap/learn.md +459 -0
- package/commands/cap/memory.md +275 -0
- package/commands/cap/migrate-feature-map.md +91 -0
- package/commands/cap/migrate-memory.md +108 -0
- package/commands/cap/migrate-tags.md +91 -0
- package/commands/cap/migrate.md +131 -0
- package/commands/cap/prototype.md +510 -0
- package/commands/cap/reconcile.md +121 -0
- package/commands/cap/review.md +360 -0
- package/commands/cap/save.md +72 -0
- package/commands/cap/scan.md +404 -0
- package/commands/cap/start.md +356 -0
- package/commands/cap/status.md +118 -0
- package/commands/cap/test-audit.md +262 -0
- package/commands/cap/test.md +394 -0
- package/commands/cap/trace.md +133 -0
- package/commands/cap/ui.md +167 -0
- package/hooks/dist/cap-check-update.js +115 -0
- package/hooks/dist/cap-context-monitor.js +185 -0
- package/hooks/dist/cap-learn-review-hook.js +114 -0
- package/hooks/dist/cap-learning-hook.js +192 -0
- package/hooks/dist/cap-memory.js +299 -0
- package/hooks/dist/cap-prompt-guard.js +97 -0
- package/hooks/dist/cap-statusline.js +157 -0
- package/hooks/dist/cap-tag-observer.js +115 -0
- package/hooks/dist/cap-version-check.js +112 -0
- package/hooks/dist/cap-workflow-guard.js +175 -0
- package/hooks/hooks.json +55 -0
- package/package.json +58 -0
- package/scripts/base64-scan.sh +262 -0
- package/scripts/build-hooks.js +93 -0
- package/scripts/cap-removal-checklist.md +202 -0
- package/scripts/prompt-injection-scan.sh +199 -0
- package/scripts/run-tests.cjs +181 -0
- package/scripts/secret-scan.sh +227 -0
|
@@ -0,0 +1,1438 @@
|
|
|
1
|
+
// @cap-feature(feature:F-007) Test Audit — assertion analysis, coverage parsing, mutation testing, anti-pattern detection
|
|
2
|
+
// @cap-decision Regex-based assertion counting -- no AST parsing needed for counting assert/expect patterns.
|
|
3
|
+
// @cap-decision Simple mutation engine -- flip operators, negate conditions, remove returns. No external mutation framework.
|
|
4
|
+
// @cap-constraint Zero external dependencies -- uses only Node.js built-ins (fs, path, child_process).
|
|
5
|
+
|
|
6
|
+
'use strict';
|
|
7
|
+
|
|
8
|
+
const fs = require('node:fs');
|
|
9
|
+
const path = require('node:path');
|
|
10
|
+
const { execSync } = require('node:child_process');
|
|
11
|
+
|
|
12
|
+
// Patterns that count as assertions
|
|
13
|
+
const ASSERTION_PATTERNS = [
|
|
14
|
+
/assert\.\w+/,
|
|
15
|
+
/expect\(/,
|
|
16
|
+
/\.toBe\(/,
|
|
17
|
+
/\.toEqual\(/,
|
|
18
|
+
/\.toThrow\(/,
|
|
19
|
+
/\.toHaveLength\(/,
|
|
20
|
+
/\.toContain\(/,
|
|
21
|
+
/\.toMatch\(/,
|
|
22
|
+
/\.toBeTruthy\(/,
|
|
23
|
+
/\.toBeFalsy\(/,
|
|
24
|
+
/\.toBeNull\(/,
|
|
25
|
+
/\.toBeUndefined\(/,
|
|
26
|
+
/\.toBeDefined\(/,
|
|
27
|
+
/\.toBeGreaterThan\(/,
|
|
28
|
+
/\.toBeLessThan\(/,
|
|
29
|
+
/\.toHaveBeenCalled/,
|
|
30
|
+
/\.toHaveProperty\(/,
|
|
31
|
+
/\.toStrictEqual\(/,
|
|
32
|
+
/\.rejects\./,
|
|
33
|
+
/\.resolves\./,
|
|
34
|
+
/assert\.strictEqual/,
|
|
35
|
+
/assert\.deepStrictEqual/,
|
|
36
|
+
/assert\.ok/,
|
|
37
|
+
/assert\.throws/,
|
|
38
|
+
/assert\.rejects/,
|
|
39
|
+
/assert\.doesNotThrow/,
|
|
40
|
+
/assert\.match/,
|
|
41
|
+
/assert\.notStrictEqual/,
|
|
42
|
+
];
|
|
43
|
+
|
|
44
|
+
// Weak assertion patterns (anti-patterns)
|
|
45
|
+
const WEAK_ASSERTION_PATTERNS = [
|
|
46
|
+
{ pattern: /\.toBeDefined\(\)/, name: 'toBeDefined-only', severity: 'warning', description: 'Weak assertion: only checks value is defined, not correctness' },
|
|
47
|
+
{ pattern: /\.toBeTruthy\(\)/, name: 'toBeTruthy-only', severity: 'warning', description: 'Weak assertion: only checks truthiness, not specific value' },
|
|
48
|
+
{ pattern: /\.toBeFalsy\(\)/, name: 'toBeFalsy-only', severity: 'info', description: 'Potentially weak assertion: only checks falsiness' },
|
|
49
|
+
{ pattern: /typeof\s+\w+\s*===?\s*['"]/, name: 'typeof-only', severity: 'warning', description: 'Weak assertion: only checks type, not value' },
|
|
50
|
+
{ pattern: /\.toMatchSnapshot\(\)/, name: 'snapshot-logic', severity: 'warning', description: 'Snapshot test on logic code -- prefer explicit assertions' },
|
|
51
|
+
{ pattern: /expect\([^)]+\)\s*$/, name: 'expect-no-matcher', severity: 'error', description: 'expect() without matcher -- test always passes' },
|
|
52
|
+
];
|
|
53
|
+
|
|
54
|
+
// Test block patterns for detecting individual tests
|
|
55
|
+
const TEST_BLOCK_RE = /^\s*(?:it|test)\s*\(\s*['"`]([^'"`]+)['"`]/;
|
|
56
|
+
const DESCRIBE_BLOCK_RE = /^\s*describe\s*\(\s*['"`]([^'"`]+)['"`]/;
|
|
57
|
+
|
|
58
|
+
// Default test file extensions
|
|
59
|
+
const DEFAULT_TEST_EXTENSIONS = ['.test.cjs', '.test.js', '.test.mjs', '.test.ts', '.test.tsx', '.spec.cjs', '.spec.js', '.spec.ts'];
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Find test files in a directory recursively.
|
|
63
|
+
* @param {string} dir - Directory to search
|
|
64
|
+
* @param {string[]} extensions - Test file extensions to match
|
|
65
|
+
* @returns {string[]} - Array of absolute file paths
|
|
66
|
+
*/
|
|
67
|
+
function findTestFiles(dir, extensions = DEFAULT_TEST_EXTENSIONS) {
|
|
68
|
+
const files = [];
|
|
69
|
+
const EXCLUDE = ['node_modules', '.git', 'dist', 'build', 'coverage', '.cap'];
|
|
70
|
+
|
|
71
|
+
function walk(d) {
|
|
72
|
+
let entries;
|
|
73
|
+
try {
|
|
74
|
+
entries = fs.readdirSync(d, { withFileTypes: true });
|
|
75
|
+
} catch (_e) {
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
for (const entry of entries) {
|
|
79
|
+
const fullPath = path.join(d, entry.name);
|
|
80
|
+
if (entry.isDirectory()) {
|
|
81
|
+
if (EXCLUDE.includes(entry.name)) continue;
|
|
82
|
+
walk(fullPath);
|
|
83
|
+
} else if (entry.isFile()) {
|
|
84
|
+
const hasTestExt = extensions.some(ext => entry.name.endsWith(ext));
|
|
85
|
+
if (hasTestExt) files.push(fullPath);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
walk(dir);
|
|
91
|
+
return files;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Count assertions in test files. Flags tests with zero assertions.
|
|
96
|
+
*
|
|
97
|
+
* @param {string} projectRoot
|
|
98
|
+
* @param {Object} options - { testPattern: glob, extensions: ['.test.ts', '.test.cjs'] }
|
|
99
|
+
* @returns {{ totalTests: number, totalAssertions: number, emptyTests: Array<{file, name, line}>, assertionDensity: number }}
|
|
100
|
+
*/
|
|
101
|
+
function analyzeAssertions(projectRoot, options = {}) {
|
|
102
|
+
const extensions = options.extensions || DEFAULT_TEST_EXTENSIONS;
|
|
103
|
+
const testFiles = findTestFiles(projectRoot, extensions);
|
|
104
|
+
|
|
105
|
+
let totalTests = 0;
|
|
106
|
+
let totalAssertions = 0;
|
|
107
|
+
const emptyTests = [];
|
|
108
|
+
|
|
109
|
+
for (const filePath of testFiles) {
|
|
110
|
+
let content;
|
|
111
|
+
try {
|
|
112
|
+
content = fs.readFileSync(filePath, 'utf8');
|
|
113
|
+
} catch (_e) {
|
|
114
|
+
continue;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const relativePath = path.relative(projectRoot, filePath);
|
|
118
|
+
const lines = content.split('\n');
|
|
119
|
+
|
|
120
|
+
// Track test blocks and their assertion counts
|
|
121
|
+
let currentTest = null;
|
|
122
|
+
let braceDepth = 0;
|
|
123
|
+
let testAssertionCount = 0;
|
|
124
|
+
|
|
125
|
+
for (let i = 0; i < lines.length; i++) {
|
|
126
|
+
const line = lines[i];
|
|
127
|
+
|
|
128
|
+
// Check for test block start
|
|
129
|
+
const testMatch = line.match(TEST_BLOCK_RE);
|
|
130
|
+
if (testMatch) {
|
|
131
|
+
// Save previous test if it had no assertions
|
|
132
|
+
if (currentTest && testAssertionCount === 0) {
|
|
133
|
+
emptyTests.push({
|
|
134
|
+
file: relativePath,
|
|
135
|
+
name: currentTest.name,
|
|
136
|
+
line: currentTest.line,
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
totalTests++;
|
|
140
|
+
currentTest = { name: testMatch[1], line: i + 1 };
|
|
141
|
+
testAssertionCount = 0;
|
|
142
|
+
braceDepth = 0;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// Count assertions on this line
|
|
146
|
+
if (currentTest) {
|
|
147
|
+
for (const pattern of ASSERTION_PATTERNS) {
|
|
148
|
+
if (pattern.test(line)) {
|
|
149
|
+
testAssertionCount++;
|
|
150
|
+
totalAssertions++;
|
|
151
|
+
break; // Count one assertion per line max
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// Check last test in file
|
|
158
|
+
if (currentTest && testAssertionCount === 0) {
|
|
159
|
+
emptyTests.push({
|
|
160
|
+
file: relativePath,
|
|
161
|
+
name: currentTest.name,
|
|
162
|
+
line: currentTest.line,
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
return {
|
|
168
|
+
totalTests,
|
|
169
|
+
totalAssertions,
|
|
170
|
+
emptyTests,
|
|
171
|
+
assertionDensity: totalTests > 0 ? Math.round((totalAssertions / totalTests) * 100) / 100 : 0,
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Run coverage analysis. Prefers Node's native `--experimental-test-coverage`
|
|
177
|
+
* (offline, zero-dep, available on Node >= 20). Falls back to `npx c8` when
|
|
178
|
+
* the test command isn't a `node --test` invocation or native coverage fails.
|
|
179
|
+
*
|
|
180
|
+
* @param {string} projectRoot
|
|
181
|
+
* @param {string} testCommand - e.g., 'node --test tests/' or 'npx vitest run'
|
|
182
|
+
* @param {{ preferC8?: boolean }} [options] - preferC8 forces the legacy path (tests only)
|
|
183
|
+
* @returns {{ lines: number, branches: number, functions: number, uncoveredFiles: string[], coverageByFile: Object, source?: ('native'|'c8') }}
|
|
184
|
+
*/
|
|
185
|
+
// @cap-todo(ac:F-053/AC-1) Prefer Node native --experimental-test-coverage when the test command is `node --test`.
|
|
186
|
+
// @cap-todo(ac:F-053/AC-2) Fall back to `npx c8` when native is unavailable or the test command uses vitest/etc.
|
|
187
|
+
function analyzeCoverage(projectRoot, testCommand, options) {
|
|
188
|
+
const opts = options || {};
|
|
189
|
+
const useC8 = opts.preferC8 === true || !supportsNativeCoverage(testCommand);
|
|
190
|
+
if (!useC8) {
|
|
191
|
+
const native = analyzeCoverageNative(projectRoot, testCommand);
|
|
192
|
+
if (!native.error) {
|
|
193
|
+
native.source = 'native';
|
|
194
|
+
return native;
|
|
195
|
+
}
|
|
196
|
+
// Native failed — fall through to c8 with a deprecation-safe error note preserved
|
|
197
|
+
}
|
|
198
|
+
const legacy = analyzeCoverageC8(projectRoot, testCommand);
|
|
199
|
+
legacy.source = 'c8';
|
|
200
|
+
return legacy;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// @cap-decision A test command qualifies for native coverage when it starts with
|
|
204
|
+
// `node --test` or `node --test-only …` and references files/paths directly. Commands
|
|
205
|
+
// that wrap tests in another runner (vitest, jest, ts-node) cannot be injected with
|
|
206
|
+
// `--experimental-test-coverage` — c8 remains the right tool for those.
|
|
207
|
+
function supportsNativeCoverage(testCommand) {
|
|
208
|
+
if (typeof testCommand !== 'string') return false;
|
|
209
|
+
const trimmed = testCommand.trim();
|
|
210
|
+
return /^node\s+(?:--[\w-]+(?:=\S+)?\s+)*--test(\s|$)/.test(trimmed);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Coverage via Node's built-in `--experimental-test-coverage`. Parses the
|
|
215
|
+
* text-format report emitted to stdout. Runs offline, no external deps.
|
|
216
|
+
*
|
|
217
|
+
* @param {string} projectRoot
|
|
218
|
+
* @param {string} testCommand - must start with `node --test …`
|
|
219
|
+
* @returns {{ lines:number, branches:number, functions:number, uncoveredFiles:string[], coverageByFile:Object, error?:string }}
|
|
220
|
+
*/
|
|
221
|
+
// @cap-todo(ac:F-053/AC-3) Parse Node's native coverage text into the same shape parseCoverage() returns.
|
|
222
|
+
// @cap-todo(ac:F-053/AC-4) Native path must work offline — no npx, no network.
|
|
223
|
+
function analyzeCoverageNative(projectRoot, testCommand) {
|
|
224
|
+
const result = {
|
|
225
|
+
lines: 0,
|
|
226
|
+
branches: 0,
|
|
227
|
+
functions: 0,
|
|
228
|
+
uncoveredFiles: [],
|
|
229
|
+
coverageByFile: {},
|
|
230
|
+
};
|
|
231
|
+
|
|
232
|
+
// Inject native coverage flags into the `node --test` invocation. Keep whatever
|
|
233
|
+
// flags the caller already passed (e.g. --test-isolation=none from run-tests.cjs).
|
|
234
|
+
const injected = testCommand.replace(
|
|
235
|
+
/^node(\s+)/,
|
|
236
|
+
`node$1--experimental-test-coverage --test-reporter=spec `
|
|
237
|
+
);
|
|
238
|
+
|
|
239
|
+
// Scrub env vars that would hijack the child's coverage channel — when /cap:test-audit
|
|
240
|
+
// is itself invoked under c8 or `node --experimental-test-coverage`, NODE_V8_COVERAGE
|
|
241
|
+
// and friends leak in and route the child's raw v8 profile into the parent's dir,
|
|
242
|
+
// leaving stdout without the expected text report.
|
|
243
|
+
const env = { ...process.env };
|
|
244
|
+
delete env.NODE_V8_COVERAGE;
|
|
245
|
+
delete env.NODE_OPTIONS;
|
|
246
|
+
|
|
247
|
+
let stdout;
|
|
248
|
+
try {
|
|
249
|
+
stdout = execSync(injected, {
|
|
250
|
+
cwd: projectRoot,
|
|
251
|
+
env,
|
|
252
|
+
encoding: 'utf8',
|
|
253
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
254
|
+
timeout: 180000,
|
|
255
|
+
maxBuffer: 32 * 1024 * 1024,
|
|
256
|
+
});
|
|
257
|
+
} catch (e) {
|
|
258
|
+
// Tests may fail but still produce coverage output on stdout
|
|
259
|
+
stdout = (e.stdout && e.stdout.toString()) || '';
|
|
260
|
+
if (!stdout.includes('start of coverage report')) {
|
|
261
|
+
result.error = 'Native coverage run failed: ' + (e.message || 'unknown error');
|
|
262
|
+
return result;
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
return parseNativeCoverageOutput(stdout, result);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* Parse the text-format coverage report Node emits between
|
|
271
|
+
* `ℹ start of coverage report` and `ℹ end of coverage report`. Extracts per-file
|
|
272
|
+
* percentages and the "all files" summary row. Accepts buffers without the ℹ prefix
|
|
273
|
+
* (the node --test reporter emits it but older versions may omit it).
|
|
274
|
+
*
|
|
275
|
+
* @param {string} stdout
|
|
276
|
+
* @param {Object} result - Result object to mutate in place
|
|
277
|
+
* @returns {Object}
|
|
278
|
+
*/
|
|
279
|
+
function parseNativeCoverageOutput(stdout, result) {
|
|
280
|
+
const lines = stdout.split('\n');
|
|
281
|
+
let inReport = false;
|
|
282
|
+
const FILE_ROW_RE = /^(?:ℹ\s*)?\s*([A-Za-z0-9._\-/]+\.(?:cjs|js|mjs|tsx?|jsx?))\s*\|\s*([0-9.]+)\s*\|\s*([0-9.]+)\s*\|\s*([0-9.]+)\s*\|\s*(.*)$/;
|
|
283
|
+
const ALL_FILES_RE = /^(?:ℹ\s*)?\s*all files\s*\|\s*([0-9.]+)\s*\|\s*([0-9.]+)\s*\|\s*([0-9.]+)\s*\|/i;
|
|
284
|
+
|
|
285
|
+
for (const raw of lines) {
|
|
286
|
+
if (raw.includes('start of coverage report')) { inReport = true; continue; }
|
|
287
|
+
if (raw.includes('end of coverage report')) { inReport = false; continue; }
|
|
288
|
+
if (!inReport) continue;
|
|
289
|
+
|
|
290
|
+
const all = raw.match(ALL_FILES_RE);
|
|
291
|
+
if (all) {
|
|
292
|
+
result.lines = Number(all[1]);
|
|
293
|
+
result.branches = Number(all[2]);
|
|
294
|
+
result.functions = Number(all[3]);
|
|
295
|
+
continue;
|
|
296
|
+
}
|
|
297
|
+
const m = raw.match(FILE_ROW_RE);
|
|
298
|
+
if (!m) continue;
|
|
299
|
+
const file = m[1];
|
|
300
|
+
const linePct = Number(m[2]);
|
|
301
|
+
const branchPct = Number(m[3]);
|
|
302
|
+
const funcPct = Number(m[4]);
|
|
303
|
+
result.coverageByFile[file] = { lines: linePct, branches: branchPct, functions: funcPct };
|
|
304
|
+
if (linePct < 50) result.uncoveredFiles.push(file);
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
if (Object.keys(result.coverageByFile).length === 0 && result.lines === 0) {
|
|
308
|
+
result.error = 'Native coverage produced no parseable rows';
|
|
309
|
+
}
|
|
310
|
+
return result;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/**
|
|
314
|
+
* Legacy c8 path. Kept as fallback for projects that use vitest/jest/etc.
|
|
315
|
+
* @param {string} projectRoot
|
|
316
|
+
* @param {string} testCommand
|
|
317
|
+
* @returns {{ lines:number, branches:number, functions:number, uncoveredFiles:string[], coverageByFile:Object, error?:string }}
|
|
318
|
+
*/
|
|
319
|
+
function analyzeCoverageC8(projectRoot, testCommand) {
|
|
320
|
+
const result = {
|
|
321
|
+
lines: 0,
|
|
322
|
+
branches: 0,
|
|
323
|
+
functions: 0,
|
|
324
|
+
uncoveredFiles: [],
|
|
325
|
+
coverageByFile: {},
|
|
326
|
+
};
|
|
327
|
+
|
|
328
|
+
// Check if c8 is available
|
|
329
|
+
try {
|
|
330
|
+
execSync('npx c8 --version', { cwd: projectRoot, stdio: 'pipe', timeout: 10000 });
|
|
331
|
+
} catch (_e) {
|
|
332
|
+
result.error = 'c8 not available. For node --test projects use native coverage (Node >= 20); otherwise install c8 via `npm install -D c8`.';
|
|
333
|
+
return result;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
// Run tests with c8 JSON reporter
|
|
337
|
+
const coverageDir = path.join(projectRoot, '.cap', 'coverage');
|
|
338
|
+
try {
|
|
339
|
+
execSync(
|
|
340
|
+
`npx c8 --reporter json --report-dir "${coverageDir}" ${testCommand}`,
|
|
341
|
+
{ cwd: projectRoot, stdio: 'pipe', timeout: 120000 }
|
|
342
|
+
);
|
|
343
|
+
} catch (e) {
|
|
344
|
+
// Tests might fail but still produce coverage
|
|
345
|
+
if (!fs.existsSync(path.join(coverageDir, 'coverage-summary.json'))) {
|
|
346
|
+
result.error = 'Coverage run failed: ' + (e.message || 'unknown error');
|
|
347
|
+
return result;
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
// Parse coverage JSON
|
|
352
|
+
const summaryPath = path.join(coverageDir, 'coverage-summary.json');
|
|
353
|
+
if (!fs.existsSync(summaryPath)) {
|
|
354
|
+
result.error = 'Coverage summary not found at ' + summaryPath;
|
|
355
|
+
return result;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
try {
|
|
359
|
+
const summary = JSON.parse(fs.readFileSync(summaryPath, 'utf8'));
|
|
360
|
+
const total = summary.total || {};
|
|
361
|
+
result.lines = (total.lines && total.lines.pct) || 0;
|
|
362
|
+
result.branches = (total.branches && total.branches.pct) || 0;
|
|
363
|
+
result.functions = (total.functions && total.functions.pct) || 0;
|
|
364
|
+
|
|
365
|
+
// Per-file coverage
|
|
366
|
+
for (const [filePath, data] of Object.entries(summary)) {
|
|
367
|
+
if (filePath === 'total') continue;
|
|
368
|
+
const relPath = path.relative(projectRoot, filePath);
|
|
369
|
+
const linePct = (data.lines && data.lines.pct) || 0;
|
|
370
|
+
result.coverageByFile[relPath] = {
|
|
371
|
+
lines: linePct,
|
|
372
|
+
branches: (data.branches && data.branches.pct) || 0,
|
|
373
|
+
functions: (data.functions && data.functions.pct) || 0,
|
|
374
|
+
};
|
|
375
|
+
if (linePct < 50) {
|
|
376
|
+
result.uncoveredFiles.push(relPath);
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
} catch (e) {
|
|
380
|
+
result.error = 'Failed to parse coverage JSON: ' + e.message;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
return result;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
// Mutation operators for simple mutation testing
|
|
387
|
+
const MUTATION_OPERATORS = [
|
|
388
|
+
{
|
|
389
|
+
name: 'flip-equality',
|
|
390
|
+
description: 'Flip === to !==',
|
|
391
|
+
pattern: /===/g,
|
|
392
|
+
replacement: '!==',
|
|
393
|
+
},
|
|
394
|
+
{
|
|
395
|
+
name: 'flip-inequality',
|
|
396
|
+
description: 'Flip !== to ===',
|
|
397
|
+
pattern: /!==/g,
|
|
398
|
+
replacement: '===',
|
|
399
|
+
},
|
|
400
|
+
{
|
|
401
|
+
name: 'flip-gt',
|
|
402
|
+
description: 'Flip > to <',
|
|
403
|
+
pattern: /(?<!=)>(?!=)/g,
|
|
404
|
+
replacement: '<',
|
|
405
|
+
},
|
|
406
|
+
{
|
|
407
|
+
name: 'flip-lt',
|
|
408
|
+
description: 'Flip < to >',
|
|
409
|
+
pattern: /(?<!=)<(?!=)/g,
|
|
410
|
+
replacement: '>',
|
|
411
|
+
},
|
|
412
|
+
{
|
|
413
|
+
name: 'flip-true',
|
|
414
|
+
description: 'Flip true to false',
|
|
415
|
+
pattern: /\btrue\b/g,
|
|
416
|
+
replacement: 'false',
|
|
417
|
+
},
|
|
418
|
+
{
|
|
419
|
+
name: 'flip-false',
|
|
420
|
+
description: 'Flip false to true',
|
|
421
|
+
pattern: /\bfalse\b/g,
|
|
422
|
+
replacement: 'true',
|
|
423
|
+
},
|
|
424
|
+
{
|
|
425
|
+
name: 'remove-return',
|
|
426
|
+
description: 'Remove return value (return undefined)',
|
|
427
|
+
pattern: /return\s+[^;}\n]+/g,
|
|
428
|
+
replacement: 'return undefined',
|
|
429
|
+
},
|
|
430
|
+
{
|
|
431
|
+
name: 'flip-plus-minus',
|
|
432
|
+
description: 'Flip + to -',
|
|
433
|
+
pattern: /(?<=[a-zA-Z0-9_)\]]) \+ (?=[a-zA-Z0-9_(])/g,
|
|
434
|
+
replacement: ' - ',
|
|
435
|
+
},
|
|
436
|
+
{
|
|
437
|
+
name: 'flip-and-or',
|
|
438
|
+
description: 'Flip && to ||',
|
|
439
|
+
pattern: /&&/g,
|
|
440
|
+
replacement: '||',
|
|
441
|
+
},
|
|
442
|
+
{
|
|
443
|
+
name: 'flip-or-and',
|
|
444
|
+
description: 'Flip || to &&',
|
|
445
|
+
pattern: /\|\|/g,
|
|
446
|
+
replacement: '&&',
|
|
447
|
+
},
|
|
448
|
+
];
|
|
449
|
+
|
|
450
|
+
/**
|
|
451
|
+
* Apply a single mutation to a specific line in file content.
|
|
452
|
+
*
|
|
453
|
+
* @param {string} content - File content
|
|
454
|
+
* @param {number} lineIndex - 0-based line index to mutate
|
|
455
|
+
* @param {Object} operator - Mutation operator with pattern and replacement
|
|
456
|
+
* @returns {{ mutated: string, applied: boolean, description: string }}
|
|
457
|
+
*/
|
|
458
|
+
function applyMutation(content, lineIndex, operator) {
|
|
459
|
+
const lines = content.split('\n');
|
|
460
|
+
if (lineIndex < 0 || lineIndex >= lines.length) {
|
|
461
|
+
return { mutated: content, applied: false, description: '' };
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
const originalLine = lines[lineIndex];
|
|
465
|
+
// Skip comment lines
|
|
466
|
+
if (/^\s*(?:\/\/|\/\*|\*|#|--)/.test(originalLine)) {
|
|
467
|
+
return { mutated: content, applied: false, description: '' };
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
const mutatedLine = originalLine.replace(operator.pattern, operator.replacement);
|
|
471
|
+
if (mutatedLine === originalLine) {
|
|
472
|
+
return { mutated: content, applied: false, description: '' };
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
lines[lineIndex] = mutatedLine;
|
|
476
|
+
return {
|
|
477
|
+
mutated: lines.join('\n'),
|
|
478
|
+
applied: true,
|
|
479
|
+
description: `${operator.description} on line ${lineIndex + 1}`,
|
|
480
|
+
};
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
/**
|
|
484
|
+
* Run mutation testing on specified files.
|
|
485
|
+
*
|
|
486
|
+
* @param {string} projectRoot
|
|
487
|
+
* @param {string[]} targetFiles - files to mutate (relative paths)
|
|
488
|
+
* @param {string} testCommand
|
|
489
|
+
* @param {Object} options - { mutations: 10, timeout: 30000 }
|
|
490
|
+
* @returns {{ mutationsTotal: number, mutationsCaught: number, mutationScore: number, survived: Array<{file, line, mutation, description}> }}
|
|
491
|
+
*/
|
|
492
|
+
function runMutationTests(projectRoot, targetFiles, testCommand, options = {}) {
|
|
493
|
+
const maxMutations = options.mutations || 10;
|
|
494
|
+
const timeout = options.timeout || 30000;
|
|
495
|
+
const result = {
|
|
496
|
+
mutationsTotal: 0,
|
|
497
|
+
mutationsCaught: 0,
|
|
498
|
+
mutationScore: 0,
|
|
499
|
+
survived: [],
|
|
500
|
+
};
|
|
501
|
+
|
|
502
|
+
// Collect candidate mutations from target files
|
|
503
|
+
const candidates = [];
|
|
504
|
+
for (const relFile of targetFiles) {
|
|
505
|
+
const absPath = path.join(projectRoot, relFile);
|
|
506
|
+
let content;
|
|
507
|
+
try {
|
|
508
|
+
content = fs.readFileSync(absPath, 'utf8');
|
|
509
|
+
} catch (_e) {
|
|
510
|
+
continue;
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
const lines = content.split('\n');
|
|
514
|
+
for (let i = 0; i < lines.length; i++) {
|
|
515
|
+
for (const op of MUTATION_OPERATORS) {
|
|
516
|
+
if (op.pattern.test(lines[i]) && !/^\s*(?:\/\/|\/\*|\*|#|--)/.test(lines[i])) {
|
|
517
|
+
candidates.push({ file: relFile, absPath, lineIndex: i, operator: op });
|
|
518
|
+
// Reset regex lastIndex since they are global
|
|
519
|
+
op.pattern.lastIndex = 0;
|
|
520
|
+
}
|
|
521
|
+
op.pattern.lastIndex = 0;
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
// Randomly select mutations up to maxMutations
|
|
527
|
+
const selected = [];
|
|
528
|
+
const shuffled = candidates.sort(() => Math.random() - 0.5);
|
|
529
|
+
for (let i = 0; i < Math.min(maxMutations, shuffled.length); i++) {
|
|
530
|
+
selected.push(shuffled[i]);
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
// Apply each mutation, run tests, check if caught
|
|
534
|
+
for (const candidate of selected) {
|
|
535
|
+
const originalContent = fs.readFileSync(candidate.absPath, 'utf8');
|
|
536
|
+
|
|
537
|
+
const { mutated, applied, description } = applyMutation(
|
|
538
|
+
originalContent,
|
|
539
|
+
candidate.lineIndex,
|
|
540
|
+
candidate.operator
|
|
541
|
+
);
|
|
542
|
+
|
|
543
|
+
if (!applied) continue;
|
|
544
|
+
|
|
545
|
+
result.mutationsTotal++;
|
|
546
|
+
|
|
547
|
+
// Write mutated file
|
|
548
|
+
try {
|
|
549
|
+
fs.writeFileSync(candidate.absPath, mutated, 'utf8');
|
|
550
|
+
} catch (_e) {
|
|
551
|
+
// Cannot write -- skip
|
|
552
|
+
continue;
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
// Run tests
|
|
556
|
+
let testsPassed = false;
|
|
557
|
+
try {
|
|
558
|
+
execSync(testCommand, {
|
|
559
|
+
cwd: projectRoot,
|
|
560
|
+
stdio: 'pipe',
|
|
561
|
+
timeout,
|
|
562
|
+
});
|
|
563
|
+
testsPassed = true;
|
|
564
|
+
} catch (_e) {
|
|
565
|
+
// Tests failed -- mutation was caught
|
|
566
|
+
testsPassed = false;
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
// Restore original
|
|
570
|
+
fs.writeFileSync(candidate.absPath, originalContent, 'utf8');
|
|
571
|
+
|
|
572
|
+
if (testsPassed) {
|
|
573
|
+
// Mutation survived -- tests didn't catch it
|
|
574
|
+
result.survived.push({
|
|
575
|
+
file: candidate.file,
|
|
576
|
+
line: candidate.lineIndex + 1,
|
|
577
|
+
mutation: candidate.operator.name,
|
|
578
|
+
description,
|
|
579
|
+
});
|
|
580
|
+
} else {
|
|
581
|
+
result.mutationsCaught++;
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
result.mutationScore = result.mutationsTotal > 0
|
|
586
|
+
? Math.round((result.mutationsCaught / result.mutationsTotal) * 100)
|
|
587
|
+
: 0;
|
|
588
|
+
|
|
589
|
+
return result;
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
// @cap-decision Test Diversity measures the breadth of test types — error paths, edge cases, boundary conditions.
|
|
593
|
+
// Projects that only test happy paths score low; adversarial testing is rewarded.
|
|
594
|
+
|
|
595
|
+
/** Patterns that indicate error-path testing. */
|
|
596
|
+
const ERROR_PATH_PATTERNS = [
|
|
597
|
+
/\bthrow[s]?\b/i,
|
|
598
|
+
/\breject[s]?\b/i,
|
|
599
|
+
/\berror\b/i,
|
|
600
|
+
/\bfail[s]?\b/i,
|
|
601
|
+
/\binvalid\b/i,
|
|
602
|
+
/\bmalformed\b/i,
|
|
603
|
+
/\bcorrupt/i,
|
|
604
|
+
/\btimeout\b/i,
|
|
605
|
+
/\bexception\b/i,
|
|
606
|
+
/\.toThrow\(/,
|
|
607
|
+
/assert\.throws\(/,
|
|
608
|
+
/assert\.rejects\(/,
|
|
609
|
+
];
|
|
610
|
+
|
|
611
|
+
/** Patterns that indicate edge-case / boundary testing. */
|
|
612
|
+
const EDGE_CASE_PATTERNS = [
|
|
613
|
+
/\bnull\b/,
|
|
614
|
+
/\bundefined\b/,
|
|
615
|
+
/\bempty\b/i,
|
|
616
|
+
/\bboundary\b/i,
|
|
617
|
+
/\bedge[- ]?case/i,
|
|
618
|
+
/\bzero\b/i,
|
|
619
|
+
/\bnegative\b/i,
|
|
620
|
+
/\boverflow\b/i,
|
|
621
|
+
/\bmax\b/i,
|
|
622
|
+
/\bmin\b/i,
|
|
623
|
+
/\bhuge\b/i,
|
|
624
|
+
/\blarge\b/i,
|
|
625
|
+
/\bspecial char/i,
|
|
626
|
+
/\bunicode\b/i,
|
|
627
|
+
/\badversarial\b/i,
|
|
628
|
+
];
|
|
629
|
+
|
|
630
|
+
/**
|
|
631
|
+
* Analyze test diversity — measures how well tests cover error paths and edge cases.
|
|
632
|
+
* Returns a diversity score (0-100) based on the proportion of tests that exercise
|
|
633
|
+
* non-happy-path scenarios.
|
|
634
|
+
*
|
|
635
|
+
* @param {string} projectRoot
|
|
636
|
+
* @param {Object} [options]
|
|
637
|
+
* @param {string[]} [options.extensions] - File extensions to scan
|
|
638
|
+
* @returns {{ diversityScore: number, totalTests: number, errorPathTests: number, edgeCaseTests: number, happyPathOnlyTests: number, diversityRatio: number }}
|
|
639
|
+
*/
|
|
640
|
+
function analyzeTestDiversity(projectRoot, options = {}) {
|
|
641
|
+
const testFiles = findTestFiles(projectRoot, options.extensions);
|
|
642
|
+
let totalTests = 0;
|
|
643
|
+
let errorPathTests = 0;
|
|
644
|
+
let edgeCaseTests = 0;
|
|
645
|
+
|
|
646
|
+
for (const filePath of testFiles) {
|
|
647
|
+
let content;
|
|
648
|
+
try {
|
|
649
|
+
content = fs.readFileSync(filePath, 'utf8');
|
|
650
|
+
} catch (_e) {
|
|
651
|
+
continue;
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
const lines = content.split('\n');
|
|
655
|
+
|
|
656
|
+
for (let i = 0; i < lines.length; i++) {
|
|
657
|
+
const testMatch = lines[i].match(TEST_BLOCK_RE);
|
|
658
|
+
if (!testMatch) continue;
|
|
659
|
+
|
|
660
|
+
totalTests++;
|
|
661
|
+
const testName = testMatch[1] || '';
|
|
662
|
+
|
|
663
|
+
// Collect test body (until next test or describe block)
|
|
664
|
+
let testBody = testName;
|
|
665
|
+
for (let j = i + 1; j < lines.length; j++) {
|
|
666
|
+
if (TEST_BLOCK_RE.test(lines[j]) || DESCRIBE_BLOCK_RE.test(lines[j])) break;
|
|
667
|
+
testBody += ' ' + lines[j];
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
// Check if test exercises error paths
|
|
671
|
+
const isErrorPath = ERROR_PATH_PATTERNS.some(p => p.test(testName)) ||
|
|
672
|
+
ERROR_PATH_PATTERNS.some(p => p.test(testBody));
|
|
673
|
+
|
|
674
|
+
// Check if test exercises edge cases
|
|
675
|
+
const isEdgeCase = EDGE_CASE_PATTERNS.some(p => p.test(testName)) ||
|
|
676
|
+
EDGE_CASE_PATTERNS.some(p => p.test(testBody));
|
|
677
|
+
|
|
678
|
+
if (isErrorPath) errorPathTests++;
|
|
679
|
+
if (isEdgeCase) edgeCaseTests++;
|
|
680
|
+
}
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
const diverseTests = new Set(); // count unique diverse tests (a test can be both error + edge)
|
|
684
|
+
// We approximate: diverse = max(errorPathTests, edgeCaseTests) + min(errorPathTests, edgeCaseTests) * 0.5
|
|
685
|
+
// This avoids double-counting while rewarding tests that cover both
|
|
686
|
+
const diverseCount = Math.min(totalTests, errorPathTests + edgeCaseTests);
|
|
687
|
+
const diversityRatio = totalTests > 0 ? diverseCount / totalTests : 0;
|
|
688
|
+
const diversityScore = Math.round(diversityRatio * 100);
|
|
689
|
+
|
|
690
|
+
return {
|
|
691
|
+
diversityScore,
|
|
692
|
+
totalTests,
|
|
693
|
+
errorPathTests,
|
|
694
|
+
edgeCaseTests,
|
|
695
|
+
happyPathOnlyTests: Math.max(0, totalTests - diverseCount),
|
|
696
|
+
diversityRatio: Math.round(diversityRatio * 100) / 100,
|
|
697
|
+
};
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
/**
|
|
701
|
+
* Generate spot-check suggestions for human review.
|
|
702
|
+
*
|
|
703
|
+
* @param {string} projectRoot
|
|
704
|
+
* @param {Object} options - { count: 3, criticalPaths: ['auth', 'payment', 'booking'] }
|
|
705
|
+
* @returns {Array<{ file: string, testName: string, line: number, suggestion: string, productionFile: string, productionLine: number }>}
|
|
706
|
+
*/
|
|
707
|
+
// @cap-decision Critical Path Coverage measures whether the most important code paths have dedicated tests.
|
|
708
|
+
// Uses configurable path keywords to identify critical areas and checks for test file presence + assertion density.
|
|
709
|
+
|
|
710
|
+
/** Default critical path keywords — areas where bugs are most costly. */
|
|
711
|
+
const DEFAULT_CRITICAL_PATHS = ['auth', 'security', 'payment', 'session', 'migration', 'rls', 'permission', 'encrypt', 'token', 'credential'];
|
|
712
|
+
|
|
713
|
+
/**
|
|
714
|
+
* Analyze critical path test coverage.
|
|
715
|
+
* Scans source files for critical path keywords, then checks if corresponding test files exist
|
|
716
|
+
* and have sufficient assertions.
|
|
717
|
+
*
|
|
718
|
+
* @param {string} projectRoot
|
|
719
|
+
* @param {Object} [options]
|
|
720
|
+
* @param {string[]} [options.criticalPaths] - Keywords identifying critical paths
|
|
721
|
+
* @param {string[]} [options.extensions] - Test file extensions
|
|
722
|
+
* @returns {{ score: number, criticalFiles: number, testedFiles: number, wellTestedFiles: number, untestedPaths: string[], coverage: number }}
|
|
723
|
+
*/
|
|
724
|
+
function analyzeCriticalPathCoverage(projectRoot, options = {}) {
|
|
725
|
+
const criticalKeywords = options.criticalPaths || DEFAULT_CRITICAL_PATHS;
|
|
726
|
+
|
|
727
|
+
// Step 1: Find source files matching critical path keywords
|
|
728
|
+
const libDir = path.join(projectRoot, 'cap', 'bin', 'lib');
|
|
729
|
+
let sourceFiles = [];
|
|
730
|
+
try {
|
|
731
|
+
if (fs.existsSync(libDir)) {
|
|
732
|
+
sourceFiles = fs.readdirSync(libDir)
|
|
733
|
+
.filter(f => f.endsWith('.cjs') && !f.includes('.test.'))
|
|
734
|
+
.map(f => ({ name: f, path: path.join('cap', 'bin', 'lib', f) }));
|
|
735
|
+
}
|
|
736
|
+
} catch (_e) { /* ignore */ }
|
|
737
|
+
|
|
738
|
+
// Also scan other common source directories
|
|
739
|
+
const otherDirs = ['bin', 'hooks', 'scripts'];
|
|
740
|
+
for (const dir of otherDirs) {
|
|
741
|
+
const dirPath = path.join(projectRoot, dir);
|
|
742
|
+
try {
|
|
743
|
+
if (fs.existsSync(dirPath)) {
|
|
744
|
+
const files = fs.readdirSync(dirPath)
|
|
745
|
+
.filter(f => f.endsWith('.js') || f.endsWith('.cjs') || f.endsWith('.mjs'))
|
|
746
|
+
.map(f => ({ name: f, path: path.join(dir, f) }));
|
|
747
|
+
sourceFiles.push(...files);
|
|
748
|
+
}
|
|
749
|
+
} catch (_e) { /* ignore */ }
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
// Identify critical source files — files whose name or content contains critical keywords
|
|
753
|
+
const criticalFiles = [];
|
|
754
|
+
for (const sf of sourceFiles) {
|
|
755
|
+
const lowerName = sf.name.toLowerCase();
|
|
756
|
+
const isCriticalByName = criticalKeywords.some(kw => lowerName.includes(kw));
|
|
757
|
+
|
|
758
|
+
if (isCriticalByName) {
|
|
759
|
+
criticalFiles.push(sf);
|
|
760
|
+
continue;
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
// Check file content for critical keywords (first 50 lines)
|
|
764
|
+
try {
|
|
765
|
+
const content = fs.readFileSync(path.join(projectRoot, sf.path), 'utf8');
|
|
766
|
+
const head = content.split('\n').slice(0, 50).join(' ').toLowerCase();
|
|
767
|
+
const isCriticalByContent = criticalKeywords.some(kw => head.includes(kw));
|
|
768
|
+
if (isCriticalByContent) {
|
|
769
|
+
criticalFiles.push(sf);
|
|
770
|
+
}
|
|
771
|
+
} catch (_e) { /* ignore */ }
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
if (criticalFiles.length === 0) {
|
|
775
|
+
return { score: 100, criticalFiles: 0, testedFiles: 0, wellTestedFiles: 0, untestedPaths: [], coverage: 1.0 };
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
// Step 2: Check which critical files have corresponding test files with assertions
|
|
779
|
+
const testFiles = findTestFiles(projectRoot, options.extensions);
|
|
780
|
+
const testFileNames = testFiles.map(f => path.basename(f).toLowerCase());
|
|
781
|
+
const testFileContents = {};
|
|
782
|
+
for (const tf of testFiles) {
|
|
783
|
+
try {
|
|
784
|
+
testFileContents[path.basename(tf).toLowerCase()] = fs.readFileSync(tf, 'utf8');
|
|
785
|
+
} catch (_e) { /* ignore */ }
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
let testedFiles = 0;
|
|
789
|
+
let wellTestedFiles = 0;
|
|
790
|
+
const untestedPaths = [];
|
|
791
|
+
|
|
792
|
+
for (const cf of criticalFiles) {
|
|
793
|
+
// Derive expected test file name: cap-session.cjs → cap-session.test.cjs
|
|
794
|
+
const baseName = cf.name.replace(/\.(cjs|js|mjs)$/, '');
|
|
795
|
+
const possibleTestNames = [
|
|
796
|
+
`${baseName}.test.cjs`,
|
|
797
|
+
`${baseName}.test.js`,
|
|
798
|
+
`${baseName}.test.ts`,
|
|
799
|
+
`${baseName}.test.mjs`,
|
|
800
|
+
];
|
|
801
|
+
|
|
802
|
+
const matchedTest = possibleTestNames.find(tn => testFileNames.includes(tn));
|
|
803
|
+
if (!matchedTest) {
|
|
804
|
+
untestedPaths.push(cf.path);
|
|
805
|
+
continue;
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
testedFiles++;
|
|
809
|
+
|
|
810
|
+
// Check assertion density in the test file
|
|
811
|
+
const content = testFileContents[matchedTest] || '';
|
|
812
|
+
let assertionCount = 0;
|
|
813
|
+
let testCount = 0;
|
|
814
|
+
for (const line of content.split('\n')) {
|
|
815
|
+
if (TEST_BLOCK_RE.test(line)) testCount++;
|
|
816
|
+
for (const p of ASSERTION_PATTERNS) {
|
|
817
|
+
if (p.test(line)) { assertionCount++; break; }
|
|
818
|
+
}
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
// "Well tested" = at least 5 tests and 2+ assertions per test
|
|
822
|
+
if (testCount >= 5 && (testCount > 0 ? assertionCount / testCount : 0) >= 2) {
|
|
823
|
+
wellTestedFiles++;
|
|
824
|
+
}
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
const coverage = criticalFiles.length > 0 ? testedFiles / criticalFiles.length : 0;
|
|
828
|
+
const wellTestedRatio = criticalFiles.length > 0 ? wellTestedFiles / criticalFiles.length : 0;
|
|
829
|
+
// Score: 60% from having tests, 40% from having GOOD tests
|
|
830
|
+
const score = Math.round((coverage * 0.6 + wellTestedRatio * 0.4) * 100);
|
|
831
|
+
|
|
832
|
+
return {
|
|
833
|
+
score,
|
|
834
|
+
criticalFiles: criticalFiles.length,
|
|
835
|
+
testedFiles,
|
|
836
|
+
wellTestedFiles,
|
|
837
|
+
untestedPaths,
|
|
838
|
+
coverage: Math.round(coverage * 100) / 100,
|
|
839
|
+
};
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
function generateSpotChecks(projectRoot, options = {}) {
|
|
843
|
+
const count = options.count || 3;
|
|
844
|
+
const criticalPaths = options.criticalPaths || ['auth', 'payment', 'booking', 'rls', 'security'];
|
|
845
|
+
const testFiles = findTestFiles(projectRoot);
|
|
846
|
+
const checks = [];
|
|
847
|
+
|
|
848
|
+
for (const filePath of testFiles) {
|
|
849
|
+
let content;
|
|
850
|
+
try {
|
|
851
|
+
content = fs.readFileSync(filePath, 'utf8');
|
|
852
|
+
} catch (_e) {
|
|
853
|
+
continue;
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
const relativePath = path.relative(projectRoot, filePath);
|
|
857
|
+
const lines = content.split('\n');
|
|
858
|
+
const lowerPath = relativePath.toLowerCase();
|
|
859
|
+
|
|
860
|
+
// Score based on critical path presence
|
|
861
|
+
const isCritical = criticalPaths.some(cp => lowerPath.includes(cp));
|
|
862
|
+
|
|
863
|
+
for (let i = 0; i < lines.length; i++) {
|
|
864
|
+
const testMatch = lines[i].match(TEST_BLOCK_RE);
|
|
865
|
+
if (!testMatch) continue;
|
|
866
|
+
|
|
867
|
+
// Count assertions in this test (rough: until next test or describe)
|
|
868
|
+
let assertCount = 0;
|
|
869
|
+
for (let j = i + 1; j < lines.length; j++) {
|
|
870
|
+
if (TEST_BLOCK_RE.test(lines[j]) || DESCRIBE_BLOCK_RE.test(lines[j])) break;
|
|
871
|
+
for (const p of ASSERTION_PATTERNS) {
|
|
872
|
+
if (p.test(lines[j])) { assertCount++; break; }
|
|
873
|
+
}
|
|
874
|
+
}
|
|
875
|
+
|
|
876
|
+
// Try to find associated production file
|
|
877
|
+
let productionFile = '';
|
|
878
|
+
let productionLine = 0;
|
|
879
|
+
// Look for require/import statements to find the production module
|
|
880
|
+
for (let j = 0; j < Math.min(20, lines.length); j++) {
|
|
881
|
+
const reqMatch = lines[j].match(/require\(['"]([^'"]+)['"]\)/);
|
|
882
|
+
const impMatch = lines[j].match(/from\s+['"]([^'"]+)['"]/);
|
|
883
|
+
const mod = reqMatch ? reqMatch[1] : (impMatch ? impMatch[1] : null);
|
|
884
|
+
if (mod && !mod.includes('node:') && !mod.includes('vitest') && !mod.includes('assert')) {
|
|
885
|
+
productionFile = mod;
|
|
886
|
+
productionLine = 1;
|
|
887
|
+
break;
|
|
888
|
+
}
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
const score = (isCritical ? 10 : 0) + Math.max(0, 5 - assertCount);
|
|
892
|
+
checks.push({
|
|
893
|
+
file: relativePath,
|
|
894
|
+
testName: testMatch[1],
|
|
895
|
+
line: i + 1,
|
|
896
|
+
suggestion: isCritical
|
|
897
|
+
? `Critical path test -- verify this catches real failures`
|
|
898
|
+
: `Low assertion count (${assertCount}) -- verify test is meaningful`,
|
|
899
|
+
productionFile,
|
|
900
|
+
productionLine,
|
|
901
|
+
_score: score,
|
|
902
|
+
});
|
|
903
|
+
}
|
|
904
|
+
}
|
|
905
|
+
|
|
906
|
+
// Sort by score descending and take top N
|
|
907
|
+
checks.sort((a, b) => b._score - a._score);
|
|
908
|
+
return checks.slice(0, count).map(({ _score, ...rest }) => rest);
|
|
909
|
+
}
|
|
910
|
+
|
|
911
|
+
/**
|
|
912
|
+
* Detect test quality anti-patterns.
|
|
913
|
+
*
|
|
914
|
+
* @param {string} projectRoot
|
|
915
|
+
* @param {Object} options
|
|
916
|
+
* @returns {{ flags: Array<{file, line, pattern, severity, description}> }}
|
|
917
|
+
*/
|
|
918
|
+
function detectAntiPatterns(projectRoot, options = {}) {
|
|
919
|
+
const extensions = options.extensions || DEFAULT_TEST_EXTENSIONS;
|
|
920
|
+
const testFiles = findTestFiles(projectRoot, extensions);
|
|
921
|
+
const flags = [];
|
|
922
|
+
|
|
923
|
+
for (const filePath of testFiles) {
|
|
924
|
+
let content;
|
|
925
|
+
try {
|
|
926
|
+
content = fs.readFileSync(filePath, 'utf8');
|
|
927
|
+
} catch (_e) {
|
|
928
|
+
continue;
|
|
929
|
+
}
|
|
930
|
+
|
|
931
|
+
const relativePath = path.relative(projectRoot, filePath);
|
|
932
|
+
const lines = content.split('\n');
|
|
933
|
+
let insideTest = false;
|
|
934
|
+
let testHasStrongAssertion = false;
|
|
935
|
+
let testStartLine = 0;
|
|
936
|
+
let testName = '';
|
|
937
|
+
|
|
938
|
+
for (let i = 0; i < lines.length; i++) {
|
|
939
|
+
const line = lines[i];
|
|
940
|
+
|
|
941
|
+
// Track test block boundaries
|
|
942
|
+
const testMatch = line.match(TEST_BLOCK_RE);
|
|
943
|
+
if (testMatch) {
|
|
944
|
+
// Check previous test for weak-only assertions
|
|
945
|
+
if (insideTest && !testHasStrongAssertion && testName) {
|
|
946
|
+
flags.push({
|
|
947
|
+
file: relativePath,
|
|
948
|
+
line: testStartLine,
|
|
949
|
+
pattern: 'weak-assertions-only',
|
|
950
|
+
severity: 'warning',
|
|
951
|
+
description: `Test "${testName}" may only have weak assertions`,
|
|
952
|
+
});
|
|
953
|
+
}
|
|
954
|
+
insideTest = true;
|
|
955
|
+
testHasStrongAssertion = false;
|
|
956
|
+
testStartLine = i + 1;
|
|
957
|
+
testName = testMatch[1];
|
|
958
|
+
}
|
|
959
|
+
|
|
960
|
+
// Check for weak assertion patterns
|
|
961
|
+
for (const weak of WEAK_ASSERTION_PATTERNS) {
|
|
962
|
+
if (weak.pattern.test(line)) {
|
|
963
|
+
flags.push({
|
|
964
|
+
file: relativePath,
|
|
965
|
+
line: i + 1,
|
|
966
|
+
pattern: weak.name,
|
|
967
|
+
severity: weak.severity,
|
|
968
|
+
description: weak.description,
|
|
969
|
+
});
|
|
970
|
+
}
|
|
971
|
+
}
|
|
972
|
+
|
|
973
|
+
// Check for strong assertion (anything in ASSERTION_PATTERNS that is not in weak list)
|
|
974
|
+
if (insideTest) {
|
|
975
|
+
const isAssertion = ASSERTION_PATTERNS.some(p => p.test(line));
|
|
976
|
+
const isWeak = WEAK_ASSERTION_PATTERNS.some(w => w.pattern.test(line));
|
|
977
|
+
if (isAssertion && !isWeak) {
|
|
978
|
+
testHasStrongAssertion = true;
|
|
979
|
+
}
|
|
980
|
+
}
|
|
981
|
+
|
|
982
|
+
// Check for empty test body: it('name', () => {})
|
|
983
|
+
if (/(?:it|test)\s*\([^)]+,\s*(?:\(\)\s*=>|function\s*\(\))\s*\{\s*\}\s*\)/.test(line)) {
|
|
984
|
+
flags.push({
|
|
985
|
+
file: relativePath,
|
|
986
|
+
line: i + 1,
|
|
987
|
+
pattern: 'empty-test-body',
|
|
988
|
+
severity: 'error',
|
|
989
|
+
description: 'Empty test body -- test will always pass',
|
|
990
|
+
});
|
|
991
|
+
}
|
|
992
|
+
}
|
|
993
|
+
|
|
994
|
+
// Check final test in file
|
|
995
|
+
if (insideTest && !testHasStrongAssertion && testName) {
|
|
996
|
+
flags.push({
|
|
997
|
+
file: relativePath,
|
|
998
|
+
line: testStartLine,
|
|
999
|
+
pattern: 'weak-assertions-only',
|
|
1000
|
+
severity: 'warning',
|
|
1001
|
+
description: `Test "${testName}" may only have weak assertions`,
|
|
1002
|
+
});
|
|
1003
|
+
}
|
|
1004
|
+
}
|
|
1005
|
+
|
|
1006
|
+
return { flags };
|
|
1007
|
+
}
|
|
1008
|
+
|
|
1009
|
+
/**
|
|
1010
|
+
* Compute trust score from audit components.
|
|
1011
|
+
*
|
|
1012
|
+
* @param {Object} assertions - from analyzeAssertions
|
|
1013
|
+
* @param {Object} coverage - from analyzeCoverage (may be null)
|
|
1014
|
+
* @param {Object} mutations - from runMutationTests (may be null)
|
|
1015
|
+
* @param {Object} antiPatterns - from detectAntiPatterns
|
|
1016
|
+
* @returns {number} - 0 to 100
|
|
1017
|
+
*/
|
|
1018
|
+
function computeTrustScore(assertions, coverage, mutations, antiPatterns, diversity, criticalPath) {
|
|
1019
|
+
let score = 0;
|
|
1020
|
+
|
|
1021
|
+
// Assertion density (max 25 points)
|
|
1022
|
+
// 2+ assertions per test = full marks
|
|
1023
|
+
if (assertions.assertionDensity >= 2) score += 25;
|
|
1024
|
+
else if (assertions.assertionDensity >= 1) score += 15;
|
|
1025
|
+
else if (assertions.assertionDensity >= 0.5) score += 8;
|
|
1026
|
+
|
|
1027
|
+
// Empty tests penalty (max -10)
|
|
1028
|
+
if (assertions.totalTests > 0) {
|
|
1029
|
+
const emptyRatio = assertions.emptyTests.length / assertions.totalTests;
|
|
1030
|
+
score -= Math.round(emptyRatio * 10);
|
|
1031
|
+
}
|
|
1032
|
+
|
|
1033
|
+
// Coverage (max 25 points)
|
|
1034
|
+
if (coverage && !coverage.error) {
|
|
1035
|
+
score += Math.round(coverage.lines * 0.13); // Up to 13 points
|
|
1036
|
+
score += Math.round(coverage.branches * 0.08); // Up to 8 points
|
|
1037
|
+
score += Math.round(coverage.functions * 0.04); // Up to 4 points
|
|
1038
|
+
} else {
|
|
1039
|
+
score += 13; // Neutral if no coverage data
|
|
1040
|
+
}
|
|
1041
|
+
|
|
1042
|
+
// Mutation score (max 25 points)
|
|
1043
|
+
if (mutations && mutations.mutationsTotal > 0) {
|
|
1044
|
+
score += Math.round(mutations.mutationScore * 0.25);
|
|
1045
|
+
} else {
|
|
1046
|
+
score += 13; // Neutral if no mutation data
|
|
1047
|
+
}
|
|
1048
|
+
|
|
1049
|
+
// Test diversity (max 15 points)
|
|
1050
|
+
// @cap-decision Diversity rewards error-path and edge-case testing — adversarial mindset is scored.
|
|
1051
|
+
if (diversity && diversity.totalTests > 0) {
|
|
1052
|
+
// 30%+ diverse tests = full marks, linear scale below that
|
|
1053
|
+
const ratio = diversity.diversityRatio;
|
|
1054
|
+
if (ratio >= 0.30) score += 15;
|
|
1055
|
+
else if (ratio >= 0.20) score += 12;
|
|
1056
|
+
else if (ratio >= 0.10) score += 8;
|
|
1057
|
+
else if (ratio > 0) score += 4;
|
|
1058
|
+
} else {
|
|
1059
|
+
score += 8; // Neutral if no diversity data
|
|
1060
|
+
}
|
|
1061
|
+
|
|
1062
|
+
// Critical path coverage (max 10 points)
|
|
1063
|
+
// @cap-decision Critical path coverage rewards testing the most important code paths (auth, security, payment).
|
|
1064
|
+
if (criticalPath && criticalPath.criticalFiles > 0) {
|
|
1065
|
+
score += Math.round(criticalPath.score * 0.10);
|
|
1066
|
+
} else {
|
|
1067
|
+
score += 5; // Neutral if no critical paths detected
|
|
1068
|
+
}
|
|
1069
|
+
|
|
1070
|
+
// Anti-pattern penalty (max -15)
|
|
1071
|
+
if (antiPatterns && antiPatterns.flags) {
|
|
1072
|
+
const errorCount = antiPatterns.flags.filter(f => f.severity === 'error').length;
|
|
1073
|
+
const warningCount = antiPatterns.flags.filter(f => f.severity === 'warning').length;
|
|
1074
|
+
score -= Math.min(15, errorCount * 5 + warningCount * 2);
|
|
1075
|
+
}
|
|
1076
|
+
|
|
1077
|
+
return Math.max(0, Math.min(100, score));
|
|
1078
|
+
}
|
|
1079
|
+
|
|
1080
|
+
/**
|
|
1081
|
+
* Generate the full test audit report as structured data.
|
|
1082
|
+
*
|
|
1083
|
+
* @param {string} projectRoot
|
|
1084
|
+
* @param {Object} options
|
|
1085
|
+
* @returns {Object} TestAuditReport
|
|
1086
|
+
*/
|
|
1087
|
+
function generateAuditReport(projectRoot, options = {}) {
|
|
1088
|
+
const testCommand = options.testCommand || 'node --test tests/';
|
|
1089
|
+
const criticalPaths = options.criticalPaths || ['auth', 'payment', 'booking', 'rls', 'security'];
|
|
1090
|
+
const runCoverage = options.coverage !== false;
|
|
1091
|
+
const runMutations = options.mutations !== false;
|
|
1092
|
+
const mutationCount = options.mutationCount || 10;
|
|
1093
|
+
let targetFiles = options.targetFiles || [];
|
|
1094
|
+
|
|
1095
|
+
// @cap-decision Auto-discover mutation target files when none explicitly provided.
|
|
1096
|
+
// Scans cap/bin/lib/*.cjs as default targets for mutation testing.
|
|
1097
|
+
if (targetFiles.length === 0 && runMutations) {
|
|
1098
|
+
const libDir = path.join(projectRoot, 'cap', 'bin', 'lib');
|
|
1099
|
+
if (fs.existsSync(libDir)) {
|
|
1100
|
+
try {
|
|
1101
|
+
targetFiles = fs.readdirSync(libDir)
|
|
1102
|
+
.filter(f => f.endsWith('.cjs') && !f.includes('.test.'))
|
|
1103
|
+
.map(f => path.join('cap', 'bin', 'lib', f));
|
|
1104
|
+
} catch (_e) { /* ignore */ }
|
|
1105
|
+
}
|
|
1106
|
+
}
|
|
1107
|
+
|
|
1108
|
+
// Step 1: Assertion analysis (always runs)
|
|
1109
|
+
const assertions = analyzeAssertions(projectRoot, {
|
|
1110
|
+
extensions: options.extensions,
|
|
1111
|
+
});
|
|
1112
|
+
|
|
1113
|
+
// Step 2: Coverage (optional)
|
|
1114
|
+
let coverage = null;
|
|
1115
|
+
if (runCoverage) {
|
|
1116
|
+
coverage = analyzeCoverage(projectRoot, testCommand);
|
|
1117
|
+
}
|
|
1118
|
+
|
|
1119
|
+
// Step 3: Mutation testing (optional, on target files)
|
|
1120
|
+
let mutations = null;
|
|
1121
|
+
if (runMutations && targetFiles.length > 0) {
|
|
1122
|
+
mutations = runMutationTests(projectRoot, targetFiles, testCommand, {
|
|
1123
|
+
mutations: mutationCount,
|
|
1124
|
+
timeout: options.timeout || 30000,
|
|
1125
|
+
});
|
|
1126
|
+
}
|
|
1127
|
+
|
|
1128
|
+
// Step 4: Spot checks
|
|
1129
|
+
const spotChecks = generateSpotChecks(projectRoot, {
|
|
1130
|
+
count: options.spotCheckCount || 3,
|
|
1131
|
+
criticalPaths,
|
|
1132
|
+
});
|
|
1133
|
+
|
|
1134
|
+
// Step 5: Anti-patterns
|
|
1135
|
+
const antiPatterns = detectAntiPatterns(projectRoot, {
|
|
1136
|
+
extensions: options.extensions,
|
|
1137
|
+
});
|
|
1138
|
+
|
|
1139
|
+
// Step 6: Test diversity
|
|
1140
|
+
const diversity = analyzeTestDiversity(projectRoot, {
|
|
1141
|
+
extensions: options.extensions,
|
|
1142
|
+
});
|
|
1143
|
+
|
|
1144
|
+
// Step 7: Critical path coverage
|
|
1145
|
+
const criticalPath = analyzeCriticalPathCoverage(projectRoot, {
|
|
1146
|
+
criticalPaths,
|
|
1147
|
+
extensions: options.extensions,
|
|
1148
|
+
});
|
|
1149
|
+
|
|
1150
|
+
// Step 8: Trust score
|
|
1151
|
+
const trustScore = computeTrustScore(assertions, coverage, mutations, antiPatterns, diversity, criticalPath);
|
|
1152
|
+
|
|
1153
|
+
return {
|
|
1154
|
+
timestamp: new Date().toISOString(),
|
|
1155
|
+
projectRoot,
|
|
1156
|
+
assertions,
|
|
1157
|
+
coverage,
|
|
1158
|
+
mutations,
|
|
1159
|
+
spotChecks,
|
|
1160
|
+
antiPatterns,
|
|
1161
|
+
diversity,
|
|
1162
|
+
criticalPath,
|
|
1163
|
+
trustScore,
|
|
1164
|
+
};
|
|
1165
|
+
}
|
|
1166
|
+
|
|
1167
|
+
/**
|
|
1168
|
+
* Format an audit report as a readable Markdown string.
|
|
1169
|
+
*
|
|
1170
|
+
* @param {Object} report - from generateAuditReport
|
|
1171
|
+
* @param {string} projectName - display name for the project
|
|
1172
|
+
* @returns {string}
|
|
1173
|
+
*/
|
|
1174
|
+
function formatAuditReport(report, projectName = 'project') {
|
|
1175
|
+
const lines = [];
|
|
1176
|
+
lines.push(`Test Audit -- ${projectName}`);
|
|
1177
|
+
lines.push('='.repeat(lines[0].length));
|
|
1178
|
+
lines.push('');
|
|
1179
|
+
|
|
1180
|
+
// Assertions
|
|
1181
|
+
lines.push('ASSERTIONS');
|
|
1182
|
+
lines.push(` Total tests: ${report.assertions.totalTests}`);
|
|
1183
|
+
lines.push(` Total assertions: ${report.assertions.totalAssertions}`);
|
|
1184
|
+
lines.push(` Assertion density: ${report.assertions.assertionDensity} per test`);
|
|
1185
|
+
lines.push(` Empty tests (0 assertions): ${report.assertions.emptyTests.length}`);
|
|
1186
|
+
if (report.assertions.emptyTests.length > 0) {
|
|
1187
|
+
for (const et of report.assertions.emptyTests) {
|
|
1188
|
+
lines.push(` ${et.file}:${et.line} -- "${et.name}"`);
|
|
1189
|
+
}
|
|
1190
|
+
}
|
|
1191
|
+
lines.push('');
|
|
1192
|
+
|
|
1193
|
+
// Coverage
|
|
1194
|
+
if (report.coverage) {
|
|
1195
|
+
lines.push('COVERAGE');
|
|
1196
|
+
if (report.coverage.error) {
|
|
1197
|
+
lines.push(` Error: ${report.coverage.error}`);
|
|
1198
|
+
} else {
|
|
1199
|
+
lines.push(` Lines: ${report.coverage.lines}% Branches: ${report.coverage.branches}% Functions: ${report.coverage.functions}%`);
|
|
1200
|
+
if (report.coverage.uncoveredFiles.length > 0) {
|
|
1201
|
+
lines.push(' Uncovered critical files:');
|
|
1202
|
+
for (const f of report.coverage.uncoveredFiles) {
|
|
1203
|
+
lines.push(` ${f}`);
|
|
1204
|
+
}
|
|
1205
|
+
}
|
|
1206
|
+
}
|
|
1207
|
+
lines.push('');
|
|
1208
|
+
}
|
|
1209
|
+
|
|
1210
|
+
// Mutations
|
|
1211
|
+
if (report.mutations) {
|
|
1212
|
+
lines.push('MUTATION SCORE');
|
|
1213
|
+
lines.push(` Mutations: ${report.mutations.mutationsTotal} applied, ${report.mutations.mutationsCaught} caught (${report.mutations.mutationScore}%)`);
|
|
1214
|
+
if (report.mutations.survived.length > 0) {
|
|
1215
|
+
lines.push(' Survived mutations (tests didn\'t catch):');
|
|
1216
|
+
for (const s of report.mutations.survived) {
|
|
1217
|
+
lines.push(` ${s.file}:${s.line} -- ${s.description}`);
|
|
1218
|
+
}
|
|
1219
|
+
}
|
|
1220
|
+
lines.push('');
|
|
1221
|
+
}
|
|
1222
|
+
|
|
1223
|
+
// Spot checks
|
|
1224
|
+
if (report.spotChecks.length > 0) {
|
|
1225
|
+
lines.push('SPOT-CHECK GUIDE (for human review)');
|
|
1226
|
+
report.spotChecks.forEach((sc, idx) => {
|
|
1227
|
+
lines.push(` ${idx + 1}. ${sc.file}:${sc.line} -- "${sc.testName}"`);
|
|
1228
|
+
if (sc.productionFile) {
|
|
1229
|
+
lines.push(` Break: Delete a line in ${sc.productionFile}`);
|
|
1230
|
+
lines.push(` Expected: This test should turn RED`);
|
|
1231
|
+
}
|
|
1232
|
+
lines.push(` ${sc.suggestion}`);
|
|
1233
|
+
lines.push(` [ ] Verified [ ] Suspect`);
|
|
1234
|
+
lines.push('');
|
|
1235
|
+
});
|
|
1236
|
+
}
|
|
1237
|
+
|
|
1238
|
+
// Anti-patterns
|
|
1239
|
+
if (report.antiPatterns.flags.length > 0) {
|
|
1240
|
+
lines.push('ANTI-PATTERNS');
|
|
1241
|
+
for (const flag of report.antiPatterns.flags) {
|
|
1242
|
+
lines.push(` ${flag.severity.toUpperCase()} ${flag.file}:${flag.line} -- ${flag.description}`);
|
|
1243
|
+
}
|
|
1244
|
+
lines.push('');
|
|
1245
|
+
}
|
|
1246
|
+
|
|
1247
|
+
// Critical path coverage
|
|
1248
|
+
if (report.criticalPath) {
|
|
1249
|
+
lines.push('CRITICAL PATH COVERAGE');
|
|
1250
|
+
lines.push(` Score: ${report.criticalPath.score}%`);
|
|
1251
|
+
lines.push(` Critical files: ${report.criticalPath.criticalFiles}`);
|
|
1252
|
+
lines.push(` With tests: ${report.criticalPath.testedFiles} (${report.criticalPath.wellTestedFiles} well-tested)`);
|
|
1253
|
+
if (report.criticalPath.untestedPaths.length > 0) {
|
|
1254
|
+
lines.push(` UNTESTED: ${report.criticalPath.untestedPaths.join(', ')}`);
|
|
1255
|
+
}
|
|
1256
|
+
lines.push('');
|
|
1257
|
+
}
|
|
1258
|
+
|
|
1259
|
+
// Test diversity
|
|
1260
|
+
if (report.diversity) {
|
|
1261
|
+
lines.push('TEST DIVERSITY');
|
|
1262
|
+
lines.push(` Score: ${report.diversity.diversityScore}%`);
|
|
1263
|
+
lines.push(` Error-path tests: ${report.diversity.errorPathTests} / ${report.diversity.totalTests}`);
|
|
1264
|
+
lines.push(` Edge-case tests: ${report.diversity.edgeCaseTests} / ${report.diversity.totalTests}`);
|
|
1265
|
+
lines.push(` Happy-path only: ${report.diversity.happyPathOnlyTests} / ${report.diversity.totalTests}`);
|
|
1266
|
+
lines.push('');
|
|
1267
|
+
}
|
|
1268
|
+
|
|
1269
|
+
lines.push(`TRUST SCORE: ${report.trustScore}/100`);
|
|
1270
|
+
|
|
1271
|
+
// Improvement suggestions for low trust scores
|
|
1272
|
+
if (report.trustScore < 70) {
|
|
1273
|
+
lines.push('');
|
|
1274
|
+
const suggestions = generateImprovementSuggestions(report);
|
|
1275
|
+
lines.push('IMPROVEMENT SUGGESTIONS');
|
|
1276
|
+
lines.push(` Current score: ${report.trustScore}/100 (target: 70+)`);
|
|
1277
|
+
lines.push('');
|
|
1278
|
+
for (const s of suggestions) {
|
|
1279
|
+
lines.push(` ${s.priority}. ${s.title} (+${s.points} pts)`);
|
|
1280
|
+
lines.push(` ${s.action}`);
|
|
1281
|
+
if (s.command) lines.push(` Run: ${s.command}`);
|
|
1282
|
+
lines.push('');
|
|
1283
|
+
}
|
|
1284
|
+
}
|
|
1285
|
+
|
|
1286
|
+
lines.push('');
|
|
1287
|
+
lines.push(`Generated: ${report.timestamp}`);
|
|
1288
|
+
|
|
1289
|
+
return lines.join('\n');
|
|
1290
|
+
}
|
|
1291
|
+
|
|
1292
|
+
/**
|
|
1293
|
+
* Generate prioritized improvement suggestions based on audit report.
|
|
1294
|
+
* Returns suggestions sorted by potential point gain (highest first).
|
|
1295
|
+
*
|
|
1296
|
+
* @param {Object} report - from generateAuditReport
|
|
1297
|
+
* @returns {Array<{priority: number, title: string, points: number, action: string, command?: string}>}
|
|
1298
|
+
*/
|
|
1299
|
+
function generateImprovementSuggestions(report) {
|
|
1300
|
+
const suggestions = [];
|
|
1301
|
+
|
|
1302
|
+
// Assertion density
|
|
1303
|
+
if (report.assertions.assertionDensity < 2) {
|
|
1304
|
+
const currentPts = report.assertions.assertionDensity >= 1 ? 20 : report.assertions.assertionDensity >= 0.5 ? 10 : 0;
|
|
1305
|
+
const gain = 30 - currentPts;
|
|
1306
|
+
if (gain > 0) {
|
|
1307
|
+
suggestions.push({
|
|
1308
|
+
title: 'Increase assertion density',
|
|
1309
|
+
points: gain,
|
|
1310
|
+
action: report.assertions.assertionDensity < 1
|
|
1311
|
+
? `Tests average ${report.assertions.assertionDensity.toFixed(1)} assertions each. Add specific value checks (assert.strictEqual, assert.deepStrictEqual) — aim for 2+ assertions per test.`
|
|
1312
|
+
: `Tests average ${report.assertions.assertionDensity.toFixed(1)} assertions each. Add edge case checks and boundary assertions to reach 2+ per test.`,
|
|
1313
|
+
});
|
|
1314
|
+
}
|
|
1315
|
+
}
|
|
1316
|
+
|
|
1317
|
+
// Empty tests
|
|
1318
|
+
if (report.assertions.emptyTests.length > 0) {
|
|
1319
|
+
const emptyRatio = report.assertions.emptyTests.length / Math.max(1, report.assertions.totalTests);
|
|
1320
|
+
const penalty = Math.round(emptyRatio * 10);
|
|
1321
|
+
suggestions.push({
|
|
1322
|
+
title: `Fix ${report.assertions.emptyTests.length} empty test(s)`,
|
|
1323
|
+
points: penalty,
|
|
1324
|
+
action: `These tests have 0 assertions: ${report.assertions.emptyTests.slice(0, 3).map(t => t.file + ':' + t.line).join(', ')}${report.assertions.emptyTests.length > 3 ? '...' : ''}. Add at least one assert per test.`,
|
|
1325
|
+
});
|
|
1326
|
+
}
|
|
1327
|
+
|
|
1328
|
+
// Coverage
|
|
1329
|
+
if (report.coverage && !report.coverage.error) {
|
|
1330
|
+
if (report.coverage.lines < 70) {
|
|
1331
|
+
const currentPts = Math.round(report.coverage.lines * 0.15) + Math.round(report.coverage.branches * 0.10) + Math.round(report.coverage.functions * 0.05);
|
|
1332
|
+
const targetPts = Math.round(70 * 0.15) + Math.round(50 * 0.10) + Math.round(60 * 0.05);
|
|
1333
|
+
const gain = Math.max(0, targetPts - currentPts);
|
|
1334
|
+
suggestions.push({
|
|
1335
|
+
title: 'Increase code coverage',
|
|
1336
|
+
points: gain,
|
|
1337
|
+
action: `Lines: ${report.coverage.lines}% (target: 70%+). Focus on uncovered critical files first.`,
|
|
1338
|
+
command: 'npm run test:coverage',
|
|
1339
|
+
});
|
|
1340
|
+
}
|
|
1341
|
+
if (report.coverage.branches < 50) {
|
|
1342
|
+
suggestions.push({
|
|
1343
|
+
title: 'Improve branch coverage',
|
|
1344
|
+
points: 5,
|
|
1345
|
+
action: `Branch coverage is ${report.coverage.branches}%. Add tests for if/else, switch, and ternary branches — especially error paths.`,
|
|
1346
|
+
});
|
|
1347
|
+
}
|
|
1348
|
+
}
|
|
1349
|
+
|
|
1350
|
+
// Mutations
|
|
1351
|
+
if (report.mutations && report.mutations.mutationsTotal > 0 && report.mutations.mutationScore < 60) {
|
|
1352
|
+
const currentPts = Math.round(report.mutations.mutationScore * 0.25);
|
|
1353
|
+
const targetPts = Math.round(80 * 0.25);
|
|
1354
|
+
const gain = Math.max(0, targetPts - currentPts);
|
|
1355
|
+
if (report.mutations.survived.length > 0) {
|
|
1356
|
+
suggestions.push({
|
|
1357
|
+
title: 'Catch surviving mutations',
|
|
1358
|
+
points: gain,
|
|
1359
|
+
action: `${report.mutations.survived.length} mutation(s) survived — tests didn't detect code changes. Add assertions for: ${report.mutations.survived.slice(0, 2).map(s => s.file + ':' + s.line + ' (' + s.description + ')').join('; ')}.`,
|
|
1360
|
+
command: '/cap:test-audit --mutations 20',
|
|
1361
|
+
});
|
|
1362
|
+
}
|
|
1363
|
+
}
|
|
1364
|
+
|
|
1365
|
+
// Critical path coverage
|
|
1366
|
+
if (report.criticalPath && report.criticalPath.score < 80) {
|
|
1367
|
+
const currentPts = Math.round(report.criticalPath.score * 0.10);
|
|
1368
|
+
const gain = Math.max(0, 10 - currentPts);
|
|
1369
|
+
if (gain > 0) {
|
|
1370
|
+
suggestions.push({
|
|
1371
|
+
title: 'Improve critical path test coverage',
|
|
1372
|
+
points: gain,
|
|
1373
|
+
action: `${report.criticalPath.untestedPaths.length} critical file(s) have no tests: ${report.criticalPath.untestedPaths.slice(0, 3).join(', ')}. Add dedicated test files for these security/auth/payment paths.`,
|
|
1374
|
+
});
|
|
1375
|
+
}
|
|
1376
|
+
}
|
|
1377
|
+
|
|
1378
|
+
// Test diversity
|
|
1379
|
+
if (report.diversity && report.diversity.diversityRatio < 0.30) {
|
|
1380
|
+
const currentPts = report.diversity.diversityRatio >= 0.20 ? 12 : report.diversity.diversityRatio >= 0.10 ? 8 : report.diversity.diversityRatio > 0 ? 4 : 0;
|
|
1381
|
+
const gain = 15 - currentPts;
|
|
1382
|
+
if (gain > 0) {
|
|
1383
|
+
suggestions.push({
|
|
1384
|
+
title: 'Increase test diversity',
|
|
1385
|
+
points: gain,
|
|
1386
|
+
action: `Only ${report.diversity.diversityRatio * 100}% of tests cover error paths or edge cases (target: 30%+). Add tests for: null/undefined inputs, invalid data, timeouts, boundary values, error throwing.`,
|
|
1387
|
+
});
|
|
1388
|
+
}
|
|
1389
|
+
}
|
|
1390
|
+
|
|
1391
|
+
// Anti-patterns
|
|
1392
|
+
if (report.antiPatterns && report.antiPatterns.flags.length > 0) {
|
|
1393
|
+
const errors = report.antiPatterns.flags.filter(f => f.severity === 'error');
|
|
1394
|
+
const warnings = report.antiPatterns.flags.filter(f => f.severity === 'warning');
|
|
1395
|
+
const penalty = Math.min(15, errors.length * 5 + warnings.length * 2);
|
|
1396
|
+
if (penalty > 0) {
|
|
1397
|
+
const topIssue = errors[0] || warnings[0];
|
|
1398
|
+
suggestions.push({
|
|
1399
|
+
title: `Fix ${errors.length + warnings.length} anti-pattern(s)`,
|
|
1400
|
+
points: penalty,
|
|
1401
|
+
action: `Top issue: ${topIssue.description} (${topIssue.file}:${topIssue.line}). Replace weak assertions with specific value checks.`,
|
|
1402
|
+
});
|
|
1403
|
+
}
|
|
1404
|
+
}
|
|
1405
|
+
|
|
1406
|
+
// Sort by potential points gained (highest first)
|
|
1407
|
+
suggestions.sort((a, b) => b.points - a.points);
|
|
1408
|
+
|
|
1409
|
+
// Add priority numbers
|
|
1410
|
+
return suggestions.map((s, i) => ({ ...s, priority: i + 1 }));
|
|
1411
|
+
}
|
|
1412
|
+
|
|
1413
|
+
module.exports = {
|
|
1414
|
+
analyzeAssertions,
|
|
1415
|
+
analyzeCoverage,
|
|
1416
|
+
analyzeCoverageNative,
|
|
1417
|
+
analyzeCoverageC8,
|
|
1418
|
+
parseNativeCoverageOutput,
|
|
1419
|
+
supportsNativeCoverage,
|
|
1420
|
+
analyzeTestDiversity,
|
|
1421
|
+
analyzeCriticalPathCoverage,
|
|
1422
|
+
runMutationTests,
|
|
1423
|
+
generateSpotChecks,
|
|
1424
|
+
detectAntiPatterns,
|
|
1425
|
+
generateAuditReport,
|
|
1426
|
+
formatAuditReport,
|
|
1427
|
+
computeTrustScore,
|
|
1428
|
+
generateImprovementSuggestions,
|
|
1429
|
+
findTestFiles,
|
|
1430
|
+
applyMutation,
|
|
1431
|
+
ASSERTION_PATTERNS,
|
|
1432
|
+
WEAK_ASSERTION_PATTERNS,
|
|
1433
|
+
ERROR_PATH_PATTERNS,
|
|
1434
|
+
EDGE_CASE_PATTERNS,
|
|
1435
|
+
DEFAULT_CRITICAL_PATHS,
|
|
1436
|
+
MUTATION_OPERATORS,
|
|
1437
|
+
DEFAULT_TEST_EXTENSIONS,
|
|
1438
|
+
};
|