@praneeth_54/agentdoctor 0.2.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.
Files changed (80) hide show
  1. package/CHANGELOG.md +107 -3
  2. package/README.md +86 -35
  3. package/dist/agents/inspect.js +58 -51
  4. package/dist/cli/commands/doctor.js +1 -1
  5. package/dist/cli/commands/explain.js +2 -2
  6. package/dist/cli/commands/fix.d.ts +7 -5
  7. package/dist/cli/commands/fix.js +73 -12
  8. package/dist/cli/commands/scan.d.ts +5 -0
  9. package/dist/cli/commands/scan.js +25 -6
  10. package/dist/cli/commands/verify.d.ts +19 -0
  11. package/dist/cli/commands/verify.js +70 -0
  12. package/dist/cli/program.js +97 -33
  13. package/dist/constants.d.ts +1 -1
  14. package/dist/constants.js +1 -1
  15. package/dist/core/fix/apply.d.ts +14 -0
  16. package/dist/core/fix/apply.js +47 -0
  17. package/dist/core/fix/patterns.d.ts +7 -0
  18. package/dist/core/fix/patterns.js +21 -0
  19. package/dist/core/fix/plan.d.ts +9 -0
  20. package/dist/core/fix/plan.js +247 -0
  21. package/dist/core/fix/render.d.ts +8 -0
  22. package/dist/core/fix/render.js +116 -0
  23. package/dist/core/fix/run.d.ts +15 -0
  24. package/dist/core/fix/run.js +58 -0
  25. package/dist/core/fix/types.d.ts +34 -0
  26. package/dist/core/fix/types.js +6 -0
  27. package/dist/core/fix/writers/claude-settings.d.ts +16 -0
  28. package/dist/core/fix/writers/claude-settings.js +100 -0
  29. package/dist/core/fix/writers/codex-config.d.ts +22 -0
  30. package/dist/core/fix/writers/codex-config.js +183 -0
  31. package/dist/core/fix/writers/cursorignore.d.ts +13 -0
  32. package/dist/core/fix/writers/cursorignore.js +68 -0
  33. package/dist/core/path-resolution/index.d.ts +5 -0
  34. package/dist/core/path-resolution/index.js +5 -0
  35. package/dist/core/path-resolution/prepare.d.ts +33 -0
  36. package/dist/core/path-resolution/prepare.js +98 -0
  37. package/dist/core/policy/evaluate.d.ts +44 -0
  38. package/dist/core/policy/evaluate.js +120 -0
  39. package/dist/core/rules/claude-deny.d.ts +8 -0
  40. package/dist/core/rules/claude-deny.js +39 -0
  41. package/dist/core/rules/codex-deny.d.ts +5 -0
  42. package/dist/core/rules/codex-deny.js +42 -0
  43. package/dist/core/rules/context/generated-directory.js +52 -8
  44. package/dist/core/rules/context/large-log-file.js +27 -10
  45. package/dist/core/rules/ignore.js +2 -5
  46. package/dist/core/rules/instructions/missing-path-reference.d.ts +5 -1
  47. package/dist/core/rules/instructions/missing-path-reference.js +114 -7
  48. package/dist/core/rules/path-kind.d.ts +27 -0
  49. package/dist/core/rules/path-kind.js +180 -0
  50. package/dist/core/rules/security/env-file-exposure.js +23 -38
  51. package/dist/core/rules/security/private-key-file.js +4 -0
  52. package/dist/core/rules/text-cache.js +46 -40
  53. package/dist/core/verify/compare.d.ts +29 -0
  54. package/dist/core/verify/compare.js +56 -0
  55. package/dist/core/verify/load-baseline.d.ts +15 -0
  56. package/dist/core/verify/load-baseline.js +71 -0
  57. package/dist/core/verify/verify.d.ts +26 -0
  58. package/dist/core/verify/verify.js +40 -0
  59. package/dist/discovery/files.js +7 -2
  60. package/dist/discovery/log-like.d.ts +2 -0
  61. package/dist/discovery/log-like.js +7 -0
  62. package/dist/index.d.ts +10 -0
  63. package/dist/index.js +6 -0
  64. package/dist/reporters/github/annotations.d.ts +8 -0
  65. package/dist/reporters/github/annotations.js +42 -0
  66. package/dist/reporters/github/emit.d.ts +23 -0
  67. package/dist/reporters/github/emit.js +31 -0
  68. package/dist/reporters/github/summary.d.ts +20 -0
  69. package/dist/reporters/github/summary.js +111 -0
  70. package/dist/reporters/terminal/report.d.ts +5 -0
  71. package/dist/reporters/terminal/report.js +73 -4
  72. package/dist/reporters/verify/json.d.ts +5 -0
  73. package/dist/reporters/verify/json.js +51 -0
  74. package/dist/reporters/verify/terminal.d.ts +6 -0
  75. package/dist/reporters/verify/terminal.js +71 -0
  76. package/dist/utils/fs.d.ts +5 -0
  77. package/dist/utils/fs.js +34 -0
  78. package/dist/utils/path.d.ts +5 -0
  79. package/dist/utils/path.js +16 -0
  80. package/package.json +11 -1
