@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,370 @@
1
+ /**
2
+ * modernize.js — Function 3: modernization / optimization scan.
3
+ *
4
+ * "More deterministic, not hallucinating" is implemented as three detector
5
+ * tiers, all rule-based; the LLM only *elaborates* findings into proposals
6
+ * (heavy mode, optional) and cannot create or score them.
7
+ *
8
+ * MODES are breadth knobs, not intelligence knobs:
9
+ * light — dependency tier only (fast; safe for every push)
10
+ * medium — + code-pattern tier on the largest workflows (top 3 by file count)
11
+ * heavy — + code-pattern tier on ALL workflows + architecture tier
12
+ * (+ --propose drafts LLM migration plans per finding)
13
+ *
14
+ * OPERATIONAL IMPACT is derived per finding:
15
+ * reach occurrences / touched files, normalized against repo size
16
+ * certainty detector-defined: curated table = 1.0 (deprecated by upstream,
17
+ * a fact), npm-outdated = 0.9 (registry-measured), AST pattern
18
+ * = 1.0 for occurrence (the pattern IS there) scaled by whether
19
+ * replacement is behavior-identical (table constant per rule)
20
+ * safety test presence over touched files (how safely can we change it)
21
+ * impact = reach × certainty; priority = impact × (0.5 + 0.5 × safety)
22
+ * Constants are per-rule policy, documented inline — never per-run guesses.
23
+ */
24
+
25
+ import fs from "node:fs";
26
+ import path from "node:path";
27
+ import { execSync } from "node:child_process";
28
+ import { performance } from "node:perf_hooks";
29
+ import { detectCircularDependencies, isTestFile } from "./graph.js";
30
+ import { mergeFindingLifecycle } from "./regression.js";
31
+
32
+ /* ---------------- Tier 1: dependency health ---------------- */
33
+
34
+ // Curated table: upstream-deprecated or community-superseded packages.
35
+ // certainty 1.0 = deprecation/supersession is a published fact, not an opinion.
36
+ export const LEGACY_DEPS = {
37
+ request: { replacement: "undici / native fetch", reason: "deprecated by maintainers (Feb 2020)", kind: "deprecated" },
38
+ moment: { replacement: "dayjs / date-fns / Temporal", reason: "in maintenance mode per project docs", kind: "maintenance-mode" },
39
+ "node-sass": { replacement: "sass (dart-sass)", reason: "deprecated in favor of dart-sass", kind: "deprecated" },
40
+ "babel-eslint": { replacement: "@babel/eslint-parser", reason: "renamed/superseded", kind: "superseded" },
41
+ tslint: { replacement: "eslint + typescript-eslint", reason: "deprecated by Palantir", kind: "deprecated" },
42
+ "left-pad": { replacement: "String.prototype.padStart", reason: "native replacement exists", kind: "native-replacement" },
43
+ mkdirp: { replacement: "fs.mkdir({recursive:true})", reason: "native replacement exists", kind: "native-replacement" },
44
+ rimraf: { replacement: "fs.rm({recursive:true,force:true})", reason: "native replacement exists (Node 14.14+)", kind: "native-replacement" },
45
+ "body-parser": { replacement: "express.json()/express.urlencoded()", reason: "bundled into Express 4.16+", kind: "bundled" },
46
+ bluebird: { replacement: "native Promise", reason: "native promises match performance/features", kind: "native-replacement" },
47
+ underscore: { replacement: "native array/object methods or lodash-es", reason: "largely superseded", kind: "superseded" },
48
+ q: { replacement: "native Promise", reason: "native replacement exists", kind: "native-replacement" },
49
+ };
50
+
51
+ function depFindings(rootDir, pkg, { checkRegistry = true, registryTimeoutMs = 2000 } = {}) {
52
+ const findings = [];
53
+ const deps = { ...(pkg?.dependencies ?? {}), ...(pkg?.devDependencies ?? {}) };
54
+ for (const [name, meta] of Object.entries(LEGACY_DEPS)) {
55
+ if (deps[name]) {
56
+ findings.push({
57
+ tier: "dependency", rule: `legacy-dep:${name}`,
58
+ detail: `\`${name}\` — ${meta.reason}. Suggested replacement: ${meta.replacement}.`,
59
+ certainty: 1.0, occurrences: 1, files: ["package.json"], suggestion: meta.replacement,
60
+ });
61
+ }
62
+ }
63
+ // registry-measured staleness (best effort; offline → signal skipped, reported)
64
+ if (!checkRegistry) {
65
+ findings.push({ tier: "dependency", rule: "registry-skipped", informational: true,
66
+ detail: "npm outdated skipped by configuration — registry staleness signal skipped.", certainty: 1.0, occurrences: 0, files: [] });
67
+ return findings;
68
+ }
69
+ try {
70
+ const out = execSync("npm outdated --json", { cwd: rootDir, stdio: ["ignore", "pipe", "ignore"], timeout: registryTimeoutMs }).toString();
71
+ const outdated = out.trim() ? JSON.parse(out) : {};
72
+ const majors = Object.entries(outdated).filter(([, v]) =>
73
+ v.current && v.latest && v.current.split(".")[0] !== v.latest.split(".")[0]);
74
+ if (majors.length) {
75
+ findings.push({
76
+ tier: "dependency", rule: "major-versions-behind",
77
+ detail: `${majors.length} dependencies a major version behind: ${majors.map(([k, v]) => `${k} (${v.current}→${v.latest})`).join(", ")}.`,
78
+ certainty: 0.9, occurrences: majors.length, files: ["package.json"],
79
+ suggestion: "staged major upgrades, largest-risk first",
80
+ });
81
+ }
82
+ } catch {
83
+ findings.push({ tier: "dependency", rule: "registry-unavailable", informational: true,
84
+ detail: "npm outdated unavailable (offline or no registry access) — staleness signal skipped.", certainty: 1.0, occurrences: 0, files: [] });
85
+ }
86
+ return findings;
87
+ }
88
+
89
+ /* ---------------- Tier 2: code patterns (from the map — zero extra parsing) ---------------- */
90
+
91
+ // Each rule reads FileNode data the parser already extracted.
92
+ // behaviorSafe: replacement is semantically identical (raises certainty of benefit).
93
+ const PATTERN_RULES = [
94
+ {
95
+ rule: "var-declarations", behaviorSafe: true,
96
+ detect: (f) => f.varCount,
97
+ detail: (n) => `${n} \`var\` declaration(s) — migrate to let/const for block scoping.`,
98
+ suggestion: "let/const migration (codemod-able)",
99
+ },
100
+ {
101
+ rule: "promise-then-chains", behaviorSafe: false,
102
+ detect: (f) => f.functions.reduce((a, fn) => a + fn.calls.filter((c) => c.endsWith(".then") || c.endsWith(".catch")).length, 0),
103
+ detail: (n) => `${n} .then/.catch chain site(s) — candidates for async/await.`,
104
+ suggestion: "async/await refactor",
105
+ },
106
+ {
107
+ rule: "deprecated-node-api", behaviorSafe: false,
108
+ detect: (f) => f.functions.reduce((a, fn) => a + fn.calls.filter((c) =>
109
+ ["url.parse", "fs.exists", "util.isArray", "querystring.parse"].includes(c)).length, 0),
110
+ detail: (n) => `${n} call(s) to deprecated Node APIs (url.parse / fs.exists / util.isArray / querystring.parse).`,
111
+ suggestion: "WHATWG URL, fs.access/existsSync, Array.isArray, URLSearchParams",
112
+ },
113
+ {
114
+ // A .cjs extension is Node's explicit, unconditional CommonJS opt-out —
115
+ // Node treats it as CommonJS regardless of package.json's "type" field,
116
+ // so there is no real format ambiguity or interop risk there (unlike a
117
+ // plain .js/.mjs file, where Node has to guess from "type" and a mismatch
118
+ // is a genuine bug). Only flag the genuinely ambiguous case.
119
+ rule: "cjs-in-esm-project", behaviorSafe: false, projectLevel: true,
120
+ detect: (f, pkg) => (pkg?.type === "module" && f.moduleType === "script" && !f.file.endsWith(".cjs") ? 1 : 0),
121
+ detail: (n) => `${n} ambiguous CommonJS file(s) (.js/.mjs using require()/module.exports, not .cjs) inside an ` +
122
+ `ESM ("type":"module") package — Node must guess the format here, real interop risk. ` +
123
+ `(.cjs files are Node's explicit CommonJS opt-out and are excluded — no ambiguity there.)`,
124
+ suggestion: "convert to ESM imports/exports, or rename to .cjs to make the CommonJS choice explicit",
125
+ },
126
+ ];
127
+
128
+ function patternFindings(graph, pkg, scopeFiles, generatedSet) {
129
+ const findings = [];
130
+ // Generated files (bundler output, etc.) are excluded from every rule here
131
+ // — a var-declarations/promise-chain/CJS finding pointing at a build
132
+ // artifact tells you to hand-edit something that gets silently overwritten
133
+ // on the next build. The real fix target is the authored source the
134
+ // bundler reads from, which — being hand-written — gets scanned normally.
135
+ const inScope = graph.files.filter((f) => scopeFiles.has(f.file) && !generatedSet.has(f.file));
136
+ for (const rule of PATTERN_RULES) {
137
+ let occurrences = 0;
138
+ const files = [];
139
+ for (const f of inScope) {
140
+ const n = rule.detect(f, pkg) || 0;
141
+ if (n > 0) { occurrences += n; files.push(f.file); }
142
+ }
143
+ if (occurrences > 0) {
144
+ findings.push({
145
+ tier: "code-pattern", rule: rule.rule,
146
+ detail: rule.detail(occurrences),
147
+ certainty: 1.0, // occurrence is a fact; benefit certainty encoded in behaviorSafe
148
+ behaviorSafe: rule.behaviorSafe,
149
+ occurrences, files, suggestion: rule.suggestion,
150
+ });
151
+ }
152
+ }
153
+ return findings;
154
+ }
155
+
156
+ /* ---------------- Tier 3: architecture (heavy only) ---------------- */
157
+
158
+ function architectureFindings(graph) {
159
+ const findings = [];
160
+ const totalFiles = graph.files.length;
161
+
162
+ // monolith workflow: one workflow covering >70% of a repo with 12+ files
163
+ for (const wf of graph.workflows) {
164
+ if (totalFiles >= 12 && wf.files.length / totalFiles > 0.7) {
165
+ findings.push({
166
+ tier: "architecture", rule: "monolithic-workflow",
167
+ detail: `Workflow ${wf.id} spans ${wf.files.length}/${totalFiles} files (${Math.round(100 * wf.files.length / totalFiles)}%) — module-boundary or service-split candidate.`,
168
+ certainty: 1.0, occurrences: 1, files: wf.files,
169
+ suggestion: "extract sub-workflows along import-cluster boundaries",
170
+ });
171
+ }
172
+ }
173
+ // unclassified reachability: orphans beyond noise. graph.orphans already
174
+ // excludes files classified as generated artifacts or dynamically-loaded
175
+ // (see reachability.js) — what's left needs human classification, it is
176
+ // NOT asserted to be dead code. Leading with "dead code" overclaims
177
+ // certainty the static graph doesn't have; leading with "needs
178
+ // classification" matches what's actually known.
179
+ if (graph.orphans.length >= 3) {
180
+ // Orphan status is only as trustworthy as the call graph it's derived from:
181
+ // a low call-resolution rate (CJS require() indirection, dynamic dispatch,
182
+ // etc.) means real callers can be missed, producing false "orphan" claims.
183
+ // Disclose that on the finding itself rather than only as a top-level stat.
184
+ const resolutionRate = graph.stats.callResolutionRate;
185
+ const lowConfidence = resolutionRate < 0.9;
186
+ const excludedCount = (graph.reachability?.generatedArtifacts.length ?? 0) + (graph.reachability?.dynamicallyLoaded.length ?? 0);
187
+ findings.push({
188
+ tier: "architecture", rule: "orphan-cluster",
189
+ detail: `${graph.orphans.length} file(s) need classification — unreachable from any statically-traced entry ` +
190
+ "point. This is NOT a dead-code claim: verify each one (audit queue, not a deletion queue) before deleting or wiring in." +
191
+ (excludedCount > 0
192
+ ? ` (${excludedCount} other file(s) were already excluded from this count as generated artifacts or ` +
193
+ "dynamically-loaded — real evidence for each is in the project's reachability data, not asserted blindly.)"
194
+ : "") +
195
+ (lowConfidence
196
+ ? ` CAVEAT: call resolution is only ${(resolutionRate * 100).toFixed(1)}% — some of these may have real ` +
197
+ "callers the static analysis couldn't trace (e.g. dynamic require()/CJS indirection); verify before deleting."
198
+ : ""),
199
+ certainty: lowConfidence ? Number((0.5 + 0.5 * resolutionRate).toFixed(2)) : 1.0,
200
+ occurrences: graph.orphans.length, files: graph.orphans,
201
+ suggestion: "delete, or register the missing entry point",
202
+ });
203
+ }
204
+ // low-confidence workflow: derived score under 0.4 flags fragile territory
205
+ for (const wf of graph.workflows) {
206
+ if (wf.confidence && wf.confidence.score < 0.4) {
207
+ findings.push({
208
+ tier: "architecture", rule: "fragile-workflow",
209
+ detail: `Workflow ${wf.id} derived confidence ${wf.confidence.score} — weakest signals: ${
210
+ Object.entries(wf.confidence.signals).filter(([, s]) => s.value !== null && s.value < 0.5).map(([k]) => k).join(", ") || "n/a"}.`,
211
+ certainty: 1.0, occurrences: 1, files: wf.files,
212
+ suggestion: "raise the named signals before modernizing on top of this workflow",
213
+ });
214
+ }
215
+ }
216
+ // circular import dependencies: purely graph-derived (Tarjan SCCs over the
217
+ // resolved import graph) — a structural fact, not a heuristic guess.
218
+ for (const cycle of detectCircularDependencies(graph)) {
219
+ findings.push({
220
+ tier: "architecture", rule: "circular-dependency",
221
+ detail: `${cycle.length} file(s) mutually depend on each other in an import cycle: ${cycle.join(" -> ")}.`,
222
+ certainty: 1.0, occurrences: cycle.length, files: cycle,
223
+ suggestion: "extract the shared interface into a separate module both sides can import without importing each other",
224
+ });
225
+ }
226
+ // duplicate/near-duplicate functions: grouped by AST shape-hash (parser.js),
227
+ // which normalizes local variable names and literal values but keeps
228
+ // control-flow shape and called-function/property names literal — an exact
229
+ // structural match under that normalization, not a fuzzy guess. Generated
230
+ // files are excluded — repeated shapes inside a single bundler output are
231
+ // an artifact of the build, not hand-duplicated code worth consolidating.
232
+ const generatedSet = new Set(graph.generatedFiles.map((g) => g.file));
233
+ const shapeGroups = new Map();
234
+ for (const f of graph.files) {
235
+ if (generatedSet.has(f.file)) continue;
236
+ for (const fn of f.functions) {
237
+ if (!fn.shapeHash) continue;
238
+ if (!shapeGroups.has(fn.shapeHash)) shapeGroups.set(fn.shapeHash, []);
239
+ shapeGroups.get(fn.shapeHash).push({ file: f.file, name: fn.name });
240
+ }
241
+ }
242
+ for (const members of shapeGroups.values()) {
243
+ if (members.length < 2) continue;
244
+ const files = [...new Set(members.map((m) => m.file))];
245
+ findings.push({
246
+ tier: "architecture", rule: "duplicate-functions",
247
+ detail: `${members.length} structurally identical function(s) (same logic; variable names and literal values ignored): ${
248
+ members.map((m) => `${m.file}#${m.name}`).join(", ")}.`,
249
+ certainty: 1.0, occurrences: members.length, files,
250
+ suggestion: "extract the shared logic into one function every call site imports",
251
+ });
252
+ }
253
+ return findings;
254
+ }
255
+
256
+ /* ---------------- Impact scoring + orchestration ---------------- */
257
+
258
+ // Test-only files never ship — a finding confined entirely to test scaffolding
259
+ // (e.g. a fixture helper duplicated across 11 *.test.js files) is real
260
+ // maintenance debt, but it is categorically lower-stakes than the same
261
+ // pattern in production code, and a rule like duplicate-functions scans all
262
+ // files without distinguishing them. Without this, raw file-count/occurrence
263
+ // breadth in test files can outweigh a smaller finding in real, connected
264
+ // production code purely because it touches more files — verified against
265
+ // mapd's own self-scan, where an 11-file test-fixture duplicate outranked a
266
+ // 2-file production duplicate with real workflow blast radius. Weighting
267
+ // (not excluding) keeps large-scale test-suite disrepair visible, just far
268
+ // less urgent than anything touching shipped code.
269
+ const TEST_FILE_OPERATIONAL_WEIGHT = 0.15;
270
+
271
+ function operationalWeight(file) { return isTestFile(file) ? TEST_FILE_OPERATIONAL_WEIGHT : 1; }
272
+
273
+ function hasTestFor(allFiles, file) {
274
+ const base = path.posix.basename(file).replace(/\.(js|ts|jsx|tsx|mjs|cjs)$/, "");
275
+ return allFiles.some((f) => /(\.test\.|\.spec\.|__tests__\/|tests?\/)/.test(f) && f.includes(base));
276
+ }
277
+
278
+ function scoreFinding(finding, graph) {
279
+ if (finding.informational) return finding;
280
+ const repoFiles = Math.max(1, graph.files.length);
281
+ const weightedFileCount = finding.files.reduce((a, f) => a + operationalWeight(f), 0);
282
+ const avgWeight = finding.files.length ? weightedFileCount / finding.files.length : 1;
283
+ const reach = Math.min(1, weightedFileCount / repoFiles + (finding.occurrences * avgWeight) / (repoFiles * 5));
284
+ const allFiles = graph.files.map((f) => f.file);
285
+ const tested = finding.files.filter((f) => f === "package.json" || hasTestFor(allFiles, f)).length;
286
+ const safety = finding.files.length ? tested / finding.files.length : 1;
287
+ const impact = reach * finding.certainty;
288
+ finding.operationalImpact = {
289
+ reach: Number(reach.toFixed(3)),
290
+ certainty: finding.certainty,
291
+ safety: Number(safety.toFixed(3)),
292
+ impact: Number(impact.toFixed(3)),
293
+ priority: Number((impact * (0.5 + 0.5 * safety)).toFixed(3)),
294
+ method: "impact-composite-v2 (reach×certainty on operationally-weighted files/occurrences — test-only files count for less, they don't ship — priority damped by test safety)",
295
+ };
296
+ return finding;
297
+ }
298
+
299
+ function timed(profile, name, fn) {
300
+ if (!profile) return fn();
301
+ const startedAt = performance.now();
302
+ try {
303
+ return fn();
304
+ } finally {
305
+ profile.steps.push({ name, durationMs: Number((performance.now() - startedAt).toFixed(2)) });
306
+ }
307
+ }
308
+
309
+ export function runModernizationScan(rootDir, graph, pkg, mode = "medium", {
310
+ profile: withProfile = false,
311
+ checkRegistry = true,
312
+ registryTimeoutMs = 2000,
313
+ } = {}) {
314
+ const findings = [];
315
+ const startedAt = performance.now();
316
+ const profile = withProfile ? { steps: [] } : null;
317
+
318
+ findings.push(...timed(profile, "dependency-health", () => depFindings(rootDir, pkg, { checkRegistry, registryTimeoutMs }))); // all modes
319
+
320
+ if (mode === "medium" || mode === "heavy") {
321
+ findings.push(...timed(profile, "code-patterns", () => {
322
+ const wfs = [...graph.workflows].sort((a, b) => b.files.length - a.files.length);
323
+ const scoped = mode === "heavy" ? wfs : wfs.slice(0, 3);
324
+ const scopeFiles = new Set(scoped.flatMap((w) => w.files));
325
+ if (mode === "heavy") for (const o of graph.orphans) scopeFiles.add(o);
326
+ const generatedSet = new Set(graph.generatedFiles.map((g) => g.file));
327
+ return patternFindings(graph, pkg, scopeFiles, generatedSet);
328
+ }));
329
+ }
330
+ if (mode === "heavy") findings.push(...timed(profile, "architecture", () => architectureFindings(graph)));
331
+
332
+ timed(profile, "score-and-sort", () => {
333
+ for (const f of findings) scoreFinding(f, graph);
334
+ findings.sort((a, b) => (b.operationalImpact?.priority ?? -1) - (a.operationalImpact?.priority ?? -1));
335
+ });
336
+
337
+ const report = {
338
+ generatedAt: new Date().toISOString(), mode,
339
+ findings: findings.map((f) => ({ ...f, status: f.informational ? "info" : "awaiting-approval" })),
340
+ };
341
+ if (profile) {
342
+ profile.totalMs = Number((performance.now() - startedAt).toFixed(2));
343
+ report.profile = profile;
344
+ }
345
+ return report;
346
+ }
347
+
348
+ /**
349
+ * Persist a modernization report, merging its findings against the previous
350
+ * report for this mode through the same lifecycle rules as `mapd check`
351
+ * (regression.js mergeFindingLifecycle): unreproduced open findings become
352
+ * "resolved" with evidence, human dismissals are preserved, terminal entries
353
+ * stay as audit trail. Mutates report.findings to the merged set so every
354
+ * consumer (cli/chat/mcp) sees the same lifecycle-aware report.
355
+ * Returns { path, resolvedNow }.
356
+ */
357
+ export function saveModernizationReport(rootDir, report) {
358
+ const p = path.join(rootDir, ".mapd", `modernize-${report.mode}.json`);
359
+ fs.mkdirSync(path.dirname(p), { recursive: true });
360
+
361
+ let prev = null;
362
+ try { prev = JSON.parse(fs.readFileSync(p, "utf8")); } catch { /* first run for this mode */ }
363
+
364
+ const { findings, resolvedNow } = mergeFindingLifecycle(
365
+ report.findings, prev?.findings, (f) => `${f.rule}:${f.detail}`, `mapd modernize ${report.mode}`,
366
+ );
367
+ report.findings = findings;
368
+ fs.writeFileSync(p, JSON.stringify(report, null, 2));
369
+ return { path: p, resolvedNow };
370
+ }
@@ -0,0 +1,64 @@
1
+ /**
2
+ * parseCache.js - persistent, content-hash keyed parser cache.
3
+ *
4
+ * parseProject already knows how to reuse a Map of rel -> {hash,node}; this
5
+ * module only persists that Map between CLI/chat/MCP processes. The cache is
6
+ * intentionally a performance artifact: unreadable, stale, or schema-mismatched
7
+ * cache files are ignored and rebuilt from source.
8
+ */
9
+
10
+ import fs from "node:fs";
11
+ import path from "node:path";
12
+ import crypto from "node:crypto";
13
+
14
+ const CACHE_SCHEMA = 1;
15
+ const PARSER_SEMANTICS_VERSION = 2;
16
+
17
+ function cacheDir(rootDir) {
18
+ return path.join(path.resolve(rootDir), ".mapd", "cache");
19
+ }
20
+
21
+ export function parseCachePath(rootDir) {
22
+ return path.join(cacheDir(rootDir), "parse-v1.json");
23
+ }
24
+
25
+ export function parserConfigSignature(config = {}) {
26
+ const payload = {
27
+ parser: PARSER_SEMANTICS_VERSION,
28
+ include: config.project?.include ?? [],
29
+ exclude: config.project?.exclude ?? [],
30
+ maxFileSizeBytes: config.mapping?.maxFileSizeBytes ?? null,
31
+ };
32
+ return crypto.createHash("sha1").update(JSON.stringify(payload)).digest("hex");
33
+ }
34
+
35
+ export function loadPersistentParseCache(rootDir, config = {}) {
36
+ const p = parseCachePath(rootDir);
37
+ let raw;
38
+ try { raw = JSON.parse(fs.readFileSync(p, "utf8")); } catch { return new Map(); }
39
+ if (raw?.mapdSchema !== CACHE_SCHEMA) return new Map();
40
+ if (raw?.configSignature !== parserConfigSignature(config)) return new Map();
41
+ const entries = raw.entries && typeof raw.entries === "object" ? raw.entries : {};
42
+ return new Map(Object.entries(entries));
43
+ }
44
+
45
+ export function savePersistentParseCache(rootDir, config = {}, cache = new Map()) {
46
+ const dir = cacheDir(rootDir);
47
+ fs.mkdirSync(dir, { recursive: true });
48
+ const p = parseCachePath(rootDir);
49
+ const tmp = path.join(dir, `parse-v1.${process.pid}.${crypto.randomBytes(4).toString("hex")}.tmp`);
50
+ const body = {
51
+ mapdSchema: CACHE_SCHEMA,
52
+ configSignature: parserConfigSignature(config),
53
+ savedAt: new Date().toISOString(),
54
+ entries: Object.fromEntries(cache),
55
+ };
56
+ fs.writeFileSync(tmp, JSON.stringify(body));
57
+ try {
58
+ fs.renameSync(tmp, p);
59
+ } catch (e) {
60
+ try { fs.unlinkSync(tmp); } catch { /* best-effort cleanup */ }
61
+ throw e;
62
+ }
63
+ return p;
64
+ }