@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,193 @@
1
+ /**
2
+ * regression.js — Baseline diffing.
3
+ *
4
+ * `mapd baseline` snapshots the scored graph into .mapd/baseline.json.
5
+ * `mapd check` re-maps and diffs. Findings are deterministic facts about the
6
+ * graph delta; severity is rule-derived. The optional LLM layer may *explain*
7
+ * a finding or *propose* a fix, but it may not create, suppress, or rescore one.
8
+ *
9
+ * Approval flow: findings are written to .mapd/findings.json with
10
+ * status "awaiting-approval". Nothing is ever auto-fixed.
11
+ */
12
+
13
+ import fs from "node:fs";
14
+ import path from "node:path";
15
+
16
+ const MAPD_DIR = ".mapd";
17
+
18
+ /** Bump when the graph shape changes in a way that invalidates old baselines. */
19
+ export const BASELINE_SCHEMA = 4; // 3 = honest testPresence; 4 = per-workflow resolutionRate + repo-level coverageOfRepo — scores shift, old baselines aren't comparable
20
+
21
+ export function baselinePath(rootDir) {
22
+ return path.join(rootDir, MAPD_DIR, "baseline.json");
23
+ }
24
+
25
+ export function saveBaseline(rootDir, graph) {
26
+ const dir = path.join(rootDir, MAPD_DIR);
27
+ fs.mkdirSync(dir, { recursive: true });
28
+ fs.writeFileSync(baselinePath(rootDir), JSON.stringify({ ...graph, mapdSchema: BASELINE_SCHEMA }, null, 2));
29
+ return baselinePath(rootDir);
30
+ }
31
+
32
+ /** Returns { graph, schemaMismatch } — a mismatch means re-baseline, not silent diffing. */
33
+ export function loadBaseline(rootDir) {
34
+ try {
35
+ const graph = JSON.parse(fs.readFileSync(baselinePath(rootDir), "utf8"));
36
+ return { graph, schemaMismatch: graph.mapdSchema !== BASELINE_SCHEMA ? { found: graph.mapdSchema ?? 1, expected: BASELINE_SCHEMA } : null };
37
+ } catch {
38
+ return null;
39
+ }
40
+ }
41
+
42
+ const fnKey = (f) => f; // "file#name" ids already unique
43
+
44
+ export function diffGraphs(baseline, current) {
45
+ const findings = [];
46
+ const add = (severity, kind, detail, evidence) =>
47
+ findings.push({ severity, kind, detail, evidence, status: "awaiting-approval" });
48
+
49
+ const baseWf = new Map(baseline.workflows.map((w) => [w.id, w]));
50
+ const curWf = new Map(current.workflows.map((w) => [w.id, w]));
51
+
52
+ // 1. Removed / added workflows
53
+ for (const [id, w] of baseWf) {
54
+ if (!curWf.has(id)) add("high", "workflow-removed",
55
+ `Workflow ${id} (entry: ${w.entry.file}) no longer exists.`,
56
+ { baselineFiles: w.files.length, workflow: id });
57
+ }
58
+ for (const [id, w] of curWf) {
59
+ if (!baseWf.has(id)) add("info", "workflow-added",
60
+ `New workflow ${id} detected (entry: ${w.entry.file}).`,
61
+ { files: w.files.length });
62
+ }
63
+
64
+ // 2. Confidence regressions (derived score dropped materially)
65
+ for (const [id, cw] of curWf) {
66
+ const bw = baseWf.get(id);
67
+ if (!bw?.confidence || !cw.confidence) continue;
68
+ const drop = bw.confidence.score - cw.confidence.score;
69
+ if (drop >= 0.1) {
70
+ // find which signals degraded — evidence, not narrative
71
+ const degraded = Object.entries(cw.confidence.signals)
72
+ .filter(([k, s]) => {
73
+ const b = bw.confidence.signals[k];
74
+ return b && s.value !== null && b.value !== null && b.value - s.value >= 0.05;
75
+ })
76
+ .map(([k, s]) => ({ signal: k, from: bw.confidence.signals[k].value, to: s.value }));
77
+ add(drop >= 0.25 ? "high" : "medium", "confidence-regression",
78
+ `Workflow ${id} confidence dropped ${bw.confidence.score} → ${cw.confidence.score}.`,
79
+ { degradedSignals: degraded, workflow: id });
80
+ }
81
+ }
82
+
83
+ // 3. Exported-surface breaks (removed exports = potential breaking change)
84
+ for (const [id, cw] of curWf) {
85
+ const bw = baseWf.get(id);
86
+ if (!bw) continue;
87
+ const removed = bw.exportedSurface.filter((e) => !cw.exportedSurface.includes(e));
88
+ if (!removed.length) continue;
89
+ // name the file(s) that exported them at baseline, so evidence/impact can point at real code
90
+ const gone = new Set(removed);
91
+ const definers = baseline.files
92
+ .filter((f) => bw.files.includes(f.file) && (f.exports ?? []).some((e) => gone.has(e)))
93
+ .map((f) => f.file);
94
+ add("high", "export-removed",
95
+ `Workflow ${id} removed exported symbols: ${removed.join(", ")}.`,
96
+ { removed, workflow: id, files: definers });
97
+ }
98
+
99
+ // 4. Call-resolution degradation (new dangling call edges)
100
+ const baseUnresolved = baseline.stats.totalCalls - baseline.stats.resolvedCalls;
101
+ const curUnresolved = current.stats.totalCalls - current.stats.resolvedCalls;
102
+ if (curUnresolved > baseUnresolved + 5 &&
103
+ current.stats.callResolutionRate < baseline.stats.callResolutionRate - 0.03) {
104
+ add("medium", "resolution-degradation",
105
+ `Unresolved call edges grew ${baseUnresolved} → ${curUnresolved}; resolution rate ` +
106
+ `${baseline.stats.callResolutionRate.toFixed(3)} → ${current.stats.callResolutionRate.toFixed(3)}.`,
107
+ { baseUnresolved, curUnresolved });
108
+ }
109
+
110
+ // 5. New orphans (files that fell out of every workflow)
111
+ const newOrphans = current.orphans.filter((f) => !baseline.orphans.includes(f));
112
+ if (newOrphans.length) add("low", "new-orphans",
113
+ `${newOrphans.length} file(s) are no longer reachable from any entry point.`,
114
+ { files: newOrphans });
115
+
116
+ // 6. New parse failures
117
+ const baseUnparsed = new Set(baseline.files.filter((f) => !f.parsed).map((f) => f.file));
118
+ const newUnparsed = current.files.filter((f) => !f.parsed && !baseUnparsed.has(f.file)).map((f) => f.file);
119
+ if (newUnparsed.length) add("high", "parse-failure",
120
+ `File(s) newly failing to parse: ${newUnparsed.join(", ")}.`, { files: newUnparsed });
121
+
122
+ return findings;
123
+ }
124
+
125
+ /**
126
+ * Merge a fresh scan's findings against a previous report's so the finding
127
+ * lifecycle closes itself instead of silently forgetting. Shared by `mapd
128
+ * check` (findings.json, kind+detail identity) and `mapd modernize`
129
+ * (modernize-<mode>.json, rule+detail identity):
130
+ *
131
+ * - a previously open (awaiting-approval/approved) finding that no longer
132
+ * reproduces is carried forward as status "resolved" with the re-scan as
133
+ * evidence — never just dropped;
134
+ * - a finding that still reproduces and was already dismissed KEEPS its
135
+ * dismissal (Map'd does not re-nag a human decision) — same for an existing
136
+ * resolved/applied record;
137
+ * - already-terminal entries (resolved/dismissed) are carried as audit trail.
138
+ *
139
+ * `key` is the content-identity function (must match what review.js hashes
140
+ * into queue IDs for that report type). Returns { findings, resolvedNow }.
141
+ */
142
+ export function mergeFindingLifecycle(newFindings, prevFindings, key, resolvedBy) {
143
+ const prevByKey = new Map((prevFindings ?? []).map((f) => [key(f), f]));
144
+ const newKeys = new Set(newFindings.map(key));
145
+
146
+ const merged = newFindings.map((f) => {
147
+ const old = prevByKey.get(key(f));
148
+ if (old && old.status && old.status !== "awaiting-approval") {
149
+ // still reproduces, but a human (or an applied fix) already decided —
150
+ // preserve that decision and its stamp instead of resurrecting the item
151
+ const carried = { ...f, status: old.status };
152
+ for (const k of ["dismissed", "approved", "resolved", "applied"]) if (old[k]) carried[k] = old[k];
153
+ return carried;
154
+ }
155
+ return f;
156
+ });
157
+
158
+ let resolvedNow = 0;
159
+ for (const old of prevFindings ?? []) {
160
+ if (key(old) && newKeys.has(key(old))) continue;
161
+ if (old.status === "awaiting-approval" || old.status === "approved") {
162
+ merged.push({
163
+ ...old,
164
+ status: "resolved",
165
+ resolved: { at: new Date().toISOString(), by: resolvedBy, reason: "not reproduced by re-scan against the current tree" },
166
+ });
167
+ resolvedNow++;
168
+ } else if (old.status === "resolved" || old.status === "dismissed") {
169
+ merged.push(old); // terminal states stay visible as audit trail
170
+ }
171
+ }
172
+ return { findings: merged, resolvedNow };
173
+ }
174
+
175
+ /** Content identity for a check finding — the same kind+detail key review.js hashes into queue IDs. */
176
+ const findingKey = (f) => `${f.kind}:${f.detail}`;
177
+
178
+ /**
179
+ * Persist a check run's findings through mergeFindingLifecycle (see above).
180
+ * Returns { path, resolvedNow } — resolvedNow is how many open findings this
181
+ * run auto-resolved, for callers to disclose.
182
+ */
183
+ export function saveFindings(rootDir, findings) {
184
+ const p = path.join(rootDir, MAPD_DIR, "findings.json");
185
+ fs.mkdirSync(path.dirname(p), { recursive: true });
186
+
187
+ let prev = null;
188
+ try { prev = JSON.parse(fs.readFileSync(p, "utf8")); } catch { /* first run */ }
189
+
190
+ const { findings: merged, resolvedNow } = mergeFindingLifecycle(findings, prev?.findings, findingKey, "mapd check");
191
+ fs.writeFileSync(p, JSON.stringify({ generatedAt: new Date().toISOString(), findings: merged }, null, 2));
192
+ return { path: p, resolvedNow };
193
+ }
@@ -0,0 +1,92 @@
1
+ /**
2
+ * resolution.js — `mapd resolution`. Call resolution is 25% of the confidence
3
+ * score, but the raw rate doesn't tell you WHERE to act. This ranks the call
4
+ * sites dragging it down by real blast radius, names the anonymous functions
5
+ * that hide edges, and — crucially — does NOT treat calls into external
6
+ * packages as your problem to fix.
7
+ *
8
+ * All deterministic: it reads the call edges the graph already classified
9
+ * (external / cross-file / dynamic / unresolved / local / global) plus the
10
+ * parsed function list. No guessing which call "should" resolve.
11
+ */
12
+
13
+ const fileOf = (ref) => String(ref).split("#")[0];
14
+ const ANON = /^<anon[:>]/;
15
+
16
+ export function analyzeResolution(graph, { top = 10 } = {}) {
17
+ const edges = graph.callEdges ?? [];
18
+ const byType = {};
19
+ for (const e of edges) byType[e.resolution] = (byType[e.resolution] ?? 0) + 1;
20
+
21
+ const wfByFile = new Map();
22
+ for (const w of graph.workflows) for (const f of w.files) {
23
+ if (!wfByFile.has(f)) wfByFile.set(f, []);
24
+ wfByFile.get(f).push(w.id);
25
+ }
26
+
27
+ // Hotspots: files with the most fixable (dynamic + unresolved) call sites,
28
+ // ranked by count × workflow blast radius. External/local/global are excluded —
29
+ // external is not yours to fix, local/global already resolve.
30
+ const fixable = edges.filter((e) => e.resolution === "dynamic" || e.resolution === "unresolved");
31
+ const perFile = new Map();
32
+ for (const e of fixable) {
33
+ const f = fileOf(e.from);
34
+ const rec = perFile.get(f) ?? { file: f, dynamic: 0, unresolved: 0, receivers: new Set() };
35
+ if (e.resolution === "dynamic") { rec.dynamic++; if (e.dynamicReceiver) rec.receivers.add(e.dynamicReceiver); }
36
+ else rec.unresolved++;
37
+ perFile.set(f, rec);
38
+ }
39
+ const hotspots = [...perFile.values()].map((r) => {
40
+ const workflows = wfByFile.get(r.file) ?? [];
41
+ const count = r.dynamic + r.unresolved;
42
+ return {
43
+ file: r.file, dynamic: r.dynamic, unresolved: r.unresolved, count,
44
+ workflows, blastRadius: workflows.length,
45
+ receivers: [...r.receivers].slice(0, 6),
46
+ score: count * Math.max(1, workflows.length), // rank: volume × reach
47
+ };
48
+ }).sort((a, b) => b.score - a.score).slice(0, top);
49
+
50
+ // Anonymous functions hide call edges (an unnamed callee can't be resolved).
51
+ const anon = graph.files.map((f) => ({
52
+ file: f.file,
53
+ count: (f.functions ?? []).filter((fn) => !fn.name || ANON.test(fn.name)).length,
54
+ })).filter((x) => x.count > 0).sort((a, b) => b.count - a.count).slice(0, top);
55
+
56
+ const totalCalls = graph.stats.totalCalls ?? edges.length;
57
+ const external = byType.external ?? 0;
58
+
59
+ return {
60
+ summary: {
61
+ totalCalls,
62
+ resolutionRate: graph.stats.callResolutionRate ?? null,
63
+ byType,
64
+ fixable: fixable.length,
65
+ externalExcused: external,
66
+ },
67
+ hotspots,
68
+ anonymous: anon,
69
+ anonymousTotal: graph.files.reduce((a, f) => a + (f.functions ?? []).filter((fn) => !fn.name || ANON.test(fn.name)).length, 0),
70
+ };
71
+ }
72
+
73
+ export function renderResolution(data, theme) {
74
+ const { bold, dim, red, green, yellow, cyan } = theme;
75
+ const s = data.summary;
76
+ const lines = [`\n${bold("Call resolution")} — rate ${s.resolutionRate != null ? cyan(s.resolutionRate.toFixed(3)) : "n/a"} over ${s.totalCalls} call(s)`];
77
+ const order = ["cross-file", "local", "global", "dynamic", "unresolved", "external"];
78
+ lines.push(" by type: " + order.filter((t) => s.byType[t]).map((t) => `${t} ${s.byType[t]}`).join(" · "));
79
+ lines.push(dim(` ${s.externalExcused} external call(s) into packages are NOT counted against you — Map'd can't resolve into node_modules and doesn't pretend to.`));
80
+
81
+ lines.push(`\n${bold(" Resolution hotspots")} ${dim("(fixable call sites × workflow blast radius)")}`);
82
+ if (!data.hotspots.length) lines.push(green(" None — every fixable call already resolves."));
83
+ for (const h of data.hotspots) {
84
+ lines.push(` ${bold(h.file)} ${yellow(`${h.count} fixable`)} ${dim(`(${h.dynamic} dynamic, ${h.unresolved} unresolved; reaches ${h.blastRadius} workflow(s))`)}`);
85
+ if (h.receivers.length) lines.push(dim(` dynamic receivers: ${h.receivers.join(", ")} — if a receiver resolves to a constant path it can be made static; if it's computed at runtime, keep it lazy and consider annotating dynamically-loaded.`));
86
+ }
87
+
88
+ lines.push(`\n${bold(" Anonymous functions")} ${dim(`(${data.anonymousTotal} total — naming them lets callers resolve)`)}`);
89
+ if (!data.anonymous.length) lines.push(green(" None."));
90
+ for (const a of data.anonymous) lines.push(` ${a.file} ${dim(`${a.count} anonymous`)}`);
91
+ return lines.join("\n");
92
+ }
@@ -0,0 +1,61 @@
1
+ /**
2
+ * retry.js — general-purpose retry engine with structured gate feedback.
3
+ * Used by fix.js (mapd fix), and available to chat/mcp/modernize proposal
4
+ * flows for the same gate-fail -> structured-feedback -> retry loop.
5
+ *
6
+ * The deterministic gates remain the final judge — this module never lets an
7
+ * attempt mark itself successful; success is `gates.every(g => g.passed)`.
8
+ *
9
+ * Stopping rules: all gates pass; maxAttempts reached; the same set of failed
10
+ * gates repeats with no new information (no point burning another attempt).
11
+ */
12
+
13
+ /** Turn one attempt's failed gates into one deterministic feedback sentence. */
14
+ export function buildRetryFeedback(gateResults) {
15
+ const failed = gateResults.filter((g) => !g.passed);
16
+ if (!failed.length) return null;
17
+ const parts = failed.map((g) => {
18
+ const bits = [];
19
+ if (g.missing?.length) bits.push(`missing: ${g.missing.join(", ")}`);
20
+ if (g.exitCode != null) bits.push(`exit code ${g.exitCode}`);
21
+ if (g.stderr) bits.push(`stderr: ${g.stderr.slice(0, 300).trim()}`);
22
+ return `${g.gate} failed${bits.length ? ` (${bits.join("; ")})` : ""}`;
23
+ });
24
+ return `${parts.join(". ")}. Do not repeat the same mistake — address exactly this feedback.`;
25
+ }
26
+
27
+ function failedGateSignature(gateResults) {
28
+ return gateResults.filter((g) => !g.passed).map((g) => g.gate).sort().join(",");
29
+ }
30
+
31
+ /**
32
+ * `attemptFn({attempt, feedback})` must return `{ gates, ...rest }`.
33
+ * Returns `{ success, attempts, finalAttempt, stopReason }`.
34
+ * `stopReason` is one of: "passed", "max-attempts", "repeated-failure".
35
+ */
36
+ export async function runWithRetry({ attemptFn, maxAttempts = 2, onAttemptResult } = {}) {
37
+ const attempts = [];
38
+ let previousFeedback = null;
39
+ let previousSignature = null;
40
+
41
+ for (let attempt = 1; attempt <= maxAttempts; attempt++) {
42
+ const result = await attemptFn({ attempt, feedback: previousFeedback });
43
+ const gates = result.gates ?? [];
44
+ const passed = gates.length > 0 && gates.every((g) => g.passed);
45
+ const feedbackForRetry = passed ? null : buildRetryFeedback(gates);
46
+ const record = { attempt, gates, passed, feedbackForRetry, result };
47
+ attempts.push(record);
48
+ onAttemptResult?.(record);
49
+
50
+ if (passed) return { success: true, attempts, finalAttempt: record, stopReason: "passed" };
51
+
52
+ const signature = failedGateSignature(gates);
53
+ if (previousSignature !== null && signature === previousSignature && signature !== "") {
54
+ return { success: false, attempts, finalAttempt: record, stopReason: "repeated-failure" };
55
+ }
56
+ previousSignature = signature;
57
+ previousFeedback = feedbackForRetry;
58
+ }
59
+
60
+ return { success: false, attempts, finalAttempt: attempts[attempts.length - 1], stopReason: "max-attempts" };
61
+ }
@@ -0,0 +1,219 @@
1
+ /**
2
+ * review.js — the human half of "autonomous routing, human approval."
3
+ *
4
+ * Every Map'd function emits items with status "awaiting-approval" into
5
+ * .mapd/*.json. This module presents them as one queue with stable IDs and
6
+ * performs the only two state transitions a human can make:
7
+ *
8
+ * approve — for an integration proposal, writes the verified merged source
9
+ * into the working tree (the one place approval has a side effect,
10
+ * because the artifact IS a file). For check/modernize findings,
11
+ * marks "approved" — a work-queue signal for the fix pass.
12
+ * dismiss — marks "dismissed" with an optional reason; item leaves the queue
13
+ * but stays in the report (audit trail, never deleted).
14
+ *
15
+ * IDs are content-derived (kind + file/rule hash), so they are stable across
16
+ * re-listing but change if the underlying finding changes — you can never
17
+ * approve a stale version of an item by accident.
18
+ */
19
+
20
+ import fs from "node:fs";
21
+ import path from "node:path";
22
+ import crypto from "node:crypto";
23
+ import { applyRealTreeWrite } from "./changes.js";
24
+ import { checkReportFreshness } from "./staleness.js";
25
+
26
+ const MAPD = ".mapd";
27
+
28
+ const id8 = (s) => crypto.createHash("sha256").update(s).digest("hex").slice(0, 8);
29
+
30
+ function readJson(p) {
31
+ try { return JSON.parse(fs.readFileSync(p, "utf8")); } catch { return null; }
32
+ }
33
+
34
+ /** Collect every reviewable item across all report files. */
35
+ export function loadQueue(rootDir) {
36
+ const dir = path.join(rootDir, MAPD);
37
+ const items = [];
38
+
39
+ // findings from `mapd check`
40
+ const findingsPath = path.join(dir, "findings.json");
41
+ const findings = readJson(findingsPath);
42
+ findings?.findings?.forEach((f, i) => {
43
+ items.push({
44
+ id: id8(`check:${f.kind}:${f.detail}`),
45
+ source: "check", file: findingsPath, index: i,
46
+ kind: f.kind, severity: f.severity, detail: f.detail, status: f.status,
47
+ hasProposal: !!f.proposal,
48
+ });
49
+ });
50
+
51
+ // modernization reports (any mode)
52
+ for (const mode of ["light", "medium", "heavy"]) {
53
+ const p = path.join(dir, `modernize-${mode}.json`);
54
+ const rep = readJson(p);
55
+ rep?.findings?.forEach((f, i) => {
56
+ if (f.informational) return;
57
+ items.push({
58
+ id: id8(`modernize:${f.rule}:${f.detail}`),
59
+ source: `modernize-${mode}`, file: p, index: i,
60
+ kind: f.rule, severity: null,
61
+ priority: f.operationalImpact?.priority ?? null,
62
+ detail: f.detail, status: f.status,
63
+ hasProposal: !!f.migrationPlan,
64
+ });
65
+ });
66
+ }
67
+
68
+ // integration reports
69
+ const intDir = path.join(dir, "integration");
70
+ if (fs.existsSync(intDir)) {
71
+ for (const name of fs.readdirSync(intDir).filter((n) => n.startsWith("report-") && n.endsWith(".json"))) {
72
+ const p = path.join(intDir, name);
73
+ const rep = readJson(p);
74
+ rep?.conflicts?.forEach((c, i) => {
75
+ if (!c.proposal) return;
76
+ items.push({
77
+ id: id8(`integrate:${rep.branch}:${c.file}`),
78
+ source: `integrate:${rep.branch}`, file: p, index: i,
79
+ kind: c.classification, severity: null,
80
+ score: c.proposal.resolutionScore?.score ?? null,
81
+ detail: `${c.file} — merge resolution proposal (${c.proposal.status})`,
82
+ status: c.proposal.status,
83
+ hasProposal: !!c.proposal.mergedSource,
84
+ targetFile: c.file,
85
+ });
86
+ });
87
+ }
88
+ }
89
+
90
+ // mapd fix proposals — mapd review --approve <id> feeds directly into applying a verified fix
91
+ const proposalsDir = path.join(dir, "proposals");
92
+ if (fs.existsSync(proposalsDir)) {
93
+ for (const name of fs.readdirSync(proposalsDir).filter((n) => n.endsWith(".json"))) {
94
+ const p = path.join(proposalsDir, name);
95
+ const prop = readJson(p);
96
+ if (!prop) continue;
97
+ const files = Object.keys(prop.filesPatch ?? {});
98
+ items.push({
99
+ id: id8(`fix:${prop.findingId}`),
100
+ source: "fix", file: p, index: null,
101
+ kind: prop.findingKind ?? "fix", severity: null,
102
+ detail: `${files.join(", ") || "(no files)"} — verified fix proposal (${prop.status})`,
103
+ status: prop.status,
104
+ hasProposal: files.length > 0,
105
+ targetFile: files[0] ?? null,
106
+ });
107
+ }
108
+ }
109
+ return items;
110
+ }
111
+
112
+ export function pending(items) {
113
+ return items.filter((i) => i.status === "awaiting-approval");
114
+ }
115
+
116
+ /**
117
+ * The five finding states the master prompt requires to be obvious, derived —
118
+ * never stored — from the raw report status plus report freshness:
119
+ *
120
+ * active — awaiting approval AND the source report is current with the tree
121
+ * stale — awaiting approval, but the source report predates a newer
122
+ * source change (see staleness.js) — act only after refreshing
123
+ * approved — human marked it approved; queued for a fix pass
124
+ * resolved — a verified fix/merge was applied to the real tree
125
+ * dismissed — human dismissed it (kept in the report as audit trail)
126
+ * historical — terminal leftovers: rolled back after apply, failed gates, etc.
127
+ *
128
+ * Deriving keeps a single source of truth: staleness can change with every
129
+ * file save, so persisting a state would immediately overclaim.
130
+ */
131
+ export function stateOf(item, staleReports = []) {
132
+ const staleSet = staleReports instanceof Set ? staleReports : new Set(staleReports);
133
+ switch (item.status) {
134
+ case "awaiting-approval":
135
+ return item.file && staleSet.has(path.basename(item.file)) ? "stale" : "active";
136
+ case "approved": return "approved";
137
+ case "approved-applied": return "resolved";
138
+ case "resolved": return "resolved"; // auto-resolved by re-check (see regression.js saveFindings)
139
+ case "dismissed": return "dismissed";
140
+ default: return "historical";
141
+ }
142
+ }
143
+
144
+ /**
145
+ * loadQueue plus a derived `state` on every item and the freshness result it
146
+ * was derived from — the one call sites should prefer whenever they show a
147
+ * queue to a human or an agent, so stale findings are never presented as
148
+ * current fact.
149
+ */
150
+ export function loadQueueWithStates(rootDir) {
151
+ const freshness = checkReportFreshness(rootDir);
152
+ const items = loadQueue(rootDir).map((i) => ({ ...i, state: stateOf(i, freshness.staleReports) }));
153
+ return { items, freshness };
154
+ }
155
+
156
+ /**
157
+ * Transition one item. Returns { ok, action, detail }.
158
+ * Approving an integration proposal writes the (already gate-verified) merged
159
+ * source to the working tree; everything else is a status change only.
160
+ */
161
+ export function transition(rootDir, item, action, reason) {
162
+ const rep = readJson(item.file);
163
+ if (!rep) return { ok: false, detail: `report file missing: ${item.file}` };
164
+
165
+ const stamp = { at: new Date().toISOString(), by: "mapd review" };
166
+
167
+ if (item.source.startsWith("integrate:")) {
168
+ const c = rep.conflicts[item.index];
169
+ if (!c?.proposal) return { ok: false, detail: "proposal no longer present in report" };
170
+ if (c.proposal.status !== "awaiting-approval")
171
+ return { ok: false, detail: `proposal is '${c.proposal.status}', not awaiting-approval` };
172
+ if (action === "approve") {
173
+ const change = applyRealTreeWrite(rootDir, c.file, c.proposal.mergedSource, "integrate");
174
+ c.proposal.status = "approved-applied";
175
+ c.proposal.applied = stamp;
176
+ c.proposal.changeId = change.id;
177
+ fs.writeFileSync(item.file, JSON.stringify(rep, null, 2));
178
+ return { ok: true, action, detail: `merged source written to ${c.file} (change ${change.id}) — review the diff, then commit`, changeId: change.id, changeIds: [change.id] };
179
+ }
180
+ c.proposal.status = "dismissed";
181
+ c.proposal.dismissed = { ...stamp, reason: reason ?? null };
182
+ fs.writeFileSync(item.file, JSON.stringify(rep, null, 2));
183
+ return { ok: true, action, detail: `proposal for ${c.file} dismissed` };
184
+ }
185
+
186
+ if (item.source === "fix") {
187
+ const prop = rep; // proposals/*.json holds a single proposal object, not a report with an array
188
+ if (prop.status !== "awaiting-approval")
189
+ return { ok: false, detail: `fix proposal is '${prop.status}', not awaiting-approval` };
190
+ if (action === "approve") {
191
+ const changeIds = [];
192
+ for (const [file, newSource] of Object.entries(prop.filesPatch ?? {})) {
193
+ const change = applyRealTreeWrite(rootDir, file, newSource, "fix");
194
+ changeIds.push(change.id);
195
+ }
196
+ prop.status = "approved-applied";
197
+ prop.applied = stamp;
198
+ prop.changeIds = changeIds;
199
+ fs.writeFileSync(item.file, JSON.stringify(prop, null, 2));
200
+ return { ok: true, action, detail: `fix applied to ${Object.keys(prop.filesPatch ?? {}).join(", ")} (changes ${changeIds.join(", ")})`, changeIds };
201
+ }
202
+ prop.status = "dismissed";
203
+ prop.dismissed = { ...stamp, reason: reason ?? null };
204
+ fs.writeFileSync(item.file, JSON.stringify(prop, null, 2));
205
+ return { ok: true, action, detail: `fix proposal dismissed` };
206
+ }
207
+
208
+ const f = rep.findings[item.index];
209
+ if (!f) return { ok: false, detail: "finding no longer present in report" };
210
+ f.status = action === "approve" ? "approved" : "dismissed";
211
+ f[action === "approve" ? "approved" : "dismissed"] = { ...stamp, ...(reason ? { reason } : {}) };
212
+ fs.writeFileSync(item.file, JSON.stringify(rep, null, 2));
213
+ return {
214
+ ok: true, action,
215
+ detail: action === "approve"
216
+ ? `finding marked approved — queued for a fix pass (mapd never auto-fixes)`
217
+ : `finding dismissed`,
218
+ };
219
+ }