@praneeth_54/agentdoctor 0.3.0-beta → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +76 -1
- package/README.md +60 -30
- package/dist/agents/inspect.js +58 -51
- package/dist/cli/commands/explain.js +1 -1
- package/dist/cli/commands/fix.js +22 -6
- package/dist/cli/commands/scan.d.ts +5 -0
- package/dist/cli/commands/scan.js +25 -6
- package/dist/cli/commands/verify.d.ts +6 -0
- package/dist/cli/commands/verify.js +25 -11
- package/dist/cli/program.js +48 -7
- package/dist/constants.d.ts +1 -1
- package/dist/constants.js +1 -1
- package/dist/core/fix/apply.d.ts +5 -1
- package/dist/core/fix/apply.js +34 -14
- package/dist/core/fix/plan.d.ts +1 -1
- package/dist/core/fix/plan.js +110 -3
- package/dist/core/fix/render.d.ts +2 -0
- package/dist/core/fix/render.js +57 -9
- package/dist/core/fix/run.js +5 -1
- package/dist/core/fix/types.d.ts +1 -1
- package/dist/core/fix/types.js +5 -1
- package/dist/core/fix/writers/claude-settings.d.ts +16 -0
- package/dist/core/fix/writers/claude-settings.js +100 -0
- package/dist/core/fix/writers/codex-config.d.ts +22 -0
- package/dist/core/fix/writers/codex-config.js +183 -0
- package/dist/core/fix/writers/cursorignore.js +2 -8
- package/dist/core/path-resolution/index.d.ts +5 -0
- package/dist/core/path-resolution/index.js +5 -0
- package/dist/core/path-resolution/prepare.d.ts +33 -0
- package/dist/core/path-resolution/prepare.js +98 -0
- package/dist/core/policy/evaluate.d.ts +44 -0
- package/dist/core/policy/evaluate.js +120 -0
- package/dist/core/rules/claude-deny.d.ts +8 -0
- package/dist/core/rules/claude-deny.js +39 -0
- package/dist/core/rules/codex-deny.d.ts +5 -0
- package/dist/core/rules/codex-deny.js +42 -0
- package/dist/core/rules/context/generated-directory.js +43 -9
- package/dist/core/rules/context/large-log-file.js +27 -10
- package/dist/core/rules/ignore.js +2 -5
- package/dist/core/rules/instructions/missing-path-reference.d.ts +5 -1
- package/dist/core/rules/instructions/missing-path-reference.js +24 -6
- package/dist/core/rules/path-kind.d.ts +15 -2
- package/dist/core/rules/path-kind.js +113 -10
- package/dist/core/rules/security/env-file-exposure.js +9 -24
- package/dist/core/rules/text-cache.js +46 -40
- package/dist/discovery/files.js +7 -2
- package/dist/discovery/log-like.d.ts +2 -0
- package/dist/discovery/log-like.js +7 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +1 -0
- package/dist/reporters/github/annotations.d.ts +8 -0
- package/dist/reporters/github/annotations.js +42 -0
- package/dist/reporters/github/emit.d.ts +23 -0
- package/dist/reporters/github/emit.js +31 -0
- package/dist/reporters/github/summary.d.ts +20 -0
- package/dist/reporters/github/summary.js +111 -0
- package/dist/reporters/terminal/report.d.ts +5 -0
- package/dist/reporters/terminal/report.js +68 -5
- package/dist/reporters/verify/terminal.d.ts +4 -0
- package/dist/reporters/verify/terminal.js +26 -0
- package/dist/utils/fs.d.ts +5 -0
- package/dist/utils/fs.js +34 -0
- package/dist/utils/path.d.ts +5 -0
- package/dist/utils/path.js +16 -0
- package/package.json +11 -1
|
@@ -32,55 +32,61 @@ export class TextCache {
|
|
|
32
32
|
return blocked;
|
|
33
33
|
}
|
|
34
34
|
try {
|
|
35
|
-
const
|
|
36
|
-
|
|
37
|
-
const
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
35
|
+
const handle = await fs.open(absolute, "r");
|
|
36
|
+
try {
|
|
37
|
+
const stat = await handle.stat();
|
|
38
|
+
if (!stat.isFile()) {
|
|
39
|
+
const result = {
|
|
40
|
+
relativePath: toPosixRelative(this.root, absolute),
|
|
41
|
+
text: null,
|
|
42
|
+
sizeBytes: 0,
|
|
43
|
+
empty: true,
|
|
44
|
+
binary: false,
|
|
45
|
+
error: "Not a regular file",
|
|
46
|
+
};
|
|
47
|
+
this.cache.set(relativePath, result);
|
|
48
|
+
return result;
|
|
49
|
+
}
|
|
50
|
+
const sizeBytes = Number(stat.size);
|
|
51
|
+
if (sizeBytes > this.maxBytes) {
|
|
52
|
+
const result = {
|
|
53
|
+
relativePath,
|
|
54
|
+
text: null,
|
|
55
|
+
sizeBytes,
|
|
56
|
+
empty: false,
|
|
57
|
+
binary: false,
|
|
58
|
+
error: "File exceeds max read size",
|
|
59
|
+
};
|
|
60
|
+
this.cache.set(relativePath, result);
|
|
61
|
+
return result;
|
|
62
|
+
}
|
|
63
|
+
const buffer = await handle.readFile();
|
|
64
|
+
if (looksBinary(buffer)) {
|
|
65
|
+
const result = {
|
|
66
|
+
relativePath,
|
|
67
|
+
text: null,
|
|
68
|
+
sizeBytes,
|
|
69
|
+
empty: sizeBytes === 0,
|
|
70
|
+
binary: true,
|
|
71
|
+
error: "Binary file skipped",
|
|
72
|
+
};
|
|
73
|
+
this.cache.set(relativePath, result);
|
|
74
|
+
return result;
|
|
75
|
+
}
|
|
76
|
+
const text = buffer.toString("utf8");
|
|
50
77
|
const result = {
|
|
51
78
|
relativePath,
|
|
52
|
-
text
|
|
79
|
+
text,
|
|
53
80
|
sizeBytes,
|
|
54
|
-
empty:
|
|
81
|
+
empty: text.trim().length === 0,
|
|
55
82
|
binary: false,
|
|
56
|
-
error: "File exceeds max read size",
|
|
57
83
|
};
|
|
58
84
|
this.cache.set(relativePath, result);
|
|
59
85
|
return result;
|
|
60
86
|
}
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
const result = {
|
|
64
|
-
relativePath,
|
|
65
|
-
text: null,
|
|
66
|
-
sizeBytes,
|
|
67
|
-
empty: sizeBytes === 0,
|
|
68
|
-
binary: true,
|
|
69
|
-
error: "Binary file skipped",
|
|
70
|
-
};
|
|
71
|
-
this.cache.set(relativePath, result);
|
|
72
|
-
return result;
|
|
87
|
+
finally {
|
|
88
|
+
await handle.close();
|
|
73
89
|
}
|
|
74
|
-
const text = buffer.toString("utf8");
|
|
75
|
-
const result = {
|
|
76
|
-
relativePath,
|
|
77
|
-
text,
|
|
78
|
-
sizeBytes,
|
|
79
|
-
empty: text.trim().length === 0,
|
|
80
|
-
binary: false,
|
|
81
|
-
};
|
|
82
|
-
this.cache.set(relativePath, result);
|
|
83
|
-
return result;
|
|
84
90
|
}
|
|
85
91
|
catch (error) {
|
|
86
92
|
const message = error instanceof Error ? error.message : "Unable to read file";
|
package/dist/discovery/files.js
CHANGED
|
@@ -2,6 +2,7 @@ import fs from "node:fs/promises";
|
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import { DEFAULT_IGNORE_DIRECTORIES, DEFAULT_MAX_FILE_SIZE_BYTES } from "../constants.js";
|
|
4
4
|
import { isPathInsideRoot, toPosixRelative } from "../utils/path.js";
|
|
5
|
+
import { isLogLikePath } from "./log-like.js";
|
|
5
6
|
/**
|
|
6
7
|
* Walk the repository without blindly reading every file.
|
|
7
8
|
* Skips common generated/dependency directories and records permission errors.
|
|
@@ -70,14 +71,18 @@ export async function discoverFiles(options) {
|
|
|
70
71
|
}
|
|
71
72
|
try {
|
|
72
73
|
const stat = await fs.lstat(absolutePath);
|
|
73
|
-
|
|
74
|
+
const sizeBytes = Number(stat.size);
|
|
75
|
+
// Oversized binaries stay skipped (content reads already refuse them).
|
|
76
|
+
// Log/dump-like paths are kept as metadata so context/large-log-file
|
|
77
|
+
// can flag the worst offenders instead of silently missing them.
|
|
78
|
+
if (sizeBytes > maxFileSizeBytes && !isLogLikePath(relativePath)) {
|
|
74
79
|
filesSkippedOversized += 1;
|
|
75
80
|
continue;
|
|
76
81
|
}
|
|
77
82
|
files.push({
|
|
78
83
|
absolutePath,
|
|
79
84
|
relativePath,
|
|
80
|
-
sizeBytes
|
|
85
|
+
sizeBytes,
|
|
81
86
|
isSymlink,
|
|
82
87
|
});
|
|
83
88
|
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
const LOG_LIKE = /\.(log|out|dump)$/i;
|
|
2
|
+
const HEAVY_NAME = /(^|\/)(debug|trace|coverage-final|chrome-devtools|heapdump)/i;
|
|
3
|
+
/** Path looks like a log/dump that can pollute agent context (size-only rules). */
|
|
4
|
+
export function isLogLikePath(relativePath) {
|
|
5
|
+
const base = relativePath.split("/").pop() ?? relativePath;
|
|
6
|
+
return LOG_LIKE.test(base) || HEAVY_NAME.test(relativePath);
|
|
7
|
+
}
|
package/dist/index.d.ts
CHANGED
|
@@ -13,4 +13,6 @@ export { runRules } from "./core/rules/run-rules.js";
|
|
|
13
13
|
export { buildFixPlan } from "./core/fix/plan.js";
|
|
14
14
|
export { applyFixPlan } from "./core/fix/apply.js";
|
|
15
15
|
export { runFix } from "./core/fix/run.js";
|
|
16
|
+
export { evaluatePolicy, evaluateScanPolicy, evaluateVerifyPolicy, parseFailOnRules, parseSeverityGate, } from "./core/policy/evaluate.js";
|
|
17
|
+
export type { PolicyOptions, PolicyViolation, PolicyViolationCode, PolicyInput, } from "./core/policy/evaluate.js";
|
|
16
18
|
export type { FixPlan, FixAction, FixApplyResult } from "./core/fix/types.js";
|
package/dist/index.js
CHANGED
|
@@ -10,3 +10,4 @@ export { runRules } from "./core/rules/run-rules.js";
|
|
|
10
10
|
export { buildFixPlan } from "./core/fix/plan.js";
|
|
11
11
|
export { applyFixPlan } from "./core/fix/apply.js";
|
|
12
12
|
export { runFix } from "./core/fix/run.js";
|
|
13
|
+
export { evaluatePolicy, evaluateScanPolicy, evaluateVerifyPolicy, parseFailOnRules, parseSeverityGate, } from "./core/policy/evaluate.js";
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { Finding } from "../../types/index.js";
|
|
2
|
+
/**
|
|
3
|
+
* GitHub Actions workflow commands for file annotations.
|
|
4
|
+
* Emitted to stderr so `--json` stdout stays pure JSON.
|
|
5
|
+
*
|
|
6
|
+
* @see https://docs.github.com/en/actions/using-workflows/workflow-commands-for-github-actions
|
|
7
|
+
*/
|
|
8
|
+
export declare function renderGithubAnnotations(findings: Finding[]): string;
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GitHub Actions workflow commands for file annotations.
|
|
3
|
+
* Emitted to stderr so `--json` stdout stays pure JSON.
|
|
4
|
+
*
|
|
5
|
+
* @see https://docs.github.com/en/actions/using-workflows/workflow-commands-for-github-actions
|
|
6
|
+
*/
|
|
7
|
+
export function renderGithubAnnotations(findings) {
|
|
8
|
+
const lines = [];
|
|
9
|
+
for (const finding of findings) {
|
|
10
|
+
lines.push(formatAnnotation(finding));
|
|
11
|
+
}
|
|
12
|
+
return lines.length > 0 ? `${lines.join("\n")}\n` : "";
|
|
13
|
+
}
|
|
14
|
+
function formatAnnotation(finding) {
|
|
15
|
+
const level = annotationLevel(finding.severity);
|
|
16
|
+
const properties = [];
|
|
17
|
+
const file = finding.evidence?.path?.replace(/\\/g, "/");
|
|
18
|
+
if (file) {
|
|
19
|
+
properties.push(`file=${escapeProperty(file)}`);
|
|
20
|
+
}
|
|
21
|
+
if (finding.evidence?.line !== undefined && Number.isFinite(finding.evidence.line)) {
|
|
22
|
+
properties.push(`line=${finding.evidence.line}`);
|
|
23
|
+
}
|
|
24
|
+
properties.push(`title=${escapeProperty(`${finding.ruleId}: ${finding.title}`)}`);
|
|
25
|
+
const message = escapeMessage(`${finding.message} (${finding.ruleId})`);
|
|
26
|
+
const props = properties.length > 0 ? ` ${properties.join(",")}` : "";
|
|
27
|
+
return `::${level}${props}::${message}`;
|
|
28
|
+
}
|
|
29
|
+
function annotationLevel(severity) {
|
|
30
|
+
return severity === "critical" ? "error" : severity === "warning" ? "warning" : "notice";
|
|
31
|
+
}
|
|
32
|
+
function escapeProperty(value) {
|
|
33
|
+
return value
|
|
34
|
+
.replace(/%/g, "%25")
|
|
35
|
+
.replace(/\r/g, "%0D")
|
|
36
|
+
.replace(/\n/g, "%0A")
|
|
37
|
+
.replace(/:/g, "%3A")
|
|
38
|
+
.replace(/,/g, "%2C");
|
|
39
|
+
}
|
|
40
|
+
function escapeMessage(value) {
|
|
41
|
+
return value.replace(/%/g, "%25").replace(/\r/g, "%0D").replace(/\n/g, "%0A");
|
|
42
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { PolicyViolation } from "../../core/policy/evaluate.js";
|
|
2
|
+
import type { Finding } from "../../types/index.js";
|
|
3
|
+
export interface GithubReportOptions {
|
|
4
|
+
summary?: boolean;
|
|
5
|
+
annotations?: boolean;
|
|
6
|
+
}
|
|
7
|
+
export declare function emitGithubReports(options: {
|
|
8
|
+
mode: "scan" | "verify";
|
|
9
|
+
findings: Finding[];
|
|
10
|
+
overallScore: number | null;
|
|
11
|
+
violations: PolicyViolation[];
|
|
12
|
+
verifySummary?: {
|
|
13
|
+
fixed: number;
|
|
14
|
+
remaining: number;
|
|
15
|
+
new: number;
|
|
16
|
+
unchanged: number;
|
|
17
|
+
};
|
|
18
|
+
summary?: boolean;
|
|
19
|
+
annotations?: boolean;
|
|
20
|
+
/** Override for tests */
|
|
21
|
+
stepSummaryPath?: string | null;
|
|
22
|
+
annotationsWrite?: (text: string) => void;
|
|
23
|
+
}): Promise<void>;
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import { renderGithubAnnotations } from "./annotations.js";
|
|
3
|
+
import { renderGithubStepSummary } from "./summary.js";
|
|
4
|
+
export async function emitGithubReports(options) {
|
|
5
|
+
if (options.annotations) {
|
|
6
|
+
const text = renderGithubAnnotations(options.findings);
|
|
7
|
+
if (text.length > 0) {
|
|
8
|
+
const write = options.annotationsWrite ?? ((chunk) => process.stderr.write(chunk));
|
|
9
|
+
write(text);
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
if (!options.summary) {
|
|
13
|
+
return;
|
|
14
|
+
}
|
|
15
|
+
const markdown = renderGithubStepSummary({
|
|
16
|
+
title: options.mode === "verify" ? "AgentDoctor Verify" : "AgentDoctor Scan",
|
|
17
|
+
overallScore: options.overallScore,
|
|
18
|
+
findings: options.findings,
|
|
19
|
+
violations: options.violations,
|
|
20
|
+
mode: options.mode,
|
|
21
|
+
...(options.verifySummary ? { verifySummary: options.verifySummary } : {}),
|
|
22
|
+
});
|
|
23
|
+
const summaryPath = options.stepSummaryPath !== undefined
|
|
24
|
+
? options.stepSummaryPath
|
|
25
|
+
: (process.env.GITHUB_STEP_SUMMARY ?? null);
|
|
26
|
+
if (summaryPath) {
|
|
27
|
+
await fs.appendFile(summaryPath, markdown, "utf8");
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
process.stderr.write("Note: --summary set but GITHUB_STEP_SUMMARY is unset; skipping step summary write.\n");
|
|
31
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { Finding, ScanResult } from "../../types/index.js";
|
|
2
|
+
import type { PolicyViolation } from "../../core/policy/evaluate.js";
|
|
3
|
+
export interface GithubSummaryInput {
|
|
4
|
+
title: string;
|
|
5
|
+
overallScore: number | null;
|
|
6
|
+
findings: Finding[];
|
|
7
|
+
violations: PolicyViolation[];
|
|
8
|
+
mode: "scan" | "verify";
|
|
9
|
+
verifySummary?: {
|
|
10
|
+
fixed: number;
|
|
11
|
+
remaining: number;
|
|
12
|
+
new: number;
|
|
13
|
+
unchanged: number;
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Markdown for `$GITHUB_STEP_SUMMARY`.
|
|
18
|
+
*/
|
|
19
|
+
export declare function renderGithubStepSummary(input: GithubSummaryInput): string;
|
|
20
|
+
export declare function renderGithubStepSummaryForScan(result: ScanResult, violations: PolicyViolation[]): string;
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Markdown for `$GITHUB_STEP_SUMMARY`.
|
|
3
|
+
*/
|
|
4
|
+
export function renderGithubStepSummary(input) {
|
|
5
|
+
const lines = [];
|
|
6
|
+
lines.push(`# AgentDoctor ${input.mode === "verify" ? "Verify" : "Scan"}`);
|
|
7
|
+
lines.push("");
|
|
8
|
+
if (input.overallScore !== null) {
|
|
9
|
+
lines.push(`**Readiness:** ${input.overallScore}/100`);
|
|
10
|
+
lines.push("");
|
|
11
|
+
}
|
|
12
|
+
if (input.violations.length > 0) {
|
|
13
|
+
lines.push("## Policy violations");
|
|
14
|
+
lines.push("");
|
|
15
|
+
for (const violation of input.violations) {
|
|
16
|
+
lines.push(`- ${violation.message}`);
|
|
17
|
+
}
|
|
18
|
+
lines.push("");
|
|
19
|
+
}
|
|
20
|
+
else {
|
|
21
|
+
lines.push("Policy gates: **passed**");
|
|
22
|
+
lines.push("");
|
|
23
|
+
}
|
|
24
|
+
if (input.verifySummary) {
|
|
25
|
+
lines.push("## Verify delta");
|
|
26
|
+
lines.push("");
|
|
27
|
+
lines.push(`| Fixed | Remaining | New | Unchanged |`);
|
|
28
|
+
lines.push(`| ---: | ---: | ---: | ---: |`);
|
|
29
|
+
lines.push(`| ${input.verifySummary.fixed} | ${input.verifySummary.remaining} | ${input.verifySummary.new} | ${input.verifySummary.unchanged} |`);
|
|
30
|
+
lines.push("");
|
|
31
|
+
}
|
|
32
|
+
const critical = input.findings.filter((f) => f.severity === "critical").length;
|
|
33
|
+
const warning = input.findings.filter((f) => f.severity === "warning").length;
|
|
34
|
+
const info = input.findings.filter((f) => f.severity === "info").length;
|
|
35
|
+
lines.push("## Findings");
|
|
36
|
+
lines.push("");
|
|
37
|
+
lines.push(`| Severity | Count |`);
|
|
38
|
+
lines.push(`| --- | ---: |`);
|
|
39
|
+
lines.push(`| Critical | ${critical} |`);
|
|
40
|
+
lines.push(`| Warning | ${warning} |`);
|
|
41
|
+
lines.push(`| Info | ${info} |`);
|
|
42
|
+
lines.push(`| **Total** | **${input.findings.length}** |`);
|
|
43
|
+
lines.push("");
|
|
44
|
+
if (input.findings.length > 0) {
|
|
45
|
+
lines.push("### Top findings");
|
|
46
|
+
lines.push("");
|
|
47
|
+
const top = [...input.findings]
|
|
48
|
+
.sort((a, b) => severityOrder(b.severity) - severityOrder(a.severity))
|
|
49
|
+
.slice(0, 20);
|
|
50
|
+
for (const finding of top) {
|
|
51
|
+
const path = finding.evidence?.path ? ` \`${finding.evidence.path}\`` : "";
|
|
52
|
+
lines.push(`- **${finding.severity}** \`${finding.ruleId}\`${path} — ${finding.title}`);
|
|
53
|
+
}
|
|
54
|
+
lines.push("");
|
|
55
|
+
}
|
|
56
|
+
if (input.violations.length > 0) {
|
|
57
|
+
lines.push(...renderGithubNextSteps(input));
|
|
58
|
+
}
|
|
59
|
+
return `${lines.join("\n")}\n`;
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Shortest path back to green when the Action failed a policy gate.
|
|
63
|
+
* Shown only on failure — success summaries stay quiet.
|
|
64
|
+
*/
|
|
65
|
+
function renderGithubNextSteps(input) {
|
|
66
|
+
const lines = [];
|
|
67
|
+
lines.push("## Next");
|
|
68
|
+
lines.push("");
|
|
69
|
+
lines.push("Policy gate failed. Shortest path back to green:");
|
|
70
|
+
lines.push("");
|
|
71
|
+
const hasSafe = input.findings.some((f) => f.fixability === "safe");
|
|
72
|
+
const topRule = [...input.findings].sort((a, b) => severityOrder(b.severity) - severityOrder(a.severity))[0]?.ruleId;
|
|
73
|
+
if (input.mode === "verify") {
|
|
74
|
+
lines.push("1. Reproduce locally: `npx @praneeth_54/agentdoctor verify`");
|
|
75
|
+
if (hasSafe) {
|
|
76
|
+
lines.push("2. Apply safe fixes: `npx @praneeth_54/agentdoctor fix -y`");
|
|
77
|
+
lines.push("3. Re-check: `npx @praneeth_54/agentdoctor verify`");
|
|
78
|
+
}
|
|
79
|
+
else {
|
|
80
|
+
lines.push(`2. Inspect a finding: \`npx @praneeth_54/agentdoctor explain ${topRule ?? "<rule-id>"}\``);
|
|
81
|
+
lines.push("3. Fix or baseline, then re-run this Action");
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
else {
|
|
85
|
+
lines.push("1. Reproduce locally: `npx @praneeth_54/agentdoctor scan --ci`");
|
|
86
|
+
if (hasSafe) {
|
|
87
|
+
lines.push("2. Apply safe fixes: `npx @praneeth_54/agentdoctor fix -y`");
|
|
88
|
+
lines.push("3. Confirm: `npx @praneeth_54/agentdoctor scan --ci`");
|
|
89
|
+
}
|
|
90
|
+
else {
|
|
91
|
+
lines.push(`2. Inspect a finding: \`npx @praneeth_54/agentdoctor explain ${topRule ?? "<rule-id>"}\``);
|
|
92
|
+
lines.push("3. Fix the gated findings, then re-run this Action");
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
lines.push("");
|
|
96
|
+
lines.push("Preview before applying: `npx @praneeth_54/agentdoctor fix --dry-run`");
|
|
97
|
+
lines.push("");
|
|
98
|
+
return lines;
|
|
99
|
+
}
|
|
100
|
+
export function renderGithubStepSummaryForScan(result, violations) {
|
|
101
|
+
return renderGithubStepSummary({
|
|
102
|
+
title: "AgentDoctor Scan",
|
|
103
|
+
overallScore: result.scores?.overall ?? null,
|
|
104
|
+
findings: result.findings,
|
|
105
|
+
violations,
|
|
106
|
+
mode: "scan",
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
function severityOrder(severity) {
|
|
110
|
+
return severity === "critical" ? 3 : severity === "warning" ? 2 : 1;
|
|
111
|
+
}
|
|
@@ -2,6 +2,11 @@ import type { ScanResult } from "../../types/index.js";
|
|
|
2
2
|
export interface TerminalReportOptions {
|
|
3
3
|
verbose?: boolean;
|
|
4
4
|
}
|
|
5
|
+
/**
|
|
6
|
+
* Close the Scan → Fix → Verify loop on first run.
|
|
7
|
+
* Empty / agentless scans already print a Next line; findings used to dead-end.
|
|
8
|
+
*/
|
|
9
|
+
export declare function renderNextSteps(result: ScanResult): string[];
|
|
5
10
|
/**
|
|
6
11
|
* Terminal report: agents and actionable findings.
|
|
7
12
|
*/
|
|
@@ -42,7 +42,12 @@ function renderFindingBlock(finding, verbose) {
|
|
|
42
42
|
: finding.severity === "warning"
|
|
43
43
|
? symbolWarn()
|
|
44
44
|
: symbolInfo();
|
|
45
|
-
|
|
45
|
+
const fixTag = finding.fixability === "safe"
|
|
46
|
+
? colors.green("[safe]")
|
|
47
|
+
: finding.fixability === "review"
|
|
48
|
+
? colors.yellow("[review]")
|
|
49
|
+
: colors.dim("[manual]");
|
|
50
|
+
lines.push(` ${icon} ${fixTag} ${sanitizeTerminalText(finding.title)}`);
|
|
46
51
|
if (finding.evidence?.path) {
|
|
47
52
|
lines.push(colors.dim(` ${sanitizeTerminalText(finding.evidence.path)}`));
|
|
48
53
|
}
|
|
@@ -59,6 +64,50 @@ function renderFindingBlock(finding, verbose) {
|
|
|
59
64
|
lines.push("");
|
|
60
65
|
return lines;
|
|
61
66
|
}
|
|
67
|
+
function countByFixability(findings) {
|
|
68
|
+
let safe = 0;
|
|
69
|
+
let review = 0;
|
|
70
|
+
let manual = 0;
|
|
71
|
+
for (const finding of findings) {
|
|
72
|
+
if (finding.fixability === "safe") {
|
|
73
|
+
safe += 1;
|
|
74
|
+
}
|
|
75
|
+
else if (finding.fixability === "review") {
|
|
76
|
+
review += 1;
|
|
77
|
+
}
|
|
78
|
+
else {
|
|
79
|
+
manual += 1;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
return { safe, review, manual };
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* Close the Scan → Fix → Verify loop on first run.
|
|
86
|
+
* Empty / agentless scans already print a Next line; findings used to dead-end.
|
|
87
|
+
*/
|
|
88
|
+
export function renderNextSteps(result) {
|
|
89
|
+
const lines = [];
|
|
90
|
+
const total = result.summary.total;
|
|
91
|
+
if (total === 0) {
|
|
92
|
+
return lines;
|
|
93
|
+
}
|
|
94
|
+
const { safe, review, manual } = countByFixability(result.findings);
|
|
95
|
+
const reviewLike = review + manual;
|
|
96
|
+
lines.push(colors.bold("Next"));
|
|
97
|
+
lines.push("");
|
|
98
|
+
if (safe > 0) {
|
|
99
|
+
lines.push(` ${safe} finding(s) are auto-fixable. Preview: ${colors.bold("agentdoctor fix --dry-run")}`);
|
|
100
|
+
lines.push(` Apply: ${colors.bold("agentdoctor fix -y")}`);
|
|
101
|
+
}
|
|
102
|
+
else if (reviewLike > 0) {
|
|
103
|
+
lines.push(` ${reviewLike} finding(s) need review or manual action — ${colors.bold("agentdoctor fix")} will not change them automatically.`);
|
|
104
|
+
lines.push(colors.dim(" Inspect a rule: agentdoctor explain <rule-id> (ids are in --json output)"));
|
|
105
|
+
}
|
|
106
|
+
lines.push(colors.dim(" Save a verify baseline: agentdoctor scan --json > agentdoctor-report.json"));
|
|
107
|
+
lines.push(colors.dim(" After changes: agentdoctor verify --baseline agentdoctor-report.json"));
|
|
108
|
+
lines.push("");
|
|
109
|
+
return lines;
|
|
110
|
+
}
|
|
62
111
|
/**
|
|
63
112
|
* Terminal report: agents and actionable findings.
|
|
64
113
|
*/
|
|
@@ -115,7 +164,10 @@ export function renderTerminalReport(result, options = {}) {
|
|
|
115
164
|
}
|
|
116
165
|
if (total === 0) {
|
|
117
166
|
if (limited) {
|
|
118
|
-
lines.push(` ${
|
|
167
|
+
lines.push(` ${symbolWarn()} Nothing to audit yet — no Cursor, Claude Code, or Codex config found`);
|
|
168
|
+
lines.push("");
|
|
169
|
+
lines.push(colors.dim(" Next: add project agent config (for example `.cursor/`, `CLAUDE.md` / `.claude/`, or `AGENTS.md`),"));
|
|
170
|
+
lines.push(colors.dim(" then re-run `agentdoctor` in this repository."));
|
|
119
171
|
}
|
|
120
172
|
else {
|
|
121
173
|
lines.push(` ${symbolOk()} No findings`);
|
|
@@ -149,7 +201,7 @@ export function renderTerminalReport(result, options = {}) {
|
|
|
149
201
|
}
|
|
150
202
|
}
|
|
151
203
|
if (total > shown) {
|
|
152
|
-
lines.push(colors.dim(` … and ${total - shown} more. Re-run with --verbose
|
|
204
|
+
lines.push(colors.dim(` … and ${total - shown} more. Re-run with --verbose for detail, or --json for the full list.`));
|
|
153
205
|
lines.push("");
|
|
154
206
|
}
|
|
155
207
|
}
|
|
@@ -158,15 +210,26 @@ export function renderTerminalReport(result, options = {}) {
|
|
|
158
210
|
lines.push(` ${result.summary.critical} critical`);
|
|
159
211
|
lines.push(` ${result.summary.warning} warning`);
|
|
160
212
|
lines.push(` ${result.summary.info} info`);
|
|
213
|
+
if (total > 0) {
|
|
214
|
+
const { safe, review, manual } = countByFixability(result.findings);
|
|
215
|
+
lines.push(colors.dim(` Fixability: ${safe} safe · ${review} review · ${manual} manual`));
|
|
216
|
+
}
|
|
161
217
|
lines.push("");
|
|
162
218
|
if (result.scoringAvailable && result.scores) {
|
|
163
|
-
|
|
164
|
-
|
|
219
|
+
if (limited) {
|
|
220
|
+
lines.push(` Readiness: n/a — configure Cursor, Claude Code, or Codex before treating scores as agent readiness`);
|
|
221
|
+
lines.push(colors.dim(` Numeric scores remain in JSON (overall ${result.scores.overall}/100) for repository-risk findings only`));
|
|
222
|
+
}
|
|
223
|
+
else {
|
|
224
|
+
lines.push(` Readiness: ${result.scores.overall}/100`);
|
|
225
|
+
lines.push(colors.dim(" Category and agent scores: agentdoctor scan --json"));
|
|
226
|
+
}
|
|
165
227
|
}
|
|
166
228
|
else {
|
|
167
229
|
lines.push(colors.dim(" Readiness scoring unavailable for this scan"));
|
|
168
230
|
}
|
|
169
231
|
lines.push("");
|
|
232
|
+
lines.push(...renderNextSteps(result));
|
|
170
233
|
if (verbose) {
|
|
171
234
|
lines.push(colors.dim("Timing"));
|
|
172
235
|
lines.push(` Discovery: ${result.timing.discoveryMs}ms`);
|
|
@@ -1,2 +1,6 @@
|
|
|
1
1
|
import type { VerifyResult } from "../../core/verify/verify.js";
|
|
2
2
|
export declare function renderVerifyTerminalReport(result: VerifyResult, verbose?: boolean): string;
|
|
3
|
+
/**
|
|
4
|
+
* Close the verify step: tell a first-time user what to do with Fixed / Remaining / New.
|
|
5
|
+
*/
|
|
6
|
+
export declare function renderVerifyNextSteps(result: VerifyResult): string[];
|
|
@@ -41,5 +41,31 @@ export function renderVerifyTerminalReport(result, verbose = false) {
|
|
|
41
41
|
lines.push(colors.dim(` Timing scan=${result.timing.scanMs}ms compare=${result.timing.compareMs}ms total=${result.timing.totalMs}ms`));
|
|
42
42
|
}
|
|
43
43
|
lines.push("");
|
|
44
|
+
lines.push(...renderVerifyNextSteps(result));
|
|
44
45
|
return `${lines.join("\n")}\n`;
|
|
45
46
|
}
|
|
47
|
+
/**
|
|
48
|
+
* Close the verify step: tell a first-time user what to do with Fixed / Remaining / New.
|
|
49
|
+
*/
|
|
50
|
+
export function renderVerifyNextSteps(result) {
|
|
51
|
+
const lines = [];
|
|
52
|
+
const { fixed, remaining, new: newly } = result.summary;
|
|
53
|
+
lines.push(colors.bold("Next"));
|
|
54
|
+
if (remaining === 0 && newly === 0) {
|
|
55
|
+
lines.push(fixed > 0
|
|
56
|
+
? " All tracked findings cleared. Optional: gate CI with agentdoctor scan --ci"
|
|
57
|
+
: " No regressions vs baseline. Optional: gate CI with agentdoctor scan --ci");
|
|
58
|
+
}
|
|
59
|
+
else {
|
|
60
|
+
if (newly > 0) {
|
|
61
|
+
lines.push(` ${newly} new finding(s) appeared since the baseline — inspect: agentdoctor scan`);
|
|
62
|
+
}
|
|
63
|
+
if (remaining > 0) {
|
|
64
|
+
lines.push(` ${remaining} finding(s) still open — auto-fixable ones: agentdoctor fix --dry-run`);
|
|
65
|
+
lines.push(colors.dim(" Review/manual leftovers: agentdoctor explain <rule-id> (ids in scan --json)"));
|
|
66
|
+
}
|
|
67
|
+
lines.push(colors.dim(" After more fixes, re-check: agentdoctor verify --baseline agentdoctor-report.json"));
|
|
68
|
+
}
|
|
69
|
+
lines.push("");
|
|
70
|
+
return lines;
|
|
71
|
+
}
|
package/dist/utils/fs.d.ts
CHANGED
|
@@ -15,3 +15,8 @@ export declare function listDirectorySafe(dirPath: string): Promise<{
|
|
|
15
15
|
ok: false;
|
|
16
16
|
error: string;
|
|
17
17
|
}>;
|
|
18
|
+
/**
|
|
19
|
+
* Write text via a same-directory temp file, then replace the target.
|
|
20
|
+
* On Windows, `rename` cannot overwrite an existing destination — remove first.
|
|
21
|
+
*/
|
|
22
|
+
export declare function atomicWriteTextFile(filePath: string, content: string): Promise<void>;
|
package/dist/utils/fs.js
CHANGED
|
@@ -61,3 +61,37 @@ export async function listDirectorySafe(dirPath) {
|
|
|
61
61
|
return { ok: false, error: message };
|
|
62
62
|
}
|
|
63
63
|
}
|
|
64
|
+
/**
|
|
65
|
+
* Write text via a same-directory temp file, then replace the target.
|
|
66
|
+
* On Windows, `rename` cannot overwrite an existing destination — remove first.
|
|
67
|
+
*/
|
|
68
|
+
export async function atomicWriteTextFile(filePath, content) {
|
|
69
|
+
const dir = path.dirname(filePath);
|
|
70
|
+
await fs.mkdir(dir, { recursive: true });
|
|
71
|
+
const tmp = path.join(dir, `.${path.basename(filePath)}.agentdoctor.${process.pid}.${Date.now()}.tmp`);
|
|
72
|
+
await fs.writeFile(tmp, content, "utf8");
|
|
73
|
+
try {
|
|
74
|
+
await replaceFile(tmp, filePath);
|
|
75
|
+
}
|
|
76
|
+
catch (error) {
|
|
77
|
+
await fs.rm(tmp, { force: true }).catch(() => undefined);
|
|
78
|
+
throw error;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
async function replaceFile(tmpPath, targetPath) {
|
|
82
|
+
try {
|
|
83
|
+
await fs.rename(tmpPath, targetPath);
|
|
84
|
+
}
|
|
85
|
+
catch (error) {
|
|
86
|
+
const code = error && typeof error === "object" && "code" in error
|
|
87
|
+
? String(error.code)
|
|
88
|
+
: "";
|
|
89
|
+
// Windows (and some locked-file cases) cannot rename over an existing path.
|
|
90
|
+
if (process.platform === "win32" || code === "EEXIST" || code === "EPERM") {
|
|
91
|
+
await fs.rm(targetPath, { force: true });
|
|
92
|
+
await fs.rename(tmpPath, targetPath);
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
throw error;
|
|
96
|
+
}
|
|
97
|
+
}
|
package/dist/utils/path.d.ts
CHANGED
|
@@ -7,6 +7,11 @@ export declare function resolveRepoRoot(inputPath: string): string;
|
|
|
7
7
|
* Produce a POSIX-style relative path for stable cross-platform reporting.
|
|
8
8
|
*/
|
|
9
9
|
export declare function toPosixRelative(from: string, to: string): string;
|
|
10
|
+
/**
|
|
11
|
+
* Normalize a repository-relative path to POSIX form without regex backtracking.
|
|
12
|
+
* Converts `\` to `/` and strips leading/trailing `/`.
|
|
13
|
+
*/
|
|
14
|
+
export declare function toRepoRelativePosix(relativePath: string): string;
|
|
10
15
|
/**
|
|
11
16
|
* Ensure a candidate path stays within a root (defense against traversal).
|
|
12
17
|
*/
|
package/dist/utils/path.js
CHANGED
|
@@ -16,6 +16,22 @@ export function resolveRepoRoot(inputPath) {
|
|
|
16
16
|
export function toPosixRelative(from, to) {
|
|
17
17
|
return path.relative(from, to).split(path.sep).join("/");
|
|
18
18
|
}
|
|
19
|
+
/**
|
|
20
|
+
* Normalize a repository-relative path to POSIX form without regex backtracking.
|
|
21
|
+
* Converts `\` to `/` and strips leading/trailing `/`.
|
|
22
|
+
*/
|
|
23
|
+
export function toRepoRelativePosix(relativePath) {
|
|
24
|
+
const value = relativePath.includes("\\") ? relativePath.split("\\").join("/") : relativePath;
|
|
25
|
+
let start = 0;
|
|
26
|
+
let end = value.length;
|
|
27
|
+
while (start < end && value.charCodeAt(start) === 47) {
|
|
28
|
+
start += 1;
|
|
29
|
+
}
|
|
30
|
+
while (end > start && value.charCodeAt(end - 1) === 47) {
|
|
31
|
+
end -= 1;
|
|
32
|
+
}
|
|
33
|
+
return start === 0 && end === value.length ? value : value.slice(start, end);
|
|
34
|
+
}
|
|
19
35
|
/**
|
|
20
36
|
* Ensure a candidate path stays within a root (defense against traversal).
|
|
21
37
|
*/
|