@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,226 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* improve.js — the planner. `mapd improve --budget 2h --risk low`.
|
|
3
|
+
*
|
|
4
|
+
* Turns the scored graph into a ranked, honest work queue: every candidate task
|
|
5
|
+
* is a real deficiency in a real signal, its expected lift is measured by
|
|
6
|
+
* re-running the actual scorer (simulateScore — never a guess), and each task
|
|
7
|
+
* carries what NOT to fake so the plan can't be satisfied deceptively. It also
|
|
8
|
+
* states the honest ceiling up front, so it never implies 1.0 is reachable when
|
|
9
|
+
* it isn't.
|
|
10
|
+
*
|
|
11
|
+
* It proposes; it never edits. The verification block tells you how to prove the
|
|
12
|
+
* work landed.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { buildScoredGraph } from "./intelligence.js";
|
|
16
|
+
import { simulateScore, ceilingScore } from "./score.js";
|
|
17
|
+
import { classifyTestCredit } from "./testGuidance.js";
|
|
18
|
+
import { bold, dim, red, green, yellow, cyan, confidenceColor } from "./theme.js";
|
|
19
|
+
|
|
20
|
+
const RISK_RANK = { low: 1, medium: 2, high: 3 };
|
|
21
|
+
|
|
22
|
+
/** "2h" | "90m" | "120" → minutes. Missing/unparseable → Infinity (no cap). */
|
|
23
|
+
export function parseBudget(b) {
|
|
24
|
+
if (b == null) return Infinity;
|
|
25
|
+
const m = String(b).trim().match(/^(\d+(?:\.\d+)?)\s*(h|hr|hrs|hour|hours|m|min|mins|minute|minutes)?$/i);
|
|
26
|
+
if (!m) return Infinity;
|
|
27
|
+
const n = parseFloat(m[1]);
|
|
28
|
+
return (m[2] || "m").toLowerCase().startsWith("h") ? n * 60 : n;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const fmtEffort = (min) => min >= 60 ? `~${Math.floor(min / 60)}h${min % 60 ? `${Math.round(min % 60)}m` : ""}` : `~${Math.round(min)}m`;
|
|
32
|
+
|
|
33
|
+
/** Generate every candidate task from real graph deficiencies, each with a measured marginal lift. */
|
|
34
|
+
function candidates(rootDir, graph) {
|
|
35
|
+
const out = [];
|
|
36
|
+
const wfOf = (file) => graph.workflows.filter((w) => w.files.includes(file)).map((w) => w.id);
|
|
37
|
+
const lift = (mutations) => simulateScore(rootDir, graph, mutations).delta;
|
|
38
|
+
|
|
39
|
+
const credit = classifyTestCredit(graph).filter((s) => s.inWorkflow);
|
|
40
|
+
|
|
41
|
+
// 1. untested workflow files → write a real test
|
|
42
|
+
for (const s of credit.filter((c) => c.status === "untested")) {
|
|
43
|
+
out.push({
|
|
44
|
+
kind: "add-test", files: [s.file], signal: "testPresence", risk: "low",
|
|
45
|
+
title: `Add a real test for ${s.file}`,
|
|
46
|
+
why: `untested file in ${wfOf(s.file).join(", ") || "a workflow"}`,
|
|
47
|
+
doStep: `import ${s.file} and exercise ${s.exportCount ? `its ${s.exportCount} export(s)` : "its behavior"}`,
|
|
48
|
+
dontFake: "an empty or import-only test earns nothing — testPresence now credits only tests that import the module and use its exports",
|
|
49
|
+
workflows: wfOf(s.file), effortMin: 15 + 5 * Math.min(s.exportCount, 5),
|
|
50
|
+
lift: lift({ addTests: [s.file] }),
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// 2. name-only padding → replace the fake test with a real one
|
|
55
|
+
for (const s of credit.filter((c) => c.status === "tested-nameonly")) {
|
|
56
|
+
out.push({
|
|
57
|
+
kind: "replace-padding", files: [s.file], signal: "testPresence", risk: "low",
|
|
58
|
+
title: `Replace padding test for ${s.file}`,
|
|
59
|
+
why: `credited only by filename coincidence (${s.credits.map((c) => c.test).join(", ")}) — earns nothing today`,
|
|
60
|
+
doStep: `make the existing test actually import ${s.file} and assert on its exports`,
|
|
61
|
+
dontFake: "this file already LOOKS tested; the honest move is a real test, not a more convincing-looking fake",
|
|
62
|
+
workflows: wfOf(s.file), effortMin: 20,
|
|
63
|
+
lift: lift({ addTests: [s.file] }),
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// 3. AST parse errors in workflow files → fix them
|
|
68
|
+
const astErr = graph.files.filter((f) => f.parserKind !== "heuristic" && f.parsed && f.parseErrors > 0 && wfOf(f.file).length);
|
|
69
|
+
for (const f of astErr) {
|
|
70
|
+
out.push({
|
|
71
|
+
kind: "fix-parse", files: [f.file], signal: "parseIntegrity", risk: "medium",
|
|
72
|
+
title: `Fix parse recovery errors in ${f.file}`,
|
|
73
|
+
why: `${f.parseErrors} parse error(s) reduce parseIntegrity for ${wfOf(f.file).join(", ")}`,
|
|
74
|
+
doStep: "resolve the syntax the parser recovered from (unsupported syntax, missing config)",
|
|
75
|
+
dontFake: "excluding the file to hide the error lowers coverage instead — fix the parse, don't mask it",
|
|
76
|
+
workflows: wfOf(f.file), effortMin: 15,
|
|
77
|
+
lift: lift({ fixParse: [f.file] }),
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// 4. call resolution — one aggregate task (can't attribute per call site here)
|
|
82
|
+
const unresolved = (graph.stats.totalCalls ?? 0) - (graph.stats.resolvedCalls ?? 0);
|
|
83
|
+
if (unresolved > 0 && (graph.stats.callResolutionRate ?? 1) < 0.999) {
|
|
84
|
+
out.push({
|
|
85
|
+
kind: "resolve-calls", files: [], signal: "resolutionRate", risk: "medium",
|
|
86
|
+
title: `Resolve ${unresolved} unresolved call site(s)`,
|
|
87
|
+
why: `call resolution is ${(graph.stats.callResolutionRate * 100).toFixed(1)}% — dynamic dispatch / anonymous functions hide edges`,
|
|
88
|
+
doStep: "name anonymous functions and make dynamic require()/import() static where safe",
|
|
89
|
+
dontFake: "some calls into external packages genuinely can't resolve — don't force those; leave them and note it",
|
|
90
|
+
workflows: [], effortMin: Math.min(unresolved * 10, 240),
|
|
91
|
+
lift: lift({ resolutionRate: 1 }),
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// 5. orphans → wire the top few into a workflow (coverage)
|
|
96
|
+
const allFiles = graph.files.length || 1;
|
|
97
|
+
const coveredNow = new Set(graph.workflows.flatMap((w) => w.files)).size;
|
|
98
|
+
(graph.orphans ?? []).slice(0, 5).forEach((file, i) => {
|
|
99
|
+
out.push({
|
|
100
|
+
kind: "wire-coverage", files: [file], signal: "coverageOfRepo", risk: "medium",
|
|
101
|
+
title: `Reach ${file} from a workflow`,
|
|
102
|
+
why: "orphan file reached by no entry point — lowers coverageOfRepo",
|
|
103
|
+
doStep: "import it from a reachable module, or confirm it is truly dead code",
|
|
104
|
+
dontFake: "if it is intentionally dormant, annotate it `intentional-dormant` — do NOT wire dead code in just to lift coverage",
|
|
105
|
+
workflows: [], effortMin: 15,
|
|
106
|
+
lift: lift({ coverageOfRepo: (coveredNow + i + 1) / allFiles }),
|
|
107
|
+
});
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
return out;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Build a budget- and risk-bounded plan. Ranks by lift-per-effort, greedily
|
|
115
|
+
* fills the budget, then measures the COMBINED projected score by simulating all
|
|
116
|
+
* selected tasks at once (so the headline number isn't a sum of rounded parts).
|
|
117
|
+
*/
|
|
118
|
+
export function planImprovements(rootDir, { budget, risk = "low" } = {}) {
|
|
119
|
+
const graph = buildScoredGraph(rootDir);
|
|
120
|
+
const budgetMin = parseBudget(budget);
|
|
121
|
+
const riskCap = RISK_RANK[risk] ?? RISK_RANK.low;
|
|
122
|
+
|
|
123
|
+
const eligible = candidates(rootDir, graph)
|
|
124
|
+
.filter((c) => RISK_RANK[c.risk] <= riskCap && c.lift > 0)
|
|
125
|
+
.sort((a, b) => (b.lift / b.effortMin) - (a.lift / a.effortMin) || b.lift - a.lift);
|
|
126
|
+
|
|
127
|
+
const selected = [];
|
|
128
|
+
let spent = 0;
|
|
129
|
+
for (const c of eligible) {
|
|
130
|
+
if (spent + c.effortMin > budgetMin) continue;
|
|
131
|
+
selected.push(c);
|
|
132
|
+
spent += c.effortMin;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// combined projection — merge every selected task's mutation and simulate once
|
|
136
|
+
const merged = { addTests: [], fixParse: [], coverageOfRepo: undefined, resolutionRate: undefined };
|
|
137
|
+
for (const c of selected) {
|
|
138
|
+
if (c.kind === "add-test" || c.kind === "replace-padding") merged.addTests.push(...c.files);
|
|
139
|
+
if (c.kind === "fix-parse") merged.fixParse.push(...c.files);
|
|
140
|
+
if (c.kind === "resolve-calls") merged.resolutionRate = 1;
|
|
141
|
+
if (c.kind === "wire-coverage") {
|
|
142
|
+
const coveredNow = new Set(graph.workflows.flatMap((w) => w.files)).size;
|
|
143
|
+
const wired = selected.filter((s) => s.kind === "wire-coverage").length;
|
|
144
|
+
merged.coverageOfRepo = (coveredNow + wired) / (graph.files.length || 1);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
const projection = simulateScore(rootDir, graph, merged);
|
|
148
|
+
const ceiling = ceilingScore(rootDir, graph);
|
|
149
|
+
|
|
150
|
+
const doNotDo = [];
|
|
151
|
+
const padding = classifyTestCredit(graph).filter((c) => c.inWorkflow && c.status === "tested-nameonly");
|
|
152
|
+
if (padding.length) doNotDo.push(`${padding.length} name-only padding test(s) already earn nothing — replacing them is real work; making them look more like tests is not.`);
|
|
153
|
+
doNotDo.push("Don't create empty or import-only test files to move testPresence — Map'd credits only real/shallow test links now.");
|
|
154
|
+
if ((graph.orphans ?? []).length) doNotDo.push("Don't wire intentionally-dormant files into a workflow to lift coverage — annotate them `intentional-dormant` instead.");
|
|
155
|
+
if (ceiling.ceilingSignalCoverage < 1) doNotDo.push(`1.0 is not honestly reachable: even maxed, signalCoverage caps at ${ceiling.ceilingSignalCoverage} (${ceiling.caps.map((c) => c.cap).join(", ")}).`);
|
|
156
|
+
|
|
157
|
+
return {
|
|
158
|
+
budget: budgetMin === Infinity ? "unbounded" : fmtEffort(budgetMin),
|
|
159
|
+
risk,
|
|
160
|
+
current: graph.repoConfidence,
|
|
161
|
+
projected: projection.after,
|
|
162
|
+
totalLift: projection.delta,
|
|
163
|
+
effortMin: spent,
|
|
164
|
+
ceiling: { value: ceiling.ceiling, signalCoverage: ceiling.ceilingSignalCoverage },
|
|
165
|
+
tasks: selected,
|
|
166
|
+
deferred: eligible.length - selected.length,
|
|
167
|
+
doNotDo,
|
|
168
|
+
verify: ["mapd check --save-baseline # once, to set the honest reference before you start", "# …do the work…", "mapd check # or ask `mapd chat`: \"why did the score change\""],
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** A compact, paste-ready task pack for Codex / Claude Code — exact files, risk, expected lift, and what not to fake. */
|
|
173
|
+
export function renderAgentPack(plan) {
|
|
174
|
+
const lines = [
|
|
175
|
+
"# Map'd improvement task pack",
|
|
176
|
+
"",
|
|
177
|
+
`Repo confidence **${plan.current} → target ${plan.projected}** (+${plan.totalLift}) within ${plan.budget}, risk ≤ ${plan.risk}. Honest ceiling ${plan.ceiling.value} (signalCoverage ${plan.ceiling.signalCoverage}).`,
|
|
178
|
+
"Do the tasks in order. Every lift below is measured by Map'd's real scorer — do not fake signals to hit the number.",
|
|
179
|
+
"",
|
|
180
|
+
"## Tasks",
|
|
181
|
+
"",
|
|
182
|
+
];
|
|
183
|
+
if (!plan.tasks.length) lines.push("_No tasks fit this budget/risk with a positive score lift._", "");
|
|
184
|
+
plan.tasks.forEach((t, i) => {
|
|
185
|
+
lines.push(`### ${i + 1}. ${t.title} \`[risk: ${t.risk} · expected +${t.lift}]\``);
|
|
186
|
+
lines.push(`- **Files:** ${t.files.length ? t.files.map((f) => `\`${f}\``).join(", ") : "(project-wide)"}`);
|
|
187
|
+
lines.push(`- **Why it matters:** ${t.why} (signal: ${t.signal})`);
|
|
188
|
+
lines.push(`- **Do:** ${t.doStep}`);
|
|
189
|
+
lines.push(`- **Do NOT fake:** ${t.dontFake}`);
|
|
190
|
+
if (t.workflows?.length) lines.push(`- **Workflows touched:** ${t.workflows.join(", ")}`);
|
|
191
|
+
lines.push("");
|
|
192
|
+
});
|
|
193
|
+
lines.push("## Do not do", "");
|
|
194
|
+
for (const d of plan.doNotDo) lines.push(`- ${d}`);
|
|
195
|
+
lines.push("", "## Verify when done", "", "```");
|
|
196
|
+
for (const v of plan.verify) lines.push(v);
|
|
197
|
+
lines.push("```");
|
|
198
|
+
return lines.join("\n");
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
export function renderImprovePlan(plan) {
|
|
202
|
+
const lines = [`\n${bold("Improve plan")} — budget ${plan.budget}, risk ${plan.risk}`];
|
|
203
|
+
lines.push(` ${confidenceColor(plan.current)(plan.current)} → projected ${confidenceColor(plan.projected)(plan.projected)} ${green(`+${plan.totalLift}`)} ${dim(`from ${plan.tasks.length} task(s), ${fmtEffort(plan.effortMin)}`)}`);
|
|
204
|
+
lines.push(` ceiling ${confidenceColor(plan.ceiling.value)(plan.ceiling.value)} ${dim(`(signalCoverage ${plan.ceiling.signalCoverage})`)}`);
|
|
205
|
+
|
|
206
|
+
if (!plan.tasks.length) {
|
|
207
|
+
lines.push(green("\n Nothing fits this budget/risk with a positive score lift."));
|
|
208
|
+
} else {
|
|
209
|
+
lines.push(`\n${bold(" Ranked tasks")} ${dim("(best lift per effort first)")}`);
|
|
210
|
+
plan.tasks.forEach((t, i) => {
|
|
211
|
+
const rc = t.risk === "low" ? green : t.risk === "medium" ? yellow : red;
|
|
212
|
+
lines.push(`\n ${bold(`${i + 1}.`)} ${rc(`[${t.risk} · ${fmtEffort(t.effortMin)} · +${t.lift}]`)} ${t.title}`);
|
|
213
|
+
lines.push(dim(` why: ${t.why}`));
|
|
214
|
+
lines.push(dim(` do: ${t.doStep}`));
|
|
215
|
+
lines.push(` ${red("don't fake:")} ${dim(t.dontFake)}`);
|
|
216
|
+
});
|
|
217
|
+
}
|
|
218
|
+
if (plan.deferred) lines.push(dim(`\n ${plan.deferred} more eligible task(s) didn't fit the budget.`));
|
|
219
|
+
|
|
220
|
+
lines.push(`\n${bold(" Do not do")} ${dim("(score-chasing / deceptive)")}`);
|
|
221
|
+
for (const d of plan.doNotDo) lines.push(` ${yellow("▪")} ${d}`);
|
|
222
|
+
|
|
223
|
+
lines.push(`\n${bold(" Verify")}`);
|
|
224
|
+
for (const v of plan.verify) lines.push(` ${cyan(v)}`);
|
|
225
|
+
return lines.join("\n");
|
|
226
|
+
}
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* integrate.js — Function 1: integration / congruence resolution.
|
|
3
|
+
*
|
|
4
|
+
* Pipeline (LLM optional, verification mandatory):
|
|
5
|
+
* 1. DETECT — attempt the merge in an isolated git worktree; collect
|
|
6
|
+
* conflicted files with base/ours/theirs content. Deterministic.
|
|
7
|
+
* 2. CLASSIFY — parse ours & theirs; compare function sets and exported
|
|
8
|
+
* surface to label each conflict:
|
|
9
|
+
* "small-scale" same functions, bodies diverged
|
|
10
|
+
* "workflow-scale" exports / function topology diverged
|
|
11
|
+
* Deterministic (this drives Devon's small-vs-POC routing).
|
|
12
|
+
* 3. PROPOSE — (optional, needs API key) LLM drafts a merged file per
|
|
13
|
+
* conflict, receiving both sides + classification.
|
|
14
|
+
* 4. VERIFY — every proposal must pass hard deterministic gates before
|
|
15
|
+
* it is even *saved as a proposal*:
|
|
16
|
+
* G1 parses cleanly (zero recovery errors)
|
|
17
|
+
* G2 preserves the UNION of both parents' exports
|
|
18
|
+
* G3 preserves the union of top-level function names
|
|
19
|
+
* A gate failure downgrades the proposal to "rejected-by-gate"
|
|
20
|
+
* with the failed gate named — it is never silently dropped.
|
|
21
|
+
* 5. APPLY — only via explicit `mapd integrate --apply`, and only
|
|
22
|
+
* proposals whose derived score ≥ threshold. Default: never.
|
|
23
|
+
*
|
|
24
|
+
* Derived resolution score (per proposal — computed, not asserted):
|
|
25
|
+
* gatePassRate (0.5) fraction of gates passed
|
|
26
|
+
* testPresence (0.3) does the conflicted file have a matching test file
|
|
27
|
+
* scaleFactor (0.2) small-scale=1.0, workflow-scale=0.4 (structural merges
|
|
28
|
+
* are inherently riskier; constant is a policy knob, not
|
|
29
|
+
* a guess about *this* merge)
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
import { execFileSync } from "node:child_process";
|
|
33
|
+
import fs from "node:fs";
|
|
34
|
+
import path from "node:path";
|
|
35
|
+
import os from "node:os";
|
|
36
|
+
import { parseSource, runStandardGates } from "./gates.js";
|
|
37
|
+
import { applyRealTreeWrite } from "./changes.js";
|
|
38
|
+
|
|
39
|
+
/** All git invocations use argument arrays — no shell, no interpolation, no injection. */
|
|
40
|
+
function git(cwd, ...args) {
|
|
41
|
+
return execFileSync("git", args, { cwd, stdio: ["ignore", "pipe", "pipe"] }).toString();
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Step 1+2: detect conflicts merging `branch` into HEAD, classify each. Deterministic. */
|
|
45
|
+
export function detectConflicts(rootDir, branch) {
|
|
46
|
+
const abs = path.resolve(rootDir);
|
|
47
|
+
// integrate is inherently git-native (it merges in an isolated worktree). If
|
|
48
|
+
// this project isn't a git repo, say so cleanly instead of letting a raw
|
|
49
|
+
// `fatal: not a git repository` escape — the rest of Map'd degrades
|
|
50
|
+
// gracefully without git, and so must this.
|
|
51
|
+
try { git(abs, "rev-parse", "--git-dir"); }
|
|
52
|
+
catch { return { mergeable: false, conflicts: [], requiresGit: true }; }
|
|
53
|
+
const worktree = fs.mkdtempSync(path.join(os.tmpdir(), "mapd-merge-"));
|
|
54
|
+
const conflicts = [];
|
|
55
|
+
let mergeable = true;
|
|
56
|
+
|
|
57
|
+
try {
|
|
58
|
+
const head = git(abs, "rev-parse", "--abbrev-ref", "HEAD").trim();
|
|
59
|
+
git(abs, "worktree", "add", "--detach", worktree, head);
|
|
60
|
+
try {
|
|
61
|
+
git(worktree, "-c", "user.email=mapd@local", "-c", "user.name=mapd", "merge", "--no-commit", "--no-ff", branch);
|
|
62
|
+
// clean merge — nothing to resolve
|
|
63
|
+
} catch (e) {
|
|
64
|
+
const status = git(worktree, "status", "--porcelain");
|
|
65
|
+
const conflictedFiles = status.split("\n")
|
|
66
|
+
.filter((l) => /^(UU|AA|DU|UD|AU|UA) /.test(l))
|
|
67
|
+
.map((l) => l.slice(3).trim());
|
|
68
|
+
if (conflictedFiles.length === 0) {
|
|
69
|
+
// merge failed for a non-conflict reason — surface it, don't fabricate a clean/empty result
|
|
70
|
+
return { mergeable: false, conflicts: [], mergeError: (e.stderr ?? e.message ?? "").toString().trim() };
|
|
71
|
+
}
|
|
72
|
+
mergeable = false;
|
|
73
|
+
|
|
74
|
+
for (const file of conflictedFiles) {
|
|
75
|
+
const show = (ref) => {
|
|
76
|
+
try { return git(worktree, "show", `${ref}:${file}`); } catch { return null; }
|
|
77
|
+
};
|
|
78
|
+
const base = show(":1"), ours = show(":2"), theirs = show(":3");
|
|
79
|
+
const conflict = { file, base, ours, theirs, classification: "unknown" };
|
|
80
|
+
|
|
81
|
+
if (ours != null && theirs != null && /\.(js|ts|jsx|tsx|mjs|cjs)$/.test(file)) {
|
|
82
|
+
const po = parseSource(ours, `${file}@ours`);
|
|
83
|
+
const pt = parseSource(theirs, `${file}@theirs`);
|
|
84
|
+
if (po.parsed && pt.parsed) {
|
|
85
|
+
const fo = new Set(po.functions.map((f) => f.name));
|
|
86
|
+
const ft = new Set(pt.functions.map((f) => f.name));
|
|
87
|
+
const eo = new Set(po.exports), et = new Set(pt.exports);
|
|
88
|
+
const sameFns = fo.size === ft.size && [...fo].every((n) => ft.has(n));
|
|
89
|
+
const sameExports = eo.size === et.size && [...eo].every((n) => et.has(n));
|
|
90
|
+
conflict.classification = sameFns && sameExports ? "small-scale" : "workflow-scale";
|
|
91
|
+
conflict.evidence = {
|
|
92
|
+
oursFns: [...fo].sort(), theirsFns: [...ft].sort(),
|
|
93
|
+
oursExports: [...eo].sort(), theirsExports: [...et].sort(),
|
|
94
|
+
};
|
|
95
|
+
// union targets for verification gates
|
|
96
|
+
conflict.requiredExports = [...new Set([...eo, ...et])].sort();
|
|
97
|
+
conflict.requiredFunctions = [...new Set([...fo, ...ft])].sort();
|
|
98
|
+
} else {
|
|
99
|
+
conflict.classification = "unparseable-side";
|
|
100
|
+
}
|
|
101
|
+
} else if (ours == null || theirs == null) {
|
|
102
|
+
conflict.classification = "delete-modify"; // one side deleted — always workflow-scale severity
|
|
103
|
+
}
|
|
104
|
+
conflicts.push(conflict);
|
|
105
|
+
}
|
|
106
|
+
try { git(worktree, "merge", "--abort"); } catch { /* detached worktree, discarded anyway */ }
|
|
107
|
+
}
|
|
108
|
+
} finally {
|
|
109
|
+
try { git(abs, "worktree", "remove", "--force", worktree); } catch { /* best effort */ }
|
|
110
|
+
fs.rmSync(worktree, { recursive: true, force: true });
|
|
111
|
+
}
|
|
112
|
+
return { mergeable, conflicts };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Step 4: hard deterministic gates on a proposed merged source. Delegates to gates.js. */
|
|
116
|
+
export function verifyProposal(conflict, mergedSource) {
|
|
117
|
+
return runStandardGates({
|
|
118
|
+
source: mergedSource,
|
|
119
|
+
label: `${conflict.file}@proposed`,
|
|
120
|
+
requiredExports: conflict.requiredExports,
|
|
121
|
+
requiredFunctions: conflict.requiredFunctions,
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** Derived resolution score. Every input measurable; policy constants documented above. */
|
|
126
|
+
export function scoreResolution(conflict, gates, hasTest) {
|
|
127
|
+
const gatePassRate = gates.length ? gates.filter((g) => g.passed).length / gates.length : 0;
|
|
128
|
+
const scaleFactor = conflict.classification === "small-scale" ? 1.0
|
|
129
|
+
: conflict.classification === "workflow-scale" ? 0.4 : 0.2;
|
|
130
|
+
const signals = {
|
|
131
|
+
gatePassRate: { value: Number(gatePassRate.toFixed(3)), weight: 0.5 },
|
|
132
|
+
testPresence: { value: hasTest ? 1 : 0, weight: 0.3 },
|
|
133
|
+
scaleFactor: { value: scaleFactor, weight: 0.2 },
|
|
134
|
+
};
|
|
135
|
+
const score = Object.values(signals).reduce((a, s) => a + s.value * s.weight, 0);
|
|
136
|
+
return { score: Number(score.toFixed(3)), signals, method: "resolution-composite-v1" };
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export function saveIntegrationReport(rootDir, branch, report) {
|
|
140
|
+
const dir = path.join(rootDir, ".mapd", "integration");
|
|
141
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
142
|
+
const p = path.join(dir, `report-${branch.replace(/[^\w.-]/g, "_")}.json`);
|
|
143
|
+
fs.writeFileSync(p, JSON.stringify(report, null, 2));
|
|
144
|
+
// proposals as reviewable files next to the report
|
|
145
|
+
for (const c of report.conflicts) {
|
|
146
|
+
if (c.proposal?.mergedSource && c.proposal.status === "awaiting-approval") {
|
|
147
|
+
const pf = path.join(dir, c.file.replace(/[\\/]/g, "__") + ".proposed");
|
|
148
|
+
fs.writeFileSync(pf, c.proposal.mergedSource);
|
|
149
|
+
c.proposal.proposalFile = pf;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
fs.writeFileSync(p, JSON.stringify(report, null, 2));
|
|
153
|
+
return p;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** Step 5: apply approved proposals above threshold. Explicit opt-in only. */
|
|
157
|
+
export function applyProposals(rootDir, report, threshold) {
|
|
158
|
+
const applied = [], skipped = [];
|
|
159
|
+
for (const c of report.conflicts) {
|
|
160
|
+
const pr = c.proposal;
|
|
161
|
+
if (pr?.status === "awaiting-approval" && pr.resolutionScore?.score >= threshold && pr.mergedSource) {
|
|
162
|
+
const change = applyRealTreeWrite(rootDir, c.file, pr.mergedSource, "integrate");
|
|
163
|
+
applied.push({ file: c.file, score: pr.resolutionScore.score, changeId: change.id });
|
|
164
|
+
} else {
|
|
165
|
+
skipped.push({ file: c.file, score: pr?.resolutionScore?.score ?? null, reason: pr ? "below threshold or gated" : "no proposal" });
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
return { applied, skipped };
|
|
169
|
+
}
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* intelligence.js — the single source of truth for "build me a scored map of
|
|
3
|
+
* this project," shared by cli.js, chat/, mcp/, and fix.js. Extracted
|
|
4
|
+
* verbatim from cli.js's original inline `buildScoredGraph` helper so every
|
|
5
|
+
* consumer sees identical results from identical inputs.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import fs from "node:fs";
|
|
9
|
+
import path from "node:path";
|
|
10
|
+
import { parseProject, ALWAYS_IGNORE } from "./parser.js";
|
|
11
|
+
import { buildGraph, loadPkg } from "./graph.js";
|
|
12
|
+
import { scoreGraph } from "./confidence.js";
|
|
13
|
+
import { traceFile } from "./trace.js";
|
|
14
|
+
import { loadConfig } from "../config/index.js";
|
|
15
|
+
import { loadPersistentParseCache, savePersistentParseCache } from "./parseCache.js";
|
|
16
|
+
|
|
17
|
+
export function buildScoredGraph(rootDir, { cache } = {}) {
|
|
18
|
+
const abs = path.resolve(rootDir);
|
|
19
|
+
if (!fs.existsSync(abs)) throw new Error(`project root does not exist: ${abs}`);
|
|
20
|
+
if (!fs.statSync(abs).isDirectory()) throw new Error(`project root is not a directory: ${abs}`);
|
|
21
|
+
const config = loadConfig(abs);
|
|
22
|
+
const parseCache = cache ?? (config.mapping?.cache === false ? null : loadPersistentParseCache(abs, config));
|
|
23
|
+
const parsed = parseProject(abs, {
|
|
24
|
+
cache: parseCache,
|
|
25
|
+
// Normal Map'd surfaces are governed by .mapdrc project.include/exclude.
|
|
26
|
+
// The low-level parser still keeps its historical defaults for direct
|
|
27
|
+
// callers/tests that do not go through buildScoredGraph.
|
|
28
|
+
ignore: ALWAYS_IGNORE,
|
|
29
|
+
include: config.project?.include ?? [],
|
|
30
|
+
exclude: config.project?.exclude ?? [],
|
|
31
|
+
maxFileSizeBytes: config.mapping?.maxFileSizeBytes ?? Infinity,
|
|
32
|
+
polyglot: config.mapping?.polyglot !== false,
|
|
33
|
+
});
|
|
34
|
+
if (!cache && config.mapping?.cache !== false) savePersistentParseCache(abs, config, parseCache);
|
|
35
|
+
// .mapdrc project.annotations: user-asserted generated/dynamically-loaded
|
|
36
|
+
// classifications flow into reachability through here, so every consumer
|
|
37
|
+
// (cli/chat/mcp/handoff/solutions) sees them without extra plumbing.
|
|
38
|
+
const annotations = config.project?.annotations ?? {};
|
|
39
|
+
const graph = buildGraph(abs, parsed, loadPkg(abs), { annotations });
|
|
40
|
+
return scoreGraph(abs, graph);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function findFileNode(graph, relFile) {
|
|
44
|
+
return graph.files.find((f) => f.file === relFile) ?? null;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Keyword-overlap search over function names/files — used by retrieval and chat. */
|
|
48
|
+
export function searchFunctions(graph, query) {
|
|
49
|
+
const terms = tokenize(query);
|
|
50
|
+
const phrase = String(query ?? "").toLowerCase().trim();
|
|
51
|
+
if (!terms.length) return [];
|
|
52
|
+
|
|
53
|
+
const workflowIdsByFile = new Map();
|
|
54
|
+
for (const wf of graph.workflows ?? []) {
|
|
55
|
+
for (const file of wf.files) {
|
|
56
|
+
if (!workflowIdsByFile.has(file)) workflowIdsByFile.set(file, []);
|
|
57
|
+
workflowIdsByFile.get(file).push(wf.id);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const hits = [];
|
|
62
|
+
for (const file of graph.files) {
|
|
63
|
+
const fileWorkflows = workflowIdsByFile.get(file.file) ?? [];
|
|
64
|
+
const fileFields = [
|
|
65
|
+
{ text: file.file, weight: 5, label: "file" },
|
|
66
|
+
{ text: path.posix.basename(file.file), weight: 5, label: "basename" },
|
|
67
|
+
{ text: (file.exports ?? []).join(" "), weight: 4, label: "exports" },
|
|
68
|
+
{ text: (file.imports ?? []).flatMap((i) => [i.source, ...(i.names ?? [])]).join(" "), weight: 2, label: "imports" },
|
|
69
|
+
{ text: fileWorkflows.join(" "), weight: 2, label: "workflow" },
|
|
70
|
+
];
|
|
71
|
+
for (const fn of file.functions) {
|
|
72
|
+
const fields = [
|
|
73
|
+
{ text: fn.name, weight: 7, label: "function" },
|
|
74
|
+
{ text: fn.exported ? "export exported public api" : "", weight: 1, label: "exported" },
|
|
75
|
+
{ text: (fn.calls ?? []).join(" "), weight: 3, label: "calls" },
|
|
76
|
+
...fileFields,
|
|
77
|
+
];
|
|
78
|
+
const scored = scoreFields(fields, terms, phrase);
|
|
79
|
+
if (scored.score > 0) {
|
|
80
|
+
hits.push({
|
|
81
|
+
file: file.file,
|
|
82
|
+
function: fn.name,
|
|
83
|
+
score: scored.score,
|
|
84
|
+
exported: fn.exported,
|
|
85
|
+
loc: fn.loc,
|
|
86
|
+
workflows: fileWorkflows,
|
|
87
|
+
matches: scored.matches,
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
return hits.sort((a, b) => b.score - a.score);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function tokenize(text) {
|
|
96
|
+
return String(text ?? "")
|
|
97
|
+
.replace(/([a-z0-9])([A-Z])/g, "$1 $2")
|
|
98
|
+
.toLowerCase()
|
|
99
|
+
.split(/[^a-z0-9_#./-]+/)
|
|
100
|
+
.flatMap((t) => t.split(/[./-]+/))
|
|
101
|
+
.map((t) => t.trim())
|
|
102
|
+
.filter((t) => t.length >= 2);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function scoreFields(fields, terms, phrase) {
|
|
106
|
+
const matches = [];
|
|
107
|
+
let score = 0;
|
|
108
|
+
for (const field of fields) {
|
|
109
|
+
const text = String(field.text ?? "").toLowerCase();
|
|
110
|
+
if (!text) continue;
|
|
111
|
+
if (phrase && phrase.length >= 3 && text.includes(phrase)) {
|
|
112
|
+
score += field.weight * 4;
|
|
113
|
+
matches.push(field.label);
|
|
114
|
+
}
|
|
115
|
+
const fieldTokens = tokenize(text);
|
|
116
|
+
for (const term of terms) {
|
|
117
|
+
const exact = fieldTokens.filter((t) => t === term).length;
|
|
118
|
+
const partial = exact ? 0 : fieldTokens.filter((t) => t.includes(term) || term.includes(t)).length;
|
|
119
|
+
if (exact || partial) {
|
|
120
|
+
score += field.weight * (exact * 2 + partial);
|
|
121
|
+
if (!matches.includes(field.label)) matches.push(field.label);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
return { score, matches };
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export function buildTaskContext(graph, query, { maxHits = 8, maxFiles = 8 } = {}) {
|
|
129
|
+
const hits = searchFunctions(graph, query).slice(0, maxHits);
|
|
130
|
+
const hitFiles = [...new Set(hits.map((h) => h.file))].slice(0, maxFiles);
|
|
131
|
+
const workflows = graph.workflows
|
|
132
|
+
.filter((wf) => wf.files.some((f) => hitFiles.includes(f)))
|
|
133
|
+
.map((wf) => ({
|
|
134
|
+
id: wf.id,
|
|
135
|
+
entry: wf.entry,
|
|
136
|
+
fileCount: wf.files.length,
|
|
137
|
+
functionCount: wf.functionCount,
|
|
138
|
+
confidence: wf.confidence?.score,
|
|
139
|
+
matchedFiles: wf.files.filter((f) => hitFiles.includes(f)),
|
|
140
|
+
}));
|
|
141
|
+
const files = hitFiles.map((rel) => {
|
|
142
|
+
const node = findFileNode(graph, rel);
|
|
143
|
+
return {
|
|
144
|
+
file: rel,
|
|
145
|
+
parsed: node?.parsed ?? false,
|
|
146
|
+
parseErrors: node?.parseErrors ?? 0,
|
|
147
|
+
// deterministic reachability, same source as /trace — so an LLM answering
|
|
148
|
+
// "is this used / reachable?" reads the engine's verdict instead of guessing
|
|
149
|
+
reachability: reachabilityOf(graph, rel),
|
|
150
|
+
loc: node?.loc ?? 0,
|
|
151
|
+
exports: node?.exports ?? [],
|
|
152
|
+
imports: (node?.imports ?? []).map((i) => ({ source: i.source, names: i.names ?? [] })),
|
|
153
|
+
functions: (node?.functions ?? []).slice(0, 20).map((fn) => ({
|
|
154
|
+
name: fn.name,
|
|
155
|
+
exported: fn.exported,
|
|
156
|
+
async: fn.async,
|
|
157
|
+
loc: fn.loc,
|
|
158
|
+
calls: (fn.calls ?? []).slice(0, 20),
|
|
159
|
+
})),
|
|
160
|
+
};
|
|
161
|
+
});
|
|
162
|
+
const caveats = [];
|
|
163
|
+
if ((graph.stats?.callResolutionRate ?? 1) < 0.9) {
|
|
164
|
+
caveats.push(`Call resolution is ${(graph.stats.callResolutionRate * 100).toFixed(1)}%; dynamic or unresolved calls may hide relationships.`);
|
|
165
|
+
}
|
|
166
|
+
if (graph.unsupported?.length) {
|
|
167
|
+
caveats.push(`${graph.unsupported.length} unsupported-language file(s) were not parsed.`);
|
|
168
|
+
}
|
|
169
|
+
return {
|
|
170
|
+
query,
|
|
171
|
+
summary: getRepoStatusSummary(graph),
|
|
172
|
+
hits,
|
|
173
|
+
files,
|
|
174
|
+
workflows,
|
|
175
|
+
caveats,
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function reachabilityOf(graph, rel) {
|
|
180
|
+
const t = traceFile(graph, rel);
|
|
181
|
+
if (t.inWorkflow) return { inWorkflow: true, workflows: t.workflows, importedBy: t.importers };
|
|
182
|
+
return { inWorkflow: false, classification: t.classification, reason: t.reason, importedBy: t.importers };
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
export function getWorkflowSummaries(graph) {
|
|
186
|
+
return graph.workflows.map((wf) => ({
|
|
187
|
+
id: wf.id, entry: wf.entry, fileCount: wf.files.length,
|
|
188
|
+
functionCount: wf.functionCount, confidence: wf.confidence.score,
|
|
189
|
+
}));
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
export function getRepoStatusSummary(graph) {
|
|
193
|
+
return {
|
|
194
|
+
fileCount: graph.stats.fileCount,
|
|
195
|
+
totalLoc: graph.stats.totalLoc,
|
|
196
|
+
workflowCount: graph.workflows.length,
|
|
197
|
+
repoConfidence: graph.repoConfidence,
|
|
198
|
+
callResolutionRate: graph.stats.callResolutionRate,
|
|
199
|
+
orphanCount: graph.orphans.length,
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/** Shared by chat's startup banner and handoff.js's prompt header. */
|
|
204
|
+
export function detectStack(pkg, graph) {
|
|
205
|
+
const deps = { ...(pkg?.dependencies ?? {}), ...(pkg?.devDependencies ?? {}) };
|
|
206
|
+
const frameworks = [];
|
|
207
|
+
if (deps.next) frameworks.push("Next.js");
|
|
208
|
+
else if (deps.react) frameworks.push("React");
|
|
209
|
+
if (deps.express) frameworks.push("Express");
|
|
210
|
+
const hasTs = graph.files.some((f) => /\.tsx?$/.test(f.file));
|
|
211
|
+
return { languages: [hasTs ? "TypeScript" : null, "JavaScript"].filter(Boolean), frameworks };
|
|
212
|
+
}
|