@praneeth_54/agentdoctor 2.0.0 → 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (168) hide show
  1. package/CHANGELOG.md +141 -4
  2. package/README.md +329 -153
  3. package/dist/agent/approvals.d.ts +24 -0
  4. package/dist/agent/approvals.js +64 -0
  5. package/dist/agent/chat/index.d.ts +7 -0
  6. package/dist/agent/chat/index.js +5 -0
  7. package/dist/agent/chat/memory.d.ts +44 -0
  8. package/dist/agent/chat/memory.js +103 -0
  9. package/dist/agent/chat/project-summary.d.ts +16 -0
  10. package/dist/agent/chat/project-summary.js +70 -0
  11. package/dist/agent/chat/prompts.d.ts +6 -0
  12. package/dist/agent/chat/prompts.js +39 -0
  13. package/dist/agent/chat/response.d.ts +19 -0
  14. package/dist/agent/chat/response.js +109 -0
  15. package/dist/agent/chat/service.d.ts +42 -0
  16. package/dist/agent/chat/service.js +252 -0
  17. package/dist/agent/chat/types.d.ts +48 -0
  18. package/dist/agent/chat/types.js +1 -0
  19. package/dist/agent/context/retrieve.d.ts +21 -0
  20. package/dist/agent/context/retrieve.js +117 -0
  21. package/dist/agent/context/truth.d.ts +6 -0
  22. package/dist/agent/context/truth.js +19 -0
  23. package/dist/agent/context/types.d.ts +23 -0
  24. package/dist/agent/context/types.js +4 -0
  25. package/dist/agent/index.d.ts +26 -0
  26. package/dist/agent/index.js +14 -0
  27. package/dist/agent/loop.d.ts +51 -0
  28. package/dist/agent/loop.js +222 -0
  29. package/dist/agent/modes.d.ts +18 -0
  30. package/dist/agent/modes.js +102 -0
  31. package/dist/agent/plan.d.ts +30 -0
  32. package/dist/agent/plan.js +121 -0
  33. package/dist/agent/runtime.d.ts +67 -0
  34. package/dist/agent/runtime.js +180 -0
  35. package/dist/agent/state.d.ts +30 -0
  36. package/dist/agent/state.js +95 -0
  37. package/dist/agent/student.d.ts +56 -0
  38. package/dist/agent/student.js +230 -0
  39. package/dist/agent/tools/execute.d.ts +18 -0
  40. package/dist/agent/tools/execute.js +355 -0
  41. package/dist/agent/tools/index.d.ts +6 -0
  42. package/dist/agent/tools/index.js +5 -0
  43. package/dist/agent/tools/registry.d.ts +7 -0
  44. package/dist/agent/tools/registry.js +212 -0
  45. package/dist/agent/tools/run.d.ts +24 -0
  46. package/dist/agent/tools/run.js +44 -0
  47. package/dist/agent/tools/types.d.ts +32 -0
  48. package/dist/agent/tools/types.js +10 -0
  49. package/dist/agent/tools/write.d.ts +24 -0
  50. package/dist/agent/tools/write.js +121 -0
  51. package/dist/agent/verify.d.ts +29 -0
  52. package/dist/agent/verify.js +210 -0
  53. package/dist/ai/config.d.ts +22 -0
  54. package/dist/ai/config.js +68 -0
  55. package/dist/ai/index.d.ts +17 -0
  56. package/dist/ai/index.js +52 -0
  57. package/dist/ai/providers/mock.d.ts +20 -0
  58. package/dist/ai/providers/mock.js +84 -0
  59. package/dist/ai/providers/none.d.ts +6 -0
  60. package/dist/ai/providers/none.js +23 -0
  61. package/dist/ai/providers/openai-compatible.d.ts +21 -0
  62. package/dist/ai/providers/openai-compatible.js +151 -0
  63. package/dist/ai/redact.d.ts +8 -0
  64. package/dist/ai/redact.js +21 -0
  65. package/dist/ai/types.d.ts +71 -0
  66. package/dist/ai/types.js +6 -0
  67. package/dist/architecture/contract.d.ts +64 -0
  68. package/dist/architecture/contract.js +373 -0
  69. package/dist/assurance/change.d.ts +211 -0
  70. package/dist/assurance/change.js +624 -0
  71. package/dist/assurance/proof.d.ts +136 -0
  72. package/dist/assurance/proof.js +338 -0
  73. package/dist/auth/index.d.ts +81 -0
  74. package/dist/auth/index.js +130 -0
  75. package/dist/auth/rbac.d.ts +9 -0
  76. package/dist/auth/rbac.js +26 -0
  77. package/dist/cli/commands/agent.d.ts +27 -0
  78. package/dist/cli/commands/agent.js +153 -0
  79. package/dist/cli/commands/architecture.d.ts +6 -0
  80. package/dist/cli/commands/architecture.js +109 -0
  81. package/dist/cli/commands/assurance.d.ts +67 -0
  82. package/dist/cli/commands/assurance.js +210 -0
  83. package/dist/cli/commands/chat.d.ts +14 -0
  84. package/dist/cli/commands/chat.js +155 -0
  85. package/dist/cli/commands/complete.js +2 -2
  86. package/dist/cli/commands/learn.d.ts +12 -0
  87. package/dist/cli/commands/learn.js +107 -0
  88. package/dist/cli/commands/platform.d.ts +2 -0
  89. package/dist/cli/commands/platform.js +5 -1
  90. package/dist/cli/commands/policy-graph-run.d.ts +40 -0
  91. package/dist/cli/commands/policy-graph-run.js +200 -0
  92. package/dist/cli/commands/workspace.d.ts +9 -0
  93. package/dist/cli/commands/workspace.js +82 -0
  94. package/dist/cli/program.js +627 -17
  95. package/dist/constants.d.ts +1 -1
  96. package/dist/constants.js +1 -1
  97. package/dist/contracts/index.d.ts +1 -1
  98. package/dist/contracts/index.js +2 -2
  99. package/dist/core/brain-product/init.js +5 -2
  100. package/dist/core/secrets/scan.d.ts +7 -1
  101. package/dist/core/secrets/scan.js +9 -9
  102. package/dist/coverage/cobertura.d.ts +6 -0
  103. package/dist/coverage/cobertura.js +52 -0
  104. package/dist/coverage/istanbul.d.ts +7 -0
  105. package/dist/coverage/istanbul.js +134 -0
  106. package/dist/coverage/lcov.d.ts +7 -0
  107. package/dist/coverage/lcov.js +59 -0
  108. package/dist/coverage/load.d.ts +9 -0
  109. package/dist/coverage/load.js +39 -0
  110. package/dist/coverage/types.d.ts +23 -0
  111. package/dist/coverage/types.js +5 -0
  112. package/dist/dashboard/server.d.ts +7 -1
  113. package/dist/dashboard/server.js +110 -6
  114. package/dist/enforcement/runner.d.ts +52 -3
  115. package/dist/enforcement/runner.js +467 -16
  116. package/dist/index.d.ts +36 -2
  117. package/dist/index.js +23 -2
  118. package/dist/intelligence/graph/build.d.ts +2 -0
  119. package/dist/intelligence/graph/build.js +49 -5
  120. package/dist/intelligence/graph/incremental.d.ts +68 -0
  121. package/dist/intelligence/graph/incremental.js +234 -0
  122. package/dist/intelligence/resolve/imports.d.ts +36 -0
  123. package/dist/intelligence/resolve/imports.js +245 -0
  124. package/dist/languages/go.d.ts +9 -0
  125. package/dist/languages/go.js +50 -0
  126. package/dist/languages/index.d.ts +11 -0
  127. package/dist/languages/index.js +76 -0
  128. package/dist/languages/php.d.ts +8 -0
  129. package/dist/languages/php.js +202 -0
  130. package/dist/languages/python.d.ts +11 -0
  131. package/dist/languages/python.js +180 -0
  132. package/dist/languages/types.d.ts +42 -0
  133. package/dist/languages/types.js +26 -0
  134. package/dist/languages/typescript.d.ts +3 -0
  135. package/dist/languages/typescript.js +95 -0
  136. package/dist/mcp/agent/registry.d.ts +13 -0
  137. package/dist/mcp/agent/registry.js +234 -0
  138. package/dist/mcp/agentdoctor/server.js +8 -1
  139. package/dist/mcp/intelligence/handlers.d.ts +5 -0
  140. package/dist/mcp/intelligence/handlers.js +229 -40
  141. package/dist/mcp/intelligence/path-safety.d.ts +2 -4
  142. package/dist/mcp/intelligence/path-safety.js +25 -44
  143. package/dist/mcp/intelligence/registry.d.ts +1 -1
  144. package/dist/mcp/intelligence/registry.js +69 -1
  145. package/dist/platform/firewall/evaluate.d.ts +6 -3
  146. package/dist/platform/firewall/evaluate.js +66 -15
  147. package/dist/platform/graph/build.js +19 -23
  148. package/dist/platform/health/analyze.js +23 -17
  149. package/dist/platform/index.js +2 -0
  150. package/dist/platform/test-impact/analyze.d.ts +21 -3
  151. package/dist/platform/test-impact/analyze.js +214 -28
  152. package/dist/platform/tokens/plan.js +39 -20
  153. package/dist/platform/types.d.ts +9 -0
  154. package/dist/policy/compose.d.ts +34 -0
  155. package/dist/policy/compose.js +118 -0
  156. package/dist/policy/packs.js +33 -1
  157. package/dist/security/paths.d.ts +21 -0
  158. package/dist/security/paths.js +133 -0
  159. package/dist/storage/postgres.d.ts +26 -0
  160. package/dist/storage/postgres.js +90 -0
  161. package/dist/storage/provider.d.ts +2 -7
  162. package/dist/storage/provider.js +22 -16
  163. package/dist/storage/sqlite.d.ts +33 -0
  164. package/dist/storage/sqlite.js +77 -0
  165. package/dist/team/auth.js +1 -1
  166. package/dist/workspace/index.d.ts +69 -0
  167. package/dist/workspace/index.js +220 -0
  168. package/package.json +9 -3
