@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,235 @@
1
+ /**
2
+ * grounding.js — the single shared mechanism any LLM-touching surface in
3
+ * mapd uses to verify a model's output against the real data it was allowed
4
+ * to draw from. Extracted from solutions.js's narrateSolutions (the first
5
+ * place this pattern existed) so every new LLM surface routes through the
6
+ * same check instead of reinventing it — or, as chat's grounded Q&A did
7
+ * until now, not having a mechanical check at all, only a prompt asking the
8
+ * model to self-qualify.
9
+ *
10
+ * Intentionally mechanical, not another LLM call: regex-extract concrete
11
+ * claims (file paths, workflow IDs, finding IDs) from the text, check each
12
+ * against the real ground-truth sets the caller provides, and report
13
+ * exactly which claims verified and which didn't. Never a fuzzy judgment —
14
+ * a claim either matches something real or it's a violation.
15
+ */
16
+
17
+ import fs from "node:fs";
18
+ import path from "node:path";
19
+ import { GLOBALS } from "./graph.js";
20
+
21
+ // "Next.js", "Node.js"… are technology names, not file claims.
22
+ const TECH_NAMES = /^(?:next|node|nuxt|vue|react|express|three|d3|chart|moment|ember|backbone|angular|nest|deno|bun|solid|svelte|electron|socket\.io|p5|pixi|ml5|tf|brain|anime|video|highlight|marked|mermaid|alpine|preact|lit|remix|gatsby|astro|vite|webpack)\.js$/i;
23
+ function extractPathLikeTokens(text) {
24
+ return (text.match(/[\w./-]+\.(?:js|jsx|ts|tsx|mjs|cjs|json|md)\b/g) ?? []).filter((t) => !TECH_NAMES.test(t));
25
+ }
26
+ function extractWorkflowIds(text) {
27
+ return text.match(/wf:[\w:./-]+/g) ?? [];
28
+ }
29
+ // mapd's finding IDs are 8-hex-char content hashes (id8() in review.js).
30
+ // Matched only as a bare 8-char hex token so normal prose hex-looking words
31
+ // don't false-positive as often (still possible, but rare, and callers only
32
+ // check this when they actually pass findingIds — see below).
33
+ function extractFindingIds(text) {
34
+ return text.match(/\b[0-9a-f]{8}\b/g) ?? [];
35
+ }
36
+ function basenamesOf(files) {
37
+ return new Set(files.map((f) => f.split("/").pop()));
38
+ }
39
+
40
+ const METADATA_FILES = [
41
+ "package.json",
42
+ "package-lock.json",
43
+ "pnpm-lock.yaml",
44
+ "yarn.lock",
45
+ "bun.lockb",
46
+ "tsconfig.json",
47
+ "jsconfig.json",
48
+ ".mapdrc",
49
+ "README.md",
50
+ "MAP.md",
51
+ ];
52
+
53
+ /**
54
+ * Grounding needs "files this answer may cite", not only "source files Map'd
55
+ * parsed into the AST graph." package.json/tsconfig/README are real project
56
+ * data Map'd consumes or documents, even though they are not JS/TS FileNodes.
57
+ */
58
+ export function buildGroundingFileList(rootDir, graph) {
59
+ const abs = path.resolve(rootDir);
60
+ const files = new Set((graph.files ?? []).map((f) => f.file));
61
+ for (const file of METADATA_FILES) {
62
+ if (fs.existsSync(path.join(abs, file))) files.add(file);
63
+ }
64
+ return [...files].sort();
65
+ }
66
+
67
+ /**
68
+ * `groundTruth`: { files?: string[], workflowIds?: string[], findingIds?: string[] }
69
+ * Only claim types with a corresponding ground-truth array get checked at
70
+ * all — omitting `findingIds` means finding-ID-shaped tokens are never
71
+ * flagged, so a caller can't be falsely told something is "grounded" for a
72
+ * claim type it never actually verified.
73
+ *
74
+ * Returns { grounded, violations: [{type, value}], checkedTypes }.
75
+ */
76
+ export function verifyGrounding(text, groundTruth = {}) {
77
+ const violations = [];
78
+ const verified = [];
79
+ const checkedTypes = [];
80
+ let remaining = text;
81
+
82
+ if (groundTruth.workflowIds) {
83
+ checkedTypes.push("workflowIds");
84
+ const valid = new Set(groundTruth.workflowIds);
85
+ const found = extractWorkflowIds(remaining);
86
+ for (const w of found) {
87
+ if (valid.has(w)) verified.push({ type: "workflow", value: w });
88
+ else violations.push({ type: "workflowId", value: w });
89
+ }
90
+ // strip matched workflow IDs before file-token scanning so a workflow
91
+ // ID's own trailing filename-looking segment isn't double-counted as an
92
+ // unrelated bare file claim (a workflow ID often embeds an entry file).
93
+ remaining = found.reduce((t, w) => t.split(w).join(" "), remaining);
94
+ }
95
+
96
+ if (groundTruth.findingIds) {
97
+ checkedTypes.push("findingIds");
98
+ const valid = new Set(groundTruth.findingIds);
99
+ for (const id of extractFindingIds(remaining)) {
100
+ if (valid.has(id)) verified.push({ type: "finding", value: id });
101
+ else violations.push({ type: "findingId", value: id });
102
+ }
103
+ }
104
+
105
+ if (groundTruth.files) {
106
+ checkedTypes.push("files");
107
+ const validFiles = new Set(groundTruth.files);
108
+ const validBasenames = basenamesOf(groundTruth.files);
109
+ for (const p of extractPathLikeTokens(remaining)) {
110
+ if (!validFiles.has(p) && !validBasenames.has(p.split("/").pop())) violations.push({ type: "file", value: p });
111
+ else verified.push({ type: "file", value: p });
112
+ }
113
+ }
114
+
115
+ if (groundTruth.graph) {
116
+ checkedTypes.push("relations", "symbols");
117
+ const index = graphIndex(groundTruth.graph);
118
+ for (const claim of extractRelationClaims(text, index)) {
119
+ const ok = claim.verb === "calls" ? fileCalls(index, claim.file, claim.target) : fileImports(index, claim.file, claim.target);
120
+ const value = `${claim.file} ${claim.verb} ${claim.target}`;
121
+ if (ok) verified.push({ type: "relation", value });
122
+ else violations.push({ type: "relation", value, detail: `no ${claim.verb === "calls" ? "call to" : "import of"} ${claim.target} in ${claim.file}` });
123
+ }
124
+ for (const sym of extractSymbolClaims(text)) {
125
+ const head = sym.split(".")[0];
126
+ if (index.symbols.has(sym) || index.symbols.has(sym.split(".").pop()) || GLOBALS.has(head) || MAPD_TERMS.has(sym)) verified.push({ type: "symbol", value: sym });
127
+ else violations.push({ type: "symbol", value: sym, detail: "no function, export, or import by that name in the map" });
128
+ }
129
+ }
130
+
131
+ return { grounded: violations.length === 0, violations, verified, checkedTypes };
132
+ }
133
+
134
+ /** One-line human summary of a verifyGrounding result — what was checked, and what failed. */
135
+ export function describeGrounding(check) {
136
+ const counts = {};
137
+ for (const v of check.verified ?? []) counts[v.type] = (counts[v.type] ?? 0) + 1;
138
+ const ok = Object.entries(counts).map(([t, n]) => `${n} ${t}${n > 1 ? "s" : ""}`).join(", ");
139
+ const bad = check.violations.map((v) => `${v.type} "${v.value}"${v.detail ? ` (${v.detail})` : ""}`);
140
+ return { ok, bad };
141
+ }
142
+
143
+ // ── claims about code: relations ("x.js calls `foo`") and symbols (`foo()`) ──
144
+
145
+ const graphIndexCache = new WeakMap();
146
+ function graphIndex(graph) {
147
+ let idx = graphIndexCache.get(graph);
148
+ if (idx) return idx;
149
+ const byFile = new Map((graph.files ?? []).map((f) => [f.file, f]));
150
+ const byBasename = new Map();
151
+ for (const f of byFile.keys()) {
152
+ const b = f.split("/").pop();
153
+ byBasename.set(b, byBasename.has(b) ? null : f); // null = ambiguous basename
154
+ }
155
+ const symbols = new Set();
156
+ for (const f of byFile.values()) {
157
+ for (const fn of f.functions ?? []) for (const part of String(fn.name).split(/\.|#/)) if (part && part !== "prototype") symbols.add(part);
158
+ for (const e of f.exports ?? []) symbols.add(e);
159
+ for (const imp of f.imports ?? []) { for (const n of imp.names ?? []) symbols.add(n); symbols.add(imp.source); }
160
+ }
161
+ idx = { byFile, byBasename, symbols };
162
+ graphIndexCache.set(graph, idx);
163
+ return idx;
164
+ }
165
+
166
+ function resolveFileClaim(index, token) {
167
+ if (index.byFile.has(token)) return token;
168
+ return index.byBasename.get(token.split("/").pop()) ?? null;
169
+ }
170
+
171
+ const lastSegment = (name) => String(name).replace(/\(\)$/, "").split(/\.|#/).pop();
172
+ const stemOf = (file) => file.split("/").pop().replace(/\.[^.]+$/, "");
173
+
174
+ function fileCalls(index, file, target) {
175
+ const want = lastSegment(target);
176
+ const node = index.byFile.get(file);
177
+ return (node?.functions ?? []).some((fn) => (fn.calls ?? []).some((c) => lastSegment(c) === want));
178
+ }
179
+
180
+ function fileImports(index, file, target) {
181
+ const node = index.byFile.get(file);
182
+ const want = lastSegment(target);
183
+ const targetStem = /\.[a-z]+$/i.test(target) ? stemOf(target) : null;
184
+ return (node?.imports ?? []).some((imp) =>
185
+ (imp.names ?? []).includes(want) || (targetStem && stemOf(imp.source ?? "") === targetStem) || imp.source === target);
186
+ }
187
+
188
+ // Third-person verb forms only: "call site", "import edge", "require()" are nouns, not claims.
189
+ const VERBS = { calls: "calls", invokes: "calls", imports: "imports", requires: "imports" };
190
+ // Map'd's own vocabulary shows up in answers about the map and is not a code symbol.
191
+ const MAPD_TERMS = new Set([
192
+ "dynamicallyLoaded", "trulyOrphaned", "generatedArtifacts", "intentionalDormant", "heuristicUnverified",
193
+ "parseIntegrity", "resolutionRate", "testPresence", "stability", "coverageOfRepo", "signalCoverage",
194
+ "repoConfidence", "callResolutionRate", "importResolutionRate", "exportedSurface", "entryPoints",
195
+ ]);
196
+ const NEGATION = /\b(?:not|never|no longer|doesn.?t|don.?t|isn.?t|without)\b/i;
197
+ const TOKEN = "`?([\\w./-]+\\.(?:js|jsx|ts|tsx|mjs|cjs))`?";
198
+
199
+ /**
200
+ * "`a.js` calls `foo`", "a.js imports b.js" → { file, verb, target }. Only
201
+ * sentences whose subject is a REAL mapped file are checkable; everything else
202
+ * is left alone rather than guessed at. Negated sentences are skipped.
203
+ */
204
+ function extractRelationClaims(text, index) {
205
+ const claims = [];
206
+ const re = new RegExp(`${TOKEN}[^.\\n\`]{0,40}?\\b(calls|invokes|imports|requires)\\b[^.\\n\`]{0,40}?\`([\\w$./-]+?)(?:\\(\\))?\``, "g");
207
+ for (const sentence of text.split(/(?<=[.!?])\s+|\n+/)) {
208
+ if (NEGATION.test(sentence)) continue;
209
+ for (const m of sentence.matchAll(re)) {
210
+ const file = resolveFileClaim(index, m[1]);
211
+ if (!file) continue; // an unknown file is already reported by the file check
212
+ const verb = VERBS[m[2].toLowerCase()];
213
+ if (verb === "calls" && /\.(?:js|jsx|ts|tsx|mjs|cjs)$/.test(m[3])) continue; // a file is imported, not called
214
+ claims.push({ file, verb, target: m[3] });
215
+ }
216
+ }
217
+ return claims;
218
+ }
219
+
220
+ /**
221
+ * Backticked identifiers that are unmistakably code — called (`foo()`),
222
+ * camelCase/PascalCase, or dotted members — never plain words, flags,
223
+ * ALL_CAPS env vars, or file paths (files have their own check).
224
+ */
225
+ function extractSymbolClaims(text) {
226
+ const out = new Set();
227
+ for (const m of text.matchAll(/`([A-Za-z_$][\w$]*(?:\.[A-Za-z_$][\w$]*)*)(\(\))?`/g)) {
228
+ const [, name, called] = m;
229
+ if (/^[A-Z0-9_]+$/.test(name)) continue;
230
+ if (/\.(?:js|jsx|ts|tsx|mjs|cjs|json|md)$/.test(name)) continue;
231
+ const codeShaped = called || name.includes(".") || /[a-z][A-Z]/.test(name) || /^[A-Z][a-z]+[A-Z]/.test(name);
232
+ if (codeShaped) out.add(name);
233
+ }
234
+ return [...out];
235
+ }
@@ -0,0 +1,157 @@
1
+ /**
2
+ * handoff.js — `mapd handoff`: package the highest-priority open findings
3
+ * into a structured prompt for an external coding agent (Claude Code, Codex)
4
+ * to execute. Mapd never edits code itself — chat is a project-understanding
5
+ * and verification layer, not a general agent (see chat/repl.js) — so this
6
+ * automates the manual step of turning findings into an agent-ready prompt,
7
+ * instead of a human hand-writing one from the review queue each time.
8
+ *
9
+ * Every section is built ONLY from real, already-computed data (the review
10
+ * queue, the workflow graph, the baseline diff) — never an invented file
11
+ * grouping, task description, or priority beyond what the underlying
12
+ * finding/scoring already produced.
13
+ */
14
+
15
+ import path from "node:path";
16
+ import { buildScoredGraph, detectStack } from "./intelligence.js";
17
+ import { loadPkg } from "./graph.js";
18
+ import { loadBaseline, diffGraphs } from "./regression.js";
19
+ import { pending, loadQueue } from "./review.js";
20
+ import { loadFinding } from "./fix.js";
21
+ import { priorityOf, filesOf } from "./findingScoring.js";
22
+ import { checkReportFreshness } from "./staleness.js";
23
+
24
+ /**
25
+ * Top-N open (check + modernize) findings, ranked by severity/priority across
26
+ * both sources. Findings sourced from a STALE report (one that predates a
27
+ * more recent source change — see staleness.js) are excluded from the ranked
28
+ * list entirely, not just annotated: a banner next to a fully-formed action
29
+ * plan is easy to skip past, and acting on a stale finding means fixing
30
+ * something that may already be fixed. The exclusion itself is disclosed
31
+ * (count + which reports), never silent. `staleReports` is
32
+ * checkReportFreshness's `staleReports` (report basenames).
33
+ */
34
+ export function collectTopFindings(rootDir, { top = 5, staleReports = [] } = {}) {
35
+ const abs = path.resolve(rootDir);
36
+ const staleSet = new Set(staleReports);
37
+ const items = pending(loadQueue(abs)).filter((i) => i.source === "check" || i.source.startsWith("modernize-"));
38
+ const fresh = items.filter((i) => !staleSet.has(path.basename(i.file)));
39
+ const excludedStaleCount = items.length - fresh.length;
40
+ const findings = fresh
41
+ .map((item) => {
42
+ const loaded = loadFinding(abs, item.id);
43
+ if (!loaded) return null;
44
+ return { item, finding: loaded.finding, priority: priorityOf(loaded.finding, item) };
45
+ })
46
+ .filter(Boolean)
47
+ .sort((a, b) => b.priority - a.priority)
48
+ .slice(0, top);
49
+ return { findings, excludedStaleCount };
50
+ }
51
+
52
+ /** Workflows newly added since the last baseline — files an external agent should treat as fragile. */
53
+ function newWorkflowsSinceBaseline(rootDir, graph) {
54
+ const abs = path.resolve(rootDir);
55
+ const loaded = loadBaseline(abs);
56
+ if (!loaded || loaded.schemaMismatch) return [];
57
+ const diff = diffGraphs(loaded.graph, graph);
58
+ const ids = diff.filter((f) => f.kind === "workflow-added").map((f) => /^New workflow (\S+) detected/.exec(f.detail)?.[1]).filter(Boolean);
59
+ return graph.workflows.filter((w) => ids.includes(w.id)).map((w) => ({ id: w.id, entry: w.entry.file, fileCount: w.files.length }));
60
+ }
61
+
62
+ /** Builds the structured handoff data — pure data, no formatting decisions. */
63
+ export function buildHandoff(rootDir, { top = 5 } = {}) {
64
+ const abs = path.resolve(rootDir);
65
+ const graph = buildScoredGraph(abs);
66
+ const pkg = loadPkg(abs);
67
+ const stack = detectStack(pkg, graph);
68
+ const freshness = checkReportFreshness(abs);
69
+ const { findings: topFindings, excludedStaleCount } = collectTopFindings(abs, { top, staleReports: freshness.staleReports });
70
+
71
+ return {
72
+ root: abs,
73
+ project: pkg?.name ?? path.basename(abs),
74
+ stack,
75
+ fileCount: graph.stats.fileCount,
76
+ workflowCount: graph.workflows.length,
77
+ repoConfidence: graph.repoConfidence,
78
+ callResolutionRate: graph.stats.callResolutionRate,
79
+ freshness,
80
+ excludedStaleCount,
81
+ newWorkflows: newWorkflowsSinceBaseline(abs, graph),
82
+ tasks: topFindings.map(({ item, finding, priority }, i) => ({
83
+ order: i + 1,
84
+ id: item.id,
85
+ source: item.source,
86
+ kind: finding.kind ?? finding.rule,
87
+ severity: finding.severity ?? null,
88
+ priority: Number(priority.toFixed(3)),
89
+ detail: finding.detail,
90
+ files: filesOf(finding),
91
+ suggestion: finding.suggestion ?? null,
92
+ })),
93
+ };
94
+ }
95
+
96
+ /** Renders `buildHandoff`'s data as a plain-text prompt, ready to paste into an external agent. */
97
+ export function renderHandoffPrompt(data) {
98
+ const lines = [];
99
+ const stackLabel = [...data.stack.languages, ...data.stack.frameworks].join(" + ") || "JavaScript";
100
+
101
+ if (data.freshness?.stale && data.excludedStaleCount > 0) {
102
+ lines.push(`⚠ STALE REPORTS EXCLUDED: ${data.freshness.staleReports.join(", ")} predate a more recent source change. ` +
103
+ `${data.excludedStaleCount} finding(s) sourced from them were EXCLUDED from the tasks below (not just flagged) — ` +
104
+ "every task shown is backed by a report that is current with the working tree. " +
105
+ "Re-run `mapd check`/`mapd modernize` to refresh the stale report(s) and include their findings.");
106
+ lines.push("");
107
+ } else if (data.freshness?.stale) {
108
+ // stale report(s) exist but contributed no open findings — one quiet line,
109
+ // not a warning banner about zero exclusions
110
+ lines.push(`note: ${data.freshness.staleReports.join(", ")} predate a more recent source change but contributed no open findings; re-run \`mapd check\`/\`mapd modernize\` to refresh.`);
111
+ lines.push("");
112
+ }
113
+
114
+ lines.push("CONTEXT");
115
+ lines.push("=======");
116
+ lines.push(`${data.fileCount}-file ${stackLabel} project (${data.project}).`);
117
+ lines.push(`repo confidence: ${data.repoConfidence} call resolution: ${(data.callResolutionRate * 100).toFixed(1)}%`);
118
+ if (data.tasks.length) {
119
+ lines.push(`Map'd identified ${data.tasks.length} priority finding(s) below, highest first. Work through them in order.`);
120
+ lines.push("Do not refactor beyond the stated scope for each task.");
121
+ }
122
+ lines.push("");
123
+
124
+ if (data.newWorkflows.length) {
125
+ lines.push("REGRESSION GUARD");
126
+ lines.push("================");
127
+ lines.push("These workflows were newly detected since the last baseline — treat their files as fragile;");
128
+ lines.push("preserve existing import/export surface unless a task below explicitly targets them.");
129
+ for (const wf of data.newWorkflows) lines.push(` - ${wf.id} (entry: ${wf.entry}, ${wf.fileCount} files)`);
130
+ lines.push("");
131
+ }
132
+
133
+ if (!data.tasks.length) {
134
+ lines.push(data.excludedStaleCount > 0
135
+ ? `All ${data.excludedStaleCount} open finding(s) come from stale report(s) — nothing current to hand off. Re-run \`mapd check\`/\`mapd modernize\` first.`
136
+ : "No open findings — nothing to hand off. Run `mapd check` / `mapd modernize` first.");
137
+ return lines.join("\n");
138
+ }
139
+
140
+ lines.push("━".repeat(70));
141
+ for (const task of data.tasks) {
142
+ const header = `TASK ${task.order} — ${String(task.kind).toUpperCase()}`;
143
+ lines.push("");
144
+ lines.push(header);
145
+ lines.push("=".repeat(header.length));
146
+ lines.push(`Finding ID: ${task.id} | source: ${task.source} | priority: ${task.priority}` +
147
+ (task.severity ? ` | severity: ${task.severity}` : ""));
148
+ lines.push(`Problem: ${task.detail}`);
149
+ if (task.files.length) lines.push(`Files: ${task.files.join(", ")}`);
150
+ if (task.suggestion) lines.push(`Suggested direction: ${task.suggestion}`);
151
+ lines.push("After this task: run `mapd check` and re-verify no new regressions before moving to the next task.");
152
+ }
153
+ lines.push("");
154
+ lines.push("━".repeat(70));
155
+ lines.push(`EXECUTION ORDER: Task 1 → Task ${data.tasks.length}, in order. One commit per task. Do not batch.`);
156
+ return lines.join("\n");
157
+ }
@@ -0,0 +1,218 @@
1
+ /**
2
+ * importResolver.js — resolves import specifiers the way the project's OWN
3
+ * toolchain resolves them, not just by relative-path guessing. Every alias
4
+ * mapd can't resolve becomes a false "unresolved edge" that depresses call
5
+ * resolution and inflates orphan candidates — so this attacks orphan/
6
+ * confidence accuracy at the root instead of adding downstream caveats.
7
+ *
8
+ * Sources of truth, all read from the project's real configuration:
9
+ * - tsconfig.json / jsconfig.json `compilerOptions.paths` + `baseUrl`
10
+ * (root-level file only; `extends` chains are not followed — a miss
11
+ * falls through to the old behavior, never a wrong resolution)
12
+ * - package.json `imports` (Node's `#`-prefixed subpath imports)
13
+ * - package.json `exports` (self-referencing imports of the package's
14
+ * own name — Node resolves these through the exports map)
15
+ * - TypeScript's node16/nodenext ESM convention: source says
16
+ * `import "./x.js"` but the file on disk is `x.ts`/`x.tsx`
17
+ *
18
+ * DESIGN RULE (same as everywhere else): a candidate only resolves if the
19
+ * target file actually exists in the project's file set. An alias that
20
+ * matches but points at nothing stays "unresolved" — never fabricated.
21
+ */
22
+
23
+ import fs from "node:fs";
24
+ import path from "node:path";
25
+
26
+ /**
27
+ * Tolerates // and block comments plus trailing commas — tsconfig.json is
28
+ * JSONC in practice. Must be string-aware, not regex-based: tsconfig paths
29
+ * keys literally contain "/*" (e.g. "@/*"), which a naive block-comment
30
+ * regex would eat as a comment opener and corrupt the whole document.
31
+ */
32
+ function stripJsonc(raw) {
33
+ let out = "";
34
+ let inString = false;
35
+ for (let i = 0; i < raw.length; i++) {
36
+ const ch = raw[i];
37
+ if (inString) {
38
+ out += ch;
39
+ if (ch === "\\") { out += raw[++i] ?? ""; continue; } // escaped char, incl. \"
40
+ if (ch === '"') inString = false;
41
+ continue;
42
+ }
43
+ if (ch === '"') { inString = true; out += ch; continue; }
44
+ if (ch === "/" && raw[i + 1] === "/") { while (i < raw.length && raw[i] !== "\n") i++; out += "\n"; continue; }
45
+ if (ch === "/" && raw[i + 1] === "*") { i += 2; while (i < raw.length && !(raw[i] === "*" && raw[i + 1] === "/")) i++; i++; continue; }
46
+ if (ch === "}" || ch === "]") { out = out.replace(/,\s*$/, ""); } // trailing comma, only ever outside strings here
47
+ out += ch;
48
+ }
49
+ return out;
50
+ }
51
+
52
+ function readJsonc(absPath) {
53
+ let raw;
54
+ try { raw = fs.readFileSync(absPath, "utf8"); } catch { return null; }
55
+ try {
56
+ return JSON.parse(stripJsonc(raw));
57
+ } catch {
58
+ return null;
59
+ }
60
+ }
61
+
62
+ /**
63
+ * Loads `paths` aliases from tsconfig.json (preferred) or jsconfig.json.
64
+ * Returns [{ keyPrefix, keySuffix, targets: [{prefix, suffix}] }] where a
65
+ * single `*` wildcard splits key/target into prefix+suffix (TS allows at
66
+ * most one `*` per pattern); exact keys have keySuffix === null.
67
+ */
68
+ export function loadPathAliases(rootDir) {
69
+ for (const name of ["tsconfig.json", "jsconfig.json"]) {
70
+ const config = readJsonc(path.join(rootDir, name));
71
+ const paths = config?.compilerOptions?.paths;
72
+ if (!paths || typeof paths !== "object") continue;
73
+ const baseUrl = config.compilerOptions.baseUrl ?? ".";
74
+ const aliases = [];
75
+ for (const [key, targets] of Object.entries(paths)) {
76
+ if (!Array.isArray(targets)) continue;
77
+ const starIdx = key.indexOf("*");
78
+ const entry = {
79
+ keyPrefix: starIdx === -1 ? key : key.slice(0, starIdx),
80
+ keySuffix: starIdx === -1 ? null : key.slice(starIdx + 1),
81
+ targets: targets
82
+ .filter((t) => typeof t === "string")
83
+ .map((t) => {
84
+ const tStar = t.indexOf("*");
85
+ const joined = (p) => path.posix.normalize(path.posix.join(baseUrl, p));
86
+ return tStar === -1
87
+ ? { prefix: joined(t), suffix: "" }
88
+ : { prefix: joined(t.slice(0, tStar)), suffix: t.slice(tStar + 1) };
89
+ }),
90
+ };
91
+ if (entry.targets.length) aliases.push(entry);
92
+ }
93
+ if (aliases.length) return aliases;
94
+ }
95
+ return [];
96
+ }
97
+
98
+ /** Unwraps a package.json imports/exports value: plain string, or a conditions object (first matching common condition). */
99
+ function unwrapConditional(value) {
100
+ if (typeof value === "string") return value;
101
+ if (value && typeof value === "object") {
102
+ for (const cond of ["import", "require", "node", "default"]) {
103
+ if (typeof value[cond] === "string") return value[cond];
104
+ if (value[cond] && typeof value[cond] === "object") {
105
+ const nested = unwrapConditional(value[cond]);
106
+ if (nested) return nested;
107
+ }
108
+ }
109
+ }
110
+ return null;
111
+ }
112
+
113
+ /** Matches `spec` against a subpath map (package.json imports/exports): exact first, then single-`*` patterns. */
114
+ function resolveSubpathMap(map, spec) {
115
+ if (!map || typeof map !== "object") return null;
116
+ if (map[spec] !== undefined) return unwrapConditional(map[spec]);
117
+ for (const [key, value] of Object.entries(map)) {
118
+ const starIdx = key.indexOf("*");
119
+ if (starIdx === -1) continue;
120
+ const prefix = key.slice(0, starIdx);
121
+ const suffix = key.slice(starIdx + 1);
122
+ if (spec.startsWith(prefix) && spec.endsWith(suffix) && spec.length >= prefix.length + suffix.length) {
123
+ const matched = spec.slice(prefix.length, spec.length - suffix.length || undefined);
124
+ const target = unwrapConditional(value);
125
+ return target ? target.replace("*", matched) : null;
126
+ }
127
+ }
128
+ return null;
129
+ }
130
+
131
+ /**
132
+ * Creates the resolver used for every import edge in buildGraph.
133
+ * `resolve(fromFile, spec)` returns exactly the old resolveImport contract:
134
+ * `{internal}` | `{external}` | `{unresolved}`.
135
+ */
136
+ export function createImportResolver(rootDir, fileSet, pkg) {
137
+ const aliases = loadPathAliases(rootDir);
138
+ const importsMap = pkg?.imports ?? null;
139
+ const exportsMap = pkg?.exports ?? null;
140
+ const pkgName = typeof pkg?.name === "string" ? pkg.name : null;
141
+
142
+ /** Existence-checked candidate expansion; includes the TS-ESM `.js` → `.ts` swap. */
143
+ function tryFile(basePosix) {
144
+ const base = path.posix.normalize(basePosix);
145
+ const candidates = [
146
+ base,
147
+ `${base}.js`, `${base}.ts`, `${base}.jsx`, `${base}.tsx`, `${base}.mjs`, `${base}.cjs`,
148
+ `${base}/index.js`, `${base}/index.ts`,
149
+ ];
150
+ // node16/nodenext TypeScript: the import says .js/.mjs/.cjs but the file
151
+ // on disk is the .ts flavor — the compiler rewrites at emit time.
152
+ const extSwap = { ".js": [".ts", ".tsx"], ".mjs": [".mts"], ".cjs": [".cts"] };
153
+ for (const [from, tos] of Object.entries(extSwap)) {
154
+ if (base.endsWith(from)) for (const to of tos) candidates.push(base.slice(0, -from.length) + to);
155
+ }
156
+ for (const c of candidates) {
157
+ const norm = path.posix.normalize(c);
158
+ if (fileSet.has(norm)) return norm;
159
+ }
160
+ return null;
161
+ }
162
+
163
+ function resolve(fromFile, spec) {
164
+ // 1. relative / absolute — the original behavior, plus the extension swap
165
+ if (spec.startsWith(".") || spec.startsWith("/")) {
166
+ const base = path.posix.join(path.posix.dirname(fromFile.split(path.sep).join("/")), spec);
167
+ const hit = tryFile(base);
168
+ return hit ? { internal: hit } : { unresolved: spec };
169
+ }
170
+
171
+ // 2. package.json "imports" — Node reserves the # prefix for these, so an
172
+ // unmatched #-spec can never be an external package: honest unresolved.
173
+ if (spec.startsWith("#")) {
174
+ const target = importsMap ? resolveSubpathMap(importsMap, spec) : null;
175
+ if (target) {
176
+ const hit = tryFile(target.startsWith("./") ? target.slice(2) : target);
177
+ if (hit) return { internal: hit };
178
+ }
179
+ return { unresolved: spec };
180
+ }
181
+
182
+ // 3. tsconfig/jsconfig paths aliases
183
+ for (const alias of aliases) {
184
+ let remainder = null;
185
+ if (alias.keySuffix === null) {
186
+ if (spec === alias.keyPrefix) remainder = "";
187
+ else continue;
188
+ } else if (spec.startsWith(alias.keyPrefix) && spec.endsWith(alias.keySuffix)) {
189
+ remainder = spec.slice(alias.keyPrefix.length, spec.length - alias.keySuffix.length || undefined);
190
+ } else continue;
191
+ for (const t of alias.targets) {
192
+ const hit = tryFile(path.posix.join(t.prefix, remainder + t.suffix));
193
+ if (hit) return { internal: hit };
194
+ }
195
+ // alias matched but no target file exists — a real broken alias, not an
196
+ // external package; report it honestly rather than misclassifying.
197
+ return { unresolved: spec };
198
+ }
199
+
200
+ // 4. self-referencing import of the package's own name via "exports"
201
+ if (pkgName && (spec === pkgName || spec.startsWith(`${pkgName}/`))) {
202
+ const subpath = spec === pkgName ? "." : `./${spec.slice(pkgName.length + 1)}`;
203
+ const target = typeof exportsMap === "string" && subpath === "."
204
+ ? exportsMap
205
+ : resolveSubpathMap(exportsMap, subpath);
206
+ if (target) {
207
+ const hit = tryFile(target.startsWith("./") ? target.slice(2) : target);
208
+ if (hit) return { internal: hit };
209
+ }
210
+ // fall through: a package importing its own name with no matching
211
+ // export behaves like an external lookup at runtime too
212
+ }
213
+
214
+ return { external: spec };
215
+ }
216
+
217
+ return { resolve, aliasCount: aliases.length };
218
+ }