@reddoorla/maintenance 0.6.6 → 0.6.8

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/dist/index.js CHANGED
@@ -31,6 +31,11 @@ var defaultSpawn = (cmd, args, opts = {}) => new Promise((resolve, reject) => {
31
31
  import { readFile } from "fs/promises";
32
32
  import { join } from "path";
33
33
 
34
+ // src/util/site.ts
35
+ function siteLabel(site) {
36
+ return site.name ?? site.path;
37
+ }
38
+
34
39
  // src/configs/baseline-versions.ts
35
40
  var baselineVersions = {
36
41
  // SvelteKit core
@@ -71,9 +76,6 @@ var baselineVersions = {
71
76
  };
72
77
 
73
78
  // src/audits/deps.ts
74
- function siteLabel(site) {
75
- return site.name ?? site.path;
76
- }
77
79
  function stripCaret(range) {
78
80
  return range.replace(/^[\^~]/, "");
79
81
  }
@@ -146,9 +148,6 @@ import { check as prettierCheck, resolveConfig as prettierResolveConfig } from "
146
148
  import { glob } from "tinyglobby";
147
149
  var TARGET_GLOBS = ["**/*.{ts,js,svelte}"];
148
150
  var IGNORE = ["node_modules/**", "dist/**", ".svelte-kit/**", "build/**", ".netlify/**"];
149
- function siteLabel2(site) {
150
- return site.name ?? site.path;
151
- }
152
151
  async function listFiles(cwd) {
153
152
  return glob(TARGET_GLOBS, { cwd, ignore: IGNORE, absolute: false });
154
153
  }
@@ -158,7 +157,7 @@ async function lintAudit(ctx) {
158
157
  if (!existsSync(configPath)) {
159
158
  return {
160
159
  audit: "lint",
161
- site: siteLabel2(site),
160
+ site: siteLabel(site),
162
161
  status: "skip",
163
162
  summary: "no eslint config at site root"
164
163
  };
@@ -184,7 +183,7 @@ async function lintAudit(ctx) {
184
183
  const summary = status === "pass" ? `lint clean across ${relFiles.length} files` : `${eslintErrors} eslint errors, ${eslintWarnings} warnings, ${prettierUnformatted.length} unformatted`;
185
184
  return {
186
185
  audit: "lint",
187
- site: siteLabel2(site),
186
+ site: siteLabel(site),
188
187
  status,
189
188
  summary,
190
189
  details: {
@@ -197,9 +196,6 @@ async function lintAudit(ctx) {
197
196
  }
198
197
 
199
198
  // src/audits/security.ts
200
- function siteLabel3(site) {
201
- return site.name ?? site.path;
202
- }
203
199
  function classify(v) {
204
200
  if (v.critical > 0 || v.high > 0) return "fail";
205
201
  if (v.moderate > 0 || v.low > 0) return "warn";
@@ -285,7 +281,8 @@ async function runAuditTool(spawn2, cmd, args, cwd) {
285
281
  if (errEnvelope && typeof errEnvelope === "object") {
286
282
  return { kind: "error", reason: errEnvelope.code ?? "error envelope returned" };
287
283
  }
288
- if (!parsed.metadata?.vulnerabilities) {
284
+ const vulnsMeta = parsed.metadata?.vulnerabilities;
285
+ if (!vulnsMeta || Object.keys(vulnsMeta).length === 0) {
289
286
  return { kind: "error", reason: "no metadata.vulnerabilities in output" };
290
287
  }
291
288
  return { kind: "ok", parsed };
@@ -293,7 +290,7 @@ async function runAuditTool(spawn2, cmd, args, cwd) {
293
290
  async function securityAudit(ctx) {
294
291
  const spawn2 = ctx.spawn ?? defaultSpawn;
295
292
  const site = ctx.site;
296
- const label = siteLabel3(site);
293
+ const label = siteLabel(site);
297
294
  let used = "pnpm audit";
298
295
  let result = await runAuditTool(spawn2, "pnpm", ["audit", "--json", "--prod"], site.path);
299
296
  if (result.kind !== "ok") {
@@ -374,9 +371,6 @@ var lighthouseConfig = {
374
371
  };
375
372
 
376
373
  // src/audits/lighthouse.ts
377
- function siteLabel4(site) {
378
- return site.name ?? site.path;
379
- }
380
374
  async function readJsonMaybe(path) {
381
375
  try {
382
376
  const raw = await readFile3(path, "utf-8");
@@ -414,7 +408,7 @@ function messageForAssertion(a) {
414
408
  async function lighthouseAudit(ctx) {
415
409
  const spawn2 = ctx.spawn ?? defaultSpawn;
416
410
  const site = ctx.site;
417
- const label = siteLabel4(site);
411
+ const label = siteLabel(site);
418
412
  const configDir = await mkdtemp(join3(tmpdir(), "reddoor-lhci-"));
419
413
  const configPath = join3(configDir, "lighthouserc.json");
420
414
  await writeFile(configPath, JSON.stringify(lighthouseConfig), "utf-8");
@@ -512,9 +506,6 @@ var playwrightA11yConfig = defineConfig({
512
506
 
513
507
  // src/audits/a11y.ts
514
508
  var RESULTS_REL = ".reddoor-a11y/results.json";
515
- function siteLabel5(site) {
516
- return site.name ?? site.path;
517
- }
518
509
  async function readJsonMaybe2(path) {
519
510
  try {
520
511
  const raw = await readFile4(path, "utf-8");
@@ -572,7 +563,7 @@ test("a11y across configured routes", async ({ page }) => {
572
563
  async function a11yAudit(ctx) {
573
564
  const spawn2 = ctx.spawn ?? defaultSpawn;
574
565
  const site = ctx.site;
575
- const label = siteLabel5(site);
566
+ const label = siteLabel(site);
576
567
  const specDir = await mkdtemp2(join4(tmpdir2(), "reddoor-a11y-spec-"));
577
568
  const specPath = join4(specDir, "a11y.spec.ts");
578
569
  await writeFile2(specPath, buildSpec(), "utf-8");
@@ -820,9 +811,8 @@ async function git(cwd, args) {
820
811
  return exec("git", args, { cwd, env: process.env });
821
812
  }
822
813
  function branchName(recipe, when = /* @__PURE__ */ new Date()) {
823
- const iso = when.toISOString().replace(/[-:.]/g, "").replace(/Z$/, "Z");
824
- const trimmed = iso.replace(/(\d{8}T\d{6})\d+(Z)$/, "$1$2");
825
- return `maint/${recipe}-${trimmed}`;
814
+ const compact = when.toISOString().replace(/[-:.]/g, "");
815
+ return `maint/${recipe}-${compact}`;
826
816
  }
827
817
  async function isWorkingTreeClean(cwd) {
828
818
  const { stdout } = await git(cwd, ["status", "--porcelain"]);
@@ -851,11 +841,67 @@ async function commit(cwd, message) {
851
841
  return sha.trim();
852
842
  }
853
843
 
844
+ // src/recipes/_with-recipe.ts
845
+ async function withRecipe(body) {
846
+ const label = siteLabel(body.site);
847
+ if (body.checkTreeFirst && !await isWorkingTreeClean(body.site.path)) {
848
+ throw new Error(`refusing to run: working tree is not clean at ${body.site.path}`);
849
+ }
850
+ const planned = await body.plan();
851
+ if (planned.kind === "noop") {
852
+ return {
853
+ recipe: body.name,
854
+ site: label,
855
+ status: "noop",
856
+ commits: [],
857
+ ...planned.notes ? { notes: planned.notes } : {}
858
+ };
859
+ }
860
+ if (planned.kind === "failed") {
861
+ return {
862
+ recipe: body.name,
863
+ site: label,
864
+ status: "failed",
865
+ commits: [],
866
+ notes: planned.notes
867
+ };
868
+ }
869
+ if (!body.checkTreeFirst && !await isWorkingTreeClean(body.site.path)) {
870
+ throw new Error(`refusing to run: working tree is not clean at ${body.site.path}`);
871
+ }
872
+ const branch = branchName(body.name);
873
+ await createBranch(body.site.path, branch);
874
+ const shas = [];
875
+ const result = await body.apply(planned.plan, {
876
+ cwd: body.site.path,
877
+ branch,
878
+ commit: async (msg) => {
879
+ const sha = await commit(body.site.path, msg);
880
+ if (sha) shas.push(sha);
881
+ return sha;
882
+ }
883
+ });
884
+ if (result.kind === "failed") {
885
+ return {
886
+ recipe: body.name,
887
+ site: label,
888
+ status: "failed",
889
+ commits: shas,
890
+ notes: result.notes
891
+ };
892
+ }
893
+ const notes = result.notes ? `${result.notes}; branch: ${branch}` : `branch: ${branch}`;
894
+ return {
895
+ recipe: body.name,
896
+ site: label,
897
+ status: shas.length > 0 ? "applied" : "noop",
898
+ commits: shas,
899
+ notes
900
+ };
901
+ }
902
+
854
903
  // src/recipes/sync-configs.ts
855
904
  var GITIGNORE_CONFIG = "gitignore";
856
- function siteLabel6(site) {
857
- return site.name ?? site.path;
858
- }
859
905
  async function readMaybe(path) {
860
906
  try {
861
907
  return await readFile5(path, "utf-8");
@@ -886,56 +932,38 @@ async function applyGitignore(cwd, plan) {
886
932
  }
887
933
  }
888
934
  async function syncConfigs(site, opts = {}) {
889
- const label = siteLabel6(site);
890
935
  const requested = opts.which ?? ALL_TEMPLATES.map((t) => t.config).concat(GITIGNORE_CONFIG);
891
936
  const templateNames = requested.filter((c) => c !== GITIGNORE_CONFIG);
892
937
  const templates = templatesByName(templateNames);
893
938
  const includeGitignore = requested.includes(GITIGNORE_CONFIG);
894
- const templateDiffs = await planTemplateDiffs(site.path, templates);
895
- const gitignorePlan = includeGitignore ? await planGitignore(site.path) : { kind: "noop" };
896
- if (templateDiffs.length === 0 && gitignorePlan.kind === "noop") {
897
- return {
898
- recipe: "sync-configs",
899
- site: label,
900
- status: "noop",
901
- commits: [],
902
- notes: "all targeted configs already match"
903
- };
904
- }
905
- if (!await isWorkingTreeClean(site.path)) {
906
- throw new Error(`refusing to run: working tree is not clean at ${site.path}`);
907
- }
908
- const branch = branchName("sync-configs");
909
- await createBranch(site.path, branch);
910
- const shas = [];
911
- for (const t of templateDiffs) {
912
- await writeFile3(join5(site.path, t.path), t.contents, "utf-8");
913
- const sha = await commit(
914
- site.path,
915
- `chore: sync ${t.config} config from @reddoorla/maintenance`
916
- );
917
- if (sha) shas.push(sha);
918
- }
919
- if (gitignorePlan.kind === "apply") {
920
- await applyGitignore(site.path, gitignorePlan);
921
- const sha = await commit(site.path, `chore: sync gitignore from @reddoorla/maintenance`);
922
- if (sha) shas.push(sha);
923
- }
924
- return {
925
- recipe: "sync-configs",
926
- site: label,
927
- status: "applied",
928
- commits: shas,
929
- notes: `branch: ${branch}`
930
- };
939
+ return withRecipe({
940
+ name: "sync-configs",
941
+ site,
942
+ plan: async () => {
943
+ const templateDiffs = await planTemplateDiffs(site.path, templates);
944
+ const gitignorePlan = includeGitignore ? await planGitignore(site.path) : { kind: "noop" };
945
+ if (templateDiffs.length === 0 && gitignorePlan.kind === "noop") {
946
+ return { kind: "noop", notes: "all targeted configs already match" };
947
+ }
948
+ return { kind: "apply", plan: { templateDiffs, gitignorePlan } };
949
+ },
950
+ apply: async ({ templateDiffs, gitignorePlan }, { commit: commit2 }) => {
951
+ for (const t of templateDiffs) {
952
+ await writeFile3(join5(site.path, t.path), t.contents, "utf-8");
953
+ await commit2(`chore: sync ${t.config} config from @reddoorla/maintenance`);
954
+ }
955
+ if (gitignorePlan.kind === "apply") {
956
+ await applyGitignore(site.path, gitignorePlan);
957
+ await commit2(`chore: sync gitignore from @reddoorla/maintenance`);
958
+ }
959
+ return { kind: "ok" };
960
+ }
961
+ });
931
962
  }
932
963
 
933
964
  // src/recipes/bump-deps.ts
934
965
  import { stat } from "fs/promises";
935
966
  import { join as join6 } from "path";
936
- function siteLabel7(site) {
937
- return site.name ?? site.path;
938
- }
939
967
  async function exists(path) {
940
968
  try {
941
969
  await stat(path);
@@ -954,62 +982,51 @@ function upFlagsForGroup(group) {
954
982
  return [];
955
983
  }
956
984
  async function bumpDeps(site, opts = {}) {
957
- const label = siteLabel7(site);
958
985
  const group = opts.group ?? "minor";
959
986
  const spawn2 = opts.spawn ?? defaultSpawn;
960
- const hasPnpmLock = await exists(join6(site.path, "pnpm-lock.yaml"));
961
- if (!hasPnpmLock) {
962
- const hasNpmLock = await exists(join6(site.path, "package-lock.json"));
963
- const hasYarnLock = await exists(join6(site.path, "yarn.lock"));
964
- if (hasNpmLock || hasYarnLock) {
965
- const competing = hasNpmLock ? "package-lock.json" : "yarn.lock";
966
- return {
967
- recipe: "bump-deps",
968
- site: label,
969
- status: "failed",
970
- commits: [],
971
- notes: `site has ${competing} but no pnpm-lock.yaml \u2014 run convert-to-pnpm first`
972
- };
987
+ return withRecipe({
988
+ name: "bump-deps",
989
+ site,
990
+ // pnpm install (in plan) mutates the lockfile, so the clean-tree check
991
+ // MUST happen first — otherwise a desynced-lockfile resync would silently
992
+ // land on top of whatever else was in the tree.
993
+ checkTreeFirst: true,
994
+ plan: async () => {
995
+ const hasPnpmLock = await exists(join6(site.path, "pnpm-lock.yaml"));
996
+ if (!hasPnpmLock) {
997
+ const hasNpmLock = await exists(join6(site.path, "package-lock.json"));
998
+ const hasYarnLock = await exists(join6(site.path, "yarn.lock"));
999
+ if (hasNpmLock || hasYarnLock) {
1000
+ const competing = hasNpmLock ? "package-lock.json" : "yarn.lock";
1001
+ return {
1002
+ kind: "failed",
1003
+ notes: `site has ${competing} but no pnpm-lock.yaml \u2014 run convert-to-pnpm first`
1004
+ };
1005
+ }
1006
+ }
1007
+ await spawn2("pnpm", ["install"], { cwd: site.path, streaming: true });
1008
+ const outdated = await spawn2(
1009
+ "pnpm",
1010
+ ["outdated", "--json", ...outdatedFlagsForGroup(group)],
1011
+ { cwd: site.path }
1012
+ );
1013
+ let parsed;
1014
+ try {
1015
+ parsed = JSON.parse(outdated.stdout || "{}");
1016
+ } catch {
1017
+ parsed = {};
1018
+ }
1019
+ if (Object.keys(parsed).length === 0) {
1020
+ return { kind: "noop", notes: `pnpm outdated reported nothing for group=${group}` };
1021
+ }
1022
+ return { kind: "apply", plan: { group } };
1023
+ },
1024
+ apply: async ({ group: g }, { commit: commit2, cwd }) => {
1025
+ await spawn2("pnpm", ["up", ...upFlagsForGroup(g)], { cwd, streaming: true });
1026
+ await commit2(`chore(deps): bump dependencies (${g})`);
1027
+ return { kind: "ok" };
973
1028
  }
974
- }
975
- if (!await isWorkingTreeClean(site.path)) {
976
- throw new Error(`refusing to run: working tree is not clean at ${site.path}`);
977
- }
978
- await spawn2("pnpm", ["install"], { cwd: site.path, streaming: true });
979
- const outdated = await spawn2("pnpm", ["outdated", "--json", ...outdatedFlagsForGroup(group)], {
980
- cwd: site.path
981
- });
982
- let parsed;
983
- try {
984
- parsed = JSON.parse(outdated.stdout || "{}");
985
- } catch {
986
- parsed = {};
987
- }
988
- const nothingToDo = Object.keys(parsed).length === 0;
989
- if (nothingToDo) {
990
- return {
991
- recipe: "bump-deps",
992
- site: label,
993
- status: "noop",
994
- commits: [],
995
- notes: `pnpm outdated reported nothing for group=${group}`
996
- };
997
- }
998
- const branch = branchName("bump-deps");
999
- await createBranch(site.path, branch);
1000
- await spawn2("pnpm", ["up", ...upFlagsForGroup(group)], {
1001
- cwd: site.path,
1002
- streaming: true
1003
1029
  });
1004
- const sha = await commit(site.path, `chore(deps): bump dependencies (${group})`);
1005
- const shas = sha ? [sha] : [];
1006
- return {
1007
- recipe: "bump-deps",
1008
- site: label,
1009
- status: shas.length > 0 ? "applied" : "noop",
1010
- commits: shas,
1011
- notes: `branch: ${branch}`
1012
- };
1013
1030
  }
1014
1031
 
1015
1032
  // src/recipes/svelte-5/index.ts
@@ -1196,6 +1213,34 @@ import { glob as glob2 } from "tinyglobby";
1196
1213
  // src/recipes/svelte-5/codemods/on-event-to-handler.ts
1197
1214
  var SCRIPT_BLOCK = /<script\b[^>]*>[\s\S]*?<\/script>/g;
1198
1215
  var SIMPLE_ON_EVENT = /\bon:([a-z]+)(?=\s*=)/g;
1216
+ var MODIFIER_EVENT = /\bon:[a-z]+\|[a-zA-Z]+(?=\s*=)/g;
1217
+ function flagEventModifiers(source) {
1218
+ const insertions = [];
1219
+ let m;
1220
+ MODIFIER_EVENT.lastIndex = 0;
1221
+ while ((m = MODIFIER_EVENT.exec(source)) !== null) {
1222
+ const tagStart = source.lastIndexOf("<", m.index);
1223
+ if (tagStart === -1) continue;
1224
+ const prevLineEnd = tagStart - 1;
1225
+ if (prevLineEnd >= 0) {
1226
+ const prevLineStart = source.lastIndexOf("\n", prevLineEnd - 1) + 1;
1227
+ const prevLine = source.slice(prevLineStart, prevLineEnd + 1);
1228
+ if (/<!--\s*@migration-task/.test(prevLine)) continue;
1229
+ }
1230
+ const lineStart = source.lastIndexOf("\n", tagStart - 1) + 1;
1231
+ const indent = source.slice(lineStart, tagStart);
1232
+ const safeIndent = /^[ \t]*$/.test(indent) ? indent : "";
1233
+ insertions.push({ tagStart, indent: safeIndent, modifier: m[0] });
1234
+ }
1235
+ let out = source;
1236
+ for (let i = insertions.length - 1; i >= 0; i--) {
1237
+ const { tagStart, indent, modifier } = insertions[i];
1238
+ const comment = `<!-- @migration-task: Svelte 5 removed event modifier syntax (\`${modifier}\`). Rewrite inline, e.g. onclick={(e) => { e.preventDefault(); ... }}. -->
1239
+ ${indent}`;
1240
+ out = out.slice(0, tagStart) + comment + out.slice(tagStart);
1241
+ }
1242
+ return out;
1243
+ }
1199
1244
  function onEventToHandler(source) {
1200
1245
  const masked = [];
1201
1246
  const placeholder = (i) => ` SCRIPT_${i} `;
@@ -1203,8 +1248,9 @@ function onEventToHandler(source) {
1203
1248
  masked.push(match);
1204
1249
  return placeholder(masked.length - 1);
1205
1250
  });
1206
- const rewritten = intermediate.replace(SIMPLE_ON_EVENT, (_full, name) => `on${name}`);
1207
- let out = rewritten;
1251
+ let processed = intermediate.replace(SIMPLE_ON_EVENT, (_full, name) => `on${name}`);
1252
+ processed = flagEventModifiers(processed);
1253
+ let out = processed;
1208
1254
  masked.forEach((blk, i) => {
1209
1255
  out = out.replace(placeholder(i), blk);
1210
1256
  });
@@ -1254,6 +1300,22 @@ function exportLetToProps(source) {
1254
1300
  return source.replace(SCRIPT_BLOCK2, (full) => full.replace(inner, body));
1255
1301
  }
1256
1302
 
1303
+ // src/util/svelte-source.ts
1304
+ function findStringEnd(source, openIdx) {
1305
+ const quote = source[openIdx];
1306
+ let i = openIdx + 1;
1307
+ while (i < source.length) {
1308
+ const ch = source[i];
1309
+ if (ch === "\\") {
1310
+ i += 2;
1311
+ continue;
1312
+ }
1313
+ if (ch === quote) return i;
1314
+ i++;
1315
+ }
1316
+ return -1;
1317
+ }
1318
+
1257
1319
  // src/recipes/svelte-5/codemods/dollar-restprops.ts
1258
1320
  function removeInterfaceBlock(source) {
1259
1321
  const re = /^\s*interface\s+\$\$Props\s*\{/m;
@@ -1277,20 +1339,6 @@ function removeInterfaceBlock(source) {
1277
1339
  out = out.slice(0, match.index) + out.slice(endIdx);
1278
1340
  }
1279
1341
  }
1280
- function findStringEnd(source, openIdx) {
1281
- const quote = source[openIdx];
1282
- let i = openIdx + 1;
1283
- while (i < source.length) {
1284
- const ch = source[i];
1285
- if (ch === "\\") {
1286
- i += 2;
1287
- continue;
1288
- }
1289
- if (ch === quote) return i;
1290
- i++;
1291
- }
1292
- return -1;
1293
- }
1294
1342
  function maskStringLiterals(source) {
1295
1343
  const strings = [];
1296
1344
  let out = "";
@@ -1367,7 +1415,8 @@ function stateEffectSyncToDerived(source) {
1367
1415
 
1368
1416
  // src/recipes/svelte-5/codemods/dollar-props-class.ts
1369
1417
  var PROPS_DESTRUCTURE = /let\s*\{([\s\S]*?)\}(\s*:\s*\{([\s\S]*?)\})?\s*=\s*\$props\(\)/;
1370
- var DOLLAR_PROPS_CLASS = /\$\$props\.class\b/g;
1418
+ var HAS_DOLLAR_PROPS_CLASS = /\$\$props\.class\b/;
1419
+ var DOLLAR_PROPS_CLASS_GLOBAL = /\$\$props\.class\b/g;
1371
1420
  var DOLLAR_PROPS_ANY = /\$\$props\b/;
1372
1421
  var SCRIPT_BLOCK4 = /<script\b[^>]*>[\s\S]*?<\/script>/g;
1373
1422
  var MIGRATION_TASK = /^<!--\s*@migration-task[\s\S]*?-->\s*\n?/gm;
@@ -1389,8 +1438,7 @@ function restoreScripts(masked, blocks) {
1389
1438
  }
1390
1439
  function dollarPropsClass(source) {
1391
1440
  const { masked } = maskScripts(source);
1392
- if (!DOLLAR_PROPS_CLASS.test(masked)) return source;
1393
- DOLLAR_PROPS_CLASS.lastIndex = 0;
1441
+ if (!HAS_DOLLAR_PROPS_CLASS.test(masked)) return source;
1394
1442
  if (!PROPS_DESTRUCTURE.test(source)) return source;
1395
1443
  let updated = source.replace(PROPS_DESTRUCTURE, (full, body, typeAnno, typeBody) => {
1396
1444
  if (/\bclass\s*:/.test(body)) return full;
@@ -1404,7 +1452,7 @@ function dollarPropsClass(source) {
1404
1452
  return `let { ${newBody} } = $props()`;
1405
1453
  });
1406
1454
  const reMasked = maskScripts(updated);
1407
- const templateRewritten = reMasked.masked.replace(DOLLAR_PROPS_CLASS, IDENT);
1455
+ const templateRewritten = reMasked.masked.replace(DOLLAR_PROPS_CLASS_GLOBAL, IDENT);
1408
1456
  updated = restoreScripts(templateRewritten, reMasked.blocks);
1409
1457
  const stripped = updated.replace(MIGRATION_TASK, "");
1410
1458
  if (!DOLLAR_PROPS_ANY.test(stripped)) {
@@ -1423,7 +1471,7 @@ function findMatchingClose(source, openIdx) {
1423
1471
  while (i < source.length) {
1424
1472
  const ch = source[i];
1425
1473
  if (ch === '"' || ch === "'" || ch === "`") {
1426
- const closeStr = findStringClose(source, i);
1474
+ const closeStr = findStringEnd(source, i);
1427
1475
  if (closeStr === -1) return -1;
1428
1476
  i = closeStr + 1;
1429
1477
  continue;
@@ -1437,20 +1485,6 @@ function findMatchingClose(source, openIdx) {
1437
1485
  }
1438
1486
  return -1;
1439
1487
  }
1440
- function findStringClose(source, openIdx) {
1441
- const quote = source[openIdx];
1442
- let i = openIdx + 1;
1443
- while (i < source.length) {
1444
- const ch = source[i];
1445
- if (ch === "\\") {
1446
- i += 2;
1447
- continue;
1448
- }
1449
- if (ch === quote) return i;
1450
- i++;
1451
- }
1452
- return -1;
1453
- }
1454
1488
  var MIGRATION_MARKER = "// @migration-task: $effect won't trigger UI updates on plain `let` bindings \u2014 refine mutated locals to $state or split into per-variable $derived.";
1455
1489
  function transformBlocks(body) {
1456
1490
  const out = [];
@@ -1562,9 +1596,6 @@ async function writeMigrationSummary(input) {
1562
1596
  }
1563
1597
 
1564
1598
  // src/recipes/svelte-5/index.ts
1565
- function siteLabel8(site) {
1566
- return site.name ?? site.path;
1567
- }
1568
1599
  async function alreadyOnSvelte5(cwd) {
1569
1600
  try {
1570
1601
  const pkg = await readPackageJson(join12(cwd, "package.json"));
@@ -1575,111 +1606,73 @@ async function alreadyOnSvelte5(cwd) {
1575
1606
  }
1576
1607
  }
1577
1608
  async function upgradeSvelte4to5(site, opts = {}) {
1578
- const label = siteLabel8(site);
1579
1609
  const spawn2 = opts.spawn ?? defaultSpawn;
1580
- if (await alreadyOnSvelte5(site.path)) {
1581
- return {
1582
- recipe: "svelte-4-to-5",
1583
- site: label,
1584
- status: "noop",
1585
- commits: [],
1586
- notes: "site already declares svelte ^5.x"
1587
- };
1588
- }
1589
- if (!await isWorkingTreeClean(site.path)) {
1590
- throw new Error(`refusing to run: working tree is not clean at ${site.path}`);
1591
- }
1592
- const branch = branchName("svelte-4-to-5");
1593
- await createBranch(site.path, branch);
1594
- const shas = [];
1595
- const bumped = await bumpToSvelte5Versions(site.path);
1596
- if (bumped) {
1597
- const sha = await commit(site.path, "chore(svelte5): bump svelte/kit/vite/vite-plugin-svelte");
1598
- if (sha) shas.push(sha);
1599
- }
1600
- const configChanged = await migrateSvelteConfig(site.path);
1601
- if (configChanged) {
1602
- const sha = await commit(
1603
- site.path,
1604
- "refactor(svelte5): migrate svelte.config.js (drop vitePreprocess)"
1605
- );
1606
- if (sha) shas.push(sha);
1607
- }
1608
- const migrate = await runSvelteMigrate(site.path, spawn2);
1609
- if (migrate.ran) {
1610
- const sha = await commit(site.path, "refactor(svelte5): run official svelte-migrate codemod");
1611
- if (sha) shas.push(sha);
1612
- }
1613
- const tw = await upgradeTailwind(site.path, spawn2);
1614
- if (tw.ran) {
1615
- const sha = await commit(site.path, "chore(svelte5): tailwindcss 3 \u2192 4 upgrade");
1616
- if (sha) shas.push(sha);
1617
- }
1618
- const codemods = await applyGotchaCodemods(site.path);
1619
- if (codemods.filesChanged > 0) {
1620
- const sha = await commit(
1621
- site.path,
1622
- `refactor(svelte5): apply gotcha codemods (${codemods.filesChanged} files)`
1623
- );
1624
- if (sha) shas.push(sha);
1625
- }
1626
- await verifyMigration(site.path, spawn2);
1627
- const verifySha = await commit(site.path, "chore(svelte5): pnpm install + check");
1628
- if (verifySha) shas.push(verifySha);
1629
- await writeMigrationSummary({
1630
- cwd: site.path,
1631
- filesChangedByCodemods: codemods.filesChanged,
1632
- svelteMigrateRan: migrate.ran,
1633
- tailwindUpgraded: tw.ran
1610
+ return withRecipe({
1611
+ name: "svelte-4-to-5",
1612
+ site,
1613
+ plan: async () => {
1614
+ if (await alreadyOnSvelte5(site.path)) {
1615
+ return { kind: "noop", notes: "site already declares svelte ^5.x" };
1616
+ }
1617
+ return { kind: "apply", plan: true };
1618
+ },
1619
+ apply: async (_plan, { commit: commit2, cwd }) => {
1620
+ const bumped = await bumpToSvelte5Versions(cwd);
1621
+ if (bumped) {
1622
+ await commit2("chore(svelte5): bump svelte/kit/vite/vite-plugin-svelte");
1623
+ }
1624
+ const configChanged = await migrateSvelteConfig(cwd);
1625
+ if (configChanged) {
1626
+ await commit2("refactor(svelte5): migrate svelte.config.js (drop vitePreprocess)");
1627
+ }
1628
+ const migrate = await runSvelteMigrate(cwd, spawn2);
1629
+ if (migrate.ran) {
1630
+ await commit2("refactor(svelte5): run official svelte-migrate codemod");
1631
+ }
1632
+ const tw = await upgradeTailwind(cwd, spawn2);
1633
+ if (tw.ran) {
1634
+ await commit2("chore(svelte5): tailwindcss 3 \u2192 4 upgrade");
1635
+ }
1636
+ const codemods = await applyGotchaCodemods(cwd);
1637
+ if (codemods.filesChanged > 0) {
1638
+ await commit2(`refactor(svelte5): apply gotcha codemods (${codemods.filesChanged} files)`);
1639
+ }
1640
+ await verifyMigration(cwd, spawn2);
1641
+ await commit2("chore(svelte5): pnpm install + check");
1642
+ await writeMigrationSummary({
1643
+ cwd,
1644
+ filesChangedByCodemods: codemods.filesChanged,
1645
+ svelteMigrateRan: migrate.ran,
1646
+ tailwindUpgraded: tw.ran
1647
+ });
1648
+ await commit2("docs(svelte5): add MIGRATION_SVELTE_5.md summary");
1649
+ return { kind: "ok" };
1650
+ }
1634
1651
  });
1635
- const summarySha = await commit(site.path, "docs(svelte5): add MIGRATION_SVELTE_5.md summary");
1636
- if (summarySha) shas.push(summarySha);
1637
- return {
1638
- recipe: "svelte-4-to-5",
1639
- site: label,
1640
- status: shas.length > 0 ? "applied" : "noop",
1641
- commits: shas,
1642
- notes: `branch: ${branch}`
1643
- };
1644
1652
  }
1645
1653
 
1646
1654
  // src/recipes/svelte-codemods.ts
1647
1655
  import { writeFile as writeFile8 } from "fs/promises";
1648
1656
  import { join as join13 } from "path";
1649
- function siteLabel9(site) {
1650
- return site.name ?? site.path;
1651
- }
1652
1657
  async function svelteCodemods(site) {
1653
- const label = siteLabel9(site);
1654
- const changes = await planGotchaCodemods(site.path);
1655
- if (changes.length === 0) {
1656
- return {
1657
- recipe: "svelte-codemods",
1658
- site: label,
1659
- status: "noop",
1660
- commits: [],
1661
- notes: "no codemod targets matched"
1662
- };
1663
- }
1664
- if (!await isWorkingTreeClean(site.path)) {
1665
- throw new Error(`refusing to run: working tree is not clean at ${site.path}`);
1666
- }
1667
- const branch = branchName("svelte-codemods");
1668
- await createBranch(site.path, branch);
1669
- for (const c of changes) {
1670
- await writeFile8(join13(site.path, c.rel), c.after, "utf-8");
1671
- }
1672
- const sha = await commit(
1673
- site.path,
1674
- `refactor(svelte5): apply codemods (${changes.length} files)`
1675
- );
1676
- return {
1677
- recipe: "svelte-codemods",
1678
- site: label,
1679
- status: "applied",
1680
- commits: sha ? [sha] : [],
1681
- notes: `branch: ${branch}`
1682
- };
1658
+ return withRecipe({
1659
+ name: "svelte-codemods",
1660
+ site,
1661
+ plan: async () => {
1662
+ const changes = await planGotchaCodemods(site.path);
1663
+ if (changes.length === 0) {
1664
+ return { kind: "noop", notes: "no codemod targets matched" };
1665
+ }
1666
+ return { kind: "apply", plan: changes };
1667
+ },
1668
+ apply: async (changes, { commit: commit2, cwd }) => {
1669
+ for (const c of changes) {
1670
+ await writeFile8(join13(cwd, c.rel), c.after, "utf-8");
1671
+ }
1672
+ await commit2(`refactor(svelte5): apply codemods (${changes.length} files)`);
1673
+ return { kind: "ok" };
1674
+ }
1675
+ });
1683
1676
  }
1684
1677
 
1685
1678
  // src/recipes/convert-to-pnpm.ts
@@ -1714,84 +1707,56 @@ async function exists2(path) {
1714
1707
  return false;
1715
1708
  }
1716
1709
  }
1717
- function siteLabel10(site) {
1718
- return site.name ?? site.path;
1719
- }
1720
1710
  async function convertToPnpm(site, opts = {}) {
1721
- const label = siteLabel10(site);
1722
1711
  const spawn2 = opts.spawn ?? defaultSpawn;
1723
1712
  const pnpmVersion = opts.pnpmVersion ?? DEFAULT_PNPM_VERSION;
1724
1713
  const pnpmLockPath = join14(site.path, "pnpm-lock.yaml");
1725
1714
  const npmLockPath = join14(site.path, "package-lock.json");
1726
1715
  const yarnLockPath = join14(site.path, "yarn.lock");
1727
- if (await exists2(pnpmLockPath)) {
1728
- return {
1729
- recipe: "convert-to-pnpm",
1730
- site: label,
1731
- status: "noop",
1732
- commits: [],
1733
- notes: "site already has pnpm-lock.yaml"
1734
- };
1735
- }
1736
- const hasNpmLock = await exists2(npmLockPath);
1737
- const hasYarnLock = await exists2(yarnLockPath);
1738
- if (!hasNpmLock && !hasYarnLock) {
1739
- return {
1740
- recipe: "convert-to-pnpm",
1741
- site: label,
1742
- status: "noop",
1743
- commits: [],
1744
- notes: "no convertible lockfile (package-lock.json or yarn.lock) at site root"
1745
- };
1746
- }
1747
- if (!await isWorkingTreeClean(site.path)) {
1748
- throw new Error(`refusing to run: working tree is not clean at ${site.path}`);
1749
- }
1750
- const branch = branchName("convert-to-pnpm");
1751
- await createBranch(site.path, branch);
1752
- const shas = [];
1753
- if (hasNpmLock) await rm3(npmLockPath, { force: true });
1754
- if (hasYarnLock) await rm3(yarnLockPath, { force: true });
1755
- const sourceLock = hasNpmLock ? "package-lock.json" : "yarn.lock";
1756
- const lockSha = await commit(site.path, `chore(pnpm): remove ${sourceLock}`);
1757
- if (lockSha) shas.push(lockSha);
1758
- const pkgPath = join14(site.path, "package.json");
1759
- const pkg = await readPackageJson(pkgPath);
1760
- const next = { ...pkg, packageManager: `pnpm@${pnpmVersion}` };
1761
- if (pkg.scripts && typeof pkg.scripts === "object") {
1762
- const { scripts: rewritten, changedCount } = rewriteScriptsForPnpm(
1763
- pkg.scripts
1764
- );
1765
- if (changedCount > 0) {
1766
- next.scripts = rewritten;
1716
+ return withRecipe({
1717
+ name: "convert-to-pnpm",
1718
+ site,
1719
+ plan: async () => {
1720
+ if (await exists2(pnpmLockPath)) {
1721
+ return { kind: "noop", notes: "site already has pnpm-lock.yaml" };
1722
+ }
1723
+ const hasNpmLock = await exists2(npmLockPath);
1724
+ const hasYarnLock = await exists2(yarnLockPath);
1725
+ if (!hasNpmLock && !hasYarnLock) {
1726
+ return {
1727
+ kind: "noop",
1728
+ notes: "no convertible lockfile (package-lock.json or yarn.lock) at site root"
1729
+ };
1730
+ }
1731
+ return { kind: "apply", plan: { hasNpmLock, hasYarnLock } };
1732
+ },
1733
+ apply: async ({ hasNpmLock, hasYarnLock }, { commit: commit2, cwd }) => {
1734
+ if (hasNpmLock) await rm3(npmLockPath, { force: true });
1735
+ if (hasYarnLock) await rm3(yarnLockPath, { force: true });
1736
+ const sourceLock = hasNpmLock ? "package-lock.json" : "yarn.lock";
1737
+ await commit2(`chore(pnpm): remove ${sourceLock}`);
1738
+ const pkgPath = join14(cwd, "package.json");
1739
+ const pkg = await readPackageJson(pkgPath);
1740
+ const next = { ...pkg, packageManager: `pnpm@${pnpmVersion}` };
1741
+ if (pkg.scripts && typeof pkg.scripts === "object") {
1742
+ const { scripts: rewritten, changedCount } = rewriteScriptsForPnpm(
1743
+ pkg.scripts
1744
+ );
1745
+ if (changedCount > 0) {
1746
+ next.scripts = rewritten;
1747
+ }
1748
+ }
1749
+ await writePackageJson(pkgPath, next);
1750
+ await commit2("chore(pnpm): pin packageManager + rewrite npm scripts");
1751
+ await rm3(join14(cwd, "node_modules"), { recursive: true, force: true });
1752
+ const installResult = await spawn2("pnpm", ["install"], { cwd, streaming: true });
1753
+ if (installResult.code !== 0) {
1754
+ return { kind: "failed", notes: `pnpm install failed (exit ${installResult.code})` };
1755
+ }
1756
+ await commit2("chore(pnpm): add pnpm-lock.yaml");
1757
+ return { kind: "ok" };
1767
1758
  }
1768
- }
1769
- await writePackageJson(pkgPath, next);
1770
- const pkgSha = await commit(site.path, "chore(pnpm): pin packageManager + rewrite npm scripts");
1771
- if (pkgSha) shas.push(pkgSha);
1772
- await rm3(join14(site.path, "node_modules"), { recursive: true, force: true });
1773
- const installResult = await spawn2("pnpm", ["install"], {
1774
- cwd: site.path,
1775
- streaming: true
1776
1759
  });
1777
- if (installResult.code !== 0) {
1778
- return {
1779
- recipe: "convert-to-pnpm",
1780
- site: label,
1781
- status: "failed",
1782
- commits: shas,
1783
- notes: `pnpm install failed (exit ${installResult.code}). branch ${branch} left for inspection.`
1784
- };
1785
- }
1786
- const installSha = await commit(site.path, "chore(pnpm): add pnpm-lock.yaml");
1787
- if (installSha) shas.push(installSha);
1788
- return {
1789
- recipe: "convert-to-pnpm",
1790
- site: label,
1791
- status: "applied",
1792
- commits: shas,
1793
- notes: `branch: ${branch}`
1794
- };
1795
1760
  }
1796
1761
 
1797
1762
  // src/recipes/onboard.ts
@@ -1844,81 +1809,63 @@ async function exists3(path) {
1844
1809
  return false;
1845
1810
  }
1846
1811
  }
1847
- function siteLabel11(site) {
1848
- return site.name ?? site.path;
1849
- }
1850
1812
  function isDeclared(pkg, name) {
1851
1813
  return Boolean(pkg.dependencies?.[name] ?? pkg.devDependencies?.[name]);
1852
1814
  }
1853
1815
  async function onboard(site, opts = {}) {
1854
- const label = siteLabel11(site);
1855
1816
  const spawn2 = opts.spawn ?? defaultSpawn;
1856
1817
  const audits = opts.audits ?? ["lighthouse", "a11y"];
1857
1818
  const packageVersion = opts.packageVersion ?? selfCaretRange(import.meta.url);
1858
- if (!await exists3(join16(site.path, "pnpm-lock.yaml"))) {
1859
- return {
1860
- recipe: "onboard",
1861
- site: label,
1862
- status: "failed",
1863
- commits: [],
1864
- notes: "no pnpm-lock.yaml at site root \u2014 run convert-to-pnpm first"
1865
- };
1866
- }
1867
- const pkgPath = join16(site.path, "package.json");
1868
- const pkg = await readPackageJson(pkgPath);
1869
- const toAdd = [];
1870
- if (!isDeclared(pkg, PACKAGE_NAME)) {
1871
- toAdd.push({ name: PACKAGE_NAME, version: packageVersion });
1872
- }
1873
- for (const audit of audits) {
1874
- for (const dep of AUDIT_DEPS[audit]) {
1875
- if (!isDeclared(pkg, dep.name)) toAdd.push(dep);
1819
+ return withRecipe({
1820
+ name: "onboard",
1821
+ site,
1822
+ plan: async () => {
1823
+ if (!await exists3(join16(site.path, "pnpm-lock.yaml"))) {
1824
+ return {
1825
+ kind: "failed",
1826
+ notes: "no pnpm-lock.yaml at site root \u2014 run convert-to-pnpm first"
1827
+ };
1828
+ }
1829
+ const pkgPath = join16(site.path, "package.json");
1830
+ const pkg = await readPackageJson(pkgPath);
1831
+ const toAdd = [];
1832
+ if (!isDeclared(pkg, PACKAGE_NAME)) {
1833
+ toAdd.push({ name: PACKAGE_NAME, version: packageVersion });
1834
+ }
1835
+ for (const audit of audits) {
1836
+ for (const dep of AUDIT_DEPS[audit]) {
1837
+ if (!isDeclared(pkg, dep.name)) toAdd.push(dep);
1838
+ }
1839
+ }
1840
+ if (toAdd.length === 0) {
1841
+ return {
1842
+ kind: "noop",
1843
+ notes: `site already has ${PACKAGE_NAME} and audit deps (${audits.join("+")})`
1844
+ };
1845
+ }
1846
+ return { kind: "apply", plan: { pkg, toAdd } };
1847
+ },
1848
+ apply: async ({ pkg, toAdd }, { commit: commit2, cwd }) => {
1849
+ const pkgPath = join16(cwd, "package.json");
1850
+ let next = pkg;
1851
+ for (const dep of toAdd) {
1852
+ next = bumpDep(next, dep.name, dep.version);
1853
+ }
1854
+ await writePackageJson(pkgPath, next);
1855
+ const installResult = await spawn2("pnpm", ["install"], { cwd, streaming: true });
1856
+ if (installResult.code !== 0) {
1857
+ return {
1858
+ kind: "failed",
1859
+ notes: `pnpm install failed (exit ${installResult.code})`
1860
+ };
1861
+ }
1862
+ await commit2(`chore(reddoor): onboard with ${PACKAGE_NAME} ${packageVersion}`);
1863
+ return {
1864
+ kind: "ok",
1865
+ notes: `Added ${toAdd.length} dep(s): ${toAdd.map((d) => d.name).join(", ")}`
1866
+ };
1876
1867
  }
1877
- }
1878
- if (toAdd.length === 0) {
1879
- return {
1880
- recipe: "onboard",
1881
- site: label,
1882
- status: "noop",
1883
- commits: [],
1884
- notes: `site already has ${PACKAGE_NAME} and audit deps (${audits.join("+")})`
1885
- };
1886
- }
1887
- if (!await isWorkingTreeClean(site.path)) {
1888
- throw new Error(`refusing to run: working tree is not clean at ${site.path}`);
1889
- }
1890
- const branch = branchName("onboard");
1891
- await createBranch(site.path, branch);
1892
- let next = pkg;
1893
- for (const dep of toAdd) {
1894
- next = bumpDep(next, dep.name, dep.version);
1895
- }
1896
- await writePackageJson(pkgPath, next);
1897
- const installResult = await spawn2("pnpm", ["install"], {
1898
- cwd: site.path,
1899
- streaming: true
1900
1868
  });
1901
- if (installResult.code !== 0) {
1902
- return {
1903
- recipe: "onboard",
1904
- site: label,
1905
- status: "failed",
1906
- commits: [],
1907
- notes: `pnpm install failed (exit ${installResult.code}). branch ${branch} left for inspection.`
1908
- };
1909
- }
1910
- const sha = await commit(
1911
- site.path,
1912
- `chore(reddoor): onboard with ${PACKAGE_NAME} ${packageVersion}`
1913
- );
1914
- const shas = sha ? [sha] : [];
1915
- return {
1916
- recipe: "onboard",
1917
- site: label,
1918
- status: "applied",
1919
- commits: shas,
1920
- notes: `branch: ${branch}. Added ${toAdd.length} dep(s): ${toAdd.map((d) => d.name).join(", ")}`
1921
- };
1922
1869
  }
1923
1870
 
1924
1871
  // src/recipes/index.ts