@praneeth_54/agentdoctor 2.0.0 → 2.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +73 -4
- package/README.md +283 -158
- package/dist/architecture/contract.d.ts +64 -0
- package/dist/architecture/contract.js +373 -0
- package/dist/assurance/change.d.ts +211 -0
- package/dist/assurance/change.js +624 -0
- package/dist/assurance/proof.d.ts +136 -0
- package/dist/assurance/proof.js +338 -0
- package/dist/auth/index.d.ts +81 -0
- package/dist/auth/index.js +130 -0
- package/dist/auth/rbac.d.ts +9 -0
- package/dist/auth/rbac.js +26 -0
- package/dist/cli/commands/architecture.d.ts +6 -0
- package/dist/cli/commands/architecture.js +109 -0
- package/dist/cli/commands/assurance.d.ts +67 -0
- package/dist/cli/commands/assurance.js +210 -0
- package/dist/cli/commands/complete.js +2 -2
- package/dist/cli/commands/platform.d.ts +2 -0
- package/dist/cli/commands/platform.js +5 -1
- package/dist/cli/commands/policy-graph-run.d.ts +40 -0
- package/dist/cli/commands/policy-graph-run.js +200 -0
- package/dist/cli/commands/workspace.d.ts +9 -0
- package/dist/cli/commands/workspace.js +82 -0
- package/dist/cli/program.js +535 -17
- package/dist/constants.d.ts +1 -1
- package/dist/constants.js +1 -1
- package/dist/contracts/index.d.ts +1 -1
- package/dist/contracts/index.js +2 -2
- package/dist/core/brain-product/init.js +5 -2
- package/dist/core/secrets/scan.d.ts +7 -1
- package/dist/core/secrets/scan.js +9 -9
- package/dist/coverage/cobertura.d.ts +6 -0
- package/dist/coverage/cobertura.js +52 -0
- package/dist/coverage/istanbul.d.ts +7 -0
- package/dist/coverage/istanbul.js +134 -0
- package/dist/coverage/lcov.d.ts +7 -0
- package/dist/coverage/lcov.js +59 -0
- package/dist/coverage/load.d.ts +9 -0
- package/dist/coverage/load.js +39 -0
- package/dist/coverage/types.d.ts +23 -0
- package/dist/coverage/types.js +5 -0
- package/dist/dashboard/server.d.ts +1 -1
- package/dist/dashboard/server.js +21 -5
- package/dist/enforcement/runner.d.ts +47 -3
- package/dist/enforcement/runner.js +420 -16
- package/dist/index.d.ts +23 -2
- package/dist/index.js +15 -2
- package/dist/intelligence/graph/build.d.ts +2 -0
- package/dist/intelligence/graph/build.js +49 -5
- package/dist/intelligence/graph/incremental.d.ts +68 -0
- package/dist/intelligence/graph/incremental.js +234 -0
- package/dist/intelligence/resolve/imports.d.ts +36 -0
- package/dist/intelligence/resolve/imports.js +245 -0
- package/dist/languages/go.d.ts +9 -0
- package/dist/languages/go.js +45 -0
- package/dist/languages/index.d.ts +11 -0
- package/dist/languages/index.js +76 -0
- package/dist/languages/php.d.ts +7 -0
- package/dist/languages/php.js +193 -0
- package/dist/languages/python.d.ts +7 -0
- package/dist/languages/python.js +150 -0
- package/dist/languages/types.d.ts +42 -0
- package/dist/languages/types.js +26 -0
- package/dist/languages/typescript.d.ts +3 -0
- package/dist/languages/typescript.js +95 -0
- package/dist/mcp/intelligence/handlers.d.ts +5 -0
- package/dist/mcp/intelligence/handlers.js +229 -40
- package/dist/mcp/intelligence/path-safety.d.ts +2 -4
- package/dist/mcp/intelligence/path-safety.js +25 -44
- package/dist/mcp/intelligence/registry.d.ts +1 -1
- package/dist/mcp/intelligence/registry.js +69 -1
- package/dist/platform/firewall/evaluate.d.ts +6 -3
- package/dist/platform/firewall/evaluate.js +66 -15
- package/dist/platform/graph/build.js +19 -23
- package/dist/platform/health/analyze.js +23 -17
- package/dist/platform/index.js +2 -0
- package/dist/platform/test-impact/analyze.d.ts +21 -3
- package/dist/platform/test-impact/analyze.js +214 -28
- package/dist/platform/tokens/plan.js +39 -20
- package/dist/platform/types.d.ts +9 -0
- package/dist/policy/compose.d.ts +34 -0
- package/dist/policy/compose.js +118 -0
- package/dist/policy/packs.js +33 -1
- package/dist/security/paths.d.ts +21 -0
- package/dist/security/paths.js +105 -0
- package/dist/storage/postgres.d.ts +26 -0
- package/dist/storage/postgres.js +90 -0
- package/dist/storage/provider.d.ts +2 -7
- package/dist/storage/provider.js +22 -16
- package/dist/storage/sqlite.d.ts +33 -0
- package/dist/storage/sqlite.js +77 -0
- package/dist/team/auth.js +1 -1
- package/dist/workspace/index.d.ts +69 -0
- package/dist/workspace/index.js +220 -0
- 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: [
|
|
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
|
-
*
|
|
166
|
-
*
|
|
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:
|
|
189
|
-
source: "
|
|
190
|
-
validationError: "Malformed
|
|
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:
|
|
209
|
-
source: "
|
|
210
|
-
...(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
|
-
|
|
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
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
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
|
-
|
|
180
|
-
|
|
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
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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;
|
package/dist/platform/index.js
CHANGED
|
@@ -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
|
|
28
|
-
*
|
|
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
|
|
29
|
-
*
|
|
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
|
-
|
|
33
|
-
const
|
|
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
|
-
|
|
50
|
-
|
|
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 (
|
|
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
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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:
|
|
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
|
-
|
|
132
|
-
|
|
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) {
|