@@ -2,12 +2,22 @@ import fs from "node:fs/promises";
2
2
  import path from "node:path";
3
3
  import { platformDir, writeJsonArtifact } from "../store.js";
4
4
  import { resolveRepoRoot } from "../../utils/path.js";
5
+ import { composePolicy } from "../../policy/compose.js";
5
6
  const EVALUATE_ONLY = "Evaluate-only: no commands are executed and no agents are intercepted.";
6
7
  const DEFAULT_POLICY = {
7
8
  version: "2.0",
8
9
  defaultDecision: "require-approval",
9
10
  failClosed: false,
10
- shellAllowlist: ["npm test", "npm run test", "vitest", "eslint", "tsc", "prettier"],
11
+ shellAllowlist: [
12
+ "npm test",
13
+ "npm run test",
14
+ "npm --version",
15
+ "npm -v",
16
+ "vitest",
17
+ "eslint",
18
+ "tsc",
19
+ "prettier",
20
+ ],
11
21
  rules: [
12
22
  {
13
23
  id: "block-secret-paths",
@@ -162,11 +172,22 @@ function denyAllPolicy(reason) {
162
172
  }
163
173
  /**
164
174
  * Load local action-policy document.
165
- * Invalid file + failClosed (file flag or option) → deny-all.
166
- * Invalid file + default → documented fallback DEFAULT_POLICY.
175
+ * Layers: composed builtin pack + `.agentdoctor/policy.json` (fail-closed on malformed repo policy),
176
+ * then optional platform `firewall-policy.json` overlay (highest precedence when valid).
177
+ * Invalid platform file + failClosed → deny-all.
178
+ * Invalid platform file + default → composed policy (or DEFAULT_POLICY when compose unavailable).
167
179
  */
168
180
  export async function loadFirewallPolicy(root, options) {
169
181
  const resolved = resolveRepoRoot(root);
182
+ const composed = await composePolicy({ root: resolved });
183
+ if (composed.validationError && composed.failClosed) {
184
+ return {
185
+ policy: composed.policy,
186
+ source: "fail-closed-deny",
187
+ validationError: composed.validationError,
188
+ composeSources: composed.sources,
189
+ };
190
+ }
170
191
  const file = path.join(platformDir(resolved), "firewall-policy.json");
171
192
  try {
172
193
  const raw = await fs.readFile(file, "utf8");
@@ -175,19 +196,20 @@ export async function loadFirewallPolicy(root, options) {
175
196
  parsed = JSON.parse(raw);
176
197
  }
177
198
  catch {
178
- const failClosed = options?.failClosed === true;
199
+ const failClosed = options?.failClosed === true || composed.failClosed;
179
200
  if (failClosed) {
180
201
  return {
181
202
  policy: denyAllPolicy("Malformed policy JSON; fail-closed deny"),
182
203
  source: "fail-closed-deny",
183
204
  validationError: "Malformed JSON",
205
+ composeSources: composed.sources,
184
206
  };
185
207
  }
186
- await writeJsonArtifact(resolved, "firewall-policy.json", DEFAULT_POLICY);
187
208
  return {
188
- policy: DEFAULT_POLICY,
189
- source: "default",
190
- validationError: "Malformed JSON; wrote default policy",
209
+ policy: composed.policy,
210
+ source: "composed",
211
+ validationError: "Malformed platform firewall-policy.json; using composed policy",
212
+ composeSources: composed.sources,
191
213
  };
192
214
  }
193
215
  const validated = validateFirewallPolicy(parsed);
@@ -195,26 +217,55 @@ export async function loadFirewallPolicy(root, options) {
195
217
  const fileWantsFailClosed = parsed &&
196
218
  typeof parsed === "object" &&
197
219
  parsed.failClosed === true;
198
- const failClosed = options?.failClosed === true || fileWantsFailClosed;
220
+ const failClosed = options?.failClosed === true || fileWantsFailClosed || composed.failClosed;
199
221
  if (failClosed) {
200
222
  return {
201
223
  policy: denyAllPolicy(`Invalid policy: ${validated.error}`),
202
224
  source: "fail-closed-deny",
203
225
  ...(validated.error ? { validationError: validated.error } : {}),
226
+ composeSources: composed.sources,
204
227
  };
205
228
  }
206
- await writeJsonArtifact(resolved, "firewall-policy.json", DEFAULT_POLICY);
207
229
  return {
208
- policy: DEFAULT_POLICY,
209
- source: "default",
210
- ...(validated.error ? { validationError: validated.error } : {}),
230
+ policy: composed.policy,
231
+ source: "composed",
232
+ ...(validated.error
233
+ ? { validationError: `Invalid platform policy; using composed (${validated.error})` }
234
+ : {}),
235
+ composeSources: composed.sources,
211
236
  };
212
237
  }
213
- return { policy: validated.policy, source: "file" };
238
+ // Platform file rules first (highest precedence), then composed builtin+repo rules.
239
+ const merged = {
240
+ version: "2.0",
241
+ defaultDecision: validated.policy.defaultDecision,
242
+ failClosed: validated.policy.failClosed === true || composed.policy.failClosed === true,
243
+ ...(validated.policy.shellAllowlist
244
+ ? { shellAllowlist: validated.policy.shellAllowlist }
245
+ : composed.policy.shellAllowlist
246
+ ? { shellAllowlist: composed.policy.shellAllowlist }
247
+ : {}),
248
+ rules: [...validated.policy.rules, ...composed.policy.rules],
249
+ };
250
+ return {
251
+ policy: merged,
252
+ source: "file",
253
+ composeSources: composed.sources,
254
+ };
214
255
  }
215
256
  catch {
257
+ // No platform file: prefer repo-composed policy when `.agentdoctor/policy.json` exists.
258
+ // Otherwise seed DEFAULT_POLICY (includes allow-safe-dev-paths). Do not treat packName
259
+ // alone as a reason to skip DEFAULT_POLICY — compose always names a builtin pack.
260
+ if (composed.sources.includes("repo")) {
261
+ return {
262
+ policy: composed.policy,
263
+ source: "composed",
264
+ composeSources: composed.sources,
265
+ };
266
+ }
216
267
  await writeJsonArtifact(resolved, "firewall-policy.json", DEFAULT_POLICY);
217
- return { policy: DEFAULT_POLICY, source: "default" };
268
+ return { policy: DEFAULT_POLICY, source: "default", composeSources: composed.sources };
218
269
  }
219
270
  }
220
271
  function ruleMatches(rule, action) {
@@ -1,6 +1,7 @@
1
1
  import fs from "node:fs/promises";
2
2
  import path from "node:path";
3
3
  import { createHash } from "node:crypto";
4
+ import { readTextFile } from "../../utils/fs.js";
4
5
  import { resolveRepoRoot, toPosixRelative } from "../../utils/path.js";
5
6
  const SKIP = new Set([
6
7
  "node_modules",
@@ -154,30 +155,25 @@ export async function buildRepositoryGraph(rootInput) {
154
155
  const fileId = nodeId("file", rel);
155
156
  nodes.push({ id: fileId, kind, label: path.basename(rel), path: rel });
156
157
  if (CODE_EXT.has(ext)) {
157
- try {
158
- const st = await fs.stat(absolute);
159
- if (st.size > 256 * 1024)
160
- continue;
161
- const content = await fs.readFile(absolute, "utf8");
162
- nodes.push(...extractSymbols(content, rel));
163
- const { edges: importEdges, dependencyNodes } = extractImports(content, rel);
164
- edges.push(...importEdges);
165
- for (const dep of dependencyNodes) {
166
- if (!nodes.some((n) => n.id === dep.id))
167
- nodes.push(dep);
168
- }
169
- if (/router\.|app\.(get|post|put|delete)|@app\.(get|post)/i.test(content)) {
170
- nodes.push({
171
- id: nodeId("api", rel),
172
- kind: "api",
173
- label: `api:${path.basename(rel)}`,
174
- path: rel,
175
- meta: { evidence: "inferred" },
176
- });
177
- }
158
+ // open+fstat+read via readTextFile — avoids exists/stat→read TOCTOU
159
+ const content = await readTextFile(absolute, 256 * 1024);
160
+ if (content === null)
161
+ continue;
162
+ nodes.push(...extractSymbols(content, rel));
163
+ const { edges: importEdges, dependencyNodes } = extractImports(content, rel);
164
+ edges.push(...importEdges);
165
+ for (const dep of dependencyNodes) {
166
+ if (!nodes.some((n) => n.id === dep.id))
167
+ nodes.push(dep);
178
168
  }
179
- catch {
180
- // skip unreadable
169
+ if (/router\.|app\.(get|post|put|delete)|@app\.(get|post)/i.test(content)) {
170
+ nodes.push({
171
+ id: nodeId("api", rel),
172
+ kind: "api",
173
+ label: `api:${path.basename(rel)}`,
174
+ path: rel,
175
+ meta: { evidence: "inferred" },
176
+ });
181
177
  }
182
178
  }
183
179
  }
@@ -19,24 +19,30 @@ export async function analyzeCodeHealth(rootInput, graph) {
19
19
  let content;
20
20
  let size;
21
21
  try {
22
- const st = await fs.stat(absolute);
23
- size = st.size;
24
- if (size > 512 * 1024) {
25
- findings.push({
26
- id: fid("filesize", node.path),
27
- module: "code-health",
28
- severity: "medium",
29
- title: "Large source file",
30
- message: `${node.path} is ${size} bytes`,
31
- recommendation: "Split into smaller modules to reduce change risk",
32
- confidence: 0.9,
33
- evidence: [{ kind: "verified", path: node.path, detail: `sizeBytes=${size}` }],
34
- });
35
- continue;
22
+ const handle = await fs.open(absolute, "r");
23
+ try {
24
+ const st = await handle.stat();
25
+ size = st.size;
26
+ if (size > 512 * 1024) {
27
+ findings.push({
28
+ id: fid("filesize", node.path),
29
+ module: "code-health",
30
+ severity: "medium",
31
+ title: "Large source file",
32
+ message: `${node.path} is ${size} bytes`,
33
+ recommendation: "Split into smaller modules to reduce change risk",
34
+ confidence: 0.9,
35
+ evidence: [{ kind: "verified", path: node.path, detail: `sizeBytes=${size}` }],
36
+ });
37
+ continue;
38
+ }
39
+ if (!/\.(ts|tsx|js|jsx|py|go)$/i.test(node.path))
40
+ continue;
41
+ content = await handle.readFile("utf8");
42
+ }
43
+ finally {
44
+ await handle.close();
36
45
  }
37
- if (!/\.(ts|tsx|js|jsx|py|go)$/i.test(node.path))
38
- continue;
39
- content = await fs.readFile(absolute, "utf8");
40
46
  }
41
47
  catch {
42
48
  continue;
@@ -15,12 +15,14 @@ import { resolveRepoRoot } from "../utils/path.js";
15
15
  function toTestImpactSnapshot(report) {
16
16
  return {
17
17
  gitAvailable: report.gitAvailable,
18
+ mode: report.mode,
18
19
  changedFiles: report.changedFiles,
19
20
  recommendedTests: report.recommendedTests,
20
21
  relatedModules: report.relatedModules,
21
22
  missingTestWarnings: report.missingTestWarnings,
22
23
  uncoveredAreas: report.uncoveredAreas,
23
24
  skipRisk: report.skipRisk,
25
+ coverage: report.coverage,
24
26
  limitations: report.limitations,
25
27
  };
26
28
  }
@@ -1,3 +1,4 @@
1
+ import type { CoverageFormat } from "../../coverage/types.js";
1
2
  import type { EvidenceKind } from "../types.js";
2
3
  export interface TestImpactItem {
3
4
  testPath: string;
@@ -12,21 +13,38 @@ export interface MissingTestWarning {
12
13
  message: string;
13
14
  relatedModules: string[];
14
15
  }
16
+ export interface CoverageImpactSummary {
17
+ format: CoverageFormat;
18
+ filesWithHits: number;
19
+ changedCoveredLines: number;
20
+ changedUncoveredLines: number;
21
+ coverageHits: number;
22
+ /** How recommended tests were attributed. */
23
+ testAttribution: "coverage-map" | "hybrid-heuristic" | "none";
24
+ }
15
25
  export interface TestImpactReport {
16
26
  root: string;
17
27
  gitAvailable: boolean;
28
+ mode: "coverage-backed" | "heuristic";
18
29
  changedFiles: string[];
19
30
  recommendedTests: TestImpactItem[];
20
31
  relatedModules: string[];
21
32
  uncoveredAreas: string[];
22
33
  missingTestWarnings: MissingTestWarning[];
23
34
  skipRisk: string;
35
+ coverage: CoverageImpactSummary | null;
24
36
  limitations: string[];
25
37
  }
38
+ export interface AnalyzeTestImpactOptions {
39
+ root?: string;
40
+ coveragePath?: string;
41
+ since?: string;
42
+ }
26
43
  /**
27
- * Module H — Test impact analysis (naming + path heuristics).
28
- * Never claims tests passed unless actually executed elsewhere.
44
+ * Module H — Test impact analysis.
45
+ * Heuristic by default; coverage-backed when a coverage file is loaded and used
46
+ * for changed-line evidence. Never claims tests passed.
29
47
  */
30
- export declare function analyzeTestImpact(rootInput: string): Promise<TestImpactReport>;
48
+ export declare function analyzeTestImpact(rootInput: string | AnalyzeTestImpactOptions, maybeOptions?: AnalyzeTestImpactOptions): Promise<TestImpactReport>;
31
49
  export declare function persistTestImpactReport(root: string, report: TestImpactReport): Promise<string>;
32
50
  export declare function formatTestImpactHuman(report: TestImpactReport): string;
@@ -1,6 +1,8 @@
1
1
  import fs from "node:fs/promises";
2
2
  import path from "node:path";
3
3
  import { analyzeChanges } from "../../core/changes/analyze.js";
4
+ import { loadCoverage } from "../../coverage/load.js";
5
+ import { normalizeCoveragePath } from "../../coverage/types.js";
4
6
  import { resolveRepoRoot } from "../../utils/path.js";
5
7
  import { writeJsonArtifact } from "../store.js";
6
8
  function modulesForPath(file, attributed) {
@@ -24,41 +26,130 @@ async function fileExists(root, rel) {
24
26
  return false;
25
27
  }
26
28
  }
29
+ function isTestPath(file) {
30
+ return /\.(test|spec)\./i.test(file) || file.includes("__tests__");
31
+ }
32
+ function heuristicCandidates(file) {
33
+ const ext = path.extname(file);
34
+ const base = ext ? file.slice(0, -ext.length) : file;
35
+ return [
36
+ `${base}.test${ext || ".ts"}`,
37
+ `${base}.spec${ext || ".ts"}`,
38
+ file.replace(/\/src\//, "/tests/"),
39
+ file.replace(/^src\//, "tests/"),
40
+ ].filter((c, i, arr) => c !== file && arr.indexOf(c) === i);
41
+ }
42
+ function coverageFileFor(changed, coverage) {
43
+ const norm = normalizeCoveragePath(changed);
44
+ if (coverage.files[norm])
45
+ return norm;
46
+ // Match by suffix (absolute paths in LCOV vs repo-relative changed paths)
47
+ const keys = Object.keys(coverage.files);
48
+ const suffixHit = keys.find((k) => k === norm || k.endsWith(`/${norm}`) || norm.endsWith(`/${k}`));
49
+ return suffixHit ?? null;
50
+ }
51
+ function countLineHits(fileCov) {
52
+ if (!fileCov)
53
+ return { covered: 0, uncovered: 0, hits: 0 };
54
+ let covered = 0;
55
+ let uncovered = 0;
56
+ let hits = 0;
57
+ for (const h of Object.values(fileCov.lines)) {
58
+ if (h > 0) {
59
+ covered += 1;
60
+ hits += h;
61
+ }
62
+ else {
63
+ uncovered += 1;
64
+ }
65
+ }
66
+ return { covered, uncovered, hits };
67
+ }
68
+ function testsFromCoverageMap(coverage, sourceKey) {
69
+ const fileCov = coverage.files[sourceKey];
70
+ const fromFile = fileCov?.coveredByTests ?? [];
71
+ const fromMap = [];
72
+ if (coverage.testToFiles) {
73
+ for (const [test, sources] of Object.entries(coverage.testToFiles)) {
74
+ if (sources.some((s) => s === sourceKey || s.endsWith(`/${sourceKey}`) || sourceKey.endsWith(`/${s}`))) {
75
+ fromMap.push(test);
76
+ }
77
+ }
78
+ }
79
+ return [...new Set([...fromFile, ...fromMap].map(normalizeCoveragePath))].sort();
80
+ }
27
81
  /**
28
- * Module H — Test impact analysis (naming + path heuristics).
29
- * Never claims tests passed unless actually executed elsewhere.
82
+ * Module H — Test impact analysis.
83
+ * Heuristic by default; coverage-backed when a coverage file is loaded and used
84
+ * for changed-line evidence. Never claims tests passed.
30
85
  */
31
- export async function analyzeTestImpact(rootInput) {
32
- const root = resolveRepoRoot(rootInput);
33
- const changes = await analyzeChanges({ root, impact: true });
86
+ export async function analyzeTestImpact(rootInput, maybeOptions) {
87
+ // Support both analyzeTestImpact(root) and analyzeTestImpact({ root, ... })
88
+ const options = typeof rootInput === "string" ? { ...(maybeOptions ?? {}), root: rootInput } : rootInput;
89
+ const root = resolveRepoRoot(options.root ?? process.cwd());
90
+ const changes = await analyzeChanges({
91
+ root,
92
+ impact: true,
93
+ ...(options.since ? { since: options.since } : {}),
94
+ });
34
95
  const changed = changes.files.map((f) => f.path);
35
96
  const recommended = [];
36
97
  const uncovered = [];
37
98
  const missingTestWarnings = [];
38
99
  const allModules = new Set();
100
+ const limitations = [
101
+ "Does not execute tests; never claims pass/fail",
102
+ ...changes.limitations,
103
+ ];
104
+ let coverage = null;
105
+ let coverageSummary = null;
106
+ let mode = "heuristic";
107
+ let testAttribution = "none";
108
+ if (options.coveragePath) {
109
+ try {
110
+ coverage = await loadCoverage(options.coveragePath);
111
+ if (Object.keys(coverage.files).length === 0) {
112
+ limitations.push(`Coverage path provided but no file hits loaded (${coverage.format}): ${options.coveragePath}`);
113
+ limitations.push(...coverage.limitations);
114
+ coverage = null;
115
+ }
116
+ else {
117
+ limitations.push(...coverage.limitations);
118
+ }
119
+ }
120
+ catch (error) {
121
+ limitations.push(`Failed to load coverage from ${options.coveragePath}: ${error instanceof Error ? error.message : String(error)}`);
122
+ coverage = null;
123
+ }
124
+ }
39
125
  if (!changes.gitAvailable) {
40
126
  return {
41
127
  root,
42
128
  gitAvailable: false,
129
+ mode: "heuristic",
43
130
  changedFiles: [],
44
131
  recommendedTests: [],
45
132
  relatedModules: [],
46
133
  uncoveredAreas: [],
47
134
  missingTestWarnings: [],
48
135
  skipRisk: "Git not available — cannot infer changed files",
49
- limitations: [
50
- "Requires a git working tree to detect changes",
51
- "Does not execute tests; never claims pass/fail",
52
- ...changes.limitations,
53
- ],
136
+ coverage: null,
137
+ limitations: ["Requires a git working tree to detect changes", ...limitations],
54
138
  };
55
139
  }
140
+ let changedCoveredLines = 0;
141
+ let changedUncoveredLines = 0;
142
+ let coverageHits = 0;
143
+ let filesWithHits = 0;
144
+ let usedCoverageForChangedLines = false;
145
+ let usedCoverageTestMap = false;
146
+ let usedHeuristicForTests = false;
56
147
  for (const fileEntry of changes.files) {
57
148
  const file = fileEntry.path;
58
149
  const relatedModules = modulesForPath(file, fileEntry.modules);
59
150
  for (const m of relatedModules)
60
151
  allModules.add(m);
61
- if (/\.(test|spec)\./i.test(file) || file.includes("__tests__")) {
152
+ if (isTestPath(file)) {
62
153
  recommended.push({
63
154
  testPath: file,
64
155
  reason: "Changed file is itself a test",
@@ -69,14 +160,69 @@ export async function analyzeTestImpact(rootInput) {
69
160
  });
70
161
  continue;
71
162
  }
72
- const ext = path.extname(file);
73
- const base = ext ? file.slice(0, -ext.length) : file;
74
- const candidates = [
75
- `${base}.test${ext || ".ts"}`,
76
- `${base}.spec${ext || ".ts"}`,
77
- file.replace(/\/src\//, "/tests/"),
78
- file.replace(/^src\//, "tests/"),
79
- ].filter((c, i, arr) => c !== file && arr.indexOf(c) === i);
163
+ // Coverage-backed changed-line evidence
164
+ if (coverage) {
165
+ const covKey = coverageFileFor(file, coverage);
166
+ if (covKey) {
167
+ const counts = countLineHits(coverage.files[covKey]);
168
+ changedCoveredLines += counts.covered;
169
+ changedUncoveredLines += counts.uncovered;
170
+ coverageHits += counts.hits;
171
+ if (counts.covered + counts.uncovered > 0) {
172
+ usedCoverageForChangedLines = true;
173
+ filesWithHits += 1;
174
+ }
175
+ const mappedTests = testsFromCoverageMap(coverage, covKey);
176
+ if (mappedTests.length > 0) {
177
+ usedCoverageTestMap = true;
178
+ for (const testPath of mappedTests) {
179
+ const exists = await fileExists(root, testPath);
180
+ recommended.push({
181
+ testPath,
182
+ reason: `Coverage map links test to changed source ${file}`,
183
+ confidence: exists ? 0.9 : 0.75,
184
+ evidence: "verified",
185
+ exists,
186
+ relatedModules,
187
+ });
188
+ }
189
+ }
190
+ else {
191
+ // Hybrid: coverage proves line hits; tests from naming heuristic
192
+ usedHeuristicForTests = true;
193
+ const candidates = heuristicCandidates(file);
194
+ let anyExists = false;
195
+ for (const c of candidates) {
196
+ const exists = await fileExists(root, c);
197
+ if (exists)
198
+ anyExists = true;
199
+ recommended.push({
200
+ testPath: c,
201
+ reason: exists
202
+ ? `Heuristic test candidate for coverage-touched file ${file} (no test→source map)`
203
+ : `Suggested test path for coverage-touched file ${file} (not found; no test→source map)`,
204
+ confidence: exists ? 0.65 : 0.35,
205
+ evidence: "inferred",
206
+ exists,
207
+ relatedModules,
208
+ });
209
+ }
210
+ if (candidates.length === 0) {
211
+ uncovered.push(file);
212
+ }
213
+ else if (!anyExists) {
214
+ missingTestWarnings.push({
215
+ sourceFile: file,
216
+ message: `No co-located or tests/ counterpart found for ${file}`,
217
+ relatedModules,
218
+ });
219
+ }
220
+ }
221
+ continue;
222
+ }
223
+ }
224
+ // Pure heuristic path (no coverage for this file, or no coverage loaded)
225
+ const candidates = heuristicCandidates(file);
80
226
  let anyExists = false;
81
227
  for (const c of candidates) {
82
228
  const exists = await fileExists(root, c);
@@ -85,10 +231,10 @@ export async function analyzeTestImpact(rootInput) {
85
231
  recommended.push({
86
232
  testPath: c,
87
233
  reason: exists
88
- ? `Existing test candidate for changed file ${file}`
89
- : `Suggested test path for changed file ${file} (file not found)`,
234
+ ? `Existing test candidate for changed file ${file} (naming heuristic)`
235
+ : `Suggested test path for changed file ${file} (file not found; naming heuristic)`,
90
236
  confidence: exists ? 0.7 : 0.4,
91
- evidence: exists ? "verified" : "inferred",
237
+ evidence: "inferred",
92
238
  exists,
93
239
  relatedModules,
94
240
  });
@@ -108,6 +254,44 @@ export async function analyzeTestImpact(rootInput) {
108
254
  for (const m of modulesForPath(rel))
109
255
  allModules.add(m);
110
256
  }
257
+ if (usedCoverageForChangedLines) {
258
+ mode = "coverage-backed";
259
+ if (usedCoverageTestMap && usedHeuristicForTests) {
260
+ testAttribution = "hybrid-heuristic";
261
+ limitations.push("mode=coverage-backed: changed-line evidence from coverage; some tests attributed via naming heuristic (hybrid)");
262
+ }
263
+ else if (usedCoverageTestMap) {
264
+ testAttribution = "coverage-map";
265
+ limitations.push("mode=coverage-backed: test attribution from coverage test→source map");
266
+ }
267
+ else {
268
+ testAttribution = "hybrid-heuristic";
269
+ limitations.push("mode=coverage-backed: coverage used for changed-line hits; test list uses naming heuristics because coverage lacks test→source map");
270
+ }
271
+ coverageSummary = {
272
+ format: coverage.format,
273
+ filesWithHits,
274
+ changedCoveredLines,
275
+ changedUncoveredLines,
276
+ coverageHits,
277
+ testAttribution,
278
+ };
279
+ }
280
+ else {
281
+ mode = "heuristic";
282
+ limitations.push("Impact mapping is path/naming heuristic — not coverage-backed (no usable coverage intersection with changed files)");
283
+ if (coverage) {
284
+ limitations.push("Coverage file was loaded but did not intersect changed source paths for line evidence");
285
+ coverageSummary = {
286
+ format: coverage.format,
287
+ filesWithHits: Object.keys(coverage.files).length,
288
+ changedCoveredLines: 0,
289
+ changedUncoveredLines: 0,
290
+ coverageHits: 0,
291
+ testAttribution: "none",
292
+ };
293
+ }
294
+ }
111
295
  const seen = new Set();
112
296
  const deduped = recommended.filter((r) => {
113
297
  if (seen.has(r.testPath))
@@ -118,6 +302,7 @@ export async function analyzeTestImpact(rootInput) {
118
302
  return {
119
303
  root,
120
304
  gitAvailable: true,
305
+ mode,
121
306
  changedFiles: changed,
122
307
  recommendedTests: deduped,
123
308
  relatedModules: [...allModules].sort(),
@@ -128,11 +313,8 @@ export async function analyzeTestImpact(rootInput) {
128
313
  : missingTestWarnings.length > 0
129
314
  ? "Changed source files lack matching tests — skipping tests risks regressions"
130
315
  : "Skipping recommended tests may miss regressions in changed modules",
131
- limitations: [
132
- "Impact mapping is path/naming heuristic — not a full coverage DB",
133
- "Does not execute tests; never claims pass/fail",
134
- ...changes.limitations,
135
- ],
316
+ coverage: coverageSummary,
317
+ limitations,
136
318
  };
137
319
  }
138
320
  export async function persistTestImpactReport(root, report) {
@@ -141,6 +323,7 @@ export async function persistTestImpactReport(root, report) {
141
323
  export function formatTestImpactHuman(report) {
142
324
  const lines = [
143
325
  "AgentDoctor 2.0 test-impact",
326
+ ` mode: ${report.mode}`,
144
327
  ` git: ${report.gitAvailable ? "available" : "unavailable"}`,
145
328
  ` changed files: ${report.changedFiles.length}`,
146
329
  ` recommended tests: ${report.recommendedTests.length}`,
@@ -148,10 +331,13 @@ export function formatTestImpactHuman(report) {
148
331
  ` related modules: ${report.relatedModules.slice(0, 12).join(", ") || "(none)"}`,
149
332
  ` skip risk: ${report.skipRisk}`,
150
333
  ];
334
+ if (report.coverage) {
335
+ lines.push(` coverage: format=${report.coverage.format} filesWithHits=${report.coverage.filesWithHits} coveredLines=${report.coverage.changedCoveredLines} uncoveredLines=${report.coverage.changedUncoveredLines} hits=${report.coverage.coverageHits} testAttribution=${report.coverage.testAttribution}`);
336
+ }
151
337
  if (report.recommendedTests.length) {
152
338
  lines.push(" affected tests:");
153
339
  for (const t of report.recommendedTests.slice(0, 20)) {
154
- lines.push(` - ${t.testPath} (exists=${t.exists}, confidence=${t.confidence}, ${t.reason})`);
340
+ lines.push(` - ${t.testPath} (exists=${t.exists}, confidence=${t.confidence}, evidence=${t.evidence}, ${t.reason})`);
155
341
  }
156
342
  }
157
343
  if (report.missingTestWarnings.length) {