Mads
Multi-Agent Distributed System
Loading...
Searching...
No Matches
plugin_migrate.hpp
Go to the documentation of this file.
1/*
2 ____ _ _ __ __ _ _
3 | _ \| |_ _ __ _(_)_ __ | \/ (_) __ _ _ __ __ _| |_ ___
4 | |_) | | | | |/ _` | | '_ \ | |\/| | |/ _` | '__/ _` | __/ _ \
5 | __/| | |_| | (_| | | | | | | | | | | (_| | | | (_| | || __/
6 |_| |_|\__,_|\__, |_|_| |_| |_| |_|_|\__, |_| \__,_|\__\___|
7 |___/ |___/
8
9 Data-driven migration engine for MADS C++ plugins, used by `mads plugin --update`.
10
11 A protocol jump (e.g. P7 -> P8) is described by a JSON file in share/plugin_migrations/
12 (see share/plugin_migrations/P6-P7.json). This engine detects a plugin's current protocol
13 from its CMakeLists.txt, chains the migration steps up to the target, applies the CMake tag
14 bump(s) and the source-code transforms, backs up the originals, prints a report + a manual
15 follow-up checklist, and (Tier B) compiles the migrated plugin so the compiler's pure-virtual
16 diagnostics catch anything the rewriter missed.
17
18 Source signature rewriting is done structurally, not by pattern-matching the parameter text:
19 * Tier A (SpanRewriter, active): find `name(` and balance parentheses to locate the exact
20 argument-list span, so internal reformatting / newlines / comments are irrelevant.
21 * Tier C (TreeSitterRewriter, stubbed): reserved seam for a tree-sitter-cpp CST backend,
22 selected per-transform when `ts_query` is set and MADS_ENABLE_TREE_SITTER is built in.
23 Both live behind the SignatureRewriter interface so the engine never changes when Tier C lands.
24
25 Author(s): Paolo Bosetti
26*/
27#ifndef PLUGIN_MIGRATE_HPP
28#define PLUGIN_MIGRATE_HPP
29
30#include <algorithm>
31#include <cstdio>
32#include <filesystem>
33#include <fstream>
34#include <map>
35#include <memory>
36#include <nlohmann/json.hpp>
37#include <rang.hpp>
38#include <regex>
39#include <sstream>
40#include <string>
41#include <vector>
42
43namespace Mads {
44namespace PluginMigrate {
45
46namespace fs = std::filesystem;
47using json = nlohmann::json;
48
49/* ── options & result types ───────────────────────────────────────────────── */
50
51struct Options {
52 bool dry_run = false; // compute & report, write nothing
53 bool check = true; // Tier B: compile the migrated plugin afterwards
54 int from_override = -1; // skip protocol auto-detection when >= 0
55 int to_override = -1; // migrate up to this protocol instead of the manifest default
56};
57
58// One applied edit, for the human-readable report. `line == 0` means "whole-file / unknown".
59struct Change {
60 std::size_t line = 0;
61 std::string detail;
62};
63
64/* ── small file / text helpers ────────────────────────────────────────────── */
65
66inline bool read_file(const fs::path &p, std::string &out) {
67 std::ifstream in(p, std::ios::binary);
68 if (!in)
69 return false;
70 std::ostringstream ss;
71 ss << in.rdbuf();
72 out = ss.str();
73 return true;
74}
75
76inline bool write_file(const fs::path &p, const std::string &content) {
77 std::ofstream out(p, std::ios::binary | std::ios::trunc);
78 if (!out)
79 return false;
80 out << content;
81 return true;
82}
83
84inline std::size_t line_of_offset(const std::string &s, std::size_t off) {
85 off = std::min(off, s.size());
86 return static_cast<std::size_t>(std::count(s.begin(), s.begin() + off, '\n')) + 1;
87}
88
89inline bool is_ident_char(char c) {
90 return std::isalnum(static_cast<unsigned char>(c)) || c == '_';
91}
92
93// Run a shell command, capturing combined stdout+stderr. Returns the exit code.
94inline int run_command(const std::string &cmd, std::string &output) {
95#if defined(_WIN32)
96 FILE *pipe = _popen((cmd + " 2>&1").c_str(), "r");
97#else
98 FILE *pipe = popen((cmd + " 2>&1").c_str(), "r");
99#endif
100 if (!pipe)
101 return -1;
102 char buf[4096];
103 while (std::fgets(buf, sizeof(buf), pipe) != nullptr)
104 output += buf;
105#if defined(_WIN32)
106 return _pclose(pipe);
107#else
108 return pclose(pipe);
109#endif
110}
111
112/* ── C++-aware span scan (shared by matcher and paren balancing) ──────────────
113
114 A minimal lexer that classifies the source into code / line-comment /
115 block-comment / string / char so that '(' ')' and identifiers inside comments
116 or literals are never matched. This is the "code-aware-lite" core (Tier A). */
117
118enum class LexState { Code, Line, Block, Str, Chr };
119
120// Given src[open] == '(', return the index of the matching ')', or npos.
121inline std::size_t find_matching_paren(const std::string &s, std::size_t open) {
122 int depth = 0;
124 const std::size_t n = s.size();
125 for (std::size_t i = open; i < n;) {
126 char c = s[i];
127 switch (st) {
128 case LexState::Line:
129 if (c == '\n')
130 st = LexState::Code;
131 i++;
132 continue;
133 case LexState::Block:
134 if (c == '*' && i + 1 < n && s[i + 1] == '/') {
135 st = LexState::Code;
136 i += 2;
137 } else
138 i++;
139 continue;
140 case LexState::Str:
141 if (c == '\\') {
142 i += 2;
143 continue;
144 }
145 if (c == '"')
146 st = LexState::Code;
147 i++;
148 continue;
149 case LexState::Chr:
150 if (c == '\\') {
151 i += 2;
152 continue;
153 }
154 if (c == '\'')
155 st = LexState::Code;
156 i++;
157 continue;
158 case LexState::Code:
159 break;
160 }
161 if (c == '/' && i + 1 < n && s[i + 1] == '/') {
162 st = LexState::Line;
163 i += 2;
164 continue;
165 }
166 if (c == '/' && i + 1 < n && s[i + 1] == '*') {
167 st = LexState::Block;
168 i += 2;
169 continue;
170 }
171 if (c == '"') {
172 st = LexState::Str;
173 i++;
174 continue;
175 }
176 if (c == '\'') {
177 st = LexState::Chr;
178 i++;
179 continue;
180 }
181 if (c == '(') {
182 depth++;
183 i++;
184 continue;
185 }
186 if (c == ')') {
187 if (--depth == 0)
188 return i;
189 i++;
190 continue;
191 }
192 i++;
193 }
194 return std::string::npos;
195}
196
197/* ── source transform model ───────────────────────────────────────────────── */
198
199// A whole-method signature rewrite (`kind: "method"`): replace the argument list
200// of a declaration of `name` with `params` (which includes the parentheses).
202 std::string name; // method identifier, e.g. "load_data"
203 std::string params; // replacement arg list incl. "()"
204 std::string require_qualifier; // e.g. "override"; empty => not required
205 std::string ts_query; // reserved for Tier C; empty => use Tier A
206 std::string description;
207};
208
209/* ── matcher abstraction (the Tier A / Tier C seam) ───────────────────────── */
210
211// The engine depends only on this interface, so a tree-sitter backend can be
212// dropped in later without touching chaining, reporting, or the CLI.
214public:
215 virtual ~SignatureRewriter() = default;
216 // Rewrite every declaration of t.name in `src` in place; append one Change per
217 // edit. Returns the number of edits applied.
218 virtual int rewrite_method(std::string &src, const MethodTransform &t,
219 std::vector<Change> &out) = 0;
220};
221
222// Tier A: anchor on the method name, balance parentheses to find the argument
223// list, and swap it. Immune to whitespace/newlines/comments inside the list.
225public:
226 int rewrite_method(std::string &src, const MethodTransform &t,
227 std::vector<Change> &out) override {
228 struct Span {
229 std::size_t open, close;
230 };
231 std::vector<Span> spans;
232
234 char last_code = '\0'; // last significant code char before the cursor
235 const std::string &name = t.name;
236 const std::size_t n = src.size();
237
238 for (std::size_t i = 0; i < n;) {
239 char c = src[i];
240 switch (st) {
241 case LexState::Line:
242 if (c == '\n')
243 st = LexState::Code;
244 i++;
245 continue;
246 case LexState::Block:
247 if (c == '*' && i + 1 < n && src[i + 1] == '/') {
248 st = LexState::Code;
249 i += 2;
250 } else
251 i++;
252 continue;
253 case LexState::Str:
254 if (c == '\\') {
255 i += 2;
256 continue;
257 }
258 if (c == '"')
259 st = LexState::Code;
260 i++;
261 continue;
262 case LexState::Chr:
263 if (c == '\\') {
264 i += 2;
265 continue;
266 }
267 if (c == '\'')
268 st = LexState::Code;
269 i++;
270 continue;
271 case LexState::Code:
272 break;
273 }
274
275 if (c == '/' && i + 1 < n && src[i + 1] == '/') {
276 st = LexState::Line;
277 i += 2;
278 continue;
279 }
280 if (c == '/' && i + 1 < n && src[i + 1] == '*') {
281 st = LexState::Block;
282 i += 2;
283 continue;
284 }
285 if (c == '"') {
286 st = LexState::Str;
287 last_code = c;
288 i++;
289 continue;
290 }
291 if (c == '\'') {
292 st = LexState::Chr;
293 last_code = c;
294 i++;
295 continue;
296 }
297 if (std::isspace(static_cast<unsigned char>(c))) {
298 i++;
299 continue;
300 }
301
302 // Candidate identifier match with word boundaries.
303 if (is_ident_char(c) && !name.empty() && c == name[0] &&
304 (i == 0 || !is_ident_char(src[i - 1])) &&
305 src.compare(i, name.size(), name) == 0) {
306 std::size_t after = i + name.size();
307 bool end_boundary = (after >= n) || !is_ident_char(src[after]);
308 std::size_t j = after;
309 while (j < n && std::isspace(static_cast<unsigned char>(src[j])))
310 j++;
311 if (end_boundary && j < n && src[j] == '(') {
312 // Declaration vs. call: a call is member/arrow/scope-qualified, i.e.
313 // preceded by '.', '->' (ends in '>'), or '::' (ends in ':').
314 bool is_call =
315 (last_code == '.' || last_code == '>' || last_code == ':');
316 if (!is_call) {
317 std::size_t close = find_matching_paren(src, j);
318 if (close != std::string::npos) {
319 bool ok = true;
320 if (!t.require_qualifier.empty()) {
321 std::size_t k = close + 1;
322 while (k < n && std::isspace(static_cast<unsigned char>(src[k])))
323 k++;
324 std::size_t qn = t.require_qualifier.size();
325 ok = src.compare(k, qn, t.require_qualifier) == 0 &&
326 (k + qn >= n || !is_ident_char(src[k + qn]));
327 }
328 if (ok)
329 spans.push_back({j, close});
330 }
331 }
332 }
333 last_code = src[after - 1];
334 i = after;
335 continue;
336 }
337
338 last_code = c;
339 i++;
340 }
341
342 // Apply from last to first so earlier offsets stay valid.
343 int count = 0;
344 for (auto it = spans.rbegin(); it != spans.rend(); ++it) {
345 std::size_t line = line_of_offset(src, it->open);
346 src.replace(it->open, it->close - it->open + 1, t.params);
347 out.push_back({line, t.description});
348 count++;
349 }
350 return count;
351 }
352};
353
354#if defined(MADS_ENABLE_TREE_SITTER)
355// Tier C: true CST rewriting via tree-sitter-cpp. Not built by default.
356// TODO(Tier C): parse `src` with tree-sitter-cpp, run t.ts_query, and replace the
357// byte range of the matched `parameter_list` node with t.params.
358class TreeSitterRewriter : public SignatureRewriter {
359public:
360 int rewrite_method(std::string &, const MethodTransform &,
361 std::vector<Change> &) override {
362 throw std::runtime_error("TreeSitterRewriter not implemented yet");
363 }
364};
365#else
366// Stub kept compiled so the seam stays visible and make_rewriter() can dispatch
367// to it once tree-sitter-cpp is vendored in.
369public:
370 int rewrite_method(std::string &, const MethodTransform &t,
371 std::vector<Change> &) override {
372 throw std::runtime_error(
373 "tree-sitter matcher (Tier C) not built: rebuild MADS with "
374 "-DMADS_ENABLE_TREE_SITTER=ON. Transform for method '" +
375 t.name + "' requested it via a non-null ts_query.");
376 }
377};
378#endif
379
380// Pick the matcher for a transform: Tier C when it carries a tree-sitter query
381// and the backend is compiled in, otherwise the dependency-free Tier A scanner.
382inline std::unique_ptr<SignatureRewriter>
384#if defined(MADS_ENABLE_TREE_SITTER)
385 if (!t.ts_query.empty())
386 return std::make_unique<TreeSitterRewriter>();
387#else
388 (void)t;
389#endif
390 return std::make_unique<SpanRewriter>();
391}
392
393/* ── literal / regex source transforms ────────────────────────────────────── */
394
395inline int apply_literal(std::string &src, const std::string &find,
396 const std::string &replace, const std::string &desc,
397 std::vector<Change> &out) {
398 int count = 0;
399 std::size_t pos = 0;
400 while ((pos = src.find(find, pos)) != std::string::npos) {
401 out.push_back({line_of_offset(src, pos), desc});
402 src.replace(pos, find.size(), replace);
403 pos += replace.size();
404 count++;
405 }
406 return count;
407}
408
409inline int apply_regex(std::string &src, const std::string &pattern,
410 const std::string &replacement, const std::string &flags,
411 const std::string &desc, std::vector<Change> &out) {
412 auto opts = std::regex::ECMAScript;
413 if (flags.find('i') != std::string::npos)
414 opts |= std::regex::icase;
415 std::regex re(pattern, opts);
416 auto first = std::sregex_iterator(src.begin(), src.end(), re);
417 int count = static_cast<int>(std::distance(first, std::sregex_iterator()));
418 if (count > 0) {
419 src = std::regex_replace(src, re, replacement);
420 out.push_back({0, desc});
421 }
422 return count;
423}
424
425/* ── CMake transforms ─────────────────────────────────────────────────────── */
426
427// Parse the protocol number from the plugin FetchContent block (the `-P<N>`
428// suffix of GIT_TAG). Returns -1 if not found.
429inline int detect_protocol(const std::string &cmake) {
430 std::regex re(
431 R"(FetchContent_Populate\s*\‍(\s*plugin\b[\s\S]*?GIT_TAG\s+v[0-9.]+-P([0-9]+))");
432 std::smatch m;
433 if (std::regex_search(cmake, m, re))
434 return std::stoi(m[1].str());
435 return -1;
436}
437
438// Rewrite the GIT_TAG of a named FetchContent block (`plugin`, `pugg`, ...).
439//
440// A regex alone is fragile here: a `#` comment containing the token GIT_TAG
441// before the real one would be matched (lazy `[\s\S]*?GIT_TAG` is comment-blind),
442// and building a `std::regex_replace` replacement as `"$1" + new_tag` breaks when
443// new_tag starts with a digit (e.g. pugg "1.2.0" -> "$11.2.0" reads as $11). So we
444// only use a regex to locate the block header, then tokenize the block with a tiny
445// CMake-aware scanner (honouring `#` line comments, quoted args, and nested parens)
446// to find the GIT_TAG value token, and replace exactly that span by position. This
447// tolerates arbitrary whitespace/line-splitting and comments.
448inline int bump_git_tag(std::string &cmake, const std::string &block,
449 const std::string &new_tag, std::vector<Change> &out) {
450 std::regex header("FetchContent_(?:Populate|Declare)\\s*\\(\\s*" + block +
451 "\\b");
452 std::smatch m;
453 if (!std::regex_search(cmake, m, header))
454 return 0;
455
456 const std::size_t n = cmake.size();
457 std::size_t i = m.position(0) + m.length(0); // just past the block name
458 int depth = 1; // inside the block's '('
459 bool saw_tag = false;
460 std::size_t val_begin = std::string::npos, val_end = std::string::npos;
461
462 while (i < n && depth > 0) {
463 const char c = cmake[i];
464 if (c == '#') { // CMake line comment: skip to end of line
465 while (i < n && cmake[i] != '\n')
466 ++i;
467 continue;
468 }
469 if (c == '"') { // quoted argument: its inner content may be the value
470 const std::size_t q_begin = ++i;
471 while (i < n && cmake[i] != '"') {
472 if (cmake[i] == '\\' && i + 1 < n)
473 ++i;
474 ++i;
475 }
476 const std::size_t q_end = i;
477 if (i < n)
478 ++i; // consume closing quote
479 if (saw_tag) {
480 val_begin = q_begin;
481 val_end = q_end;
482 break;
483 }
484 continue;
485 }
486 if (c == '(') { ++depth; ++i; continue; }
487 if (c == ')') { --depth; ++i; continue; }
488 if (std::isspace(static_cast<unsigned char>(c))) { ++i; continue; }
489
490 // A bare (unquoted) token: runs until whitespace or a delimiter.
491 const std::size_t tok_begin = i;
492 while (i < n && !std::isspace(static_cast<unsigned char>(cmake[i])) &&
493 cmake[i] != '(' && cmake[i] != ')' && cmake[i] != '#' &&
494 cmake[i] != '"')
495 ++i;
496 if (saw_tag) {
497 val_begin = tok_begin;
498 val_end = i;
499 break;
500 }
501 if (cmake.compare(tok_begin, i - tok_begin, "GIT_TAG") == 0)
502 saw_tag = true;
503 }
504
505 if (val_begin == std::string::npos)
506 return 0;
507 if (cmake.compare(val_begin, val_end - val_begin, new_tag) == 0)
508 return 0;
509 const std::size_t line = line_of_offset(cmake, val_begin);
510 cmake.replace(val_begin, val_end - val_begin, new_tag);
511 out.push_back({line, block + " GIT_TAG -> " + new_tag});
512 return 1;
513}
514
515/* ── migration definition loading & chaining ──────────────────────────────── */
516
517struct Migration {
518 int from = -1;
519 int to = -1;
520 std::string lang = "cpp";
522 fs::path file;
523};
524
525// Load every *.json in `dir` that targets C++ (lang == "cpp"), keyed by `from`.
526inline std::map<int, Migration> load_migrations(const fs::path &dir) {
527 std::map<int, Migration> steps;
528 if (!fs::is_directory(dir))
529 return steps;
530 for (const auto &entry : fs::directory_iterator(dir)) {
531 if (entry.path().extension() != ".json")
532 continue;
533 std::string text;
534 if (!read_file(entry.path(), text))
535 continue;
536 json doc;
537 try {
538 doc = json::parse(text);
539 } catch (const std::exception &) {
540 continue; // skip malformed files
541 }
542 Migration m;
543 m.file = entry.path();
544 m.from = doc.value("from", -1);
545 m.to = doc.value("to", -1);
546 m.lang = doc.value("lang", "cpp");
547 m.doc = std::move(doc);
548 if (m.lang == "cpp" && m.from >= 0 && m.to == m.from + 1)
549 steps[m.from] = std::move(m);
550 }
551 return steps;
552}
553
554/* ── per-file / overall report ────────────────────────────────────────────── */
555
557 fs::path path;
558 std::string original;
559 std::string updated;
560 std::vector<Change> changes;
561 bool dirty() const { return updated != original; }
562};
563
564// Apply the `source` transforms of one migration step to one file buffer.
565inline void apply_source_transforms(const json &step, FileReport &fr) {
566 if (!step.contains("source"))
567 return;
568 for (const auto &t : step["source"]) {
569 const std::string kind = t.value("kind", "");
570 const std::string desc = t.value("description", kind);
571 if (kind == "method") {
573 mt.name = t.value("name", "");
574 mt.params = t.value("params", "");
575 mt.require_qualifier = t.value("require_qualifier", "");
576 mt.ts_query = t.contains("ts_query") && !t["ts_query"].is_null()
577 ? t["ts_query"].get<std::string>()
578 : "";
579 mt.description = desc;
580 auto rw = make_rewriter(mt);
581 rw->rewrite_method(fr.updated, mt, fr.changes);
582 } else if (kind == "literal") {
583 apply_literal(fr.updated, t.value("find", ""), t.value("replace", ""), desc,
584 fr.changes);
585 } else if (kind == "regex") {
586 apply_regex(fr.updated, t.value("pattern", ""), t.value("replacement", ""),
587 t.value("flags", ""), desc, fr.changes);
588 }
589 }
590}
591
592// Apply the `cmake` transforms of one migration step to the CMake buffer.
593inline void apply_cmake_transforms(const json &step, FileReport &cmake) {
594 if (!step.contains("cmake"))
595 return;
596 const json &c = step["cmake"];
597 if (c.contains("plugin_git_tag"))
598 bump_git_tag(cmake.updated, "plugin", c["plugin_git_tag"].get<std::string>(),
599 cmake.changes);
600 if (c.contains("pugg_git_tag"))
601 bump_git_tag(cmake.updated, "pugg", c["pugg_git_tag"].get<std::string>(),
602 cmake.changes);
603 if (c.contains("replacements")) {
604 for (const auto &r : c["replacements"]) {
605 apply_regex(cmake.updated, r.value("regex", ""), r.value("replacement", ""),
606 r.value("flags", ""), r.value("description", "cmake edit"),
607 cmake.changes);
608 }
609 }
610}
611
612/* ── engine entry point ───────────────────────────────────────────────────── */
613
614// Returns: 0 = migrated (or already current), 1 = usage/structural error,
615// 3 = migrated but the Tier B build check failed.
616inline int run(const fs::path &project_dir, const fs::path &migrations_dir,
617 const fs::path &deps_manifest, const Options &opts) {
618 using namespace rang;
619
620 fs::path cmake_path = project_dir / "CMakeLists.txt";
621 FileReport cmake;
622 cmake.path = cmake_path;
623 if (!read_file(cmake_path, cmake.original)) {
624 std::cerr << fg::red << "Error: no CMakeLists.txt in " << project_dir
625 << fg::reset << std::endl;
626 return 1;
627 }
628 cmake.updated = cmake.original;
629
630 int cur = opts.from_override >= 0 ? opts.from_override
631 : detect_protocol(cmake.original);
632 if (cur < 0) {
633 std::cerr << fg::red
634 << "Error: cannot detect the plugin protocol (no `GIT_TAG "
635 "v*-P<N>` in the plugin FetchContent block). Use --from to "
636 "override."
637 << fg::reset << std::endl;
638 return 1;
639 }
640
641 auto steps = load_migrations(migrations_dir);
642
643 int target = opts.to_override;
644 if (target < 0) {
645 // Default target = manifest protocol, else the highest known migration step.
646 std::string manifest_text;
647 if (read_file(deps_manifest, manifest_text)) {
648 try {
649 target = json::parse(manifest_text).value("plugin_protocol", -1);
650 } catch (const std::exception &) {
651 }
652 }
653 if (target < 0 && !steps.empty())
654 target = steps.rbegin()->second.to;
655 }
656 if (target < 0) {
657 std::cerr << fg::red << "Error: cannot determine a target protocol."
658 << fg::reset << std::endl;
659 return 1;
660 }
661
662 std::cout << style::bold << "Plugin migration: " << project_dir.filename().string()
663 << style::reset << std::endl;
664 std::cout << " Detected protocol: " << fg::yellow << "P" << cur << fg::reset
665 << " Target: " << fg::green << "P" << target << fg::reset
666 << std::endl;
667
668 if (cur >= target) {
669 std::cout << fg::green << " Already at protocol P" << cur
670 << " (>= target). Nothing to do." << fg::reset << std::endl;
671 return 0;
672 }
673
674 // Verify the full chain cur -> target exists before touching anything.
675 for (int v = cur; v < target; ++v) {
676 if (steps.find(v) == steps.end()) {
677 std::cerr << fg::red << "Error: no migration step for P" << v << " -> P"
678 << (v + 1) << " in " << migrations_dir << fg::reset << std::endl;
679 return 1;
680 }
681 }
682
683 // Collect the plugin's source files.
684 std::vector<FileReport> sources;
685 fs::path src_dir = project_dir / "src";
686 if (fs::is_directory(src_dir)) {
687 for (const auto &e : fs::directory_iterator(src_dir)) {
688 auto ext = e.path().extension().string();
689 if (ext == ".cpp" || ext == ".cxx" || ext == ".cc" || ext == ".hpp" ||
690 ext == ".h") {
691 FileReport fr;
692 fr.path = e.path();
693 if (read_file(fr.path, fr.original)) {
694 fr.updated = fr.original;
695 sources.push_back(std::move(fr));
696 }
697 }
698 }
699 }
700
701 // Apply each step in order, gathering follow-up notes.
702 std::vector<std::string> notes;
703 for (int v = cur; v < target; ++v) {
704 const json &step = steps[v].doc;
705 std::cout << " Applying step " << style::bold << "P" << v << " -> P"
706 << (v + 1) << style::reset << " (" << steps[v].file.filename().string()
707 << ")" << std::endl;
708 apply_cmake_transforms(step, cmake);
709 for (auto &fr : sources)
710 apply_source_transforms(step, fr);
711 if (step.contains("notes"))
712 for (const auto &nnote : step["notes"])
713 notes.push_back(nnote.get<std::string>());
714 }
715
716 // Report and (unless dry-run) write, backing up originals first.
717 auto report_file = [&](FileReport &fr) {
718 if (!fr.dirty())
719 return;
720 std::cout << " " << style::bold << fs::relative(fr.path, project_dir).string()
721 << style::reset << ":" << std::endl;
722 for (const auto &ch : fr.changes) {
723 std::cout << " " << fg::green << "✓" << fg::reset << " " << ch.detail;
724 if (ch.line > 0)
725 std::cout << fg::gray << " (line " << ch.line << ")" << fg::reset;
726 std::cout << std::endl;
727 }
728 if (!opts.dry_run) {
729 write_file(fr.path.string() + ".bak", fr.original);
730 write_file(fr.path, fr.updated);
731 }
732 };
733
734 int edited_files = 0;
735 report_file(cmake);
736 if (cmake.dirty())
737 edited_files++;
738 for (auto &fr : sources) {
739 report_file(fr);
740 if (fr.dirty())
741 edited_files++;
742 }
743
744 if (edited_files == 0) {
745 std::cout << fg::yellow
746 << " No matching code found to change (already migrated?)."
747 << fg::reset << std::endl;
748 } else if (opts.dry_run) {
749 std::cout << std::endl
750 << fg::yellow << style::bold << "Dry run: " << style::reset
751 << fg::yellow << "no files written (" << edited_files
752 << " would change)." << fg::reset << std::endl;
753 } else {
754 std::cout << std::endl
755 << fg::green << "Migrated " << edited_files
756 << " file(s); originals saved as *.bak." << fg::reset << std::endl;
757 }
758
759 if (!notes.empty()) {
760 std::cout << std::endl << style::bold << "MANUAL FOLLOW-UP:" << style::reset
761 << std::endl;
762 for (const auto &nnote : notes)
763 std::cout << " " << fg::yellow << "•" << fg::reset << " " << nnote
764 << std::endl;
765 }
766
767 // Tier B: compile the migrated plugin; the base classes are pure-virtual, so a
768 // botched override cannot compile. Surface any `error:` lines as follow-ups.
769 if (opts.check && !opts.dry_run && edited_files > 0) {
770 std::cout << std::endl
771 << style::bold << "Verifying (cmake build)…" << style::reset
772 << std::endl;
773 fs::path build_dir = project_dir / "build";
774 std::string out;
775 std::string cfg = "cmake -S \"" + project_dir.string() + "\" -B \"" +
776 build_dir.string() + "\"";
777 std::string bld = "cmake --build \"" + build_dir.string() + "\"";
778 int rc = run_command(cfg, out);
779 if (rc == 0)
780 rc = run_command(bld, out);
781 if (rc == 0) {
782 std::cout << " " << fg::green << "✓ Plugin compiles cleanly against P"
783 << target << "." << fg::reset << std::endl;
784 } else {
785 std::cout << " " << fg::red
786 << "✗ Build failed — the rewrite likely needs manual "
787 "fixes. Compiler errors:"
788 << fg::reset << std::endl;
789 std::istringstream iss(out);
790 std::string ln;
791 int shown = 0;
792 while (std::getline(iss, ln) && shown < 40) {
793 std::string low = ln;
794 std::transform(low.begin(), low.end(), low.begin(), ::tolower);
795 if (low.find("error") != std::string::npos) {
796 std::cout << " " << ln << std::endl;
797 shown++;
798 }
799 }
800 std::cout << fg::gray << " (full log under " << build_dir.string() << ")"
801 << fg::reset << std::endl;
802 return 3;
803 }
804 }
805
806 return 0;
807}
808
809} // namespace PluginMigrate
810} // namespace Mads
811
812#endif // PLUGIN_MIGRATE_HPP
virtual int rewrite_method(std::string &src, const MethodTransform &t, std::vector< Change > &out)=0
int rewrite_method(std::string &src, const MethodTransform &t, std::vector< Change > &out) override
int rewrite_method(std::string &, const MethodTransform &t, std::vector< Change > &) override
int apply_regex(std::string &src, const std::string &pattern, const std::string &replacement, const std::string &flags, const std::string &desc, std::vector< Change > &out)
int detect_protocol(const std::string &cmake)
int run(const fs::path &project_dir, const fs::path &migrations_dir, const fs::path &deps_manifest, const Options &opts)
int apply_literal(std::string &src, const std::string &find, const std::string &replace, const std::string &desc, std::vector< Change > &out)
int run_command(const std::string &cmd, std::string &output)
std::map< int, Migration > load_migrations(const fs::path &dir)
std::unique_ptr< SignatureRewriter > make_rewriter(const MethodTransform &t)
bool read_file(const fs::path &p, std::string &out)
void apply_source_transforms(const json &step, FileReport &fr)
int bump_git_tag(std::string &cmake, const std::string &block, const std::string &new_tag, std::vector< Change > &out)
bool write_file(const fs::path &p, const std::string &content)
bool is_ident_char(char c)
void apply_cmake_transforms(const json &step, FileReport &cmake)
std::size_t line_of_offset(const std::string &s, std::size_t off)
std::size_t find_matching_paren(const std::string &s, std::size_t open)
Definition agent.hpp:67