@fiodos/cli 0.1.26 → 0.1.28

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fiodos/cli",
3
- "version": "0.1.26",
3
+ "version": "0.1.28",
4
4
  "description": "Fiodos CLI — analyzes your app's source code and generates the in-app voice-agent manifest, then wires the orb. Powers `npx @fiodos/cli analyze`.",
5
5
  "type": "commonjs",
6
6
  "bin": {
package/src/index.js CHANGED
@@ -175,6 +175,11 @@ function logUser(line) {
175
175
  console.log(`${cyan}◉${reset} ${blue}Fiodos${reset} · ${line}`);
176
176
  }
177
177
 
178
+ /** Human-readable pointer to the per-run change registry written in the repo. */
179
+ function changeLogUserMessage(paths) {
180
+ return `We saved a summary of every file we changed — see ${paths.mdRel} (past runs in .fyodos/changes/)`;
181
+ }
182
+
178
183
  /**
179
184
  * Ask the developer to confirm before the analysis runs. `yes` continues; any
180
185
  * other answer (or `no`) cancels instantly — the command ends without analyzing.
@@ -925,7 +930,7 @@ async function main() {
925
930
  envResult: envWriteResult,
926
931
  });
927
932
  const paths = writeChangeRegistry(analysisRoot, record);
928
- logUser(`Change log: ${paths.mdRel} (history in .fyodos/changes/)`);
933
+ logUser(changeLogUserMessage(paths));
929
934
  } catch (e) {
930
935
  logDev(`[change-registry] could not write: ${e.message}`);
931
936
  }
@@ -973,7 +978,36 @@ async function main() {
973
978
  });
974
979
  logUser('Published to your project');
975
980
  if (orbWireResult && (orbWireResult.status === 'added' || orbWireResult.status === 'already')) {
976
- logUser('Commit & push this theme repo — Shopify\u2019s GitHub sync deploys the orb to your live store.');
981
+ logUser('Commit & push this theme repo.');
982
+ // Shopify's GitHub sync only auto-updates the storefront when the
983
+ // CONNECTED theme is already the LIVE one. A brand-new theme connected
984
+ // from GitHub stays a draft until published ONCE by hand — every push
985
+ // after that keeps it live automatically. Without this line the orb can
986
+ // be fully wired and pushed and still never appear to real customers,
987
+ // with nothing in the CLI output explaining why.
988
+ logUser(
989
+ 'If this is already your LIVE theme, that push alone puts the orb on your store. ' +
990
+ 'If it\u2019s still a DRAFT (check the Shopify admin theme badge), publish it ONCE — ' +
991
+ 'Online Store \u2192 Themes \u2192 \u22ef \u2192 Publish \u2014 then every future push stays live automatically.',
992
+ );
993
+ // New/trial Shopify stores are password-protected by default, and a
994
+ // password-protected storefront renders layout/password.liquid INSTEAD
995
+ // of layout/theme.liquid on every page — so without also wiring that
996
+ // layout (done above when the theme has one) the orb would be
997
+ // correctly installed and published yet invisible behind the password
998
+ // gate. Surfacing this explicitly since it is the #1 "installed
999
+ // correctly but nothing shows up" report on a fresh store.
1000
+ if (orbWireResult.password) {
1001
+ logUser(
1002
+ orbWireResult.password.status === 'failed'
1003
+ ? `Heads up: your store looks password-protected but the orb could not be added to ${orbWireResult.password.file} (${orbWireResult.password.reason}) — it will be invisible until you remove the password or fix that layout.`
1004
+ : 'Your theme has a password page (layout/password.liquid) — the orb is embedded there too, so it still shows up while your store is password-protected.',
1005
+ );
1006
+ } else {
1007
+ logUser(
1008
+ 'Heads up: if your store is still password-protected (common on new/trial stores) and your theme has NO separate layout/password.liquid, the orb won\u2019t show on the password page — remove the password (Online Store \u2192 Preferences) to test, or check under a real domain/plan.',
1009
+ );
1010
+ }
977
1011
  }
978
1012
 
979
1013
  if (!noWire) {
@@ -985,7 +1019,7 @@ async function main() {
985
1019
  orbResult: orbWireResult,
986
1020
  });
987
1021
  const paths = writeChangeRegistry(analysisRoot, record);
988
- logUser(`Change log: ${paths.mdRel} (history in .fyodos/changes/)`);
1022
+ logUser(changeLogUserMessage(paths));
989
1023
  } catch (e) {
990
1024
  logDev(`[change-registry] could not write: ${e.message}`);
991
1025
  }
@@ -1108,7 +1142,7 @@ async function main() {
1108
1142
  envResult: envWriteResult,
1109
1143
  });
1110
1144
  const paths = writeChangeRegistry(analysisRoot, record);
1111
- logUser(`Change log: ${paths.mdRel} (history in .fyodos/changes/)`);
1145
+ logUser(changeLogUserMessage(paths));
1112
1146
  } catch (e) {
1113
1147
  logDev(`[change-registry] could not write: ${e.message}`);
1114
1148
  }
@@ -1187,7 +1221,7 @@ async function main() {
1187
1221
  envResult: envWriteResult,
1188
1222
  });
1189
1223
  const paths = writeChangeRegistry(analysisRoot, record);
1190
- logUser(`Change log: ${paths.mdRel} (history in .fyodos/changes/)`);
1224
+ logUser(changeLogUserMessage(paths));
1191
1225
  } catch (e) {
1192
1226
  logDev(`[change-registry] could not write: ${e.message}`);
1193
1227
  }
@@ -127,6 +127,13 @@ function summarizeBuildOutput(raw, maxLines = 4) {
127
127
  return picked.slice(0, maxLines).join('\n').trim();
128
128
  }
129
129
 
130
+ /** Does this output contain a signal that the CODE is broken (vs npm/env noise)? */
131
+ function looksLikeCompileFailure(output) {
132
+ return /error\s+TS\d+|Type error:|Module not found|Can't resolve|Failed to compile|SyntaxError|Parsing error|Cannot find (?:module|name)/i.test(
133
+ String(output || ''),
134
+ );
135
+ }
136
+
130
137
  async function runPostWireTest(appRoot, opts = {}) {
131
138
  const timeoutMs = opts.timeoutMs || 240000;
132
139
  if (!fs.existsSync(path.join(appRoot, 'node_modules'))) {
@@ -143,12 +150,26 @@ async function runPostWireTest(appRoot, opts = {}) {
143
150
  return { ok: true, stage: 'skipped-no-deps', command: '(none)', output: 'no build/typecheck script.' };
144
151
  }
145
152
 
146
- const res = await runAsync('npm', chosen.args, {
147
- cwd: appRoot,
148
- timeout: timeoutMs,
149
- env: { ...process.env, CI: '1', NODE_ENV: process.env.NODE_ENV || 'production' },
150
- maxBuffer: 64 * 1024 * 1024,
151
- });
153
+ const runOnce = () =>
154
+ runAsync('npm', chosen.args, {
155
+ cwd: appRoot,
156
+ timeout: timeoutMs,
157
+ env: { ...process.env, CI: '1', NODE_ENV: process.env.NODE_ENV || 'production' },
158
+ maxBuffer: 64 * 1024 * 1024,
159
+ });
160
+
161
+ let res = await runOnce();
162
+ // A spawn-level or npm-level failure with ZERO compile diagnostics is
163
+ // environment noise (npm cache contention, a concurrent process touching
164
+ // package.json, transient ENOENT), not broken code. Blaming the action for
165
+ // it drops perfectly good wiring, so retry once before judging.
166
+ const npmLevelFailure =
167
+ (res.error && res.error.code !== 'ETIMEDOUT') ||
168
+ (res.status !== 0 && res.status !== null && !looksLikeCompileFailure(`${res.stdout}\n${res.stderr}`));
169
+ if (npmLevelFailure) {
170
+ await new Promise((r) => setTimeout(r, 1500));
171
+ res = await runOnce();
172
+ }
152
173
 
153
174
  if (res.error && res.error.code === 'ETIMEDOUT') {
154
175
  return { ok: false, stage: 'timeout', command: chosen.label, output: `build exceeded ${Math.round(timeoutMs / 1000)}s` };
@@ -240,19 +240,16 @@ function escapeRe(s) {
240
240
  }
241
241
 
242
242
  /**
243
- * Inject (or refresh) the snippet in layout/theme.liquid, right before
243
+ * Inject (or refresh) the snippet into ONE liquid layout file, right before
244
244
  * </body>. Idempotent via the FYODOS:ORB markers: an existing block is
245
245
  * replaced in place, so re-running the CLI updates the snippet instead of
246
- * duplicating it. Returns { status: 'added'|'already'|'skipped'|'failed',
247
- * file, reason? }.
246
+ * duplicating it. Returns { status: 'added'|'already'|'skipped'|'failed'|
247
+ * 'not-found', file, reason?, refreshed? }.
248
248
  */
249
- function wireShopifyTheme(appRoot, { apiKey, apiUrl, noWire = false } = {}) {
250
- const relFile = path.join('layout', 'theme.liquid');
251
- const file = path.join(appRoot, relFile);
249
+ function injectSnippetInLayout(file, relFile, snippet, { noWire = false } = {}) {
252
250
  if (!fs.existsSync(file)) {
253
- return { status: 'failed', file: relFile, reason: 'layout/theme.liquid not found' };
251
+ return { status: 'not-found', file: relFile };
254
252
  }
255
- const snippet = buildShopifySnippet({ apiKey, apiUrl });
256
253
  const source = fs.readFileSync(file, 'utf8');
257
254
 
258
255
  const markerRe = new RegExp(`${escapeRe(ORB_START)}[\\s\\S]*?${escapeRe(ORB_END)}`);
@@ -273,6 +270,41 @@ function wireShopifyTheme(appRoot, { apiKey, apiUrl, noWire = false } = {}) {
273
270
  return { status: 'added', file: relFile };
274
271
  }
275
272
 
273
+ /**
274
+ * Wire the theme's layout(s). layout/theme.liquid is the one every storefront
275
+ * page uses and is REQUIRED. layout/password.liquid — when the theme ships
276
+ * one (Dawn, Horizon and most modern themes do) — is a SEPARATE layout Shopify
277
+ * renders INSTEAD of theme.liquid on every page while the store has password
278
+ * protection active (the default on every new/trial store, and very common on
279
+ * dev stores used to test an install). Without wiring it too, the orb is
280
+ * correctly embedded and published yet invisible to anyone hitting the
281
+ * password gate — "installed correctly but nothing shows up". Best-effort:
282
+ * its absence is normal (older/simpler themes have none) and never fails the
283
+ * install.
284
+ *
285
+ * Returns { status, file, reason?, refreshed?, password: {status, file,...} }
286
+ * — `password` mirrors the same shape for layout/password.liquid, or null
287
+ * when that file does not exist in this theme.
288
+ */
289
+ function wireShopifyTheme(appRoot, { apiKey, apiUrl, noWire = false } = {}) {
290
+ const snippet = buildShopifySnippet({ apiKey, apiUrl });
291
+
292
+ const themeRelFile = path.join('layout', 'theme.liquid');
293
+ const themeFile = path.join(appRoot, themeRelFile);
294
+ if (!fs.existsSync(themeFile)) {
295
+ return { status: 'failed', file: themeRelFile, reason: 'layout/theme.liquid not found', password: null };
296
+ }
297
+ const themeResult = injectSnippetInLayout(themeFile, themeRelFile, snippet, { noWire });
298
+
299
+ const passwordRelFile = path.join('layout', 'password.liquid');
300
+ const passwordFile = path.join(appRoot, passwordRelFile);
301
+ const passwordResult = fs.existsSync(passwordFile)
302
+ ? injectSnippetInLayout(passwordFile, passwordRelFile, snippet, { noWire })
303
+ : null;
304
+
305
+ return { ...themeResult, password: passwordResult };
306
+ }
307
+
276
308
  function reportShopifyWire(result, colors = {}, { quiet = false } = {}) {
277
309
  const { blue = '', cyan = '', reset = '' } = colors;
278
310
  const say = (line) => console.error(`${cyan}◉${reset} ${blue}Fiodos${reset} · ${line}`);
@@ -285,6 +317,13 @@ function reportShopifyWire(result, colors = {}, { quiet = false } = {}) {
285
317
  } else if (!quiet) {
286
318
  say(`orb embed skipped (${result.reason || result.status})`);
287
319
  }
320
+
321
+ const pw = result.password;
322
+ if (pw && (pw.status === 'added' || pw.status === 'already')) {
323
+ say(`orb also embedded in ${pw.file} — keeps working while your store is password-protected`);
324
+ } else if (pw && pw.status === 'failed') {
325
+ say(`could not embed the orb in ${pw.file}: ${pw.reason} — the orb won't show while your store is password-protected`);
326
+ }
288
327
  }
289
328
 
290
329
  module.exports = {
package/src/verifyWire.js CHANGED
@@ -52,8 +52,16 @@ function normErr(line) {
52
52
  * really broken), as opposed to a style violation? These mean the safety net
53
53
  * MUST act: the action does not actually work.
54
54
  */
55
+ // "Declared but never used" (TS6133/6192/6196/6198/6199/6205) is a STRICTNESS
56
+ // gate (noUnusedLocals), never broken code. It also appears as an ARTIFACT of
57
+ // per-action isolated verification: wiring one bridge action alone leaves the
58
+ // sibling symbols of the same destructuring temporarily unused, which used to
59
+ // fail every action in that file (the navigate_section/logout production bug).
60
+ const UNUSED_CODE_TS_RE = /error\s+TS6(?:133|192|196|198|199|205)\b/i;
61
+
55
62
  function isRealCompileError(line) {
56
63
  const l = String(line);
64
+ if (UNUSED_CODE_TS_RE.test(l)) return false;
57
65
  return /error\s+TS\d+|Type error:|Cannot find (?:module|name)|is not assignable|does not exist on type|has no exported member|Module not found|SyntaxError|Unexpected (?:token|keyword|reserved word)|Expression expected|Parsing error/i.test(l);
58
66
  }
59
67
 
@@ -66,6 +74,8 @@ function isRealCompileError(line) {
66
74
  function isLintError(line) {
67
75
  const l = String(line);
68
76
  if (isRealCompileError(l)) return false;
77
+ // Unused-symbol strictness (see UNUSED_CODE_TS_RE) is style, not brokenness.
78
+ if (UNUSED_CODE_TS_RE.test(l)) return true;
69
79
  // Reference to a known ESLint rule namespace / a bare `no-*` rule.
70
80
  if (/@typescript-eslint\/|react-hooks\/|jsx-a11y\/|@next\/next\/|\bimport\/[a-z-]+|\breact\/[a-z-]+|prettier\/|\bno-[a-z][a-z-]+\b/.test(l)) return true;
71
81
  // ESLint compact format "12:5 Error message rule" without a TS error code.
@@ -98,6 +98,66 @@ function readFileSafe(appRoot, fileRel) {
98
98
  }
99
99
  }
100
100
 
101
+ const SOURCE_EXTS = ['.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs', '.vue', '.svelte'];
102
+
103
+ /** Read tsconfig/jsconfig path aliases once per appRoot: [{ prefix, target }]. */
104
+ const aliasCache = new Map();
105
+ function readPathAliases(appRoot) {
106
+ if (aliasCache.has(appRoot)) return aliasCache.get(appRoot);
107
+ const aliases = [];
108
+ for (const cfgName of ['tsconfig.json', 'tsconfig.app.json', 'jsconfig.json']) {
109
+ try {
110
+ const raw = fs.readFileSync(path.join(appRoot, cfgName), 'utf8');
111
+ // Tolerant parse: strip comments and trailing commas (tsconfig is JSONC).
112
+ const cfg = JSON.parse(raw.replace(/\/\*[\s\S]*?\*\/|\/\/[^\n]*/g, '').replace(/,\s*([}\]])/g, '$1'));
113
+ const co = cfg.compilerOptions || {};
114
+ const baseUrl = co.baseUrl || '.';
115
+ for (const [key, targets] of Object.entries(co.paths || {})) {
116
+ const target = Array.isArray(targets) ? targets[0] : targets;
117
+ if (!target) continue;
118
+ aliases.push({
119
+ prefix: key.replace(/\*$/, ''),
120
+ target: normRel(path.join(baseUrl, String(target).replace(/\*$/, ''))),
121
+ });
122
+ }
123
+ } catch {
124
+ /* no config or unparsable — skip */
125
+ }
126
+ }
127
+ aliasCache.set(appRoot, aliases);
128
+ return aliases;
129
+ }
130
+
131
+ /**
132
+ * Resolve an AI-proposed import path to a REAL file, tolerating what a human
133
+ * would write: tsconfig path aliases ('@/context/AppProvider') and
134
+ * extensionless specifiers. Returns the resolved app-relative path (with
135
+ * extension) or null. Rejecting these outright un-wired real actions whose
136
+ * only "fault" was that the AI wrote the import the way the codebase does.
137
+ */
138
+ function resolveSourceFile(appRoot, fromRel) {
139
+ const candidates = [normRel(fromRel)];
140
+ for (const { prefix, target } of readPathAliases(appRoot)) {
141
+ const f = candidates[0];
142
+ if (f === prefix.replace(/\/$/, '') || f.startsWith(prefix)) {
143
+ candidates.push(normRel(target + f.slice(prefix.length)));
144
+ }
145
+ }
146
+ for (const rel of candidates) {
147
+ const abs = path.join(appRoot, rel);
148
+ if (fs.existsSync(abs) && fs.statSync(abs).isFile()) return rel;
149
+ for (const ext of SOURCE_EXTS) {
150
+ if (fs.existsSync(`${abs}${ext}`)) return `${rel}${ext}`;
151
+ }
152
+ if (fs.existsSync(abs) && fs.statSync(abs).isDirectory()) {
153
+ for (const ext of SOURCE_EXTS) {
154
+ if (fs.existsSync(path.join(abs, `index${ext}`))) return normRel(path.join(rel, `index${ext}`));
155
+ }
156
+ }
157
+ }
158
+ return null;
159
+ }
160
+
101
161
  /** Count non-overlapping occurrences of a literal substring. */
102
162
  function countOccurrences(haystack, needle) {
103
163
  if (!needle) return 0;
@@ -112,14 +172,15 @@ function countOccurrences(haystack, needle) {
112
172
 
113
173
  // ── Mechanical verification (the safety net for AI-proposed wiring) ─────────────
114
174
 
115
- /** True if `symbol` appears as a standalone identifier somewhere in the file. */
175
+ /** True if `symbol` appears as a standalone identifier somewhere in the file.
176
+ * The file path tolerates aliases/extensionless specifiers (resolveSourceFile). */
116
177
  function fileHasSymbol(appRoot, fileRel, symbol) {
117
178
  if (!fileRel || !symbol) return false;
118
- const abs = path.join(appRoot, normRel(fileRel));
119
- if (!fs.existsSync(abs)) return false;
179
+ const resolved = resolveSourceFile(appRoot, fileRel);
180
+ if (!resolved) return false;
120
181
  let content;
121
182
  try {
122
- content = fs.readFileSync(abs, 'utf8');
183
+ content = fs.readFileSync(path.join(appRoot, resolved), 'utf8');
123
184
  } catch {
124
185
  return false;
125
186
  }
@@ -419,11 +480,12 @@ function buildAiEntry({ appRoot, fyodosDirRel, base, aiW, manifestParams }) {
419
480
  const importedNames = new Set();
420
481
  for (const imp of rawImports) {
421
482
  const name = String((imp && imp.name) || '').trim();
422
- const from = normRel(imp && imp.from);
483
+ const rawFrom = normRel(imp && imp.from);
423
484
  const kind = (imp && imp.kind) || 'named';
424
- if (!name || !from) return { reason: 'an import proposed by the AI is incomplete (missing name/from)' };
425
- if (!fs.existsSync(path.join(appRoot, from))) {
426
- return { reason: `the proposed import points to a non-existent file: '${from}'` };
485
+ if (!name || !rawFrom) return { reason: 'an import proposed by the AI is incomplete (missing name/from)' };
486
+ const from = resolveSourceFile(appRoot, rawFrom);
487
+ if (!from) {
488
+ return { reason: `the proposed import points to a non-existent file: '${rawFrom}'` };
427
489
  }
428
490
  // For named imports the symbol must be present in that file; default/namespace
429
491
  // bind the module's default/whole export so only file existence is checked.
@@ -554,9 +616,15 @@ function buildAiBridgeEntry({ appRoot, fyodosDirRel, base, aiW }) {
554
616
  const why = aiW.bridgeReason ? ` (${aiW.bridgeReason})` : '';
555
617
 
556
618
  if (!file) return { reason: `the AI marked a bridge but did not indicate the component file${why}` };
557
- const content = readFileSafe(appRoot, file);
558
- if (content == null) return { reason: `could not read the component '${file}' to apply the bridge` };
619
+ const rawContent = readFileSafe(appRoot, file);
620
+ if (rawContent == null) return { reason: `could not read the component '${file}' to apply the bridge` };
559
621
  if (!invoke) return { reason: `the AI did not provide the bridge 'invoke' expression for '${file}'` };
622
+ // Anchor decisions must be made on the content the block will ACTUALLY be
623
+ // inserted into: applyComponentEdits strips our previous blocks first. On a
624
+ // re-run over an already-wired file, the old generated block contains the
625
+ // same invoke expressions, so counting occurrences on the raw file falsely
626
+ // flags unique anchors as "ambiguous" and un-wires working actions.
627
+ const content = stripFiodosBlocks(rawContent);
560
628
 
561
629
  // Anchor resolution ladder: exact-unique AI anchor → harmless-ambiguous AI
562
630
  // anchor → mechanically derived declaration anchor → review. The old behavior
@@ -604,10 +672,11 @@ function buildAiBridgeEntry({ appRoot, fyodosDirRel, base, aiW }) {
604
672
  const importedNames = new Set();
605
673
  for (const imp of Array.isArray(b.imports) ? b.imports : []) {
606
674
  const name = String((imp && imp.name) || '').trim();
607
- const from = normRel(imp && imp.from);
675
+ const rawFrom = normRel(imp && imp.from);
608
676
  const kind = (imp && imp.kind) || 'named';
609
- if (!name || !from) return { reason: 'a bridge import is incomplete (missing name/from)' };
610
- if (!fs.existsSync(path.join(appRoot, from))) return { reason: `the bridge import points to a non-existent file: '${from}'` };
677
+ if (!name || !rawFrom) return { reason: 'a bridge import is incomplete (missing name/from)' };
678
+ const from = resolveSourceFile(appRoot, rawFrom);
679
+ if (!from) return { reason: `the bridge import points to a non-existent file: '${rawFrom}'` };
611
680
  if (kind === 'named' && !fileHasSymbol(appRoot, from, name)) return { reason: `named import '${name}' does not exist in '${from}'` };
612
681
  imports.push({ kind, name, importPath: relImport(path.dirname(file), from) });
613
682
  importedNames.add(name);
@@ -1195,10 +1264,31 @@ function buildHandlerDoc(plan, ctx = {}) {
1195
1264
 
1196
1265
  // ── Editing the user's own files (idempotent + reversible) ─────────────────────
1197
1266
 
1198
- /** Remove any previously-inserted Fiodos blocks (idempotence across re-runs). */
1267
+ /**
1268
+ * Header phrasings the CLI itself has ever written (current English + the
1269
+ * localized parenthetical of older versions). Only blocks carrying one of
1270
+ * these are OURS to strip.
1271
+ */
1272
+ const GENERATED_HEADER_RE = /(generated by|auto-generado por|generado por)\s+Fiodos/i;
1273
+
1274
+ /**
1275
+ * Remove previously-inserted Fiodos blocks (idempotence across re-runs) —
1276
+ * but ONLY the blocks this CLI generated. A developer can hand-write their
1277
+ * own `FYODOS:BRIDGE` block (e.g. "manual wiring — logout") following our
1278
+ * docs; that block is THEIR code and often the only user of an import
1279
+ * (`useEffect`, a hook…). Stripping it orphans the import, the strict
1280
+ * TypeScript check (noUnusedLocals) fails the candidate, and the whole
1281
+ * wiring reverts — the exact failure that left navigate_section/logout
1282
+ * unwired in production. Developer blocks are preserved verbatim.
1283
+ */
1199
1284
  function stripFiodosBlocks(content) {
1200
- const re = new RegExp(`\\n?${esc(EDIT_START_PREFIX)}[^\\n]*[\\s\\S]*?${esc(EDIT_END)}\\n?`, 'g');
1201
- return content.replace(re, '\n');
1285
+ const re = new RegExp(
1286
+ `\\n?(${esc(EDIT_START_PREFIX)}[^\\n]*)[\\s\\S]*?${esc(EDIT_END)}\\n?`,
1287
+ 'g',
1288
+ );
1289
+ return content.replace(re, (block, headerLine) =>
1290
+ GENERATED_HEADER_RE.test(headerLine) ? '\n' : block,
1291
+ );
1202
1292
  }
1203
1293
 
1204
1294
  /** Insert an import block right after the file's last top-level import line. */
@@ -1414,20 +1504,32 @@ function pickRelevantFiles(files, evidence, candidate, intent) {
1414
1504
  return picked.length ? picked : files.slice(0, 4);
1415
1505
  }
1416
1506
 
1417
- /** Write ONLY one entry (registry + bridge + its edit) for isolated verification. */
1507
+ /**
1508
+ * Write ONLY one entry (registry + bridge + its edit) for isolated verification.
1509
+ * Registry/bridge files from a PREVIOUS install are snapshotted and restored on
1510
+ * revert instead of deleted: deleting them would break every OTHER file that
1511
+ * still imports `../fyodos/bridge` from an earlier run, making all later
1512
+ * verifications fail with a phantom "Cannot find module" (TS2307).
1513
+ */
1418
1514
  function writeIsolated({ appRoot, fyodosDirAbs, entry, ts, esm, ext }) {
1419
1515
  const onePlan = { auto: [entry], review: [], manual: [], entries: [entry] };
1420
1516
  const registryAbs = path.join(fyodosDirAbs, `${REGISTRY_BASENAME}${ext}`);
1421
1517
  const bridgeAbs = path.join(fyodosDirAbs, `${BRIDGE_BASENAME}${ext}`);
1422
- const generated = [registryAbs];
1518
+ const priorBackups = [];
1519
+ const generated = [];
1520
+ const snapshotOrCreate = (abs) => {
1521
+ if (fs.existsSync(abs)) priorBackups.push({ file: abs, abs, original: fs.readFileSync(abs, 'utf8') });
1522
+ else generated.push(abs);
1523
+ };
1524
+ snapshotOrCreate(registryAbs);
1423
1525
  if (entry.kind === 'bridge') {
1526
+ snapshotOrCreate(bridgeAbs);
1424
1527
  fs.writeFileSync(bridgeAbs, buildBridgeModule({ ts, esm }));
1425
- generated.push(bridgeAbs);
1426
1528
  }
1427
1529
  fs.writeFileSync(registryAbs, buildRegistryModule(onePlan, { ts, esm }));
1428
1530
  const edits = planComponentEdits(appRoot, onePlan, { ts });
1429
1531
  const { backups } = applyComponentEdits(appRoot, edits);
1430
- return { backups, generated, touched: [...edits.map((e) => e.file), registryAbs, bridgeAbs] };
1532
+ return { backups: [...backups, ...priorBackups], generated, touched: [...edits.map((e) => e.file), registryAbs, bridgeAbs] };
1431
1533
  }
1432
1534
 
1433
1535
  /**