@fiodos/cli 0.1.25 → 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.
@@ -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.
@@ -467,13 +529,79 @@ function buildAiEntry({ appRoot, fyodosDirRel, base, aiW, manifestParams }) {
467
529
  };
468
530
  }
469
531
 
532
+ /**
533
+ * MECHANICAL ANCHOR RESCUE — the fix for the dominant wiring failure class
534
+ * ("the insertion point in '<file>' is ambiguous / not found").
535
+ *
536
+ * The AI's anchor only needs to locate the RIGHT COMPONENT SCOPE: the actual
537
+ * insertion goes before the nearest JSX `return` (see insertRegisterBlock), so
538
+ * when the AI picks a line that appears several times in a big file we do not
539
+ * have to give up. We derive a deterministic anchor ourselves: the line where
540
+ * one of the identifiers the `invoke` expression CALLS/READS is DECLARED
541
+ * (`const [x, setX] = useState(...)`, `function foo(`, `const foo =`, a class
542
+ * field, …). Declarations are unique per scope in real code, and anchoring on
543
+ * the declaration guarantees the registration lands in the scope where the
544
+ * symbol is visible. Correctness is still gated by the same compile-regression
545
+ * + effect-probe verification as every other wiring — this only replaces the
546
+ * "reject" with a better, mechanical candidate.
547
+ */
548
+ function deriveBridgeAnchor(content, invoke) {
549
+ const src = stripLiterals(String(invoke || ''));
550
+ // Identifiers the invoke CALLS first (most specific), then everything it reads.
551
+ const ordered = [];
552
+ const pushId = (id) => {
553
+ if (!id || id === 'args' || id === 'this') return;
554
+ if (JS_KEYWORDS.has(id) || SAFE_GLOBALS.has(id)) return;
555
+ if (!ordered.includes(id)) ordered.push(id);
556
+ };
557
+ let m;
558
+ const callRe = /(?:^|[^.\w$])([A-Za-z_$][\w$]*)\s*\(/g;
559
+ while ((m = callRe.exec(src))) pushId(m[1]);
560
+ for (const id of freeIdentifiers(invoke)) pushId(id);
561
+
562
+ for (const id of ordered) {
563
+ // Full LINES that look like a declaration of `id` in the file.
564
+ const lineRe = new RegExp(
565
+ `^[ \\t]*(?:export\\s+)?(?:const|let|var|function|async\\s+function|private|public|protected|readonly)\\b[^\\n]*(?:^|[^.\\w$])${esc(id)}(?![\\w$])[^\\n]*$`,
566
+ 'gm',
567
+ );
568
+ const decls = [];
569
+ while ((m = lineRe.exec(content))) decls.push(m[0]);
570
+ // Exactly ONE declaration line, and that line must be unique as a substring
571
+ // (so indexOf-based insertion is deterministic and idempotent).
572
+ if (decls.length === 1 && countOccurrences(content, decls[0]) === 1) {
573
+ return decls[0];
574
+ }
575
+ }
576
+ return null;
577
+ }
578
+
579
+ /**
580
+ * True when an anchor that appears several times is HARMLESS: every occurrence
581
+ * resolves to the same actual insertion point (the nearest JSX `return`), so
582
+ * the ambiguity cannot change where the registration lands.
583
+ */
584
+ function ambiguousAnchorSameInsertion(content, anchor) {
585
+ const points = new Set();
586
+ let idx = content.indexOf(anchor);
587
+ while (idx !== -1) {
588
+ points.add(nearestJsxReturn(content, idx));
589
+ if (points.size > 1) return false;
590
+ idx = content.indexOf(anchor, idx + anchor.length);
591
+ }
592
+ // A single resolved point that exists (-1 = no JSX return found → not safe).
593
+ return points.size === 1 && !points.has(-1);
594
+ }
595
+
470
596
  /**
471
597
  * Verify the AI's BRIDGE proposal for ONE action. A bridge edits the user's own
472
598
  * component (under consent) to register the live, in-scope function/instance so
473
599
  * the standalone registry can reach component-local state. The verifier confirms
474
600
  * the edit can be applied SAFELY and that nothing is fabricated:
475
- * · the component file exists and the anchor line is present EXACTLY once
476
- * (so the insertion point is unambiguous and idempotent),
601
+ * · the component file exists and the anchor resolves to ONE unambiguous
602
+ * insertion point — when the AI's anchor is missing/ambiguous, a mechanical
603
+ * anchor is derived from the declaration of the invoked symbol (see
604
+ * deriveBridgeAnchor) instead of giving up,
477
605
  * · every requiredSymbol exists in the code,
478
606
  * · `invoke` does not create a detached `new Service()` instance,
479
607
  * · every identifier `invoke` reads is `args`/`this`, a safe global, an import
@@ -483,19 +611,43 @@ function buildAiEntry({ appRoot, fyodosDirRel, base, aiW, manifestParams }) {
483
611
  function buildAiBridgeEntry({ appRoot, fyodosDirRel, base, aiW }) {
484
612
  const b = aiW.bridge || {};
485
613
  const file = normRel(b.file);
486
- const anchor = String(b.anchor || '').trim();
614
+ let anchor = String(b.anchor || '').trim();
487
615
  const invoke = String(b.invoke || '').trim();
488
616
  const why = aiW.bridgeReason ? ` (${aiW.bridgeReason})` : '';
489
617
 
490
618
  if (!file) return { reason: `the AI marked a bridge but did not indicate the component file${why}` };
491
- const content = readFileSafe(appRoot, file);
492
- 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` };
493
621
  if (!invoke) return { reason: `the AI did not provide the bridge 'invoke' expression for '${file}'` };
494
- if (!anchor) return { reason: `the AI did not provide an insertion anchor in '${file}'` };
495
-
496
- const occ = countOccurrences(content, anchor);
497
- if (occ === 0) return { reason: `no safe insertion point was found in '${file}' — wire this action manually` };
498
- if (occ > 1) return { reason: `the insertion point in '${file}' is ambiguous wire this action manually` };
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);
628
+
629
+ // Anchor resolution ladder: exact-unique AI anchor → harmless-ambiguous AI
630
+ // anchor → mechanically derived declaration anchor → review. The old behavior
631
+ // (reject on 0/>1 occurrences) left real, wireable actions "manual" whenever
632
+ // the AI picked a repeated line in a big file.
633
+ let anchorDerived = false;
634
+ const occ = anchor ? countOccurrences(content, anchor) : 0;
635
+ if (occ !== 1) {
636
+ const scope = b.scope === 'class-field' ? 'class-field' : 'statement';
637
+ if (occ > 1 && scope === 'statement' && ambiguousAnchorSameInsertion(content, anchor)) {
638
+ // Keep the AI anchor: every occurrence lands on the same insertion point.
639
+ } else {
640
+ const derived = deriveBridgeAnchor(content, invoke);
641
+ if (derived) {
642
+ anchor = derived;
643
+ anchorDerived = true;
644
+ } else if (!anchor || occ === 0) {
645
+ return { reason: `no safe insertion point was found in '${file}' — wire this action manually` };
646
+ } else {
647
+ return { reason: `the insertion point in '${file}' is ambiguous — wire this action manually` };
648
+ }
649
+ }
650
+ }
499
651
 
500
652
  // requiredSymbols must really exist.
501
653
  const required = Array.isArray(aiW.requiredSymbols) ? aiW.requiredSymbols : [];
@@ -520,10 +672,11 @@ function buildAiBridgeEntry({ appRoot, fyodosDirRel, base, aiW }) {
520
672
  const importedNames = new Set();
521
673
  for (const imp of Array.isArray(b.imports) ? b.imports : []) {
522
674
  const name = String((imp && imp.name) || '').trim();
523
- const from = normRel(imp && imp.from);
675
+ const rawFrom = normRel(imp && imp.from);
524
676
  const kind = (imp && imp.kind) || 'named';
525
- if (!name || !from) return { reason: 'a bridge import is incomplete (missing name/from)' };
526
- 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}'` };
527
680
  if (kind === 'named' && !fileHasSymbol(appRoot, from, name)) return { reason: `named import '${name}' does not exist in '${from}'` };
528
681
  imports.push({ kind, name, importPath: relImport(path.dirname(file), from) });
529
682
  importedNames.add(name);
@@ -1111,10 +1264,31 @@ function buildHandlerDoc(plan, ctx = {}) {
1111
1264
 
1112
1265
  // ── Editing the user's own files (idempotent + reversible) ─────────────────────
1113
1266
 
1114
- /** 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
+ */
1115
1284
  function stripFiodosBlocks(content) {
1116
- const re = new RegExp(`\\n?${esc(EDIT_START_PREFIX)}[^\\n]*[\\s\\S]*?${esc(EDIT_END)}\\n?`, 'g');
1117
- 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
+ );
1118
1292
  }
