@dev-tren/mapd 0.21.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 (69) hide show
  1. package/LICENSE +21 -0
  2. package/MASTER_PROMPT.md +134 -0
  3. package/README.md +494 -0
  4. package/SETUP.md +108 -0
  5. package/UAT.md +77 -0
  6. package/package.json +56 -0
  7. package/src/adapters/github-app.js +79 -0
  8. package/src/agents/anthropicClient.js +18 -0
  9. package/src/agents/llm.js +196 -0
  10. package/src/agents/modelResolver.js +87 -0
  11. package/src/agents/provider.js +222 -0
  12. package/src/chat/commandRunner.js +86 -0
  13. package/src/chat/commands.js +275 -0
  14. package/src/chat/intent.js +87 -0
  15. package/src/chat/llmIntent.js +118 -0
  16. package/src/chat/repl.js +471 -0
  17. package/src/cli.js +1408 -0
  18. package/src/config/index.js +197 -0
  19. package/src/config/schema.js +119 -0
  20. package/src/core/assist.js +64 -0
  21. package/src/core/audit.js +63 -0
  22. package/src/core/changes.js +110 -0
  23. package/src/core/confidence.js +0 -0
  24. package/src/core/configLint.js +141 -0
  25. package/src/core/diagnose.js +262 -0
  26. package/src/core/docs.js +140 -0
  27. package/src/core/doctor.js +134 -0
  28. package/src/core/envFiles.js +43 -0
  29. package/src/core/events.js +53 -0
  30. package/src/core/evidence.js +212 -0
  31. package/src/core/findingScoring.js +20 -0
  32. package/src/core/fix.js +192 -0
  33. package/src/core/fixApply.js +172 -0
  34. package/src/core/frameworkEntries.js +247 -0
  35. package/src/core/gates.js +209 -0
  36. package/src/core/graph.js +467 -0
  37. package/src/core/grounding.js +235 -0
  38. package/src/core/handoff.js +157 -0
  39. package/src/core/importResolver.js +218 -0
  40. package/src/core/improve.js +226 -0
  41. package/src/core/integrate.js +169 -0
  42. package/src/core/intelligence.js +212 -0
  43. package/src/core/modernize.js +370 -0
  44. package/src/core/parseCache.js +64 -0
  45. package/src/core/parser.js +536 -0
  46. package/src/core/policy.js +65 -0
  47. package/src/core/polyglot.js +333 -0
  48. package/src/core/proc.js +25 -0
  49. package/src/core/reachability.js +543 -0
  50. package/src/core/regression.js +193 -0
  51. package/src/core/resolution.js +92 -0
  52. package/src/core/retry.js +61 -0
  53. package/src/core/review.js +219 -0
  54. package/src/core/score.js +338 -0
  55. package/src/core/security.js +0 -0
  56. package/src/core/session.js +143 -0
  57. package/src/core/solutions.js +254 -0
  58. package/src/core/staleness.js +45 -0
  59. package/src/core/testGuidance.js +226 -0
  60. package/src/core/theme.js +50 -0
  61. package/src/core/trace.js +151 -0
  62. package/src/core/verify.js +123 -0
  63. package/src/core/view.js +221 -0
  64. package/src/core/viewServer.js +88 -0
  65. package/src/core/watch.js +76 -0
  66. package/src/core/workspace.js +115 -0
  67. package/src/mcp/server.js +48 -0
  68. package/src/mcp/tools.js +423 -0
  69. package/src/server.js +84 -0
