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.
Files changed (275) hide show
  1. package/.claude-plugin/README.md +26 -0
  2. package/.claude-plugin/marketplace.json +24 -0
  3. package/.claude-plugin/plugin.json +24 -0
  4. package/LICENSE +21 -0
  5. package/README.ja-JP.md +834 -0
  6. package/README.ko-KR.md +823 -0
  7. package/README.md +806 -0
  8. package/README.pt-BR.md +452 -0
  9. package/README.zh-CN.md +800 -0
  10. package/agents/cap-architect.md +269 -0
  11. package/agents/cap-brainstormer.md +207 -0
  12. package/agents/cap-curator.md +276 -0
  13. package/agents/cap-debugger.md +365 -0
  14. package/agents/cap-designer.md +246 -0
  15. package/agents/cap-historian.md +464 -0
  16. package/agents/cap-migrator.md +291 -0
  17. package/agents/cap-prototyper.md +197 -0
  18. package/agents/cap-validator.md +308 -0
  19. package/bin/install.js +5433 -0
  20. package/cap/bin/cap-tools.cjs +853 -0
  21. package/cap/bin/lib/arc-scanner.cjs +344 -0
  22. package/cap/bin/lib/cap-affinity-engine.cjs +862 -0
  23. package/cap/bin/lib/cap-anchor.cjs +228 -0
  24. package/cap/bin/lib/cap-annotation-writer.cjs +340 -0
  25. package/cap/bin/lib/cap-checkpoint.cjs +434 -0
  26. package/cap/bin/lib/cap-cluster-detect.cjs +945 -0
  27. package/cap/bin/lib/cap-cluster-display.cjs +52 -0
  28. package/cap/bin/lib/cap-cluster-format.cjs +245 -0
  29. package/cap/bin/lib/cap-cluster-helpers.cjs +295 -0
  30. package/cap/bin/lib/cap-cluster-io.cjs +212 -0
  31. package/cap/bin/lib/cap-completeness.cjs +540 -0
  32. package/cap/bin/lib/cap-deps.cjs +583 -0
  33. package/cap/bin/lib/cap-design-families.cjs +332 -0
  34. package/cap/bin/lib/cap-design.cjs +966 -0
  35. package/cap/bin/lib/cap-divergence-detector.cjs +400 -0
  36. package/cap/bin/lib/cap-doctor.cjs +752 -0
  37. package/cap/bin/lib/cap-feature-map-internals.cjs +19 -0
  38. package/cap/bin/lib/cap-feature-map-migrate.cjs +335 -0
  39. package/cap/bin/lib/cap-feature-map-monorepo.cjs +885 -0
  40. package/cap/bin/lib/cap-feature-map-shard.cjs +315 -0
  41. package/cap/bin/lib/cap-feature-map.cjs +1943 -0
  42. package/cap/bin/lib/cap-fitness-score.cjs +1075 -0
  43. package/cap/bin/lib/cap-impact-analysis.cjs +652 -0
  44. package/cap/bin/lib/cap-learn-review.cjs +1072 -0
  45. package/cap/bin/lib/cap-learning-signals.cjs +627 -0
  46. package/cap/bin/lib/cap-loader.cjs +227 -0
  47. package/cap/bin/lib/cap-logger.cjs +57 -0
  48. package/cap/bin/lib/cap-memory-bridge.cjs +764 -0
  49. package/cap/bin/lib/cap-memory-confidence.cjs +452 -0
  50. package/cap/bin/lib/cap-memory-dir.cjs +987 -0
  51. package/cap/bin/lib/cap-memory-engine.cjs +698 -0
  52. package/cap/bin/lib/cap-memory-extends.cjs +398 -0
  53. package/cap/bin/lib/cap-memory-graph.cjs +790 -0
  54. package/cap/bin/lib/cap-memory-migrate.cjs +2015 -0
  55. package/cap/bin/lib/cap-memory-pin.cjs +183 -0
  56. package/cap/bin/lib/cap-memory-platform.cjs +490 -0
  57. package/cap/bin/lib/cap-memory-prune.cjs +707 -0
  58. package/cap/bin/lib/cap-memory-schema.cjs +812 -0
  59. package/cap/bin/lib/cap-migrate-tags.cjs +309 -0
  60. package/cap/bin/lib/cap-migrate.cjs +540 -0
  61. package/cap/bin/lib/cap-pattern-apply.cjs +1203 -0
  62. package/cap/bin/lib/cap-pattern-pipeline.cjs +1034 -0
  63. package/cap/bin/lib/cap-plugin-manifest.cjs +80 -0
  64. package/cap/bin/lib/cap-realtime-affinity.cjs +399 -0
  65. package/cap/bin/lib/cap-reconcile.cjs +570 -0
  66. package/cap/bin/lib/cap-research-gate.cjs +218 -0
  67. package/cap/bin/lib/cap-scope-filter.cjs +402 -0
  68. package/cap/bin/lib/cap-semantic-pipeline.cjs +1038 -0
  69. package/cap/bin/lib/cap-session-extract.cjs +987 -0
  70. package/cap/bin/lib/cap-session.cjs +445 -0
  71. package/cap/bin/lib/cap-snapshot-linkage.cjs +963 -0
  72. package/cap/bin/lib/cap-stack-docs.cjs +646 -0
  73. package/cap/bin/lib/cap-tag-observer.cjs +371 -0
  74. package/cap/bin/lib/cap-tag-scanner.cjs +1766 -0
  75. package/cap/bin/lib/cap-telemetry.cjs +466 -0
  76. package/cap/bin/lib/cap-test-audit.cjs +1438 -0
  77. package/cap/bin/lib/cap-thread-migrator.cjs +307 -0
  78. package/cap/bin/lib/cap-thread-synthesis.cjs +545 -0
  79. package/cap/bin/lib/cap-thread-tracker.cjs +519 -0
  80. package/cap/bin/lib/cap-trace.cjs +399 -0
  81. package/cap/bin/lib/cap-trust-mode.cjs +336 -0
  82. package/cap/bin/lib/cap-ui-design-editor.cjs +642 -0
  83. package/cap/bin/lib/cap-ui-mind-map.cjs +712 -0
  84. package/cap/bin/lib/cap-ui-thread-nav.cjs +693 -0
  85. package/cap/bin/lib/cap-ui.cjs +1245 -0
  86. package/cap/bin/lib/cap-upgrade.cjs +1028 -0
  87. package/cap/bin/lib/cli/arg-helpers.cjs +49 -0
  88. package/cap/bin/lib/cli/frontmatter-router.cjs +31 -0
  89. package/cap/bin/lib/cli/init-router.cjs +68 -0
  90. package/cap/bin/lib/cli/phase-router.cjs +102 -0
  91. package/cap/bin/lib/cli/state-router.cjs +61 -0
  92. package/cap/bin/lib/cli/template-router.cjs +37 -0
  93. package/cap/bin/lib/cli/uat-router.cjs +29 -0
  94. package/cap/bin/lib/cli/validation-router.cjs +26 -0
  95. package/cap/bin/lib/cli/verification-router.cjs +31 -0
  96. package/cap/bin/lib/cli/workstream-router.cjs +39 -0
  97. package/cap/bin/lib/commands.cjs +961 -0
  98. package/cap/bin/lib/config.cjs +467 -0
  99. package/cap/bin/lib/convention-reader.cjs +258 -0
  100. package/cap/bin/lib/core.cjs +1241 -0
  101. package/cap/bin/lib/feature-aggregator.cjs +423 -0
  102. package/cap/bin/lib/frontmatter.cjs +337 -0
  103. package/cap/bin/lib/init.cjs +1443 -0
  104. package/cap/bin/lib/manifest-generator.cjs +383 -0
  105. package/cap/bin/lib/milestone.cjs +253 -0
  106. package/cap/bin/lib/model-profiles.cjs +69 -0
  107. package/cap/bin/lib/monorepo-context.cjs +226 -0
  108. package/cap/bin/lib/monorepo-migrator.cjs +509 -0
  109. package/cap/bin/lib/phase.cjs +889 -0
  110. package/cap/bin/lib/profile-output.cjs +989 -0
  111. package/cap/bin/lib/profile-pipeline.cjs +540 -0
  112. package/cap/bin/lib/roadmap.cjs +330 -0
  113. package/cap/bin/lib/security.cjs +394 -0
  114. package/cap/bin/lib/session-manager.cjs +292 -0
  115. package/cap/bin/lib/skeleton-generator.cjs +179 -0
  116. package/cap/bin/lib/state.cjs +1032 -0
  117. package/cap/bin/lib/template.cjs +231 -0
  118. package/cap/bin/lib/test-detector.cjs +62 -0
  119. package/cap/bin/lib/uat.cjs +283 -0
  120. package/cap/bin/lib/verify.cjs +889 -0
  121. package/cap/bin/lib/workspace-detector.cjs +371 -0
  122. package/cap/bin/lib/workstream.cjs +492 -0
  123. package/cap/commands/gsd/workstreams.md +63 -0
  124. package/cap/references/arc-standard.md +315 -0
  125. package/cap/references/cap-agent-architecture.md +101 -0
  126. package/cap/references/cap-gitignore-template +9 -0
  127. package/cap/references/cap-zero-deps.md +158 -0
  128. package/cap/references/checkpoints.md +778 -0
  129. package/cap/references/continuation-format.md +249 -0
  130. package/cap/references/contract-test-templates.md +312 -0
  131. package/cap/references/feature-map-template.md +25 -0
  132. package/cap/references/git-integration.md +295 -0
  133. package/cap/references/git-planning-commit.md +38 -0
  134. package/cap/references/model-profiles.md +174 -0
  135. package/cap/references/phase-numbering.md +126 -0
  136. package/cap/references/planning-config.md +202 -0
  137. package/cap/references/property-test-templates.md +316 -0
  138. package/cap/references/security-test-templates.md +347 -0
  139. package/cap/references/session-template.json +8 -0
  140. package/cap/references/tdd.md +263 -0
  141. package/cap/references/user-profiling.md +681 -0
  142. package/cap/references/verification-patterns.md +612 -0
  143. package/cap/templates/UAT.md +265 -0
  144. package/cap/templates/claude-md.md +175 -0
  145. package/cap/templates/codebase/architecture.md +255 -0
  146. package/cap/templates/codebase/concerns.md +310 -0
  147. package/cap/templates/codebase/conventions.md +307 -0
  148. package/cap/templates/codebase/integrations.md +280 -0
  149. package/cap/templates/codebase/stack.md +186 -0
  150. package/cap/templates/codebase/structure.md +285 -0
  151. package/cap/templates/codebase/testing.md +480 -0
  152. package/cap/templates/config.json +44 -0
  153. package/cap/templates/context.md +352 -0
  154. package/cap/templates/continue-here.md +78 -0
  155. package/cap/templates/copilot-instructions.md +7 -0
  156. package/cap/templates/debug-subagent-prompt.md +91 -0
  157. package/cap/templates/discussion-log.md +63 -0
  158. package/cap/templates/milestone-archive.md +123 -0
  159. package/cap/templates/milestone.md +115 -0
  160. package/cap/templates/phase-prompt.md +610 -0
  161. package/cap/templates/planner-subagent-prompt.md +117 -0
  162. package/cap/templates/project.md +186 -0
  163. package/cap/templates/requirements.md +231 -0
  164. package/cap/templates/research-project/ARCHITECTURE.md +204 -0
  165. package/cap/templates/research-project/FEATURES.md +147 -0
  166. package/cap/templates/research-project/PITFALLS.md +200 -0
  167. package/cap/templates/research-project/STACK.md +120 -0
  168. package/cap/templates/research-project/SUMMARY.md +170 -0
  169. package/cap/templates/research.md +552 -0
  170. package/cap/templates/roadmap.md +202 -0
  171. package/cap/templates/state.md +176 -0
  172. package/cap/templates/summary.md +364 -0
  173. package/cap/templates/user-preferences.md +498 -0
  174. package/cap/templates/verification-report.md +322 -0
  175. package/cap/workflows/add-phase.md +112 -0
  176. package/cap/workflows/add-tests.md +351 -0
  177. package/cap/workflows/add-todo.md +158 -0
  178. package/cap/workflows/audit-milestone.md +340 -0
  179. package/cap/workflows/audit-uat.md +109 -0
  180. package/cap/workflows/autonomous.md +891 -0
  181. package/cap/workflows/check-todos.md +177 -0
  182. package/cap/workflows/cleanup.md +152 -0
  183. package/cap/workflows/complete-milestone.md +767 -0
  184. package/cap/workflows/diagnose-issues.md +231 -0
  185. package/cap/workflows/discovery-phase.md +289 -0
  186. package/cap/workflows/discuss-phase-assumptions.md +653 -0
  187. package/cap/workflows/discuss-phase.md +1049 -0
  188. package/cap/workflows/do.md +104 -0
  189. package/cap/workflows/execute-phase.md +846 -0
  190. package/cap/workflows/execute-plan.md +514 -0
  191. package/cap/workflows/fast.md +105 -0
  192. package/cap/workflows/forensics.md +265 -0
  193. package/cap/workflows/health.md +181 -0
  194. package/cap/workflows/help.md +660 -0
  195. package/cap/workflows/insert-phase.md +130 -0
  196. package/cap/workflows/list-phase-assumptions.md +178 -0
  197. package/cap/workflows/list-workspaces.md +56 -0
  198. package/cap/workflows/manager.md +362 -0
  199. package/cap/workflows/map-codebase.md +377 -0
  200. package/cap/workflows/milestone-summary.md +223 -0
  201. package/cap/workflows/new-milestone.md +486 -0
  202. package/cap/workflows/new-project.md +1250 -0
  203. package/cap/workflows/new-workspace.md +237 -0
  204. package/cap/workflows/next.md +97 -0
  205. package/cap/workflows/node-repair.md +92 -0
  206. package/cap/workflows/note.md +156 -0
  207. package/cap/workflows/pause-work.md +176 -0
  208. package/cap/workflows/plan-milestone-gaps.md +273 -0
  209. package/cap/workflows/plan-phase.md +857 -0
  210. package/cap/workflows/plant-seed.md +169 -0
  211. package/cap/workflows/pr-branch.md +129 -0
  212. package/cap/workflows/profile-user.md +449 -0
  213. package/cap/workflows/progress.md +507 -0
  214. package/cap/workflows/quick.md +757 -0
  215. package/cap/workflows/remove-phase.md +155 -0
  216. package/cap/workflows/remove-workspace.md +90 -0
  217. package/cap/workflows/research-phase.md +82 -0
  218. package/cap/workflows/resume-project.md +326 -0
  219. package/cap/workflows/review.md +228 -0
  220. package/cap/workflows/session-report.md +146 -0
  221. package/cap/workflows/settings.md +283 -0
  222. package/cap/workflows/ship.md +228 -0
  223. package/cap/workflows/stats.md +60 -0
  224. package/cap/workflows/transition.md +671 -0
  225. package/cap/workflows/ui-phase.md +298 -0
  226. package/cap/workflows/ui-review.md +161 -0
  227. package/cap/workflows/update.md +323 -0
  228. package/cap/workflows/validate-phase.md +170 -0
  229. package/cap/workflows/verify-phase.md +254 -0
  230. package/cap/workflows/verify-work.md +637 -0
  231. package/commands/cap/annotate.md +165 -0
  232. package/commands/cap/brainstorm.md +393 -0
  233. package/commands/cap/checkpoint.md +106 -0
  234. package/commands/cap/completeness.md +94 -0
  235. package/commands/cap/continue.md +72 -0
  236. package/commands/cap/debug.md +588 -0
  237. package/commands/cap/deps.md +169 -0
  238. package/commands/cap/design.md +479 -0
  239. package/commands/cap/init.md +354 -0
  240. package/commands/cap/iterate.md +249 -0
  241. package/commands/cap/learn.md +459 -0
  242. package/commands/cap/memory.md +275 -0
  243. package/commands/cap/migrate-feature-map.md +91 -0
  244. package/commands/cap/migrate-memory.md +108 -0
  245. package/commands/cap/migrate-tags.md +91 -0
  246. package/commands/cap/migrate.md +131 -0
  247. package/commands/cap/prototype.md +510 -0
  248. package/commands/cap/reconcile.md +121 -0
  249. package/commands/cap/review.md +360 -0
  250. package/commands/cap/save.md +72 -0
  251. package/commands/cap/scan.md +404 -0
  252. package/commands/cap/start.md +356 -0
  253. package/commands/cap/status.md +118 -0
  254. package/commands/cap/test-audit.md +262 -0
  255. package/commands/cap/test.md +394 -0
  256. package/commands/cap/trace.md +133 -0
  257. package/commands/cap/ui.md +167 -0
  258. package/hooks/dist/cap-check-update.js +115 -0
  259. package/hooks/dist/cap-context-monitor.js +185 -0
  260. package/hooks/dist/cap-learn-review-hook.js +114 -0
  261. package/hooks/dist/cap-learning-hook.js +192 -0
  262. package/hooks/dist/cap-memory.js +299 -0
  263. package/hooks/dist/cap-prompt-guard.js +97 -0
  264. package/hooks/dist/cap-statusline.js +157 -0
  265. package/hooks/dist/cap-tag-observer.js +115 -0
  266. package/hooks/dist/cap-version-check.js +112 -0
  267. package/hooks/dist/cap-workflow-guard.js +175 -0
  268. package/hooks/hooks.json +55 -0
  269. package/package.json +58 -0
  270. package/scripts/base64-scan.sh +262 -0
  271. package/scripts/build-hooks.js +93 -0
  272. package/scripts/cap-removal-checklist.md +202 -0
  273. package/scripts/prompt-injection-scan.sh +199 -0
  274. package/scripts/run-tests.cjs +181 -0
  275. 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
+ };