@@ -1,3 +1,6 @@
1
+ import { claudeDeniesPath } from "../claude-deny.js";
2
+ import { codexDeniesPath } from "../codex-deny.js";
3
+ import { isSampleOrTestPath } from "../path-kind.js";
1
4
  const TEMPLATE_BASENAMES = new Set([".env.example", ".env.sample", ".env.template", ".env.dist"]);
2
5
  const BACKUP_BASENAMES = new Set([".env_backup", ".env_old", ".env_local"]);
3
6
  /**
@@ -10,11 +13,18 @@ export function classifyEnvBasename(base) {
10
13
  return null;
11
14
  }
12
15
  const lower = normalized.toLowerCase();
16
+ if (BACKUP_BASENAMES.has(lower)) {
17
+ return "backup";
18
+ }
13
19
  if (TEMPLATE_BASENAMES.has(lower)) {
14
20
  return "template";
15
21
  }
16
- if (BACKUP_BASENAMES.has(lower)) {
17
- return "backup";
22
+ // .env.local.example, .env.testing.example, .env.example.local, etc.
23
+ if (/\.(?:example|sample|template)(?:\.|$)/i.test(normalized)) {
24
+ return "template";
25
+ }
26
+ if (/\.env(?:\.[A-Za-z0-9_-]+)*\.(?:example|sample|template)$/i.test(normalized)) {
27
+ return "template";
18
28
  }
19
29
  if (lower === ".env") {
20
30
  return "runtime";
@@ -54,28 +64,6 @@ function agentsWithoutClearExclusion(context, relativePath) {
54
64
  }
55
65
  return affected;
56
66
  }
57
- async function claudeDeniesRead(context, relativePath) {
58
- const settingsFiles = context.agents
59
- .find((a) => a.id === "claude-code")
60
- ?.configFiles.filter((f) => f.kind === "claude-settings" || f.kind === "claude-settings-local") ?? [];
61
- for (const file of settingsFiles) {
62
- const cached = await context.textCache.read(file.relativePath);
63
- if (!cached.text)
64
- continue;
65
- const patterns = [
66
- `Read(./${relativePath})`,
67
- `Read(${relativePath})`,
68
- `Read(/**/${relativePath.split("/").pop()})`,
69
- ];
70
- if (patterns.some((p) => cached.text.includes(p))) {
71
- return true;
72
- }
73
- if (/"deny"\s*:\s*\[[^\]]*"Read"\s*[,\]]/.test(cached.text)) {
74
- return true;
75
- }
76
- }
77
- return false;
78
- }
79
67
  export const envFileExposureRule = {
80
68
  id: "security/env-file-exposure",
81
69
  title: "Sensitive environment file may enter agent context",
@@ -93,19 +81,11 @@ export const envFileExposureRule = {
93
81
  if (!kind) {
94
82
  continue;
95
83
  }
84
+ if (isSampleOrTestPath(file.relativePath)) {
85
+ continue;
86
+ }
87
+ // Templates document variable names; they are not agent-readiness findings.
96
88
  if (kind === "template") {
97
- findings.push({
98
- ruleId: "security/env-file-exposure",
99
- category: "security",
100
- severity: "info",
101
- title: "Environment template file present",
102
- message: `Environment template file detected at ${file.relativePath}`,
103
- whyItMatters: "Template env files are usually placeholders, but they can still document secret names. Filename classification only — contents were not inspected.",
104
- recommendation: "Keep templates free of real credentials. Prefer documenting variable names without values.",
105
- affectedAgents: [],
106
- evidence: { path: file.relativePath, detail: "template" },
107
- fixability: "review",
108
- });
109
89
  continue;
110
90
  }
111
91
  if (!agentsPresent) {
@@ -128,10 +108,15 @@ export const envFileExposureRule = {
128
108
  }
129
109
  let affected = agentsWithoutClearExclusion(context, file.relativePath);
130
110
  if (affected.includes("claude-code")) {
131
- if (await claudeDeniesRead(context, file.relativePath)) {
111
+ if (await claudeDeniesPath(context, file.relativePath)) {
132
112
  affected = affected.filter((a) => a !== "claude-code");
133
113
  }
134
114
  }
115
+ if (affected.includes("codex")) {
116
+ if (await codexDeniesPath(context, file.relativePath)) {
117
+ affected = affected.filter((a) => a !== "codex");
118
+ }
119
+ }
135
120
  if (affected.length === 0) {
136
121
  continue;
137
122
  }
@@ -142,7 +127,7 @@ export const envFileExposureRule = {
142
127
  title: "Sensitive environment file may enter agent context",
143
128
  message: `Sensitive environment file detected and no relevant exclusion was found for: ${file.relativePath}`,
144
129
  whyItMatters: "Environment files frequently hold API keys and credentials. If readable by an AI coding agent, those values may be included in prompts or logs.",
145
- recommendation: "Add an agent-specific exclusion (for example .cursorignore or a Claude Code Read deny rule), keep the file out of version control, and rotate any credentials that may have been exposed.",
130
+ recommendation: "Add an agent-specific exclusion (for example .cursorignore, a Claude Code Read deny rule, or a Codex filesystem deny), keep the file out of version control, and rotate any credentials that may have been exposed.",
146
131
  affectedAgents: affected,
147
132
  evidence: {
148
133
  path: file.relativePath,
@@ -1,3 +1,4 @@
1
+ import { isSampleOrTestPath } from "../path-kind.js";
1
2
  const PRIVATE_KEY_PATTERNS = [
2
3
  { test: (b) => /^id_rsa$/i.test(b), label: "SSH private key" },
3
4
  { test: (b) => /^id_ed25519$/i.test(b), label: "SSH private key" },
@@ -40,6 +41,9 @@ export const privateKeyFileRule = {
40
41
  const findings = [];
41
42
  const affected = context.agents.filter((a) => a.detected || a.configured).map((a) => a.id);
42
43
  for (const file of context.discovery.files) {
44
+ if (isSampleOrTestPath(file.relativePath)) {
45
+ continue;
46
+ }
43
47
  const base = file.relativePath.split("/").pop() ?? file.relativePath;
44
48
  const label = classify(base);
45
49
  if (!label) {
@@ -32,55 +32,61 @@ export class TextCache {
32
32
  return blocked;
33
33
  }
34
34
  try {
35
- const stat = await fs.stat(absolute);
36
- if (!stat.isFile()) {
37
- const result = {
38
- relativePath: toPosixRelative(this.root, absolute),
39
- text: null,
40
- sizeBytes: 0,
41
- empty: true,
42
- binary: false,
43
- error: "Not a regular file",
44
- };
45
- this.cache.set(relativePath, result);
46
- return result;
47
- }
48
- const sizeBytes = Number(stat.size);
49
- if (sizeBytes > this.maxBytes) {
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: null,
79
+ text,
53
80
  sizeBytes,
54
- empty: false,
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
- const buffer = await fs.readFile(absolute);
62
- if (looksBinary(buffer)) {
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";
@@ -0,0 +1,29 @@
1
+ import type { Finding, FindingEvidence, Severity } from "../../types/index.js";
2
+ /** Stable subset of a finding for verify reports. */
3
+ export interface VerifyFindingRef {
4
+ id: string;
5
+ ruleId: string;
6
+ severity: Severity;
7
+ title: string;
8
+ message: string;
9
+ evidence?: FindingEvidence;
10
+ }
11
+ export interface FindingCompareResult {
12
+ fixed: VerifyFindingRef[];
13
+ remaining: VerifyFindingRef[];
14
+ new: VerifyFindingRef[];
15
+ unchanged: VerifyFindingRef[];
16
+ summary: {
17
+ fixed: number;
18
+ remaining: number;
19
+ new: number;
20
+ unchanged: number;
21
+ before: number;
22
+ after: number;
23
+ };
24
+ }
25
+ export declare function toVerifyFindingRef(finding: Finding): VerifyFindingRef;
26
+ /**
27
+ * Deterministic finding set comparison keyed by stable `Finding.id`.
28
+ */
29
+ export declare function compareFindings(before: readonly Finding[], after: readonly Finding[]): FindingCompareResult;
@@ -0,0 +1,56 @@
1
+ export function toVerifyFindingRef(finding) {
2
+ const ref = {
3
+ id: finding.id,
4
+ ruleId: finding.ruleId,
5
+ severity: finding.severity,
6
+ title: finding.title,
7
+ message: finding.message,
8
+ };
9
+ if (finding.evidence !== undefined) {
10
+ ref.evidence = { ...finding.evidence };
11
+ }
12
+ return ref;
13
+ }
14
+ function sortRefs(refs) {
15
+ return [...refs].sort((a, b) => a.id.localeCompare(b.id));
16
+ }
17
+ /**
18
+ * Deterministic finding set comparison keyed by stable `Finding.id`.
19
+ */
20
+ export function compareFindings(before, after) {
21
+ const beforeById = new Map(before.map((f) => [f.id, f]));
22
+ const afterById = new Map(after.map((f) => [f.id, f]));
23
+ const fixed = [];
24
+ const remaining = [];
25
+ const newly = [];
26
+ for (const [id, finding] of beforeById) {
27
+ if (!afterById.has(id)) {
28
+ fixed.push(toVerifyFindingRef(finding));
29
+ }
30
+ else {
31
+ remaining.push(toVerifyFindingRef(afterById.get(id)));
32
+ }
33
+ }
34
+ for (const [id, finding] of afterById) {
35
+ if (!beforeById.has(id)) {
36
+ newly.push(toVerifyFindingRef(finding));
37
+ }
38
+ }
39
+ const fixedSorted = sortRefs(fixed);
40
+ const remainingSorted = sortRefs(remaining);
41
+ const newSorted = sortRefs(newly);
42
+ return {
43
+ fixed: fixedSorted,
44
+ remaining: remainingSorted,
45
+ new: newSorted,
46
+ unchanged: remainingSorted,
47
+ summary: {
48
+ fixed: fixedSorted.length,
49
+ remaining: remainingSorted.length,
50
+ new: newSorted.length,
51
+ unchanged: remainingSorted.length,
52
+ before: before.length,
53
+ after: after.length,
54
+ },
55
+ };
56
+ }
@@ -0,0 +1,15 @@
1
+ import type { Finding } from "../../types/index.js";
2
+ declare const DEFAULT_BASELINE_NAMES: readonly ["agentdoctor-report.json", ".agentdoctor-baseline.json"];
3
+ export interface LoadedBaseline {
4
+ path: string;
5
+ findings: Finding[];
6
+ }
7
+ /**
8
+ * Load findings from a prior `scan --json` report (or a bare `{ findings: [...] }` file).
9
+ */
10
+ export declare function loadBaselineFindings(baselinePath: string): Promise<LoadedBaseline>;
11
+ /**
12
+ * Resolve baseline path: explicit flag, else first default filename that exists under root.
13
+ */
14
+ export declare function resolveBaselinePath(root: string, explicitBaseline?: string): Promise<string | undefined>;
15
+ export { DEFAULT_BASELINE_NAMES };
@@ -0,0 +1,71 @@
1
+ import fs from "node:fs/promises";
2
+ import path from "node:path";
3
+ const DEFAULT_BASELINE_NAMES = ["agentdoctor-report.json", ".agentdoctor-baseline.json"];
4
+ function isFindingLike(value) {
5
+ if (!value || typeof value !== "object") {
6
+ return false;
7
+ }
8
+ const record = value;
9
+ return (typeof record.id === "string" &&
10
+ typeof record.ruleId === "string" &&
11
+ typeof record.severity === "string" &&
12
+ typeof record.title === "string" &&
13
+ typeof record.message === "string");
14
+ }
15
+ /**
16
+ * Load findings from a prior `scan --json` report (or a bare `{ findings: [...] }` file).
17
+ */
18
+ export async function loadBaselineFindings(baselinePath) {
19
+ const absolute = path.resolve(baselinePath);
20
+ let raw;
21
+ try {
22
+ raw = await fs.readFile(absolute, "utf8");
23
+ }
24
+ catch {
25
+ throw new Error(`baseline not found or unreadable: ${absolute}`);
26
+ }
27
+ let parsed;
28
+ try {
29
+ parsed = JSON.parse(raw);
30
+ }
31
+ catch {
32
+ throw new Error(`baseline is not valid JSON: ${absolute}`);
33
+ }
34
+ if (!parsed || typeof parsed !== "object") {
35
+ throw new Error(`baseline JSON must be an object: ${absolute}`);
36
+ }
37
+ const findingsUnknown = parsed.findings;
38
+ if (!Array.isArray(findingsUnknown)) {
39
+ throw new Error(`baseline JSON must include a findings array: ${absolute}`);
40
+ }
41
+ const findings = [];
42
+ for (const item of findingsUnknown) {
43
+ if (!isFindingLike(item)) {
44
+ throw new Error(`baseline contains an invalid finding entry: ${absolute}`);
45
+ }
46
+ findings.push(item);
47
+ }
48
+ return { path: absolute, findings };
49
+ }
50
+ /**
51
+ * Resolve baseline path: explicit flag, else first default filename that exists under root.
52
+ */
53
+ export async function resolveBaselinePath(root, explicitBaseline) {
54
+ if (explicitBaseline) {
55
+ return path.isAbsolute(explicitBaseline)
56
+ ? explicitBaseline
57
+ : path.resolve(root, explicitBaseline);
58
+ }
59
+ for (const name of DEFAULT_BASELINE_NAMES) {
60
+ const candidate = path.join(root, name);
61
+ try {
62
+ await fs.access(candidate);
63
+ return candidate;
64
+ }
65
+ catch {
66
+ // try next
67
+ }
68
+ }
69
+ return undefined;
70
+ }
71
+ export { DEFAULT_BASELINE_NAMES };
@@ -0,0 +1,26 @@
1
+ import type { Scores, ScanResult } from "../../types/index.js";
2
+ import { type FindingCompareResult } from "./compare.js";
3
+ export interface VerifyOptions {
4
+ cwd?: string;
5
+ /** Path to a prior scan JSON report. Defaults to agentdoctor-report.json if present. */
6
+ baselinePath?: string;
7
+ verbose?: boolean;
8
+ }
9
+ export interface VerifyResult extends FindingCompareResult {
10
+ version: string;
11
+ repositoryRoot: string;
12
+ baselinePath: string;
13
+ after: ScanResult;
14
+ scores: Scores | null;
15
+ scoringAvailable: boolean;
16
+ timing: {
17
+ totalMs: number;
18
+ scanMs: number;
19
+ compareMs: number;
20
+ };
21
+ }
22
+ /**
23
+ * Re-scan the repository and compare against a prior scan JSON baseline.
24
+ * Reuses the public `scan()` pipeline — no duplicated rule engine.
25
+ */
26
+ export declare function verify(options?: VerifyOptions): Promise<VerifyResult>;
@@ -0,0 +1,40 @@
1
+ import { PACKAGE_VERSION } from "../../constants.js";
2
+ import { scan } from "../scanner/scan.js";
3
+ import { compareFindings } from "./compare.js";
4
+ import { loadBaselineFindings, resolveBaselinePath } from "./load-baseline.js";
5
+ /**
6
+ * Re-scan the repository and compare against a prior scan JSON baseline.
7
+ * Reuses the public `scan()` pipeline — no duplicated rule engine.
8
+ */
9
+ export async function verify(options = {}) {
10
+ const totalStarted = performance.now();
11
+ const cwd = options.cwd ?? process.cwd();
12
+ const resolvedBaseline = await resolveBaselinePath(cwd, options.baselinePath);
13
+ if (!resolvedBaseline) {
14
+ throw new Error("No verify baseline found. Run `agentdoctor scan --json > agentdoctor-report.json`, apply fixes, then re-run verify.");
15
+ }
16
+ const baseline = await loadBaselineFindings(resolvedBaseline);
17
+ const scanStarted = performance.now();
18
+ const after = await scan({
19
+ cwd,
20
+ verbose: options.verbose ?? false,
21
+ });
22
+ const scanMs = Math.round(performance.now() - scanStarted);
23
+ const compareStarted = performance.now();
24
+ const compared = compareFindings(baseline.findings, after.findings);
25
+ const compareMs = Math.round(performance.now() - compareStarted);
26
+ return {
27
+ version: PACKAGE_VERSION,
28
+ repositoryRoot: after.repository.root,
29
+ baselinePath: baseline.path,
30
+ ...compared,
31
+ after,
32
+ scores: after.scores,
33
+ scoringAvailable: after.scoringAvailable,
34
+ timing: {
35
+ totalMs: Math.round(performance.now() - totalStarted),
36
+ scanMs,
37
+ compareMs,
38
+ },
39
+ };
40
+ }
@@ -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
- if (stat.size > maxFileSizeBytes) {
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: Number(stat.size),
85
+ sizeBytes,
81
86
  isSymlink,
82
87
  });
83
88
  }
@@ -0,0 +1,2 @@
1
+ /** Path looks like a log/dump that can pollute agent context (size-only rules). */
2
+ export declare function isLogLikePath(relativePath: string): boolean;
@@ -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
@@ -1,8 +1,18 @@
1
1
  export { scan } from "./core/scanner/scan.js";
2
+ export { verify } from "./core/verify/verify.js";
3
+ export { compareFindings } from "./core/verify/compare.js";
2
4
  export { PACKAGE_VERSION } from "./constants.js";
3
5
  export type { ScanOptions, ScanResult, RepositoryInfo, Finding, FindingsSummary, Scores, AgentPresence, AgentDetectionResult, AgentSecurityAnalysisMode, CliOptions, ExitCode, } from "./types/index.js";
6
+ export type { VerifyResult, VerifyOptions } from "./core/verify/verify.js";
7
+ export type { VerifyFindingRef, FindingCompareResult } from "./core/verify/compare.js";
4
8
  export { EXIT_CODES } from "./types/index.js";
5
9
  export { agentRegistry } from "./agents/registry.js";
6
10
  export { detectAgents } from "./agents/detect-agents.js";
7
11
  export { ruleRegistry, getRuleById } from "./core/rules/registry.js";
8
12
  export { runRules } from "./core/rules/run-rules.js";
13
+ export { buildFixPlan } from "./core/fix/plan.js";
14
+ export { applyFixPlan } from "./core/fix/apply.js";
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";
18
+ export type { FixPlan, FixAction, FixApplyResult } from "./core/fix/types.js";
package/dist/index.js CHANGED
@@ -1,7 +1,13 @@
1
1
  export { scan } from "./core/scanner/scan.js";
2
+ export { verify } from "./core/verify/verify.js";
3
+ export { compareFindings } from "./core/verify/compare.js";
2
4
  export { PACKAGE_VERSION } from "./constants.js";
3
5
  export { EXIT_CODES } from "./types/index.js";
4
6
  export { agentRegistry } from "./agents/registry.js";
5
7
  export { detectAgents } from "./agents/detect-agents.js";
6
8
  export { ruleRegistry, getRuleById } from "./core/rules/registry.js";
7
9
  export { runRules } from "./core/rules/run-rules.js";
10
+ export { buildFixPlan } from "./core/fix/plan.js";
11
+ export { applyFixPlan } from "./core/fix/apply.js";
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
+ }