@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 +1 -1
- package/src/index.js +39 -5
- package/src/postWireTest.js +27 -6
- package/src/shopifyTheme.js +47 -8
- package/src/verifyWire.js +10 -0
- package/src/wireHandlers.js +122 -20
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@fiodos/cli",
|
|
3
|
-
"version": "0.1.
|
|
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(
|
|
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
|
|
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(
|
|
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(
|
|
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(
|
|
1224
|
+
logUser(changeLogUserMessage(paths));
|
|
1191
1225
|
} catch (e) {
|
|
1192
1226
|
logDev(`[change-registry] could not write: ${e.message}`);
|
|
1193
1227
|
}
|
package/src/postWireTest.js
CHANGED
|
@@ -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
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
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` };
|
package/src/shopifyTheme.js
CHANGED
|
@@ -240,19 +240,16 @@ function escapeRe(s) {
|
|
|
240
240
|
}
|
|
241
241
|
|
|
242
242
|
/**
|
|
243
|
-
* Inject (or refresh) the snippet
|
|
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
|
|
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: '
|
|
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.
|
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.
|
|
@@ -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
|
|
558
|
-
if (
|
|
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
|
|
675
|
+
const rawFrom = normRel(imp && imp.from);
|
|
608
676
|
const kind = (imp && imp.kind) || 'named';
|
|
609
|
-
if (!name || !
|
|
610
|
-
|
|
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
|
-
/**
|
|
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(
|
|
1201
|
-
|
|
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
|
-
/**
|
|
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
|
|
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
|
/**
|