@@ -0,0 +1,43 @@
1
+ /**
2
+ * envFiles.js — .env loading and inspection, project-level and user-level.
3
+ *
4
+ * Precedence: a project's own `.env` (current working directory) always wins;
5
+ * a user-level `~/.env` fills in anything the project didn't set. This means
6
+ * a key configured once in your home directory works in every project you
7
+ * run mapd from, while any project can still override it with its own `.env`
8
+ * (e.g. a different model, or a project-specific key). Node's built-in
9
+ * `process.loadEnvFile` never overwrites a variable that's already set, which
10
+ * is what makes this precedence chain work without extra bookkeeping.
11
+ */
12
+
13
+ import fs from "node:fs";
14
+ import path from "node:path";
15
+ import os from "node:os";
16
+
17
+ export const RECOGNIZED_ENV_KEYS = [
18
+ "ANTHROPIC_API_KEY", "OPENAI_API_KEY", "OPENAI_BASE_URL",
19
+ "KIMI_API_KEY", "KIMI_BASE_URL", "MAPD_MODEL", "MAPD_WEBHOOK_SECRET",
20
+ ];
21
+
22
+ /** Loads .env files in precedence order. Call once, as early as possible. Never throws. */
23
+ export function loadEnvFiles() {
24
+ try { process.loadEnvFile(); } catch { /* no project .env in cwd — fine, optional */ }
25
+ try { process.loadEnvFile(path.join(os.homedir(), ".env")); } catch { /* no user-level ~/.env — fine, optional */ }
26
+ }
27
+
28
+ function scanEnvFile(envPath) {
29
+ if (!fs.existsSync(envPath)) return { present: false, keys: [] };
30
+ let content;
31
+ try { content = fs.readFileSync(envPath, "utf8"); } catch { return { present: true, keys: [] }; }
32
+ const keys = RECOGNIZED_ENV_KEYS.filter((k) => new RegExp(`^\\s*${k}\\s*=`, "m").test(content));
33
+ return { present: true, keys };
34
+ }
35
+
36
+ /** Reports on both the project-level and user-level .env — never reads or prints values. */
37
+ export function checkEnvFiles(rootDir) {
38
+ const userPath = path.join(os.homedir(), ".env");
39
+ return {
40
+ project: { path: path.join(path.resolve(rootDir), ".env"), ...scanEnvFile(path.join(path.resolve(rootDir), ".env")) },
41
+ user: { path: userPath, ...scanEnvFile(userPath) },
42
+ };
43
+ }
@@ -0,0 +1,53 @@
1
+ /**
2
+ * events.js — structured watch events, shared by `mapd watch`'s console
3
+ * output and any in-process subscriber (chat's "watch this project and tell
4
+ * me when X regresses"). Built on node:events — zero new dependency.
5
+ */
6
+
7
+ import { EventEmitter } from "node:events";
8
+
9
+ export function createWatchBus() {
10
+ return new EventEmitter();
11
+ }
12
+
13
+ /**
14
+ * Turns one rescan's raw inputs into a structured event payload. Kept as a
15
+ * pure function (not baked into the emitter) so it's independently testable.
16
+ */
17
+ export function buildRescanEvent({ durationMs, parsed, prevGraph, newGraph, findings }) {
18
+ const newOrphans = newGraph.orphans.filter((o) => !prevGraph.orphans.includes(o));
19
+ const resolvedOrphans = prevGraph.orphans.filter((o) => !newGraph.orphans.includes(o));
20
+ const deltaConfidence = Number((newGraph.repoConfidence - prevGraph.repoConfidence).toFixed(3));
21
+
22
+ const prevWfById = new Map(prevGraph.workflows.map((w) => [w.id, w]));
23
+ const affectedWorkflows = newGraph.workflows
24
+ .filter((wf) => {
25
+ const prev = prevWfById.get(wf.id);
26
+ return !prev || prev.confidence.score !== wf.confidence.score;
27
+ })
28
+ .map((wf) => ({ id: wf.id, confidence: wf.confidence.score, previousConfidence: prevWfById.get(wf.id)?.confidence.score ?? null }));
29
+
30
+ const newFindings = findings.filter((f) => f.kind !== "workflow-added");
31
+ const resolvedFindings = findings.filter((f) => f.kind === "workflow-added");
32
+
33
+ return {
34
+ type: "remap",
35
+ at: new Date().toISOString(),
36
+ durationMs,
37
+ reparsedCount: parsed.parsedCount,
38
+ cacheHits: parsed.cacheHits,
39
+ repoConfidence: { from: prevGraph.repoConfidence, to: newGraph.repoConfidence, delta: deltaConfidence },
40
+ affectedWorkflows,
41
+ newFindings,
42
+ resolvedFindings,
43
+ newOrphans,
44
+ resolvedOrphans,
45
+ };
46
+ }
47
+
48
+ /** Emits the structured event plus one per-finding event (`regression` for high severity, `finding` otherwise). */
49
+ export function emitRescanEvent(bus, payload) {
50
+ bus.emit("remap", payload);
51
+ for (const f of payload.newFindings) bus.emit(f.severity === "high" ? "regression" : "finding", f);
52
+ for (const f of payload.resolvedFindings) bus.emit("resolved", f);
53
+ }
@@ -0,0 +1,212 @@
1
+ /**
2
+ * evidence.js — one deterministic evidence view per review-queue item
3
+ * (`mapd evidence <id>`, chat /evidence, MCP get_finding_evidence).
4
+ *
5
+ * The master prompt requires that for every meaningful claim Map'd can show
6
+ * the file paths, workflows, gate results, and config facts that support it.
7
+ * Findings already CARRY that data scattered across reports, proposals, the
8
+ * graph, and .mapdrc annotations — this module assembles it into a single
9
+ * view. Everything here is read straight from those deterministic sources;
10
+ * nothing is inferred, and anything unknown is reported as absent, not
11
+ * guessed at.
12
+ */
13
+
14
+ import fs from "node:fs";
15
+ import path from "node:path";
16
+ import { loadQueue, stateOf } from "./review.js";
17
+ import { checkReportFreshness } from "./staleness.js";
18
+ import { buildScoredGraph } from "./intelligence.js";
19
+ import { priorityOf, filesOf } from "./findingScoring.js";
20
+ import { loadConfig } from "../config/index.js";
21
+ import { applyAnnotations } from "./reachability.js";
22
+
23
+ function readJson(p) {
24
+ try { return JSON.parse(fs.readFileSync(p, "utf8")); } catch { return null; }
25
+ }
26
+
27
+ /** How the graph classifies one file's reachability — verified data only. */
28
+ function classifyFile(graph, relFile) {
29
+ const node = graph.files.find((f) => f.file === relFile) ?? null;
30
+ const workflows = graph.workflows.filter((w) => w.files.includes(relFile)).map((w) => w.id);
31
+ const generated = graph.generatedFiles?.find((g) => g.file === relFile) ?? null;
32
+ const dynamic = graph.reachability?.dynamicallyLoaded?.find((d) => d.file === relFile) ?? null;
33
+ const dormant = graph.reachability?.intentionalDormant?.find((d) => d.file === relFile) ?? null;
34
+ const heuristic = graph.reachability?.heuristicUnverified?.find((h) => h.file === relFile) ?? null;
35
+ const classification =
36
+ generated ? "generated"
37
+ : dormant ? "intentional-dormant"
38
+ : dynamic ? "dynamically-loaded"
39
+ : workflows.length ? "reachable"
40
+ : heuristic ? "heuristic-unverified"
41
+ : graph.orphans?.includes(relFile) ? "orphan"
42
+ : "unclassified";
43
+ return {
44
+ file: relFile,
45
+ inMap: !!node,
46
+ parsed: node?.parsed ?? false,
47
+ parserKind: node?.parserKind ?? (node ? "ast" : null),
48
+ loc: node?.loc ?? null,
49
+ exports: node?.exports ?? [],
50
+ workflows,
51
+ classification,
52
+ classificationReason: generated?.reason ?? dormant?.evidence ?? dynamic?.evidence ?? heuristic?.reason ?? null,
53
+ };
54
+ }
55
+
56
+ /** Gate summary for one saved fix proposal — real recorded results, no re-run. */
57
+ function summarizeProposal(prop) {
58
+ if (!prop) return null;
59
+ return {
60
+ status: prop.status,
61
+ stopReason: prop.stopReason ?? null,
62
+ files: Object.keys(prop.filesPatch ?? {}),
63
+ attempts: (prop.attempts ?? []).map((a) => ({
64
+ attempt: a.attempt,
65
+ passed: a.passed,
66
+ failedGates: (a.gates ?? []).filter((g) => !g.passed).map((g) => g.gate),
67
+ })),
68
+ changeIds: prop.changeIds ?? [],
69
+ postApplyVerification: prop.postApplyVerification
70
+ ? { ok: prop.postApplyVerification.ok, issues: prop.postApplyVerification.issues }
71
+ : null,
72
+ };
73
+ }
74
+
75
+ /**
76
+ * Assemble the full evidence view for one review-queue item. Returns null if
77
+ * the ID isn't in the queue. `graph` may be passed in to reuse an
78
+ * already-built scored graph (chat/MCP); otherwise one is built.
79
+ */
80
+ export function buildFindingEvidence(rootDir, id, { graph } = {}) {
81
+ const abs = path.resolve(rootDir);
82
+ const item = loadQueue(abs).find((i) => i.id === id);
83
+ if (!item) return null;
84
+
85
+ const freshness = checkReportFreshness(abs);
86
+ const report = readJson(item.file);
87
+ const base = {
88
+ id: item.id,
89
+ source: item.source,
90
+ kind: item.kind,
91
+ severity: item.severity ?? null,
92
+ status: item.status,
93
+ state: stateOf(item, freshness.staleReports),
94
+ detail: item.detail,
95
+ report: {
96
+ path: item.file,
97
+ generatedAt: report?.generatedAt ?? null,
98
+ stale: freshness.staleReports.includes(path.basename(item.file)),
99
+ },
100
+ };
101
+
102
+ // fix proposal: the report file IS the proposal record with its gate history
103
+ if (item.source === "fix") {
104
+ return { ...base, evidenceType: "fix-proposal", proposal: summarizeProposal(report) };
105
+ }
106
+
107
+ // integration conflict: the report carries per-conflict AST evidence
108
+ if (item.source.startsWith("integrate:")) {
109
+ const conflict = report?.conflicts?.[item.index] ?? null;
110
+ return {
111
+ ...base,
112
+ evidenceType: "integration-conflict",
113
+ conflict: conflict && {
114
+ file: conflict.file,
115
+ classification: conflict.classification,
116
+ evidence: conflict.evidence ?? null,
117
+ proposalStatus: conflict.proposal?.status ?? null,
118
+ resolutionScore: conflict.proposal?.resolutionScore?.score ?? null,
119
+ },
120
+ };
121
+ }
122
+
123
+ // check / modernize finding: raw evidence + graph-backed file context
124
+ const finding = report?.findings?.[item.index] ?? null;
125
+ if (!finding) return { ...base, evidenceType: "finding", finding: null, files: [] };
126
+
127
+ const g = graph ?? buildScoredGraph(abs);
128
+ const config = loadConfig(abs);
129
+ const files = filesOf(finding).map((f) => classifyFile(g, f));
130
+ const annotations = applyAnnotations(new Set(files.map((f) => f.file)), config.project?.annotations ?? {});
131
+ const proposal = readJson(path.join(abs, ".mapd", "proposals", `${item.id}.json`));
132
+
133
+ return {
134
+ ...base,
135
+ evidenceType: "finding",
136
+ priority: Number(priorityOf(finding, item).toFixed(3)),
137
+ finding: {
138
+ detail: finding.detail,
139
+ suggestion: finding.suggestion ?? null,
140
+ rawEvidence: finding.evidence ?? null,
141
+ },
142
+ files,
143
+ userAnnotations: annotations, // user assertions from .mapdrc, labeled as such
144
+ proposal: summarizeProposal(proposal),
145
+ repoConfidence: g.repoConfidence,
146
+ callResolutionRate: g.stats.callResolutionRate,
147
+ };
148
+ }
149
+
150
+ /** Renders buildFindingEvidence's data as terminal text — data first, no walls. */
151
+ export function renderFindingEvidence(data) {
152
+ const lines = [];
153
+ lines.push(`${data.id} [${data.source}] ${data.kind}${data.severity ? ` (${data.severity})` : ""} state: ${data.state}`);
154
+ lines.push(` ${data.detail}`);
155
+ lines.push(` report: ${data.report.path}${data.report.generatedAt ? ` generated ${data.report.generatedAt}` : ""}${data.report.stale ? " ⚠ STALE — predates a newer source change; refresh before acting" : ""}`);
156
+
157
+ if (data.evidenceType === "fix-proposal" && data.proposal) {
158
+ lines.push(` proposal: ${data.proposal.status}${data.proposal.stopReason ? ` (stopped: ${data.proposal.stopReason})` : ""}`);
159
+ lines.push(` files: ${data.proposal.files.join(", ") || "(none)"}`);
160
+ for (const a of data.proposal.attempts) {
161
+ lines.push(` attempt ${a.attempt}: ${a.passed ? "PASSED all gates" : `failed (${a.failedGates.join(", ")})`}`);
162
+ }
163
+ if (data.proposal.postApplyVerification) {
164
+ const v = data.proposal.postApplyVerification;
165
+ lines.push(` post-apply verification: ${v.ok ? "passed" : `FAILED — ${v.issues.join("; ")}`}`);
166
+ }
167
+ return lines.join("\n");
168
+ }
169
+
170
+ if (data.evidenceType === "integration-conflict") {
171
+ if (data.conflict) {
172
+ lines.push(` conflict file: ${data.conflict.file} classification: ${data.conflict.classification}`);
173
+ if (data.conflict.evidence) lines.push(` evidence: ${JSON.stringify(data.conflict.evidence)}`);
174
+ if (data.conflict.proposalStatus) lines.push(` proposal: ${data.conflict.proposalStatus}${data.conflict.resolutionScore != null ? ` (resolution score ${data.conflict.resolutionScore})` : ""}`);
175
+ } else {
176
+ lines.push(" conflict no longer present in the report.");
177
+ }
178
+ return lines.join("\n");
179
+ }
180
+
181
+ if (!data.finding) {
182
+ lines.push(" finding no longer present in the report — re-run the source command to refresh.");
183
+ return lines.join("\n");
184
+ }
185
+
186
+ if (data.priority != null) lines.push(` priority: ${data.priority} repo confidence: ${data.repoConfidence} call resolution: ${(data.callResolutionRate * 100).toFixed(1)}%`);
187
+ if (data.finding.suggestion) lines.push(` suggestion: ${data.finding.suggestion}`);
188
+ if (data.finding.rawEvidence) lines.push(` raw evidence: ${JSON.stringify(data.finding.rawEvidence)}`);
189
+
190
+ if (data.files.length) {
191
+ lines.push(" files:");
192
+ for (const f of data.files) {
193
+ const wf = f.workflows.length ? `workflows: ${f.workflows.join(", ")}` : "no workflow membership";
194
+ const reason = typeof f.classificationReason === "string" ? ` (${f.classificationReason})` : "";
195
+ lines.push(` - ${f.file} [${f.classification}${reason}] ${wf}${f.loc != null ? ` ${f.loc} loc` : f.inMap ? "" : " (not in current map)"}`);
196
+ }
197
+ } else {
198
+ lines.push(" files: none recorded on this finding.");
199
+ }
200
+
201
+ if (data.userAnnotations.length) {
202
+ lines.push(" user annotations (asserted in .mapdrc, not auto-detected):");
203
+ for (const a of data.userAnnotations) lines.push(` - ${a.file} ← "${a.pattern}": "${a.classification}"`);
204
+ }
205
+
206
+ if (data.proposal) {
207
+ lines.push(` fix proposal: ${data.proposal.status}${data.proposal.stopReason ? ` (stopped: ${data.proposal.stopReason})` : ""} — ${data.proposal.attempts.length} attempt(s)`);
208
+ } else {
209
+ lines.push(" fix proposal: none yet — `mapd fix <id> --propose` to generate one.");
210
+ }
211
+ return lines.join("\n");
212
+ }
@@ -0,0 +1,20 @@
1
+ /**
2
+ * findingScoring.js — shared scoring helpers for anything that ranks queue
3
+ * findings (handoff.js, solutions.js). One severity/priority scale and one
4
+ * files-extraction rule, so the two never silently drift apart.
5
+ */
6
+
7
+ const SEVERITY_SCORE = { high: 1.0, medium: 0.6, low: 0.3, info: 0.1 };
8
+
9
+ /** Unifies "check" findings (severity: high/medium/low) and modernize findings (a 0-1 priority) onto one scale. */
10
+ export function priorityOf(finding, item) {
11
+ if (item.source === "check") return SEVERITY_SCORE[finding.severity] ?? 0;
12
+ return finding.operationalImpact?.priority ?? item.priority ?? 0;
13
+ }
14
+
15
+ /** Modernize findings carry `files`; check (regression) findings carry it nested under `evidence`. */
16
+ export function filesOf(finding) {
17
+ if (Array.isArray(finding.files)) return finding.files;
18
+ if (Array.isArray(finding.evidence?.files)) return finding.evidence.files;
19
+ return [];
20
+ }
@@ -0,0 +1,192 @@
1
+ /**
2
+ * fix.js — the full mapd fix lifecycle: load finding -> validate relevance ->
3
+ * gather context -> propose -> verify (isolated workspace + gates) -> retry
4
+ * with structured feedback -> save a verified proposal awaiting approval.
5
+ *
6
+ * Approval and real-tree application happen in review.js/changes.js — this
7
+ * module never writes to the real working tree.
8
+ */
9
+
10
+ import fs from "node:fs";
11
+ import path from "node:path";
12
+ import { loadQueue } from "./review.js";
13
+ import { buildScoredGraph } from "./intelligence.js";
14
+ import { loadBaseline, diffGraphs } from "./regression.js";
15
+ import { loadPkg } from "./graph.js";
16
+ import { runFixGates } from "./gates.js";
17
+ import { runWithRetry } from "./retry.js";
18
+ import { createIsolatedWorkspace, applyPatchInWorkspace } from "./workspace.js";
19
+ import { proposeFix } from "../agents/llm.js";
20
+ import { getProvider } from "../agents/provider.js";
21
+
22
+ const MAPD = ".mapd";
23
+
24
+ function readJson(p) {
25
+ try { return JSON.parse(fs.readFileSync(p, "utf8")); } catch { return null; }
26
+ }
27
+
28
+ function extractWorkflowIdFromDetail(detail) {
29
+ const m = /Workflow (\S+)/.exec(detail ?? "");
30
+ return m ? m[1] : null;
31
+ }
32
+
33
+ /** Locate a finding (check or modernize sourced) by its review-queue ID. */
34
+ export function loadFinding(rootDir, id) {
35
+ const abs = path.resolve(rootDir);
36
+ const item = loadQueue(abs).find((i) => i.id === id);
37
+ if (!item) return null;
38
+ if (item.source === "fix" || item.source.startsWith("integrate:")) return null; // fix targets findings, not existing proposals
39
+ const rep = readJson(item.file);
40
+ const finding = rep?.findings?.[item.index];
41
+ if (!finding) return null;
42
+ return { item, finding, reportPath: item.file };
43
+ }
44
+
45
+ /** Deterministic file candidates for a finding: direct evidence, then workflow lookup. */
46
+ export function candidateFilesForFinding(finding, graph) {
47
+ if (Array.isArray(finding.files) && finding.files.length) return finding.files;
48
+ if (Array.isArray(finding.evidence?.files) && finding.evidence.files.length) return finding.evidence.files;
49
+ const wfId = extractWorkflowIdFromDetail(finding.detail);
50
+ if (wfId) {
51
+ const wf = graph.workflows.find((w) => w.id === wfId);
52
+ if (wf) return wf.files.slice(0, 5);
53
+ }
54
+ return [];
55
+ }
56
+
57
+ export function gatherRelevantSources(rootDir, finding, graph, { maxFiles = 3, maxCharsPerFile = 8000 } = {}) {
58
+ const abs = path.resolve(rootDir);
59
+ const files = candidateFilesForFinding(finding, graph).slice(0, maxFiles);
60
+ return files.map((file) => {
61
+ try { return { file, source: fs.readFileSync(path.join(abs, file), "utf8").slice(0, maxCharsPerFile) }; }
62
+ catch { return { file, source: null }; }
63
+ });
64
+ }
65
+
66
+ /**
67
+ * Best-effort relevance check: for check-sourced (regression) findings, does
68
+ * the same kind+detail still appear when diffing the current baseline against
69
+ * a fresh map? Modernize findings are not re-scanned here (would require a
70
+ * full re-run of the scan tier) — assumed still relevant, documented as such.
71
+ */
72
+ export function isFindingStillRelevant(rootDir, item, finding, currentGraph) {
73
+ if (item.source === "check") {
74
+ const loaded = loadBaseline(rootDir);
75
+ if (!loaded || loaded.schemaMismatch) {
76
+ return { relevant: true, reason: "no usable baseline to re-verify against — proceeding" };
77
+ }
78
+ const stillThere = diffGraphs(loaded.graph, currentGraph)
79
+ .some((f) => f.kind === finding.kind && f.detail === finding.detail);
80
+ return {
81
+ relevant: stillThere,
82
+ reason: stillThere ? "finding reproduces against the current baseline diff" : "finding no longer reproduces",
83
+ };
84
+ }
85
+ return { relevant: true, reason: "modernize findings are not re-scanned for relevance (best-effort)" };
86
+ }
87
+
88
+ function proposalsDir(rootDir) { return path.join(path.resolve(rootDir), MAPD, "proposals"); }
89
+
90
+ export function saveProposal(rootDir, proposalRecord) {
91
+ const dir = proposalsDir(rootDir);
92
+ fs.mkdirSync(dir, { recursive: true });
93
+ const file = path.join(dir, `${proposalRecord.findingId}.json`);
94
+ fs.writeFileSync(file, JSON.stringify(proposalRecord, null, 2));
95
+ return file;
96
+ }
97
+
98
+ /**
99
+ * Full lifecycle. Returns `{ ok, reason }` on early stop, or
100
+ * `{ ok: true, proposalRecord, proposalPath, attempts, stopReason }` once a
101
+ * proposal (verified or gate-exhausted) has been saved.
102
+ *
103
+ * Never fabricates a proposal without a configured provider — mirrors the
104
+ * existing `--propose requires ANTHROPIC_API_KEY` behavior used everywhere
105
+ * else in this codebase.
106
+ */
107
+ export async function runFixLifecycle(rootDir, id, config = {}, opts = {}) {
108
+ const abs = path.resolve(rootDir);
109
+ const loaded = loadFinding(abs, id);
110
+ if (!loaded) return { ok: false, reason: `no fixable finding with id ${id}` };
111
+ const { item, finding } = loaded;
112
+
113
+ const preFixGraph = buildScoredGraph(abs);
114
+ const relevance = isFindingStillRelevant(abs, item, finding, preFixGraph);
115
+ if (!relevance.relevant) {
116
+ return { ok: false, reason: `finding no longer reproducible: ${relevance.reason}` };
117
+ }
118
+
119
+ const provider = getProvider(config);
120
+ if (!provider.available()) {
121
+ return {
122
+ ok: false,
123
+ reason: "mapd fix --propose requires a configured LLM provider (ANTHROPIC_API_KEY, OPENAI_API_KEY, or KIMI_API_KEY — in .env or ~/.env); no fix was fabricated.",
124
+ };
125
+ }
126
+
127
+ const sources = gatherRelevantSources(abs, finding, preFixGraph);
128
+ const baselineLoaded = loadBaseline(abs);
129
+ const baseline = baselineLoaded && !baselineLoaded.schemaMismatch ? baselineLoaded.graph : null;
130
+ const pkg = loadPkg(abs);
131
+ const maxAttempts = opts.maxAttempts ?? config.fix?.maxAttempts ?? 2;
132
+ const forbiddenPaths = config.fix?.forbiddenPaths ?? [];
133
+
134
+ const workspaces = [];
135
+ const retryResult = await runWithRetry({
136
+ maxAttempts,
137
+ attemptFn: async ({ feedback }) => {
138
+ const proposal = await proposeFix(finding, sources, feedback, { provider });
139
+ const ws = createIsolatedWorkspace(abs);
140
+ workspaces.push(ws);
141
+
142
+ if (!proposal || !proposal.patch || Object.keys(proposal.patch).length === 0) {
143
+ return {
144
+ gates: [{ gate: "FIX-G1-patch-safety", passed: false, issues: [proposal?.reasoning_summary || "no patch produced"] }],
145
+ proposal, ws,
146
+ };
147
+ }
148
+ for (const [relFile, newSource] of Object.entries(proposal.patch)) {
149
+ applyPatchInWorkspace(ws, relFile, newSource);
150
+ }
151
+ const gates = runFixGates({
152
+ ws, patch: proposal.patch, forbiddenPaths,
153
+ maxFileSizeBytes: config.mapping?.maxFileSizeBytes,
154
+ pkg, cwd: ws.dir,
155
+ runTests: config.fix?.runTests, runLint: config.fix?.runLint, runTypecheck: config.fix?.runTypecheck,
156
+ preFixGraph, baseline, targetFinding: finding,
157
+ });
158
+ return { gates, proposal, ws };
159
+ },
160
+ });
161
+
162
+ const winningWs = retryResult.finalAttempt.result.ws;
163
+ const winningProposal = retryResult.finalAttempt.result.proposal;
164
+ for (const ws of workspaces) if (ws !== winningWs) ws.cleanup();
165
+
166
+ const proposalRecord = {
167
+ mapdSchema: 1,
168
+ findingId: id,
169
+ findingKind: finding.kind ?? finding.rule,
170
+ findingDetail: finding.detail,
171
+ filesPatch: winningProposal?.patch ?? {},
172
+ summary: winningProposal?.summary ?? "",
173
+ reasoning_summary: winningProposal?.reasoning_summary ?? "",
174
+ expected_effect: winningProposal?.expected_effect ?? "",
175
+ risks: winningProposal?.risks ?? [],
176
+ verification_plan: winningProposal?.verification_plan ?? [],
177
+ attempts: retryResult.attempts.map((a) => ({ attempt: a.attempt, passed: a.passed, gates: a.gates })),
178
+ stopReason: retryResult.stopReason,
179
+ status: retryResult.success ? "awaiting-approval" : "rejected-by-gate",
180
+ generatedBy: winningProposal?.generatedBy ?? null,
181
+ createdAt: new Date().toISOString(),
182
+ };
183
+
184
+ winningWs.cleanup();
185
+
186
+ if (opts.dryRun) {
187
+ return { ok: true, dryRun: true, proposalRecord, attempts: retryResult.attempts, stopReason: retryResult.stopReason };
188
+ }
189
+
190
+ const proposalPath = saveProposal(abs, proposalRecord);
191
+ return { ok: true, proposalRecord, proposalPath, attempts: retryResult.attempts, stopReason: retryResult.stopReason };
192
+ }