@praneeth_54/agentdoctor 1.1.0 → 2.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 (170) hide show
  1. package/CHANGELOG.md +119 -5
  2. package/README.md +165 -332
  3. package/dist/agents/aider/adapter.d.ts +1 -0
  4. package/dist/agents/aider/adapter.js +1 -0
  5. package/dist/agents/aider/detector.d.ts +14 -0
  6. package/dist/agents/aider/detector.js +120 -0
  7. package/dist/agents/copilot/adapter.d.ts +1 -0
  8. package/dist/agents/copilot/adapter.js +1 -0
  9. package/dist/agents/copilot/detector.d.ts +13 -0
  10. package/dist/agents/copilot/detector.js +113 -0
  11. package/dist/agents/gemini/adapter.d.ts +1 -0
  12. package/dist/agents/gemini/adapter.js +1 -0
  13. package/dist/agents/gemini/detector.d.ts +13 -0
  14. package/dist/agents/gemini/detector.js +151 -0
  15. package/dist/agents/registry.js +13 -1
  16. package/dist/agents/types.d.ts +1 -1
  17. package/dist/agents/windsurf/adapter.d.ts +1 -0
  18. package/dist/agents/windsurf/adapter.js +1 -0
  19. package/dist/agents/windsurf/detector.d.ts +14 -0
  20. package/dist/agents/windsurf/detector.js +119 -0
  21. package/dist/architecture/c4.d.ts +22 -0
  22. package/dist/architecture/c4.js +62 -0
  23. package/dist/cli/commands/brain.d.ts +12 -0
  24. package/dist/cli/commands/brain.js +162 -0
  25. package/dist/cli/commands/complete.d.ts +23 -0
  26. package/dist/cli/commands/complete.js +232 -0
  27. package/dist/cli/commands/explain.js +1 -1
  28. package/dist/cli/commands/fix.js +22 -2
  29. package/dist/cli/commands/mcp.d.ts +8 -0
  30. package/dist/cli/commands/mcp.js +14 -0
  31. package/dist/cli/commands/platform.d.ts +15 -0
  32. package/dist/cli/commands/platform.js +191 -0
  33. package/dist/cli/commands/v2.d.ts +55 -0
  34. package/dist/cli/commands/v2.js +278 -0
  35. package/dist/cli/program.js +809 -2
  36. package/dist/constants.d.ts +3 -2
  37. package/dist/constants.js +5 -1
  38. package/dist/contracts/adapters.d.ts +7 -0
  39. package/dist/contracts/adapters.js +60 -0
  40. package/dist/contracts/index.d.ts +162 -0
  41. package/dist/contracts/index.js +18 -0
  42. package/dist/core/baseline/store.d.ts +48 -0
  43. package/dist/core/baseline/store.js +163 -0
  44. package/dist/core/brain-cli/service.d.ts +26 -0
  45. package/dist/core/brain-cli/service.js +175 -0
  46. package/dist/core/brain-product/init.d.ts +45 -0
  47. package/dist/core/brain-product/init.js +232 -0
  48. package/dist/core/changes/analyze.d.ts +41 -0
  49. package/dist/core/changes/analyze.js +223 -0
  50. package/dist/core/context-health/analyze.d.ts +15 -0
  51. package/dist/core/context-health/analyze.js +234 -0
  52. package/dist/core/fix/apply.d.ts +21 -4
  53. package/dist/core/fix/apply.js +118 -18
  54. package/dist/core/fix/backup.d.ts +30 -0
  55. package/dist/core/fix/backup.js +193 -0
  56. package/dist/core/fix/plan.d.ts +6 -3
  57. package/dist/core/fix/plan.js +113 -9
  58. package/dist/core/fix/render.d.ts +2 -0
  59. package/dist/core/fix/render.js +36 -21
  60. package/dist/core/fix/run.js +5 -1
  61. package/dist/core/fix/safe-target.d.ts +14 -0
  62. package/dist/core/fix/safe-target.js +81 -0
  63. package/dist/core/fix/types.d.ts +12 -1
  64. package/dist/core/fix/types.js +2 -0
  65. package/dist/core/fix/writers/claude-settings.js +3 -1
  66. package/dist/core/fix/writers/codex-config.d.ts +3 -0
  67. package/dist/core/fix/writers/codex-config.js +24 -3
  68. package/dist/core/fix/writers/cursorignore.js +3 -1
  69. package/dist/core/fix/writers/simple-ignore.d.ts +16 -0
  70. package/dist/core/fix/writers/simple-ignore.js +68 -0
  71. package/dist/core/monorepo/detect.d.ts +13 -0
  72. package/dist/core/monorepo/detect.js +121 -0
  73. package/dist/core/rules/build-context.js +22 -3
  74. package/dist/core/rules/context/generated-directory.js +11 -1
  75. package/dist/core/rules/context/large-instruction-file.js +6 -0
  76. package/dist/core/rules/context/large-log-file.js +16 -1
  77. package/dist/core/rules/ignore.d.ts +11 -1
  78. package/dist/core/rules/ignore.js +22 -1
  79. package/dist/core/rules/instructions/empty-instructions.js +7 -0
  80. package/dist/core/rules/security/env-file-exposure.js +23 -2
  81. package/dist/core/rules/security/private-key-file.js +1 -1
  82. package/dist/core/scanner/scan.js +1 -1
  83. package/dist/core/schemas/validate.d.ts +6 -0
  84. package/dist/core/schemas/validate.js +62 -0
  85. package/dist/core/scoring/compute-scores.d.ts +1 -1
  86. package/dist/core/scoring/compute-scores.js +5 -1
  87. package/dist/core/scoring/placeholder.d.ts +1 -1
  88. package/dist/core/scoring/placeholder.js +5 -1
  89. package/dist/core/secrets/scan.d.ts +23 -0
  90. package/dist/core/secrets/scan.js +172 -0
  91. package/dist/dashboard/server.d.ts +24 -0
  92. package/dist/dashboard/server.js +386 -0
  93. package/dist/enforcement/runner.d.ts +23 -0
  94. package/dist/enforcement/runner.js +56 -0
  95. package/dist/index.d.ts +13 -0
  96. package/dist/index.js +12 -0
  97. package/dist/integrations/github/pr-analyze.d.ts +28 -0
  98. package/dist/integrations/github/pr-analyze.js +98 -0
  99. package/dist/integrations/local-ai/provider.d.ts +45 -0
  100. package/dist/integrations/local-ai/provider.js +104 -0
  101. package/dist/intelligence/git/analyze.d.ts +34 -0
  102. package/dist/intelligence/git/analyze.js +136 -0
  103. package/dist/intelligence/graph/build.d.ts +13 -0
  104. package/dist/intelligence/graph/build.js +215 -0
  105. package/dist/knowledge/store.d.ts +25 -0
  106. package/dist/knowledge/store.js +91 -0
  107. package/dist/mcp/agentdoctor/server.d.ts +16 -0
  108. package/dist/mcp/agentdoctor/server.js +67 -0
  109. package/dist/mcp/brain/session.js +7 -7
  110. package/dist/mcp/intelligence/handlers.d.ts +11 -0
  111. package/dist/mcp/intelligence/handlers.js +175 -0
  112. package/dist/mcp/intelligence/path-safety.d.ts +11 -0
  113. package/dist/mcp/intelligence/path-safety.js +82 -0
  114. package/dist/mcp/intelligence/registry.d.ts +8 -0
  115. package/dist/mcp/intelligence/registry.js +160 -0
  116. package/dist/ops/health.d.ts +18 -0
  117. package/dist/ops/health.js +51 -0
  118. package/dist/platform/ai-quality/analyze.d.ts +9 -0
  119. package/dist/platform/ai-quality/analyze.js +65 -0
  120. package/dist/platform/architecture/drift.d.ts +17 -0
  121. package/dist/platform/architecture/drift.js +74 -0
  122. package/dist/platform/auth/local.d.ts +15 -0
  123. package/dist/platform/auth/local.js +58 -0
  124. package/dist/platform/context-security/analyze.d.ts +6 -0
  125. package/dist/platform/context-security/analyze.js +97 -0
  126. package/dist/platform/firewall/evaluate.d.ts +54 -0
  127. package/dist/platform/firewall/evaluate.js +308 -0
  128. package/dist/platform/graph/build.d.ts +6 -0
  129. package/dist/platform/graph/build.js +193 -0
  130. package/dist/platform/health/analyze.d.ts +7 -0
  131. package/dist/platform/health/analyze.js +186 -0
  132. package/dist/platform/index.d.ts +27 -0
  133. package/dist/platform/index.js +87 -0
  134. package/dist/platform/knowledge/analyze.d.ts +20 -0
  135. package/dist/platform/knowledge/analyze.js +96 -0
  136. package/dist/platform/provenance/build.d.ts +44 -0
  137. package/dist/platform/provenance/build.js +65 -0
  138. package/dist/platform/readiness/scorecard.d.ts +5 -0
  139. package/dist/platform/readiness/scorecard.js +98 -0
  140. package/dist/platform/refactor/impact.d.ts +20 -0
  141. package/dist/platform/refactor/impact.js +60 -0
  142. package/dist/platform/reports/export.d.ts +12 -0
  143. package/dist/platform/reports/export.js +92 -0
  144. package/dist/platform/security/redact.d.ts +15 -0
  145. package/dist/platform/security/redact.js +87 -0
  146. package/dist/platform/sessions/store.d.ts +32 -0
  147. package/dist/platform/sessions/store.js +97 -0
  148. package/dist/platform/store.d.ts +6 -0
  149. package/dist/platform/store.js +57 -0
  150. package/dist/platform/test-impact/analyze.d.ts +32 -0
  151. package/dist/platform/test-impact/analyze.js +164 -0
  152. package/dist/platform/time-machine/compare.d.ts +17 -0
  153. package/dist/platform/time-machine/compare.js +62 -0
  154. package/dist/platform/tokens/plan.d.ts +28 -0
  155. package/dist/platform/tokens/plan.js +89 -0
  156. package/dist/platform/types.d.ts +111 -0
  157. package/dist/platform/types.js +5 -0
  158. package/dist/plugins/runtime.d.ts +36 -0
  159. package/dist/plugins/runtime.js +101 -0
  160. package/dist/plugins/sdk.d.ts +29 -0
  161. package/dist/plugins/sdk.js +114 -0
  162. package/dist/policy/packs.d.ts +9 -0
  163. package/dist/policy/packs.js +59 -0
  164. package/dist/reporters/terminal/report.js +38 -5
  165. package/dist/storage/provider.d.ts +35 -0
  166. package/dist/storage/provider.js +103 -0
  167. package/dist/team/auth.d.ts +35 -0
  168. package/dist/team/auth.js +70 -0
  169. package/dist/types/index.d.ts +5 -1
  170. package/package.json +14 -7