1119
1293
 
1120
1294
  /** Insert an import block right after the file's last top-level import line. */
@@ -1330,20 +1504,32 @@ function pickRelevantFiles(files, evidence, candidate, intent) {
1330
1504
  return picked.length ? picked : files.slice(0, 4);
1331
1505
  }
1332
1506
 
1333
- /** 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
+ */
1334
1514
  function writeIsolated({ appRoot, fyodosDirAbs, entry, ts, esm, ext }) {
1335
1515
  const onePlan = { auto: [entry], review: [], manual: [], entries: [entry] };
1336
1516
  const registryAbs = path.join(fyodosDirAbs, `${REGISTRY_BASENAME}${ext}`);
1337
1517
  const bridgeAbs = path.join(fyodosDirAbs, `${BRIDGE_BASENAME}${ext}`);
1338
- 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);
1339
1525
  if (entry.kind === 'bridge') {
1526
+ snapshotOrCreate(bridgeAbs);
1340
1527
  fs.writeFileSync(bridgeAbs, buildBridgeModule({ ts, esm }));
1341
- generated.push(bridgeAbs);
1342
1528
  }
1343
1529
  fs.writeFileSync(registryAbs, buildRegistryModule(onePlan, { ts, esm }));
1344
1530
  const edits = planComponentEdits(appRoot, onePlan, { ts });
1345
1531
  const { backups } = applyComponentEdits(appRoot, edits);
1346
- 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] };
1347
1533
  }
1348
1534
 
1349
1535
  /**
@@ -1854,6 +2040,8 @@ module.exports = {
1854
2040
  findExportedFunction,
1855
2041
  findStoreMethod,
1856
2042
  mapParams,
2043
+ deriveBridgeAnchor,
2044
+ ambiguousAnchorSameInsertion,
1857
2045
  // shared with wireSession (same verification + injection machinery)
1858
2046
  fileHasSymbol,
1859
2047
  freeIdentifiers,