@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
|
@@ -0,0 +1,624 @@
|
|
|
1
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
+
import { spawnSync } from "node:child_process";
|
|
3
|
+
import fs from "node:fs/promises";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { PACKAGE_VERSION } from "../constants.js";
|
|
6
|
+
import { analyzeChanges } from "../core/changes/analyze.js";
|
|
7
|
+
import { scanSecrets } from "../core/secrets/scan.js";
|
|
8
|
+
import { buildC4Views } from "../architecture/c4.js";
|
|
9
|
+
import { buildIntelligenceGraph } from "../intelligence/graph/build.js";
|
|
10
|
+
import { listKnowledge, retrieveAuthoritative } from "../knowledge/store.js";
|
|
11
|
+
import { analyzeTestImpact } from "../platform/test-impact/analyze.js";
|
|
12
|
+
import { evaluateAgentAction } from "../platform/firewall/evaluate.js";
|
|
13
|
+
import { resolveRepoRoot } from "../utils/path.js";
|
|
14
|
+
import { atomicWriteTextFile } from "../utils/fs.js";
|
|
15
|
+
export const EVIDENCE_SCHEMA_VERSION = "1.0.0";
|
|
16
|
+
function sha256(content) {
|
|
17
|
+
return createHash("sha256").update(content, "utf8").digest("hex");
|
|
18
|
+
}
|
|
19
|
+
function evidenceRoot(repoRoot, changeId) {
|
|
20
|
+
return path.join(repoRoot, ".agentdoctor", "evidence", changeId);
|
|
21
|
+
}
|
|
22
|
+
function normalizeRelPath(p) {
|
|
23
|
+
return p.replace(/\\/g, "/").replace(/^\.\//, "");
|
|
24
|
+
}
|
|
25
|
+
function uniqueSorted(values) {
|
|
26
|
+
return [...new Set(values)].filter(Boolean).sort();
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Derive callers / callees / reverse import deps from the intelligence graph
|
|
30
|
+
* for the given changed files and symbol ids. Honest: empty when graph lacks edges.
|
|
31
|
+
*/
|
|
32
|
+
export function deriveGraphChangeImpact(graph, changedFiles, changedSymbols) {
|
|
33
|
+
const changedPaths = new Set(changedFiles.map((f) => normalizeRelPath(f.path)));
|
|
34
|
+
// Include rename sources: git may report only the new path; both old/new appear as kind renamed with path as destination.
|
|
35
|
+
const changedSymbolIds = new Set(changedSymbols);
|
|
36
|
+
const nodeById = new Map(graph.nodes.map((n) => [n.id, n]));
|
|
37
|
+
const isChangedNode = (nodeId) => {
|
|
38
|
+
if (changedSymbolIds.has(nodeId))
|
|
39
|
+
return true;
|
|
40
|
+
const node = nodeById.get(nodeId);
|
|
41
|
+
if (!node)
|
|
42
|
+
return false;
|
|
43
|
+
const nodePath = node.path ? normalizeRelPath(node.path) : "";
|
|
44
|
+
if (nodePath && changedPaths.has(nodePath))
|
|
45
|
+
return true;
|
|
46
|
+
// Dependency nodes may carry resolvedPath in meta — handled via path/label.
|
|
47
|
+
if (node.kind === "dependency" && node.label) {
|
|
48
|
+
const label = normalizeRelPath(node.label);
|
|
49
|
+
if (changedPaths.has(label))
|
|
50
|
+
return true;
|
|
51
|
+
for (const p of changedPaths) {
|
|
52
|
+
if (label.endsWith(p) || label.includes(p))
|
|
53
|
+
return true;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
return false;
|
|
57
|
+
};
|
|
58
|
+
const labelOf = (nodeId) => {
|
|
59
|
+
const node = nodeById.get(nodeId);
|
|
60
|
+
if (!node)
|
|
61
|
+
return nodeId;
|
|
62
|
+
if (node.path)
|
|
63
|
+
return normalizeRelPath(node.path);
|
|
64
|
+
return node.label || nodeId;
|
|
65
|
+
};
|
|
66
|
+
const callers = new Set();
|
|
67
|
+
const callees = new Set();
|
|
68
|
+
const reverseDependencies = new Set();
|
|
69
|
+
for (const edge of graph.edges) {
|
|
70
|
+
const kind = edge.kind;
|
|
71
|
+
const fromChanged = isChangedNode(edge.from);
|
|
72
|
+
const toChanged = isChangedNode(edge.to);
|
|
73
|
+
if (kind === "calls" || kind === "imports" || kind === "extends" || kind === "implements") {
|
|
74
|
+
if (fromChanged) {
|
|
75
|
+
callees.add(labelOf(edge.to));
|
|
76
|
+
}
|
|
77
|
+
if (toChanged) {
|
|
78
|
+
callers.add(labelOf(edge.from));
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
if (kind === "imports" && toChanged) {
|
|
82
|
+
const fromLabel = labelOf(edge.from);
|
|
83
|
+
if (!changedPaths.has(normalizeRelPath(fromLabel))) {
|
|
84
|
+
reverseDependencies.add(fromLabel);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
return {
|
|
89
|
+
callers: uniqueSorted(callers).slice(0, 200),
|
|
90
|
+
callees: uniqueSorted(callees).slice(0, 200),
|
|
91
|
+
reverseDependencies: uniqueSorted(reverseDependencies).slice(0, 200),
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* Map changed files onto package.json workspaces (string globs or packages/*).
|
|
96
|
+
* Returns [] when workspaces are absent — does not invent packages.
|
|
97
|
+
*/
|
|
98
|
+
export async function derivePackageImpact(root, changedFiles) {
|
|
99
|
+
let pkgRaw;
|
|
100
|
+
try {
|
|
101
|
+
pkgRaw = await fs.readFile(path.join(root, "package.json"), "utf8");
|
|
102
|
+
}
|
|
103
|
+
catch {
|
|
104
|
+
return [];
|
|
105
|
+
}
|
|
106
|
+
let workspaces = [];
|
|
107
|
+
try {
|
|
108
|
+
const pkg = JSON.parse(pkgRaw);
|
|
109
|
+
if (Array.isArray(pkg.workspaces)) {
|
|
110
|
+
workspaces = pkg.workspaces;
|
|
111
|
+
}
|
|
112
|
+
else if (pkg.workspaces && Array.isArray(pkg.workspaces.packages)) {
|
|
113
|
+
workspaces = pkg.workspaces.packages;
|
|
114
|
+
}
|
|
115
|
+
if (workspaces.length === 0) {
|
|
116
|
+
// Single-package repo: report package name when any file changed.
|
|
117
|
+
if (changedFiles.length > 0 && typeof pkg.name === "string" && pkg.name) {
|
|
118
|
+
return [pkg.name];
|
|
119
|
+
}
|
|
120
|
+
return [];
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
catch {
|
|
124
|
+
return [];
|
|
125
|
+
}
|
|
126
|
+
const impacted = new Set();
|
|
127
|
+
for (const pattern of workspaces) {
|
|
128
|
+
// Support simple "packages/*" / "apps/*" style globs only.
|
|
129
|
+
const star = pattern.indexOf("*");
|
|
130
|
+
if (star === -1) {
|
|
131
|
+
const prefix = normalizeRelPath(pattern).replace(/\/$/, "");
|
|
132
|
+
for (const f of changedFiles) {
|
|
133
|
+
const fp = normalizeRelPath(f.path);
|
|
134
|
+
if (fp === prefix || fp.startsWith(`${prefix}/`)) {
|
|
135
|
+
impacted.add(prefix);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
continue;
|
|
139
|
+
}
|
|
140
|
+
const prefix = normalizeRelPath(pattern.slice(0, star)).replace(/\/$/, "");
|
|
141
|
+
for (const f of changedFiles) {
|
|
142
|
+
const fp = normalizeRelPath(f.path);
|
|
143
|
+
if (!fp.startsWith(prefix ? `${prefix}/` : ""))
|
|
144
|
+
continue;
|
|
145
|
+
const rest = prefix ? fp.slice(prefix.length + 1) : fp;
|
|
146
|
+
const pkgName = rest.split("/")[0];
|
|
147
|
+
if (pkgName)
|
|
148
|
+
impacted.add(prefix ? `${prefix}/${pkgName}` : pkgName);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
return uniqueSorted(impacted);
|
|
152
|
+
}
|
|
153
|
+
async function writeJson(filePath, value) {
|
|
154
|
+
const text = `${JSON.stringify(value, null, 2)}\n`;
|
|
155
|
+
await atomicWriteTextFile(filePath, text);
|
|
156
|
+
return { name: path.basename(filePath), sha256: sha256(text) };
|
|
157
|
+
}
|
|
158
|
+
function gitMeta(root) {
|
|
159
|
+
const run = (args) => {
|
|
160
|
+
const r = spawnSync("git", args, { cwd: root, encoding: "utf8" });
|
|
161
|
+
return r.status === 0 && typeof r.stdout === "string" ? r.stdout.trim() : null;
|
|
162
|
+
};
|
|
163
|
+
return {
|
|
164
|
+
head: run(["rev-parse", "HEAD"]),
|
|
165
|
+
branch: run(["rev-parse", "--abbrev-ref", "HEAD"]),
|
|
166
|
+
remote: run(["config", "--get", "remote.origin.url"]),
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
/**
|
|
170
|
+
* Assemble a ChangeAssessment from existing AgentDoctor signals.
|
|
171
|
+
* Never sets verificationStatus to "verified" — that requires evidence verify.
|
|
172
|
+
*/
|
|
173
|
+
export async function analyzeChange(options) {
|
|
174
|
+
const root = resolveRepoRoot(options.root);
|
|
175
|
+
const since = options.since;
|
|
176
|
+
const changeId = options.changeId ??
|
|
177
|
+
`chg_${createHash("sha256")
|
|
178
|
+
.update(`${root}:${Date.now()}:${randomUUID()}`)
|
|
179
|
+
.digest("hex")
|
|
180
|
+
.slice(0, 16)}`;
|
|
181
|
+
const meta = gitMeta(root);
|
|
182
|
+
const changes = await analyzeChanges({
|
|
183
|
+
root,
|
|
184
|
+
...(since ? { since } : {}),
|
|
185
|
+
impact: true,
|
|
186
|
+
});
|
|
187
|
+
const graph = await buildIntelligenceGraph({ root, mode: "auto" });
|
|
188
|
+
const changedFiles = changes.files.map((f) => ({ path: f.path, kind: f.kind }));
|
|
189
|
+
const changedSet = new Set(changedFiles.map((f) => f.path));
|
|
190
|
+
const changedSymbols = graph.nodes
|
|
191
|
+
.filter((n) => {
|
|
192
|
+
if (n.kind !== "function" && n.kind !== "class")
|
|
193
|
+
return false;
|
|
194
|
+
const nodePath = (n.path ?? "").split(path.sep).join("/");
|
|
195
|
+
return nodePath !== "" && changedSet.has(nodePath);
|
|
196
|
+
})
|
|
197
|
+
.map((n) => n.id)
|
|
198
|
+
.slice(0, 200);
|
|
199
|
+
const graphImpact = deriveGraphChangeImpact(graph, changedFiles, changedSymbols);
|
|
200
|
+
const packageImpact = await derivePackageImpact(root, changedFiles);
|
|
201
|
+
const affectedComponents = [...new Set(changes.files.flatMap((f) => f.modules ?? []))].sort();
|
|
202
|
+
const c4 = buildC4Views(graph);
|
|
203
|
+
const testImpact = await analyzeTestImpact({
|
|
204
|
+
root,
|
|
205
|
+
...(options.coveragePath ? { coveragePath: options.coveragePath } : {}),
|
|
206
|
+
});
|
|
207
|
+
const secrets = await scanSecrets({ root, enabled: true, maxFiles: 200 });
|
|
208
|
+
const knowledge = await listKnowledge(root);
|
|
209
|
+
const knowledgeRefs = knowledge
|
|
210
|
+
.filter((k) => k.status === "approved")
|
|
211
|
+
.slice(0, 20)
|
|
212
|
+
.map((rec) => {
|
|
213
|
+
const auth = retrieveAuthoritative(knowledge, rec.title);
|
|
214
|
+
return {
|
|
215
|
+
id: rec.id,
|
|
216
|
+
title: rec.title,
|
|
217
|
+
status: rec.status,
|
|
218
|
+
authoritative: !auth.abstain,
|
|
219
|
+
};
|
|
220
|
+
});
|
|
221
|
+
const policyEval = await evaluateAgentAction(root, {
|
|
222
|
+
actionId: `change-analyze-${changeId}`,
|
|
223
|
+
agentId: "agentdoctor",
|
|
224
|
+
timestamp: new Date().toISOString(),
|
|
225
|
+
type: "shell",
|
|
226
|
+
params: { command: "agentdoctor change analyze" },
|
|
227
|
+
repositoryRoot: root,
|
|
228
|
+
});
|
|
229
|
+
const kindCounts = changedFiles.reduce((acc, f) => {
|
|
230
|
+
acc[f.kind] = (acc[f.kind] ?? 0) + 1;
|
|
231
|
+
return acc;
|
|
232
|
+
}, {});
|
|
233
|
+
const limitations = [
|
|
234
|
+
...changes.limitations,
|
|
235
|
+
`Graph builder: ${graph.builder}; AST files parsed: ${graph.astFilesParsed}`,
|
|
236
|
+
"Changed symbols are graph-derived when path metadata is present",
|
|
237
|
+
"Callers/callees/reverseDependencies are graph-edge derived — incomplete when imports are UNRESOLVED",
|
|
238
|
+
"Architecture impact is inferred from C4 views — not approved architecture truth",
|
|
239
|
+
`Test impact mode=${testImpact.mode}${testImpact.coverage ? ` (format=${testImpact.coverage.format}, attribution=${testImpact.coverage.testAttribution})` : ""}`,
|
|
240
|
+
"verificationStatus is not-run until agentdoctor change verify produces an evidence bundle",
|
|
241
|
+
`Git change kinds observed: ${JSON.stringify(kindCounts)}`,
|
|
242
|
+
...secrets.limitations,
|
|
243
|
+
];
|
|
244
|
+
return {
|
|
245
|
+
changeId,
|
|
246
|
+
schemaVersion: EVIDENCE_SCHEMA_VERSION,
|
|
247
|
+
agentDoctorVersion: PACKAGE_VERSION,
|
|
248
|
+
repository: {
|
|
249
|
+
root,
|
|
250
|
+
remote: meta.remote,
|
|
251
|
+
branch: meta.branch,
|
|
252
|
+
},
|
|
253
|
+
baseRevision: since ?? null,
|
|
254
|
+
targetRevision: meta.head ?? changes.head,
|
|
255
|
+
changedFiles,
|
|
256
|
+
changedSymbols,
|
|
257
|
+
callers: graphImpact.callers,
|
|
258
|
+
callees: graphImpact.callees,
|
|
259
|
+
reverseDependencies: graphImpact.reverseDependencies,
|
|
260
|
+
packageImpact,
|
|
261
|
+
affectedComponents,
|
|
262
|
+
dependencyImpact: {
|
|
263
|
+
status: changes.impact?.status === "observed"
|
|
264
|
+
? "observed"
|
|
265
|
+
: changes.impact
|
|
266
|
+
? "unknown"
|
|
267
|
+
: "heuristic",
|
|
268
|
+
relatedPaths: changes.impact?.relatedPaths ?? graphImpact.reverseDependencies,
|
|
269
|
+
edgeCount: changes.impact?.edges.length ?? 0,
|
|
270
|
+
limitations: changes.impact
|
|
271
|
+
? [changes.impact.reason]
|
|
272
|
+
: [
|
|
273
|
+
"No Project Brain impact edges available; reverseDependencies from graph used when present",
|
|
274
|
+
],
|
|
275
|
+
},
|
|
276
|
+
architectureImpact: {
|
|
277
|
+
status: "inferred",
|
|
278
|
+
viewLevels: c4.map((v) => v.level),
|
|
279
|
+
limitations: c4.flatMap((v) => v.limitations).slice(0, 8),
|
|
280
|
+
},
|
|
281
|
+
knowledgeReferences: knowledgeRefs,
|
|
282
|
+
securityFindings: {
|
|
283
|
+
secretFindingCount: secrets.findings.length,
|
|
284
|
+
mode: "heuristic",
|
|
285
|
+
limitations: ["Secret scan is pattern-based and redacted; does not prove absence of secrets"],
|
|
286
|
+
},
|
|
287
|
+
policyResults: [
|
|
288
|
+
{
|
|
289
|
+
policyId: policyEval.policyId ?? "firewall",
|
|
290
|
+
decision: policyEval.decision,
|
|
291
|
+
reason: policyEval.reason,
|
|
292
|
+
subject: "change-analyze",
|
|
293
|
+
action: "shell:agentdoctor change analyze",
|
|
294
|
+
evidence: `evaluate-only; executionResult=${policyEval.executionResult}`,
|
|
295
|
+
},
|
|
296
|
+
],
|
|
297
|
+
testImpact: {
|
|
298
|
+
mode: testImpact.mode,
|
|
299
|
+
recommendedTests: testImpact.recommendedTests.map((t) => t.testPath),
|
|
300
|
+
confidence: testImpact.recommendedTests.some((t) => t.confidence >= 0.7) ? "medium" : "low",
|
|
301
|
+
limitations: testImpact.limitations,
|
|
302
|
+
},
|
|
303
|
+
evidence: {
|
|
304
|
+
directory: null,
|
|
305
|
+
files: [],
|
|
306
|
+
},
|
|
307
|
+
verificationStatus: "not-run",
|
|
308
|
+
limitations,
|
|
309
|
+
createdAt: new Date().toISOString(),
|
|
310
|
+
};
|
|
311
|
+
}
|
|
312
|
+
/**
|
|
313
|
+
* Persist evidence bundle. Status becomes evidence-produced (not "verified").
|
|
314
|
+
* "verified" is reserved for evidence verify when artifact hashes match.
|
|
315
|
+
*/
|
|
316
|
+
export async function verifyChange(options) {
|
|
317
|
+
const draft = await analyzeChange(options);
|
|
318
|
+
const root = resolveRepoRoot(options.root);
|
|
319
|
+
const dir = evidenceRoot(root, draft.changeId);
|
|
320
|
+
await fs.mkdir(dir, { recursive: true });
|
|
321
|
+
const graph = await buildIntelligenceGraph({ root, mode: "auto" });
|
|
322
|
+
const c4 = buildC4Views(graph);
|
|
323
|
+
const testImpact = await analyzeTestImpact({
|
|
324
|
+
root,
|
|
325
|
+
...(options.coveragePath ? { coveragePath: options.coveragePath } : {}),
|
|
326
|
+
});
|
|
327
|
+
const secrets = await scanSecrets({ root, enabled: true, maxFiles: 200 });
|
|
328
|
+
const meta = gitMeta(root);
|
|
329
|
+
const assessment = {
|
|
330
|
+
...draft,
|
|
331
|
+
testImpact: {
|
|
332
|
+
mode: testImpact.mode,
|
|
333
|
+
recommendedTests: testImpact.recommendedTests.map((t) => t.testPath),
|
|
334
|
+
confidence: testImpact.recommendedTests.some((t) => t.confidence >= 0.7) ? "medium" : "low",
|
|
335
|
+
limitations: testImpact.limitations,
|
|
336
|
+
},
|
|
337
|
+
evidence: {
|
|
338
|
+
directory: dir,
|
|
339
|
+
files: [
|
|
340
|
+
"manifest.json",
|
|
341
|
+
"change.json",
|
|
342
|
+
"graph-impact.json",
|
|
343
|
+
"architecture.json",
|
|
344
|
+
"knowledge.json",
|
|
345
|
+
"policy.json",
|
|
346
|
+
"security.json",
|
|
347
|
+
"tests.json",
|
|
348
|
+
"git.json",
|
|
349
|
+
"verification.json",
|
|
350
|
+
],
|
|
351
|
+
},
|
|
352
|
+
verificationStatus: "evidence-produced",
|
|
353
|
+
};
|
|
354
|
+
const fileHashes = [];
|
|
355
|
+
fileHashes.push(await writeJson(path.join(dir, "change.json"), assessment));
|
|
356
|
+
fileHashes.push(await writeJson(path.join(dir, "graph-impact.json"), {
|
|
357
|
+
builder: graph.builder,
|
|
358
|
+
astFilesParsed: graph.astFilesParsed,
|
|
359
|
+
nodeCount: graph.nodes.length,
|
|
360
|
+
edgeCount: graph.edges.length,
|
|
361
|
+
relatedPaths: assessment.dependencyImpact.relatedPaths,
|
|
362
|
+
limitations: [...assessment.dependencyImpact.limitations, ...graph.limitations].slice(0, 20),
|
|
363
|
+
}));
|
|
364
|
+
fileHashes.push(await writeJson(path.join(dir, "architecture.json"), {
|
|
365
|
+
status: "inferred",
|
|
366
|
+
views: c4,
|
|
367
|
+
}));
|
|
368
|
+
fileHashes.push(await writeJson(path.join(dir, "knowledge.json"), {
|
|
369
|
+
references: assessment.knowledgeReferences,
|
|
370
|
+
note: "Only approved knowledge is listed as authoritative candidates",
|
|
371
|
+
}));
|
|
372
|
+
fileHashes.push(await writeJson(path.join(dir, "policy.json"), {
|
|
373
|
+
results: assessment.policyResults,
|
|
374
|
+
executionResult: "not-executed",
|
|
375
|
+
}));
|
|
376
|
+
fileHashes.push(await writeJson(path.join(dir, "security.json"), {
|
|
377
|
+
secretFindingCount: secrets.findings.length,
|
|
378
|
+
findings: secrets.findings,
|
|
379
|
+
mode: "heuristic",
|
|
380
|
+
}));
|
|
381
|
+
fileHashes.push(await writeJson(path.join(dir, "tests.json"), {
|
|
382
|
+
mode: testImpact.mode,
|
|
383
|
+
coverage: testImpact.coverage,
|
|
384
|
+
report: testImpact,
|
|
385
|
+
}));
|
|
386
|
+
fileHashes.push(await writeJson(path.join(dir, "git.json"), {
|
|
387
|
+
...meta,
|
|
388
|
+
baseRevision: assessment.baseRevision,
|
|
389
|
+
targetRevision: assessment.targetRevision,
|
|
390
|
+
changedFiles: assessment.changedFiles,
|
|
391
|
+
}));
|
|
392
|
+
fileHashes.push(await writeJson(path.join(dir, "verification.json"), {
|
|
393
|
+
status: "evidence-produced",
|
|
394
|
+
note: "Evidence artifacts written. Use agentdoctor evidence verify to hash-check.",
|
|
395
|
+
producedAt: new Date().toISOString(),
|
|
396
|
+
}));
|
|
397
|
+
const manifest = {
|
|
398
|
+
schemaVersion: EVIDENCE_SCHEMA_VERSION,
|
|
399
|
+
agentDoctorVersion: PACKAGE_VERSION,
|
|
400
|
+
changeId: assessment.changeId,
|
|
401
|
+
repositoryRoot: root,
|
|
402
|
+
baseRevision: assessment.baseRevision,
|
|
403
|
+
targetRevision: assessment.targetRevision,
|
|
404
|
+
createdAt: new Date().toISOString(),
|
|
405
|
+
files: fileHashes,
|
|
406
|
+
verificationStatus: "evidence-produced",
|
|
407
|
+
};
|
|
408
|
+
await writeJson(path.join(dir, "manifest.json"), manifest);
|
|
409
|
+
// Durable ChangeProof from the evidence bundle (correctness never claimed).
|
|
410
|
+
const { buildProofFromEvidence } = await import("./proof.js");
|
|
411
|
+
await buildProofFromEvidence(root, assessment.changeId);
|
|
412
|
+
return { assessment, manifest };
|
|
413
|
+
}
|
|
414
|
+
/**
|
|
415
|
+
* Human-readable explanation of a ChangeAssessment (does not claim correctness).
|
|
416
|
+
*/
|
|
417
|
+
export function explainChange(assessment) {
|
|
418
|
+
const lines = [
|
|
419
|
+
`Change ${assessment.changeId}`,
|
|
420
|
+
` repository: ${assessment.repository.root}`,
|
|
421
|
+
` base → target: ${assessment.baseRevision ?? "(working tree)"} → ${assessment.targetRevision ?? "unknown"}`,
|
|
422
|
+
` changed files: ${assessment.changedFiles.length} (${assessment.changedFiles.map((f) => f.kind).join(", ") || "none"})`,
|
|
423
|
+
` changed symbols: ${assessment.changedSymbols.length}`,
|
|
424
|
+
` callers: ${assessment.callers.length}`,
|
|
425
|
+
` callees: ${assessment.callees.length}`,
|
|
426
|
+
` reverseDependencies: ${assessment.reverseDependencies.length}`,
|
|
427
|
+
` packageImpact: ${assessment.packageImpact.join(", ") || "(none)"}`,
|
|
428
|
+
` components: ${assessment.affectedComponents.join(", ") || "(none)"}`,
|
|
429
|
+
` dependency impact: ${assessment.dependencyImpact.status} (${assessment.dependencyImpact.edgeCount} edges)`,
|
|
430
|
+
` architecture: ${assessment.architectureImpact.status} [${assessment.architectureImpact.viewLevels.join(", ")}]`,
|
|
431
|
+
` knowledge refs: ${assessment.knowledgeReferences.length}`,
|
|
432
|
+
` security (heuristic): ${assessment.securityFindings.secretFindingCount} secret finding(s)`,
|
|
433
|
+
` policy: ${assessment.policyResults.map((p) => `${p.decision}`).join(", ") || "none"}`,
|
|
434
|
+
` test impact: ${assessment.testImpact.mode} / ${assessment.testImpact.confidence} (${assessment.testImpact.recommendedTests.length} recommended)`,
|
|
435
|
+
` verificationStatus: ${assessment.verificationStatus}`,
|
|
436
|
+
` evidence: ${assessment.evidence.directory ?? "(none)"}`,
|
|
437
|
+
"",
|
|
438
|
+
"This assessment assembles engineering signals. It does not prove correctness, security, or completeness.",
|
|
439
|
+
"Limitations:",
|
|
440
|
+
...assessment.limitations.slice(0, 12).map((l) => ` - ${l}`),
|
|
441
|
+
];
|
|
442
|
+
return lines.join("\n");
|
|
443
|
+
}
|
|
444
|
+
/**
|
|
445
|
+
* Summarize base/target file diffs from git for a change.
|
|
446
|
+
*/
|
|
447
|
+
export async function diffChange(options) {
|
|
448
|
+
const root = resolveRepoRoot(options.root);
|
|
449
|
+
const since = options.since;
|
|
450
|
+
const changes = await analyzeChanges({
|
|
451
|
+
root,
|
|
452
|
+
...(since ? { since } : {}),
|
|
453
|
+
});
|
|
454
|
+
const run = (args) => {
|
|
455
|
+
const r = spawnSync("git", args, {
|
|
456
|
+
cwd: root,
|
|
457
|
+
encoding: "utf8",
|
|
458
|
+
maxBuffer: 4 * 1024 * 1024,
|
|
459
|
+
});
|
|
460
|
+
return r.status === 0 && typeof r.stdout === "string" ? r.stdout.trim() : null;
|
|
461
|
+
};
|
|
462
|
+
const base = since ?? null;
|
|
463
|
+
const target = changes.head;
|
|
464
|
+
let stat = null;
|
|
465
|
+
if (base) {
|
|
466
|
+
stat = run(["diff", "--stat", `${base}...${target ?? "HEAD"}`]);
|
|
467
|
+
}
|
|
468
|
+
else {
|
|
469
|
+
stat = run(["diff", "--stat", "HEAD"]);
|
|
470
|
+
const unstaged = run(["diff", "--stat"]);
|
|
471
|
+
if (unstaged && unstaged !== stat) {
|
|
472
|
+
stat = [stat, unstaged].filter(Boolean).join("\n");
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
return {
|
|
476
|
+
changeId: options.changeId ?? null,
|
|
477
|
+
baseRevision: base,
|
|
478
|
+
targetRevision: target,
|
|
479
|
+
files: changes.files.map((f) => ({ path: f.path, kind: f.kind })),
|
|
480
|
+
summary: changes.summary,
|
|
481
|
+
stat,
|
|
482
|
+
limitations: [
|
|
483
|
+
...changes.limitations,
|
|
484
|
+
"Diff is git-derived; binary and generated files may be omitted from --stat",
|
|
485
|
+
],
|
|
486
|
+
};
|
|
487
|
+
}
|
|
488
|
+
/**
|
|
489
|
+
* Verification / evidence status for latest evidence or a specific change id.
|
|
490
|
+
*/
|
|
491
|
+
export async function changeStatus(options) {
|
|
492
|
+
const root = resolveRepoRoot(options.root);
|
|
493
|
+
let changeId = options.changeId ?? null;
|
|
494
|
+
if (!changeId) {
|
|
495
|
+
const evidenceRoot = path.join(root, ".agentdoctor", "evidence");
|
|
496
|
+
try {
|
|
497
|
+
const entries = await fs.readdir(evidenceRoot, { withFileTypes: true });
|
|
498
|
+
const dirs = entries.filter((e) => e.isDirectory() && e.name.startsWith("chg_"));
|
|
499
|
+
let newest = null;
|
|
500
|
+
for (const d of dirs) {
|
|
501
|
+
const st = await fs.stat(path.join(evidenceRoot, d.name));
|
|
502
|
+
if (!newest || st.mtimeMs > newest.mtime) {
|
|
503
|
+
newest = { name: d.name, mtime: st.mtimeMs };
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
changeId = newest?.name ?? null;
|
|
507
|
+
}
|
|
508
|
+
catch {
|
|
509
|
+
changeId = null;
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
if (!changeId) {
|
|
513
|
+
return {
|
|
514
|
+
changeId: null,
|
|
515
|
+
evidenceDirectory: null,
|
|
516
|
+
verificationStatus: "not-run",
|
|
517
|
+
evidencePresent: false,
|
|
518
|
+
proofPresent: false,
|
|
519
|
+
proofId: null,
|
|
520
|
+
integrityNote: "No evidence bundles found under .agentdoctor/evidence/",
|
|
521
|
+
files: [],
|
|
522
|
+
};
|
|
523
|
+
}
|
|
524
|
+
const inspected = await inspectEvidence({ root, changeId });
|
|
525
|
+
let verificationStatus = "not-run";
|
|
526
|
+
if (inspected.ok && inspected.manifest) {
|
|
527
|
+
const hashCheck = await verifyEvidence({ root, changeId });
|
|
528
|
+
verificationStatus = hashCheck.verificationStatus;
|
|
529
|
+
}
|
|
530
|
+
else if (inspected.ok) {
|
|
531
|
+
verificationStatus = "partial";
|
|
532
|
+
}
|
|
533
|
+
let proofPresent = false;
|
|
534
|
+
let proofId = null;
|
|
535
|
+
try {
|
|
536
|
+
const { inspectProof } = await import("./proof.js");
|
|
537
|
+
const proof = await inspectProof(root, changeId);
|
|
538
|
+
if (proof.ok && proof.proof) {
|
|
539
|
+
proofPresent = true;
|
|
540
|
+
proofId = proof.proof.proofId;
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
catch {
|
|
544
|
+
proofPresent = false;
|
|
545
|
+
}
|
|
546
|
+
return {
|
|
547
|
+
changeId,
|
|
548
|
+
evidenceDirectory: inspected.ok ? inspected.directory : null,
|
|
549
|
+
verificationStatus,
|
|
550
|
+
evidencePresent: inspected.ok,
|
|
551
|
+
proofPresent,
|
|
552
|
+
proofId,
|
|
553
|
+
integrityNote: verificationStatus === "verified"
|
|
554
|
+
? "Evidence artifact hashes match manifest (HASH integrity only — not engineering correctness)"
|
|
555
|
+
: verificationStatus === "evidence-produced" || verificationStatus === "not-run"
|
|
556
|
+
? "Run agentdoctor evidence verify / proof verify to check hashes"
|
|
557
|
+
: "Evidence hash check incomplete or failed",
|
|
558
|
+
files: inspected.presentFiles,
|
|
559
|
+
};
|
|
560
|
+
}
|
|
561
|
+
export async function inspectEvidence(options) {
|
|
562
|
+
const root = resolveRepoRoot(options.root);
|
|
563
|
+
const directory = evidenceRoot(root, options.changeId);
|
|
564
|
+
try {
|
|
565
|
+
const entries = await fs.readdir(directory);
|
|
566
|
+
let manifest = null;
|
|
567
|
+
try {
|
|
568
|
+
const raw = await fs.readFile(path.join(directory, "manifest.json"), "utf8");
|
|
569
|
+
manifest = JSON.parse(raw);
|
|
570
|
+
}
|
|
571
|
+
catch {
|
|
572
|
+
manifest = null;
|
|
573
|
+
}
|
|
574
|
+
return { ok: true, directory, manifest, presentFiles: entries.sort() };
|
|
575
|
+
}
|
|
576
|
+
catch (error) {
|
|
577
|
+
return {
|
|
578
|
+
ok: false,
|
|
579
|
+
directory,
|
|
580
|
+
manifest: null,
|
|
581
|
+
presentFiles: [],
|
|
582
|
+
error: error instanceof Error ? error.message : "Unable to read evidence directory",
|
|
583
|
+
};
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
/**
|
|
587
|
+
* Hash-check evidence bundle. Returns "verified" only when all listed files match.
|
|
588
|
+
*/
|
|
589
|
+
export async function verifyEvidence(options) {
|
|
590
|
+
const inspected = await inspectEvidence(options);
|
|
591
|
+
if (!inspected.ok || !inspected.manifest) {
|
|
592
|
+
return {
|
|
593
|
+
ok: false,
|
|
594
|
+
verificationStatus: "failed",
|
|
595
|
+
checked: [],
|
|
596
|
+
error: inspected.error ?? "manifest.json missing",
|
|
597
|
+
};
|
|
598
|
+
}
|
|
599
|
+
const checked = [];
|
|
600
|
+
for (const file of inspected.manifest.files) {
|
|
601
|
+
const filePath = path.join(inspected.directory, file.name);
|
|
602
|
+
try {
|
|
603
|
+
const text = await fs.readFile(filePath, "utf8");
|
|
604
|
+
const actual = sha256(text);
|
|
605
|
+
checked.push({
|
|
606
|
+
name: file.name,
|
|
607
|
+
match: actual === file.sha256,
|
|
608
|
+
expected: file.sha256,
|
|
609
|
+
actual,
|
|
610
|
+
});
|
|
611
|
+
}
|
|
612
|
+
catch {
|
|
613
|
+
checked.push({ name: file.name, match: false, expected: file.sha256 });
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
const allMatch = checked.length > 0 && checked.every((c) => c.match);
|
|
617
|
+
const anyMatch = checked.some((c) => c.match);
|
|
618
|
+
const verificationStatus = allMatch
|
|
619
|
+
? "verified"
|
|
620
|
+
: anyMatch
|
|
621
|
+
? "partial"
|
|
622
|
+
: "failed";
|
|
623
|
+
return { ok: allMatch, verificationStatus, checked };
|
|
624
|
+
}
|