@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.
- package/package.json +1 -1
- package/src/aiAnalyze.js +6 -4
- package/src/changeRegistry.js +55 -1
- package/src/collect.js +16 -2
- package/src/index.js +404 -8
- package/src/postWireTest.js +27 -6
- package/src/shopifyTheme.js +339 -0
- package/src/verify.js +124 -1
- package/src/verifyWire.js +10 -0
- package/src/wireHandlers.js +216 -28
- package/src/wireScreen.js +562 -0
- package/src/wireSession.js +147 -11
- package/src/wireWeb.js +115 -2
- package/src/wireWebMount.js +98 -14
package/src/wireHandlers.js
CHANGED
|
@@ -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
|
|
119
|
-
if (!
|
|
179
|
+
const resolved = resolveSourceFile(appRoot, fileRel);
|
|
180
|
+
if (!resolved) return false;
|
|
120
181
|
let content;
|
|
121
182
|
try {
|
|
122
|
-
content = fs.readFileSync(
|
|
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
|
|
483
|
+
const rawFrom = normRel(imp && imp.from);
|
|
423
484
|
const kind = (imp && imp.kind) || 'named';
|
|
424
|
-
if (!name || !
|
|
425
|
-
|
|
426
|
-
|
|
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
|
|
476
|
-
*
|
|
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
|
-
|
|
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
|
|
492
|
-
if (
|
|
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
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
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
|
|
675
|
+
const rawFrom = normRel(imp && imp.from);
|
|
524
676
|
const kind = (imp && imp.kind) || 'named';
|
|
525
|
-
if (!name || !
|
|
526
|
-
|
|
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
|
-
/**
|
|
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(
|
|
1117
|
-
|
|
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
|
-
/**
|
|
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
|
|
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,
|