@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.
- package/LICENSE +21 -0
- package/MASTER_PROMPT.md +134 -0
- package/README.md +494 -0
- package/SETUP.md +108 -0
- package/UAT.md +77 -0
- package/package.json +56 -0
- package/src/adapters/github-app.js +79 -0
- package/src/agents/anthropicClient.js +18 -0
- package/src/agents/llm.js +196 -0
- package/src/agents/modelResolver.js +87 -0
- package/src/agents/provider.js +222 -0
- package/src/chat/commandRunner.js +86 -0
- package/src/chat/commands.js +275 -0
- package/src/chat/intent.js +87 -0
- package/src/chat/llmIntent.js +118 -0
- package/src/chat/repl.js +471 -0
- package/src/cli.js +1408 -0
- package/src/config/index.js +197 -0
- package/src/config/schema.js +119 -0
- package/src/core/assist.js +64 -0
- package/src/core/audit.js +63 -0
- package/src/core/changes.js +110 -0
- package/src/core/confidence.js +0 -0
- package/src/core/configLint.js +141 -0
- package/src/core/diagnose.js +262 -0
- package/src/core/docs.js +140 -0
- package/src/core/doctor.js +134 -0
- package/src/core/envFiles.js +43 -0
- package/src/core/events.js +53 -0
- package/src/core/evidence.js +212 -0
- package/src/core/findingScoring.js +20 -0
- package/src/core/fix.js +192 -0
- package/src/core/fixApply.js +172 -0
- package/src/core/frameworkEntries.js +247 -0
- package/src/core/gates.js +209 -0
- package/src/core/graph.js +467 -0
- package/src/core/grounding.js +235 -0
- package/src/core/handoff.js +157 -0
- package/src/core/importResolver.js +218 -0
- package/src/core/improve.js +226 -0
- package/src/core/integrate.js +169 -0
- package/src/core/intelligence.js +212 -0
- package/src/core/modernize.js +370 -0
- package/src/core/parseCache.js +64 -0
- package/src/core/parser.js +536 -0
- package/src/core/policy.js +65 -0
- package/src/core/polyglot.js +333 -0
- package/src/core/proc.js +25 -0
- package/src/core/reachability.js +543 -0
- package/src/core/regression.js +193 -0
- package/src/core/resolution.js +92 -0
- package/src/core/retry.js +61 -0
- package/src/core/review.js +219 -0
- package/src/core/score.js +338 -0
- package/src/core/security.js +0 -0
- package/src/core/session.js +143 -0
- package/src/core/solutions.js +254 -0
- package/src/core/staleness.js +45 -0
- package/src/core/testGuidance.js +226 -0
- package/src/core/theme.js +50 -0
- package/src/core/trace.js +151 -0
- package/src/core/verify.js +123 -0
- package/src/core/view.js +221 -0
- package/src/core/viewServer.js +88 -0
- package/src/core/watch.js +76 -0
- package/src/core/workspace.js +115 -0
- package/src/mcp/server.js +48 -0
- package/src/mcp/tools.js +423 -0
- 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
|
+
}
|