@@ -16,14 +16,22 @@ function claudeConfigured(result) {
16
16
  function codexConfigured(result) {
17
17
  return result.agents.some((a) => a.id === "codex" && (a.detected || a.configured));
18
18
  }
19
- async function loadCursorignorePatterns(root) {
20
- const filePath = path.join(root, ".cursorignore");
21
- const text = await readTextFile(filePath, DEFAULT_MAX_IGNORE_BYTES);
19
+ function geminiConfigured(result) {
20
+ return result.agents.some((a) => a.id === "gemini-cli" && (a.detected || a.configured));
21
+ }
22
+ function aiderConfigured(result) {
23
+ return result.agents.some((a) => a.id === "aider" && (a.detected || a.configured));
24
+ }
25
+ async function loadIgnorePatterns(root, relativePath) {
26
+ const text = await readTextFile(path.join(root, relativePath), DEFAULT_MAX_IGNORE_BYTES);
22
27
  if (text === null) {
23
28
  return [];
24
29
  }
25
30
  return parseIgnoreFile(text);
26
31
  }
32
+ async function loadCursorignorePatterns(root) {
33
+ return loadIgnorePatterns(root, ".cursorignore");
34
+ }
27
35
  async function loadClaudeSettingsText(root) {
28
36
  return readTextFile(path.join(root, ".claude/settings.json"), DEFAULT_MAX_IGNORE_BYTES);
29
37
  }
@@ -32,22 +40,29 @@ async function loadCodexConfigText(root) {
32
40
  }
33
41
  /**
34
42
  * Build a fix plan from a scan result.
35
- * Safe context exclusions: Cursor `.cursorignore`, Claude Code Read deny, Codex filesystem deny.
43
+ * Safe context exclusions: Cursor `.cursorignore`, Claude Code Read deny, Codex filesystem deny,
44
+ * Gemini CLI `.geminiignore`, Aider `.aiderignore`.
36
45
  */
37
46
  export async function buildFixPlan(result) {
38
47
  const root = result.repository.root;
39
48
  const skipped = [];
40
49
  const actionsByKey = new Map();
41
50
  const existingCursorPatterns = await loadCursorignorePatterns(root);
51
+ const existingGeminiPatterns = await loadIgnorePatterns(root, ".geminiignore");
52
+ const existingAiderPatterns = await loadIgnorePatterns(root, ".aiderignore");
42
53
  const cursorIndex = createIgnoreIndex({
43
54
  gitignorePatterns: [],
44
55
  cursorignorePatterns: existingCursorPatterns,
56
+ geminiignorePatterns: existingGeminiPatterns,
57
+ aiderignorePatterns: existingAiderPatterns,
45
58
  });
46
59
  const claudeSettingsText = await loadClaudeSettingsText(root);
47
60
  const codexConfigText = await loadCodexConfigText(root);
48
61
  const hasCursor = cursorConfigured(result);
49
62
  const hasClaude = claudeConfigured(result);
50
63
  const hasCodex = codexConfigured(result);
64
+ const hasGemini = geminiConfigured(result);
65
+ const hasAider = aiderConfigured(result);
51
66
  for (const finding of result.findings) {
52
67
  if (finding.fixability !== "safe") {
53
68
  skipped.push({
@@ -82,6 +97,8 @@ export async function buildFixPlan(result) {
82
97
  const wantsCursor = finding.affectedAgents.includes("cursor");
83
98
  const wantsClaude = finding.affectedAgents.includes("claude-code");
84
99
  const wantsCodex = finding.affectedAgents.includes("codex");
100
+ const wantsGemini = finding.affectedAgents.includes("gemini-cli");
101
+ const wantsAider = finding.affectedAgents.includes("aider");
85
102
  if (wantsCursor && hasCursor) {
86
103
  if (cursorIndex.matchesCursorignore(evidencePath) ||
87
104
  cursorIndex.matchesCursorignore(pattern)) {
@@ -132,8 +149,6 @@ export async function buildFixPlan(result) {
132
149
  });
133
150
  }
134
151
  else if (codexConfigText) {
135
- // Refuse unwritable/invalid config the same way Claude Fix refuses bad JSON —
136
- // do not silently skip and leave Codex findings uncleared after a partial Cursor write.
137
152
  assertWritableCodexConfig(codexConfigText);
138
153
  mergeCodexAction(actionsByKey, finding, pattern, evidencePath, denyKey);
139
154
  }
@@ -148,8 +163,75 @@ export async function buildFixPlan(result) {
148
163
  reason: "Codex not detected; skipping config.toml deny fix",
149
164
  });
150
165
  }
166
+ if (wantsGemini && hasGemini) {
167
+ if (cursorIndex.matchesGeminiignore(evidencePath) ||
168
+ cursorIndex.matchesGeminiignore(pattern)) {
169
+ skipped.push({
170
+ findingId: finding.id,
171
+ ruleId: finding.ruleId,
172
+ reason: "Already excluded by .geminiignore",
173
+ });
174
+ }
175
+ else {
176
+ mergeSimpleIgnoreAction(actionsByKey, finding, pattern, evidencePath, {
177
+ agent: "gemini-cli",
178
+ targetRelativePath: ".geminiignore",
179
+ label: "Gemini CLI",
180
+ });
181
+ }
182
+ }
183
+ else if (wantsGemini && !hasGemini) {
184
+ skipped.push({
185
+ findingId: finding.id,
186
+ ruleId: finding.ruleId,
187
+ reason: "Gemini CLI not detected; skipping .geminiignore fix",
188
+ });
189
+ }
190
+ if (wantsAider && hasAider) {
191
+ if (cursorIndex.matchesAiderignore(evidencePath) || cursorIndex.matchesAiderignore(pattern)) {
192
+ skipped.push({
193
+ findingId: finding.id,
194
+ ruleId: finding.ruleId,
195
+ reason: "Already excluded by .aiderignore",
196
+ });
197
+ }
198
+ else {
199
+ mergeSimpleIgnoreAction(actionsByKey, finding, pattern, evidencePath, {
200
+ agent: "aider",
201
+ targetRelativePath: ".aiderignore",
202
+ label: "Aider",
203
+ });
204
+ }
205
+ }
206
+ else if (wantsAider && !hasAider) {
207
+ skipped.push({
208
+ findingId: finding.id,
209
+ ruleId: finding.ruleId,
210
+ reason: "Aider not detected; skipping .aiderignore fix",
211
+ });
212
+ }
151
213
  for (const agent of finding.affectedAgents) {
152
- if (agent === "cursor" || agent === "claude-code" || agent === "codex") {
214
+ if (agent === "cursor" ||
215
+ agent === "claude-code" ||
216
+ agent === "codex" ||
217
+ agent === "gemini-cli" ||
218
+ agent === "aider") {
219
+ continue;
220
+ }
221
+ if (agent === "copilot") {
222
+ skipped.push({
223
+ findingId: finding.id,
224
+ ruleId: finding.ruleId,
225
+ reason: "GitHub Copilot has no official project deny/ignore Fix writer; review instructions and .gitignore manually",
226
+ });
227
+ continue;
228
+ }
229
+ if (agent === "windsurf") {
230
+ skipped.push({
231
+ findingId: finding.id,
232
+ ruleId: finding.ruleId,
233
+ reason: "Windsurf has no official project deny/ignore Fix writer; review rules and .gitignore manually",
234
+ });
153
235
  continue;
154
236
  }
155
237
  skipped.push({
@@ -225,8 +307,28 @@ function mergeCodexAction(actionsByKey, finding, pattern, evidencePath, denyKey)
225
307
  description: `Exclude ${evidencePath} from Codex via filesystem deny "${denyKey}"`,
226
308
  });
227
309
  }
228
- /** Patterns that would be newly appended given current file contents. */
229
- export function missingPatternsForCursorignore(currentContent, patterns) {
310
+ function mergeSimpleIgnoreAction(actionsByKey, finding, pattern, evidencePath, options) {
311
+ const key = `${options.agent}:${options.targetRelativePath}:${pattern}`;
312
+ const existing = actionsByKey.get(key);
313
+ if (existing) {
314
+ if (!existing.findingIds.includes(finding.id)) {
315
+ existing.findingIds.push(finding.id);
316
+ }
317
+ return;
318
+ }
319
+ actionsByKey.set(key, {
320
+ id: key,
321
+ kind: "append-ignore-pattern",
322
+ agent: options.agent,
323
+ targetRelativePath: options.targetRelativePath,
324
+ pattern,
325
+ evidencePath,
326
+ findingIds: [finding.id],
327
+ description: `Exclude ${evidencePath} from ${options.label} via ${options.targetRelativePath}`,
328
+ });
329
+ }
330
+ /** Patterns that would be newly appended given current ignore-file contents. */
331
+ export function missingPatternsForIgnoreFile(currentContent, patterns) {
230
332
  const existing = currentContent === null ? [] : parseIgnoreFile(currentContent);
231
333
  const missing = [];
232
334
  for (const pattern of patterns) {
@@ -245,3 +347,5 @@ export function missingPatternsForCursorignore(currentContent, patterns) {
245
347
  }
246
348
  return missing;
247
349
  }
350
+ /** @deprecated Prefer missingPatternsForIgnoreFile */
351
+ export const missingPatternsForCursorignore = missingPatternsForIgnoreFile;
@@ -4,5 +4,7 @@ export declare function renderFixPlanTerminal(plan: FixPlan, options: {
4
4
  cursorContent: string | null;
5
5
  claudeSettingsContent?: string | null;
6
6
  codexConfigContent?: string | null;
7
+ geminiignoreContent?: string | null;
8
+ aiderignoreContent?: string | null;
7
9
  applyResult?: FixApplyResult;
8
10
  }): string;
@@ -1,6 +1,7 @@
1
1
  import { previewClaudeSettingsActions } from "./writers/claude-settings.js";
2
2
  import { previewCodexConfigActions } from "./writers/codex-config.js";
3
3
  import { previewCursorignoreActions } from "./writers/cursorignore.js";
4
+ import { previewSimpleIgnoreActions } from "./writers/simple-ignore.js";
4
5
  export function renderFixPlanTerminal(plan, options) {
5
6
  const lines = [];
6
7
  lines.push("");
@@ -39,41 +40,55 @@ export function renderFixPlanTerminal(plan, options) {
39
40
  const cursorPreview = previewCursorignoreActions(options.cursorContent, plan.actions);
40
41
  const claudePreview = previewClaudeSettingsActions(options.claudeSettingsContent ?? null, plan.actions);
41
42
  const codexPreview = previewCodexConfigActions(options.codexConfigContent ?? null, plan.actions);
42
- if (cursorPreview || claudePreview || codexPreview) {
43
+ const geminiPreview = previewSimpleIgnoreActions(options.geminiignoreContent ?? null, plan.actions, { agent: "gemini-cli", targetRelativePath: ".geminiignore" });
44
+ const aiderPreview = previewSimpleIgnoreActions(options.aiderignoreContent ?? null, plan.actions, { agent: "aider", targetRelativePath: ".aiderignore" });
45
+ const previews = [
46
+ cursorPreview,
47
+ claudePreview,
48
+ codexPreview,
49
+ geminiPreview,
50
+ aiderPreview,
51
+ ].filter((p) => p !== null);
52
+ if (previews.length > 0) {
43
53
  lines.push("");
44
54
  lines.push(" File changes:");
45
55
  let shown = false;
46
- if (cursorPreview) {
47
- lines.push(` ${cursorPreview.targetRelativePath} (+${cursorPreview.patternsToAdd.length} pattern(s))`);
48
- lines.push("");
49
- for (const previewLine of cursorPreview.preview.split("\n")) {
50
- lines.push(` ${previewLine}`);
51
- }
52
- shown = true;
53
- }
54
- if (claudePreview) {
56
+ for (const preview of previews) {
55
57
  if (shown) {
56
58
  lines.push("");
57
59
  }
58
- lines.push(` ${claudePreview.targetRelativePath} (+${claudePreview.denyRulesToAdd.length} deny rule(s))`);
60
+ const count = preview.patternsToAdd?.length ??
61
+ preview.denyRulesToAdd?.length ??
62
+ preview.denyKeysToAdd?.length ??
63
+ 0;
64
+ const unit = preview.patternsToAdd
65
+ ? "pattern(s)"
66
+ : preview.denyRulesToAdd
67
+ ? "deny rule(s)"
68
+ : "deny key(s)";
69
+ lines.push(` ${preview.targetRelativePath} (+${count} ${unit})`);
59
70
  lines.push("");
60
- for (const previewLine of claudePreview.preview.split("\n")) {
71
+ for (const previewLine of preview.preview.split("\n")) {
61
72
  lines.push(` ${previewLine}`);
62
73
  }
63
74
  shown = true;
64
75
  }
65
- if (codexPreview) {
66
- if (shown) {
67
- lines.push("");
68
- }
69
- lines.push(` ${codexPreview.targetRelativePath} (+${codexPreview.denyKeysToAdd.length} deny key(s))`);
70
- lines.push("");
71
- for (const previewLine of codexPreview.preview.split("\n")) {
72
- lines.push(` ${previewLine}`);
76
+ }
77
+ if (options.applyResult?.error) {
78
+ lines.push("");
79
+ if (options.applyResult.partial) {
80
+ lines.push(` Partial apply: wrote ${options.applyResult.writtenFiles.join(", ") || "(none)"} then failed on ${options.applyResult.failedTarget ?? "unknown target"}`);
81
+ lines.push(` Error: ${options.applyResult.error}`);
82
+ lines.push(" Safe Fix is not transactional across multiple files — review written paths manually.");
83
+ }
84
+ else {
85
+ lines.push(` Fix aborted before/during write: ${options.applyResult.error}`);
86
+ if (options.applyResult.failedTarget) {
87
+ lines.push(` Target: ${options.applyResult.failedTarget}`);
73
88
  }
74
89
  }
75
90
  }
76
- if (options.applyResult && !options.dryRun) {
91
+ else if (options.applyResult && !options.dryRun) {
77
92
  lines.push("");
78
93
  if (options.applyResult.writtenFiles.length > 0) {
79
94
  lines.push(` Wrote: ${options.applyResult.writtenFiles.join(", ")}`);
@@ -1,7 +1,7 @@
1
1
  import readline from "node:readline/promises";
2
2
  import { stdin as input, stdout as output } from "node:process";
3
3
  import { scan } from "../scanner/scan.js";
4
- import { applyFixPlan, readClaudeSettings, readCodexConfig, readCursorignore } from "./apply.js";
4
+ import { applyFixPlan, readClaudeSettings, readCodexConfig, readCursorignore, readSimpleIgnore, } from "./apply.js";
5
5
  import { buildFixPlan } from "./plan.js";
6
6
  import { renderFixPlanTerminal } from "./render.js";
7
7
  /**
@@ -13,6 +13,8 @@ export async function runFix(options) {
13
13
  const cursorContent = await readCursorignore(plan.root);
14
14
  const claudeSettingsContent = await readClaudeSettings(plan.root);
15
15
  const codexConfigContent = await readCodexConfig(plan.root);
16
+ const geminiignoreContent = await readSimpleIgnore(plan.root, ".geminiignore");
17
+ const aiderignoreContent = await readSimpleIgnore(plan.root, ".aiderignore");
16
18
  if (plan.actions.length === 0 || options.dryRun) {
17
19
  const applyResult = await applyFixPlan(plan, { dryRun: true });
18
20
  return { plan, applyResult, cancelled: false };
@@ -23,6 +25,8 @@ export async function runFix(options) {
23
25
  cursorContent,
24
26
  claudeSettingsContent,
25
27
  codexConfigContent,
28
+ geminiignoreContent,
29
+ aiderignoreContent,
26
30
  });
27
31
  process.stdout.write(previewText);
28
32
  const confirmed = await confirm("Apply these changes?");
@@ -0,0 +1,14 @@
1
+ import { type FixAllowlistedPath } from "./types.js";
2
+ /**
3
+ * Resolve an allowlisted Fix target and refuse symlink write-through /
4
+ * path-escape before any Safe Fix writer mutates the filesystem.
5
+ */
6
+ export declare function resolveSafeFixWritePath(root: string, relativePath: string): Promise<{
7
+ absolutePath: string;
8
+ relativePath: FixAllowlistedPath;
9
+ }>;
10
+ /**
11
+ * Preflight every planned Fix target before any writer mutates the repo.
12
+ * Fails fast if any target is unsafe; does not create files.
13
+ */
14
+ export declare function preflightSafeFixTargets(root: string, relativePaths: readonly string[]): Promise<FixAllowlistedPath[]>;
@@ -0,0 +1,81 @@
1
+ import fs from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { FIX_PATH_ALLOWLIST } from "./types.js";
4
+ /**
5
+ * Resolve an allowlisted Fix target and refuse symlink write-through /
6
+ * path-escape before any Safe Fix writer mutates the filesystem.
7
+ */
8
+ export async function resolveSafeFixWritePath(root, relativePath) {
9
+ if (!FIX_PATH_ALLOWLIST.includes(relativePath)) {
10
+ throw new Error(`Fix target is not allowlisted: ${relativePath}`);
11
+ }
12
+ const allowlisted = relativePath;
13
+ if (path.isAbsolute(allowlisted) || allowlisted.split(/[/\\]/).includes("..")) {
14
+ throw new Error(`Fix target path is invalid: ${allowlisted}`);
15
+ }
16
+ const rootReal = await fs.realpath(root);
17
+ const absolutePath = path.resolve(rootReal, allowlisted);
18
+ const relativeToRoot = path.relative(rootReal, absolutePath);
19
+ if (relativeToRoot.startsWith("..") ||
20
+ path.isAbsolute(relativeToRoot) ||
21
+ relativeToRoot.includes(`..${path.sep}`)) {
22
+ throw new Error(`Fix target escapes repository root: ${allowlisted}`);
23
+ }
24
+ await assertNoSymlinkAncestors(rootReal, absolutePath, allowlisted);
25
+ try {
26
+ const stat = await fs.lstat(absolutePath);
27
+ if (stat.isSymbolicLink()) {
28
+ throw new Error(`refusing to write through symlink Fix target: ${allowlisted} (replace the symlink with a regular file first)`);
29
+ }
30
+ if (stat.isDirectory()) {
31
+ throw new Error(`refusing Fix target that is a directory: ${allowlisted} (expected a regular file)`);
32
+ }
33
+ }
34
+ catch (error) {
35
+ const code = error && typeof error === "object" && "code" in error
36
+ ? String(error.code)
37
+ : "";
38
+ if (code !== "ENOENT") {
39
+ throw error;
40
+ }
41
+ }
42
+ return { absolutePath, relativePath: allowlisted };
43
+ }
44
+ /**
45
+ * Preflight every planned Fix target before any writer mutates the repo.
46
+ * Fails fast if any target is unsafe; does not create files.
47
+ */
48
+ export async function preflightSafeFixTargets(root, relativePaths) {
49
+ const unique = [...new Set(relativePaths)];
50
+ const resolved = [];
51
+ for (const relativePath of unique) {
52
+ const { relativePath: allowlisted } = await resolveSafeFixWritePath(root, relativePath);
53
+ resolved.push(allowlisted);
54
+ }
55
+ return resolved;
56
+ }
57
+ async function assertNoSymlinkAncestors(rootReal, absolutePath, label) {
58
+ let current = path.dirname(absolutePath);
59
+ while (current.startsWith(rootReal) && current !== rootReal) {
60
+ try {
61
+ const stat = await fs.lstat(current);
62
+ if (stat.isSymbolicLink()) {
63
+ throw new Error(`refusing Fix target under symlink directory: ${label} (ancestor ${path.relative(rootReal, current)} is a symlink)`);
64
+ }
65
+ }
66
+ catch (error) {
67
+ const code = error && typeof error === "object" && "code" in error
68
+ ? String(error.code)
69
+ : "";
70
+ if (code === "ENOENT") {
71
+ break;
72
+ }
73
+ throw error;
74
+ }
75
+ const parent = path.dirname(current);
76
+ if (parent === current) {
77
+ break;
78
+ }
79
+ current = parent;
80
+ }
81
+ }
@@ -1,7 +1,7 @@
1
1
  import type { AgentId } from "../../types/index.js";
2
2
  export type FixKind = "append-ignore-pattern";
3
3
  /** Allowlisted relative paths fix may create or modify. */
4
- export declare const FIX_PATH_ALLOWLIST: readonly [".cursorignore", ".claude/settings.json", ".codex/config.toml"];
4
+ export declare const FIX_PATH_ALLOWLIST: readonly [".cursorignore", ".claude/settings.json", ".codex/config.toml", ".geminiignore", ".aiderignore"];
5
5
  export type FixAllowlistedPath = (typeof FIX_PATH_ALLOWLIST)[number];
6
6
  export interface FixAction {
7
7
  id: string;
@@ -31,4 +31,15 @@ export interface FixApplyResult {
31
31
  writtenFiles: string[];
32
32
  /** Relative paths that would be / were written */
33
33
  changedFiles: string[];
34
+ /**
35
+ * Present when apply stopped after one or more successful writes.
36
+ * Safe Fix is not transactional across multiple targets.
37
+ */
38
+ partial?: boolean;
39
+ /** Relative path that failed when `partial` is true, or preflight failed. */
40
+ failedTarget?: string;
41
+ /** Human-readable failure reason. */
42
+ error?: string;
43
+ /** Audit / backup id created before apply (when writes were attempted). */
44
+ auditId?: string;
34
45
  }
@@ -3,4 +3,6 @@ export const FIX_PATH_ALLOWLIST = [
3
3
  ".cursorignore",
4
4
  ".claude/settings.json",
5
5
  ".codex/config.toml",
6
+ ".geminiignore",
7
+ ".aiderignore",
6
8
  ];
@@ -1,6 +1,7 @@
1
1
  import path from "node:path";
2
2
  import { atomicWriteTextFile, readTextFile } from "../../../utils/fs.js";
3
3
  import { claudeReadDenyRule } from "../../rules/claude-deny.js";
4
+ import { resolveSafeFixWritePath } from "../safe-target.js";
4
5
  const MAX_BYTES = 512 * 1024;
5
6
  const SETTINGS_RELATIVE = ".claude/settings.json";
6
7
  export function denyRuleForFixPattern(pattern, evidencePath) {
@@ -70,7 +71,8 @@ export async function readClaudeSettings(root) {
70
71
  return readTextFile(path.join(root, SETTINGS_RELATIVE), MAX_BYTES);
71
72
  }
72
73
  export async function writeClaudeSettings(root, content) {
73
- await atomicWriteTextFile(path.join(root, SETTINGS_RELATIVE), content);
74
+ const { absolutePath } = await resolveSafeFixWritePath(root, SETTINGS_RELATIVE);
75
+ await atomicWriteTextFile(absolutePath, content);
74
76
  }
75
77
  function parseDenyList(currentContent) {
76
78
  if (!currentContent || currentContent.trim().length === 0) {
@@ -19,4 +19,7 @@ export declare function previewCodexConfigActions(currentContent: string | null,
19
19
  export declare function readCodexConfig(root: string): Promise<string | null>;
20
20
  export declare function writeCodexConfig(root: string, content: string): Promise<void>;
21
21
  export declare function assertWritableCodexConfig(content: string): void;
22
+ /** Escape a filesystem deny key for a TOML basic string assignment. */
23
+ export declare function formatTomlDenyAssignment(key: string): string;
24
+ export declare function assertSafeCodexDenyKey(key: string): void;
22
25
  export {};
@@ -1,6 +1,7 @@
1
1
  import path from "node:path";
2
2
  import { atomicWriteTextFile, readTextFile } from "../../../utils/fs.js";
3
3
  import { codexDenyKey, configTextDeniesPath } from "../../rules/codex-deny.js";
4
+ import { resolveSafeFixWritePath } from "../safe-target.js";
4
5
  const MAX_BYTES = 512 * 1024;
5
6
  const CONFIG_RELATIVE = ".codex/config.toml";
6
7
  const AGENTDOCTOR_PROFILE = "agentdoctor_context";
@@ -55,7 +56,7 @@ export function buildCodexConfigContent(currentContent, denyKeysToAdd) {
55
56
  next += `"." = "write"\n`;
56
57
  }
57
58
  for (const key of denyKeysToAdd) {
58
- next += `"${key}" = "deny"\n`;
59
+ next += `${formatTomlDenyAssignment(key)}\n`;
59
60
  }
60
61
  if (!next.includes(END_MARKER)) {
61
62
  next += `${END_MARKER}\n`;
@@ -68,7 +69,7 @@ export function buildCodexConfigContent(currentContent, denyKeysToAdd) {
68
69
  if (configTextDeniesPath(body, key) || configTextDeniesPath(next, key)) {
69
70
  continue;
70
71
  }
71
- linesToInsert.push(`"${key}" = "deny"`);
72
+ linesToInsert.push(formatTomlDenyAssignment(key).trimEnd());
72
73
  }
73
74
  if (linesToInsert.length === 0) {
74
75
  return next;
@@ -105,7 +106,8 @@ export async function readCodexConfig(root) {
105
106
  return readTextFile(path.join(root, CONFIG_RELATIVE), MAX_BYTES);
106
107
  }
107
108
  export async function writeCodexConfig(root, content) {
108
- await atomicWriteTextFile(path.join(root, CONFIG_RELATIVE), content);
109
+ const { absolutePath } = await resolveSafeFixWritePath(root, CONFIG_RELATIVE);
110
+ await atomicWriteTextFile(absolutePath, content);
109
111
  }
110
112
  export function assertWritableCodexConfig(content) {
111
113
  if (/^\s*sandbox_mode\s*=/m.test(content)) {
@@ -171,6 +173,25 @@ function ensureTrailingNewline(value) {
171
173
  }
172
174
  return `${value}\n`;
173
175
  }
176
+ /** Escape a filesystem deny key for a TOML basic string assignment. */
177
+ export function formatTomlDenyAssignment(key) {
178
+ assertSafeCodexDenyKey(key);
179
+ return `"${escapeTomlBasicString(key)}" = "deny"`;
180
+ }
181
+ export function assertSafeCodexDenyKey(key) {
182
+ if (key.length === 0) {
183
+ throw new Error("refusing empty Codex deny key");
184
+ }
185
+ for (let i = 0; i < key.length; i++) {
186
+ const code = key.charCodeAt(i);
187
+ if (code < 32) {
188
+ throw new Error("refusing Codex deny key with control characters");
189
+ }
190
+ }
191
+ }
192
+ function escapeTomlBasicString(value) {
193
+ return value.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
194
+ }
174
195
  function formatSimpleDiff(fileLabel, before, after) {
175
196
  const lines = [];
176
197
  lines.push(`--- a/${fileLabel}`);
@@ -1,5 +1,6 @@
1
1
  import path from "node:path";
2
2
  import { atomicWriteTextFile, readTextFile } from "../../../utils/fs.js";
3
+ import { resolveSafeFixWritePath } from "../safe-target.js";
3
4
  import { missingPatternsForCursorignore } from "../plan.js";
4
5
  const MAX_BYTES = 512 * 1024;
5
6
  const BANNER = "# Added by AgentDoctor";
@@ -40,7 +41,8 @@ export async function readCursorignore(root) {
40
41
  return readTextFile(path.join(root, ".cursorignore"), MAX_BYTES);
41
42
  }
42
43
  export async function writeCursorignore(root, content) {
43
- await atomicWriteTextFile(path.join(root, ".cursorignore"), content);
44
+ const { absolutePath } = await resolveSafeFixWritePath(root, ".cursorignore");
45
+ await atomicWriteTextFile(absolutePath, content);
44
46
  }
45
47
  function formatSimpleDiff(fileLabel, before, after) {
46
48
  const beforeLines = before.length === 0 ? [] : before.split(/\r?\n/);
@@ -0,0 +1,16 @@
1
+ import type { AgentId } from "../../../types/index.js";
2
+ import type { FixAction, FixAllowlistedPath } from "../types.js";
3
+ export interface SimpleIgnoreWritePreview {
4
+ targetRelativePath: FixAllowlistedPath;
5
+ patternsToAdd: string[];
6
+ before: string;
7
+ after: string;
8
+ preview: string;
9
+ }
10
+ export declare function buildSimpleIgnoreContent(currentContent: string | null, patternsToAdd: string[]): string;
11
+ export declare function previewSimpleIgnoreActions(currentContent: string | null, actions: FixAction[], options: {
12
+ agent: AgentId;
13
+ targetRelativePath: FixAllowlistedPath;
14
+ }): SimpleIgnoreWritePreview | null;
15
+ export declare function readSimpleIgnore(root: string, relativePath: string): Promise<string | null>;
16
+ export declare function writeSimpleIgnore(root: string, relativePath: string, content: string): Promise<void>;
@@ -0,0 +1,68 @@
1
+ import path from "node:path";
2
+ import { atomicWriteTextFile, readTextFile } from "../../../utils/fs.js";
3
+ import { missingPatternsForIgnoreFile } from "../plan.js";
4
+ import { resolveSafeFixWritePath } from "../safe-target.js";
5
+ const MAX_BYTES = 512 * 1024;
6
+ const BANNER = "# Added by AgentDoctor";
7
+ export function buildSimpleIgnoreContent(currentContent, patternsToAdd) {
8
+ if (patternsToAdd.length === 0) {
9
+ return currentContent ?? "";
10
+ }
11
+ const base = currentContent ?? "";
12
+ const trimmed = base.replace(/\s+$/, "");
13
+ const block = [BANNER, ...patternsToAdd].join("\n");
14
+ if (trimmed.length === 0) {
15
+ return `${block}\n`;
16
+ }
17
+ return `${trimmed}\n\n${block}\n`;
18
+ }
19
+ export function previewSimpleIgnoreActions(currentContent, actions, options) {
20
+ const matched = actions.filter((a) => a.agent === options.agent && a.targetRelativePath === options.targetRelativePath);
21
+ if (matched.length === 0) {
22
+ return null;
23
+ }
24
+ const requested = [...new Set(matched.map((a) => a.pattern))];
25
+ const patternsToAdd = missingPatternsForIgnoreFile(currentContent, requested);
26
+ if (patternsToAdd.length === 0) {
27
+ return null;
28
+ }
29
+ const before = currentContent ?? "";
30
+ const after = buildSimpleIgnoreContent(currentContent, patternsToAdd);
31
+ const preview = formatSimpleDiff(options.targetRelativePath, before, after);
32
+ return {
33
+ targetRelativePath: options.targetRelativePath,
34
+ patternsToAdd,
35
+ before,
36
+ after,
37
+ preview,
38
+ };
39
+ }
40
+ export async function readSimpleIgnore(root, relativePath) {
41
+ return readTextFile(path.join(root, relativePath), MAX_BYTES);
42
+ }
43
+ export async function writeSimpleIgnore(root, relativePath, content) {
44
+ const { absolutePath } = await resolveSafeFixWritePath(root, relativePath);
45
+ await atomicWriteTextFile(absolutePath, content);
46
+ }
47
+ function formatSimpleDiff(fileLabel, before, after) {
48
+ const beforeLines = before.length === 0 ? [] : before.split(/\r?\n/);
49
+ const afterLines = after.split(/\r?\n/);
50
+ const lines = [];
51
+ lines.push(`--- a/${fileLabel}`);
52
+ lines.push(`+++ b/${fileLabel}`);
53
+ const addedStart = beforeLines.length === 0 ? 0 : beforeLines.length;
54
+ for (let i = addedStart; i < afterLines.length; i++) {
55
+ const line = afterLines[i];
56
+ if (line === undefined) {
57
+ continue;
58
+ }
59
+ lines.push(`+${line}`);
60
+ }
61
+ if (before.length === 0) {
62
+ lines.splice(2, 0, "@@ new file @@");
63
+ }
64
+ else {
65
+ lines.splice(2, 0, "@@ append @@");
66
+ }
67
+ return lines.join("\n");
68
+ }
@@ -0,0 +1,13 @@
1
+ export interface WorkspacePackage {
2
+ name: string;
3
+ relativePath: string;
4
+ absolutePath: string;
5
+ }
6
+ export interface MonorepoDetection {
7
+ root: string;
8
+ isMonorepo: boolean;
9
+ tool: "npm-workspaces" | "pnpm-workspaces" | "none";
10
+ packages: WorkspacePackage[];
11
+ limitations: string[];
12
+ }
13
+ export declare function detectMonorepo(rootInput: string): Promise<MonorepoDetection>;