@fiodos/cli 0.1.0

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.
@@ -0,0 +1,136 @@
1
+ /**
2
+ * Vue real-effect render probe.
3
+ *
4
+ * Mounts the user's real .vue component in jsdom using the APP's own `vue`
5
+ * runtime, compiling SFCs on the fly with `@vue/compiler-sfc` (the standard Vue
6
+ * toolchain) via a tiny esbuild plugin. Then invokes the generated handler and
7
+ * confirms the rendered UI mutates.
8
+ *
9
+ * NOTE on the test app's pre-existing build errors: we transpile with esbuild
10
+ * (type-stripping only), so the app's pre-existing `vue-tsc` *type* errors do NOT
11
+ * block rendering — the effect check is independent of that unrelated noise.
12
+ */
13
+ 'use strict';
14
+
15
+ const fs = require('fs');
16
+ const os = require('os');
17
+ const path = require('path');
18
+ const crypto = require('crypto');
19
+
20
+ const { decide, installDom, captureSnapshot, short, loadEsbuild, loadJsdom, reqFrom, emptyAssetsPlugin } = require('./renderProbe');
21
+
22
+ function vuePlugin(compilerSfc) {
23
+ return {
24
+ name: 'fyodos-vue-sfc',
25
+ setup(build) {
26
+ build.onLoad({ filter: /\.vue$/ }, (args) => {
27
+ const src = fs.readFileSync(args.path, 'utf8');
28
+ const id = crypto.createHash('md5').update(args.path).digest('hex').slice(0, 8);
29
+ const { descriptor, errors } = compilerSfc.parse(src, { filename: args.path });
30
+ if (errors && errors.length) {
31
+ return { errors: [{ text: `SFC parse: ${errors[0].message || errors[0]}` }] };
32
+ }
33
+ try {
34
+ if (descriptor.scriptSetup || descriptor.script) {
35
+ const compiled = compilerSfc.compileScript(descriptor, { id, inlineTemplate: true });
36
+ return { contents: compiled.content, loader: 'ts', resolveDir: path.dirname(args.path) };
37
+ }
38
+ // Template-only component.
39
+ const tpl = compilerSfc.compileTemplate({ source: descriptor.template ? descriptor.template.content : '', id, filename: args.path });
40
+ return { contents: `${tpl.code}\nexport default { render };\n`, loader: 'ts', resolveDir: path.dirname(args.path) };
41
+ } catch (e) {
42
+ return { errors: [{ text: `SFC compile: ${e && e.message}` }] };
43
+ }
44
+ });
45
+ },
46
+ };
47
+ }
48
+
49
+ async function probeVueEffect(appRoot, entry, ctx) {
50
+ const esbuild = loadEsbuild(appRoot);
51
+ const jsdomMod = loadJsdom();
52
+ const compilerSfc = reqFrom(appRoot, '@vue/compiler-sfc');
53
+ if (!esbuild || !jsdomMod || !compilerSfc) {
54
+ const miss = !esbuild ? 'esbuild' : !jsdomMod ? 'jsdom' : '@vue/compiler-sfc';
55
+ return { status: 'unverifiable', detail: `no se pudo montar el render headless de Vue (${miss} no disponible); cableado aplicado, efecto real no verificable automáticamente — probar a mano` };
56
+ }
57
+ const fyodosDirAbs = path.join(appRoot, ctx.fyodosDirRel);
58
+ const componentAbs = path.join(appRoot, entry.bridge.file);
59
+ const entryRel = './' + path.relative(fyodosDirAbs, componentAbs).split(path.sep).join('/');
60
+ const harnessAbs = path.join(fyodosDirAbs, '__fyodos_probe_entry.ts');
61
+ const outAbs = path.join(os.tmpdir(), `fyodos-vue-probe-${Date.now()}.cjs`);
62
+ const harness =
63
+ `import { createApp, nextTick } from 'vue';\n` +
64
+ `import Component from '${entryRel}';\n` +
65
+ `import { fyodosGeneratedRegistries } from './handlers.generated';\n` +
66
+ `export const probe = { createApp, nextTick, Component, registry: fyodosGeneratedRegistries };\n`;
67
+
68
+ let restore = () => {};
69
+ try {
70
+ fs.writeFileSync(harnessAbs, harness);
71
+ await esbuild.build({
72
+ entryPoints: [harnessAbs],
73
+ outfile: outAbs,
74
+ bundle: true,
75
+ format: 'cjs',
76
+ platform: 'node',
77
+ resolveExtensions: ['.vue', '.ts', '.mjs', '.js', '.jsx', '.tsx', '.json'],
78
+ define: {
79
+ 'process.env.NODE_ENV': '"development"',
80
+ __VUE_OPTIONS_API__: 'true',
81
+ __VUE_PROD_DEVTOOLS__: 'false',
82
+ __VUE_PROD_HYDRATION_MISMATCH_DETAILS__: 'false',
83
+ },
84
+ plugins: [emptyAssetsPlugin(), vuePlugin(compilerSfc)],
85
+ logLevel: 'silent',
86
+ absWorkingDir: fyodosDirAbs,
87
+ });
88
+
89
+ restore = installDom(jsdomMod, { storageKeys: ctx.storageKeys, kind: ctx.kind });
90
+ delete require.cache[outAbs];
91
+ const mod = require(outAbs);
92
+ const { createApp, nextTick, Component, registry } = mod.probe;
93
+ if (!Component) return { status: 'unverifiable', detail: `'${entry.bridge.file}' no exporta un componente Vue montable — efecto real no verificable a mano` };
94
+
95
+ const container = document.createElement('div');
96
+ document.body.appendChild(container);
97
+ let app;
98
+ try {
99
+ app = createApp(Component);
100
+ app.config && (app.config.warnHandler = () => {});
101
+ app.mount(container);
102
+ await nextTick();
103
+ } catch (err) {
104
+ try { app && app.unmount(); } catch {}
105
+ return { status: 'unverifiable', detail: `el componente Vue no se pudo montar en aislamiento (${short(err)}); efecto real no verificable automáticamente — probar a mano` };
106
+ }
107
+
108
+ const beforeHTML = captureSnapshot(container);
109
+ const beforeText = container.textContent || '';
110
+ const handler = registry.handlers[entry.handler];
111
+ if (typeof handler !== 'function') { try { app.unmount(); } catch {} return { status: 'fail', detail: `el registro no expone el handler '${entry.handler}'` }; }
112
+
113
+ if (ctx.sensitive) {
114
+ try { app.unmount(); } catch {}
115
+ return { status: 'unverifiable', detail: 'acción con confirmación: no se dispara su efecto en el render de prueba (seguridad); cableado verificado hasta el punto de confirmación' };
116
+ }
117
+
118
+ let invokeErr = null;
119
+ try { await handler(ctx.params); } catch (e) { invokeErr = e; }
120
+ await nextTick();
121
+ if (invokeErr) { try { app.unmount(); } catch {} return { status: 'fail', detail: `al invocar el handler la app real lanzó: ${short(invokeErr)}` }; }
122
+
123
+ const afterHTML = captureSnapshot(container);
124
+ const afterText = container.textContent || '';
125
+ try { app.unmount(); } catch {}
126
+ return decide(ctx.kind, { beforeHTML, beforeText, afterHTML, afterText });
127
+ } catch (err) {
128
+ return { status: 'unverifiable', detail: `no se pudo preparar el render de prueba de Vue (${short(err)}); efecto real no verificable automáticamente — probar a mano` };
129
+ } finally {
130
+ try { fs.existsSync(harnessAbs) && fs.rmSync(harnessAbs); } catch {}
131
+ try { fs.existsSync(outAbs) && fs.rmSync(outAbs); } catch {}
132
+ try { restore(); } catch {}
133
+ }
134
+ }
135
+
136
+ module.exports = { probeVueEffect, vuePlugin };
package/src/routes.js ADDED
@@ -0,0 +1,81 @@
1
+ /**
2
+ * Layer 1a — expo-router route tree, derived programmatically from the file
3
+ * system of the target app (app/ or src/app/).
4
+ *
5
+ * Pure static analysis: every route here has provenance "static".
6
+ */
7
+ 'use strict';
8
+
9
+ const fs = require('fs');
10
+ const path = require('path');
11
+
12
+ /** Locates the expo-router app dir ('app' or 'src/app').
13
+ * Returns null when none exists (e.g. a web app) instead of throwing, so the
14
+ * AI-first pipeline can still run with no static route ground truth. */
15
+ function findAppDir(appRoot) {
16
+ for (const candidate of ['app', 'src/app']) {
17
+ const full = path.join(appRoot, candidate);
18
+ if (fs.existsSync(full) && fs.statSync(full).isDirectory()) return full;
19
+ }
20
+ return null;
21
+ }
22
+
23
+ const SCREEN_EXT = /\.(tsx|jsx|ts|js)$/;
24
+ const IGNORED = new Set(['_layout', '+not-found', '+html']);
25
+
26
+ /**
27
+ * Walks the app dir and returns route records:
28
+ * { routePath, filePath, isDynamic, params[], group }
29
+ * expo-router conventions handled: (group) segments (stripped from URL),
30
+ * index files, [param] dynamic segments, +api files (excluded — not screens).
31
+ */
32
+ function scanRoutes(appRoot) {
33
+ const appDir = findAppDir(appRoot);
34
+ const routes = [];
35
+
36
+ // No expo-router app/ dir (typical for web apps). Return empty ground truth;
37
+ // the caller degrades to "routes proposed by the AI, not statically verified".
38
+ if (!appDir) {
39
+ return { appDir: null, routes };
40
+ }
41
+
42
+ function walk(dir, urlSegments) {
43
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
44
+ const full = path.join(dir, entry.name);
45
+ if (entry.isDirectory()) {
46
+ walk(full, [...urlSegments, entry.name]);
47
+ continue;
48
+ }
49
+ if (!SCREEN_EXT.test(entry.name)) continue;
50
+ const base = entry.name.replace(SCREEN_EXT, '');
51
+ if (IGNORED.has(base)) continue;
52
+ if (base.endsWith('+api')) continue; // API route, not a screen
53
+
54
+ const segments = base === 'index' ? urlSegments : [...urlSegments, base];
55
+ const urlParts = segments.filter((s) => !(s.startsWith('(') && s.endsWith(')')));
56
+ const groups = segments.filter((s) => s.startsWith('(') && s.endsWith(')'));
57
+ const params = urlParts
58
+ .filter((s) => s.startsWith('[') && s.endsWith(']'))
59
+ .map((s) => s.slice(1, -1));
60
+
61
+ // Route string as the navigation adapter would receive it. Keep the
62
+ // group prefix for tab roots (expo-router accepts both forms).
63
+ const routePath = '/' + segments.join('/');
64
+
65
+ routes.push({
66
+ routePath,
67
+ urlPath: '/' + urlParts.join('/'),
68
+ filePath: full,
69
+ relFile: path.relative(appRoot, full),
70
+ isDynamic: params.length > 0,
71
+ params,
72
+ groups,
73
+ });
74
+ }
75
+ }
76
+
77
+ walk(appDir, []);
78
+ return { appDir, routes };
79
+ }
80
+
81
+ module.exports = { scanRoutes, findAppDir };
package/src/verify.js ADDED
@@ -0,0 +1,172 @@
1
+ /**
2
+ * Output verifier — the mechanical layer in its NEW role.
3
+ *
4
+ * The AI reads the code and proposes; this module confirms. It can only drop
5
+ * an individual unproven claim — it can never blind the analysis (the old
6
+ * candidate-gatekeeper failure mode).
7
+ *
8
+ * Checks per action:
9
+ * 1. evidence file exists among the files sent to the model
10
+ * 2. the handler (and evidence symbol) appears as a real identifier in that
11
+ * file — word-boundary match, so 'addToCart' can't pass via 'addToCartX'
12
+ * Checks per route:
13
+ * 3. the route href maps to a statically-scanned expo-router screen
14
+ * (or BACK, the SDK convention)
15
+ *
16
+ * Anything that fails is REMOVED from the manifest and recorded in
17
+ * provenance as dropped. A hallucinated action that ships is worse than a
18
+ * missed one.
19
+ */
20
+ 'use strict';
21
+
22
+ /** Extract candidate identifiers from an evidence symbol string.
23
+ * e.g. "useAuthStore (signOut action)" → ['useAuthStore', 'signOut'] */
24
+ function identifiersIn(text) {
25
+ return String(text || '').match(/[A-Za-z_$][A-Za-z0-9_$]*/g) || [];
26
+ }
27
+
28
+ function fileHasIdentifier(content, identifier) {
29
+ if (!identifier) return false;
30
+ const re = new RegExp(`(^|[^A-Za-z0-9_$])${identifier}([^A-Za-z0-9_$]|$)`);
31
+ return re.test(content);
32
+ }
33
+
34
+ /** Normalize an expo-router href for comparison: strip (groups), trailing /index. */
35
+ function normalizeHref(href) {
36
+ return ('/' + String(href || '')
37
+ .replace(/\([^)]*\)\//g, '')
38
+ .replace(/^\/+/, '')
39
+ .replace(/\/index$/, ''))
40
+ .replace(/\/+$/, '') || '/';
41
+ }
42
+
43
+ /**
44
+ * @param manifest manifest as the model produced it
45
+ * @param evidence { routes: {intent: {file, symbol}}, actions: {intent: {file, symbol, whatItDoes}} }
46
+ * @param sentFiles array of { rel, content } that were in the prompt
47
+ * @param staticRoutes array of { routePath, urlPath, relFile } from scanRoutes
48
+ * @param opts.verifyRoutes when false, AI-proposed routes are kept WITHOUT
49
+ * filesystem verification (web apps have no expo-router ground truth).
50
+ * Actions are always verified against real code regardless.
51
+ * Defaults to true when there is a static route ground truth.
52
+ * @returns { manifest, provenance }
53
+ */
54
+ function verifyManifest(manifest, evidence, sentFiles, staticRoutes, opts = {}) {
55
+ const byRel = new Map(sentFiles.map((f) => [f.rel.replace(/\\/g, '/'), f.content]));
56
+ const verifyRoutes = opts.verifyRoutes ?? staticRoutes.length > 0;
57
+ const provenance = {
58
+ engine: 'auto-manifest-v3-ai-first',
59
+ routeVerification: verifyRoutes ? 'filesystem' : 'ai-proposed (no static ground truth)',
60
+ routes: [],
61
+ actions: [],
62
+ notes: [],
63
+ };
64
+
65
+ const validHrefs = new Set();
66
+ for (const r of staticRoutes) {
67
+ validHrefs.add(normalizeHref(r.routePath));
68
+ validHrefs.add(normalizeHref(r.urlPath));
69
+ }
70
+
71
+ const routes = [];
72
+ for (const route of manifest.routes || []) {
73
+ if (route.route === 'BACK') {
74
+ routes.push(route);
75
+ provenance.routes.push({ intent: route.intent, included: true, verification: 'static (SDK convention)' });
76
+ continue;
77
+ }
78
+ // Web mode: no filesystem route ground truth, so we cannot statically
79
+ // confirm routes. Keep what the AI proposed, but flag it as unverified —
80
+ // honesty over false precision.
81
+ if (!verifyRoutes) {
82
+ routes.push(route);
83
+ provenance.routes.push({
84
+ intent: route.intent, included: true,
85
+ verification: 'ai-proposed (route NOT statically verified — web app)',
86
+ evidence: evidence.routes?.[route.intent] || null,
87
+ });
88
+ continue;
89
+ }
90
+ const ok = validHrefs.has(normalizeHref(route.route));
91
+ if (!ok) {
92
+ provenance.routes.push({
93
+ intent: route.intent, included: false,
94
+ reason: `route '${route.route}' not found in expo-router file scan — DROPPED`,
95
+ });
96
+ continue;
97
+ }
98
+ routes.push(route);
99
+ provenance.routes.push({
100
+ intent: route.intent, included: true,
101
+ verification: 'route exists in filesystem scan',
102
+ evidence: evidence.routes?.[route.intent] || null,
103
+ });
104
+ }
105
+
106
+ const actions = [];
107
+ for (const action of manifest.actions || []) {
108
+ const ev = evidence.actions?.[action.intent] || {};
109
+ const evFile = String(ev.file || '').replace(/\\/g, '/').replace(/^\.\//, '');
110
+ const content = byRel.get(evFile);
111
+
112
+ if (!content) {
113
+ provenance.actions.push({
114
+ intent: action.intent, included: false,
115
+ reason: `evidence file '${ev.file || '(none)'}' was not among the files sent — DROPPED`,
116
+ });
117
+ continue;
118
+ }
119
+
120
+ // The handler must be a real identifier in the evidence file. Also accept
121
+ // any identifier from the symbol (covers "store.addItem"-style handlers).
122
+ const candidates = [action.handler, ...identifiersIn(ev.symbol)].filter(Boolean);
123
+ const found = candidates.find((c) => fileHasIdentifier(content, c));
124
+ if (!found) {
125
+ provenance.actions.push({
126
+ intent: action.intent, included: false,
127
+ reason: `neither handler '${action.handler}' nor symbol '${ev.symbol}' found in '${evFile}' — DROPPED`,
128
+ });
129
+ continue;
130
+ }
131
+
132
+ actions.push(action);
133
+ provenance.actions.push({
134
+ intent: action.intent, included: true,
135
+ verification: `identifier '${found}' confirmed in ${evFile}`,
136
+ evidence: ev,
137
+ });
138
+ }
139
+
140
+ // Intent namespace is shared between routes and actions (core validator).
141
+ const seen = new Set();
142
+ const dedupedRoutes = routes.filter((r) => !seen.has(r.intent) && seen.add(r.intent));
143
+ const dedupedActions = actions.filter((a) => {
144
+ if (seen.has(a.intent)) {
145
+ provenance.notes.push(`duplicate intent '${a.intent}' — kept first occurrence, action dropped`);
146
+ return false;
147
+ }
148
+ seen.add(a.intent);
149
+ return true;
150
+ });
151
+
152
+ // Schema coercion the model sometimes needs (same as the old engine).
153
+ for (const action of dedupedActions) {
154
+ for (const spec of Object.values(action.parameters || {})) {
155
+ if (spec.type === 'integer' || spec.type === 'float') spec.type = 'number';
156
+ else if (!['string', 'number', 'boolean'].includes(spec.type)) spec.type = 'string';
157
+ if (typeof spec.required !== 'boolean') spec.required = false;
158
+ if (typeof spec.description !== 'string' || !spec.description) spec.description = action.label || action.intent;
159
+ }
160
+ if (action.requireConfirmation && !action.confirmationMessageTemplate) {
161
+ action.confirmationMessageTemplate = `Confirm: ${action.label || action.intent}?`;
162
+ provenance.notes.push(`action '${action.intent}': confirmation template filled mechanically`);
163
+ }
164
+ }
165
+
166
+ return {
167
+ manifest: { ...manifest, routes: dedupedRoutes, actions: dedupedActions },
168
+ provenance,
169
+ };
170
+ }
171
+
172
+ module.exports = { verifyManifest, normalizeHref };
@@ -0,0 +1,215 @@
1
+ /**
2
+ * verifyWire — install-time verification of a SINGLE wired action.
3
+ *
4
+ * Two complementary signals drive the auto-correction loop:
5
+ * 1) COMPILE: does the project still build/typecheck with ONLY this action's
6
+ * wiring applied? (catches the real TS/shape errors that made Angular flaky).
7
+ * We diff against a pre-captured baseline so a pre-broken app (e.g. Vue with
8
+ * stale vue-tsc) is judged on "did WE add an error", not absolute success.
9
+ * 2) EFFECT: when the action's behaviour is safely simulable IN-PROCESS, run it
10
+ * against a REAL instance and confirm observable state changed. Today this
11
+ * covers the local-first data layer (Dexie) reliably; component-local state
12
+ * (React useState / Vue ref / Angular DI) is not rendered in-process, so it
13
+ * is honestly reported as "effect not simulated" (compile-verified instead).
14
+ *
15
+ * Nothing here ever fires a sensitive/irreversible action: callers pass
16
+ * `sensitive` for requireConfirmation actions and we skip execution for them.
17
+ */
18
+ 'use strict';
19
+
20
+ const fs = require('fs');
21
+ const path = require('path');
22
+
23
+ function normRel(p) {
24
+ return String(p || '').replace(/\\/g, '/').replace(/^\.\//, '');
25
+ }
26
+
27
+ // ── Compile-error attribution ──────────────────────────────────────────────────
28
+
29
+ /** Extract "path(line,col): error" / "ERROR ... path:line" style lines. */
30
+ function extractErrorLines(output) {
31
+ const lines = String(output || '').split('\n');
32
+ return lines.filter((l) => /error\s+TS\d+|: error|ERROR\b|is not assignable|Cannot find|does not exist/i.test(l));
33
+ }
34
+
35
+ /**
36
+ * Normalize an error line so the SAME pre-existing error matches across builds
37
+ * even when our inserted lines shifted its line/column numbers. Without this, a
38
+ * pre-broken app (e.g. Vue with stale vue-tsc JSX errors) would report every
39
+ * shifted error as "new" the moment we add a single line → false regressions.
40
+ */
41
+ function normErr(line) {
42
+ return String(line)
43
+ .replace(/\(\d+,\s*\d+\)/g, '(L,C)') // tsc: file(123,3)
44
+ .replace(/:\d+:\d+/g, ':L:C') // esbuild: file:123:3
45
+ .replace(/\bline\s+\d+/gi, 'line N')
46
+ .replace(/\s+/g, ' ')
47
+ .trim();
48
+ }
49
+
50
+ /**
51
+ * Did applying THIS action introduce a NEW build problem? Compare position-
52
+ * normalized error lines after vs. baseline. Any error that is new (not present
53
+ * in baseline) AND references a file we touched or our generated modules is
54
+ * attributed to this action. Returns { ok, newErrors: string[] }.
55
+ */
56
+ function compileRegressed(baselineOutput, afterOutput, touchedFiles) {
57
+ const before = new Set(extractErrorLines(baselineOutput).map(normErr));
58
+ const afterRaw = extractErrorLines(afterOutput).map((l) => l.trim());
59
+ const fresh = afterRaw.filter((l) => !before.has(normErr(l)));
60
+ const touched = (touchedFiles || []).map((f) => path.basename(normRel(f)));
61
+ const ours = fresh.filter(
62
+ (l) => /handlers\.generated|fyodos[\\/]bridge|FYODOS/i.test(l) || touched.some((t) => t && l.includes(t)),
63
+ );
64
+ if (ours.length) return { ok: false, newErrors: ours };
65
+ // Only when the app built cleanly before (no baseline errors) do we treat any
66
+ // unattributable fresh error as a regression. If it was already broken, an
67
+ // unattributable error is almost certainly pre-existing → don't blame ourselves.
68
+ if (fresh.length && !before.size) return { ok: false, newErrors: fresh.slice(0, 8) };
69
+ return { ok: true, newErrors: [] };
70
+ }
71
+
72
+ // ── Effect probe (in-process, real instance) ─────────────────────────────────────
73
+
74
+ /** Read a Dexie schema `version(n).stores({ table: 'idx', ... })` from a file. */
75
+ function readDexieStores(appRoot, fileRel) {
76
+ const abs = path.join(appRoot, normRel(fileRel));
77
+ if (!fs.existsSync(abs)) return null;
78
+ const src = fs.readFileSync(abs, 'utf8');
79
+ const m = src.match(/\.stores\(\s*\{([\s\S]*?)\}\s*\)/);
80
+ if (!m) return null;
81
+ const stores = {};
82
+ const body = m[1];
83
+ const re = /(['"]?[\w$]+['"]?)\s*:\s*(['"])([\s\S]*?)\2/g;
84
+ let mm;
85
+ while ((mm = re.exec(body))) {
86
+ const key = mm[1].replace(/['"]/g, '');
87
+ stores[key] = mm[3];
88
+ }
89
+ return Object.keys(stores).length ? stores : null;
90
+ }
91
+
92
+ /** Resolve the file that the entry imports `db` (or similar) from. */
93
+ function findImportFile(appRoot, entry, localName, fyodosDirRel) {
94
+ const imps = entry.imports || [];
95
+ const fyodosDir = fyodosDirRel || entry.fyodosDirRel || 'src/fyodos';
96
+ for (const imp of imps) {
97
+ if (imp.name === localName) {
98
+ // importPath is relative to the fyodos dir; resolve to a repo-relative file.
99
+ const base = path.join(appRoot, fyodosDir, imp.importPath);
100
+ for (const ext of ['.ts', '.js', '.mjs', '/index.ts', '/index.js']) {
101
+ if (fs.existsSync(base + ext)) return path.relative(appRoot, base + ext);
102
+ }
103
+ }
104
+ }
105
+ return null;
106
+ }
107
+
108
+ /**
109
+ * Try to PROVE the effect of a Dexie-backed action by executing its exact
110
+ * expression against a real (fake-indexeddb) database with the app's schema.
111
+ * Returns { status: 'effect-pass'|'fail'|'skipped', detail }.
112
+ */
113
+ async function probeDexie(appRoot, entry, fyodosDirRel) {
114
+ const expr = entry.callExpr || '';
115
+ const dbMatch = /\b([A-Za-z_$][\w$]*)\.([A-Za-z_$][\w$]*)\.(add|put|bulkAdd|bulkPut|update|delete)\s*\(/.exec(expr);
116
+ if (!dbMatch) return null; // not a dexie op
117
+ const dbVar = dbMatch[1];
118
+ const table = dbMatch[2];
119
+ const dbFile = findImportFile(appRoot, entry, dbVar, fyodosDirRel);
120
+ if (!dbFile) return null;
121
+ const stores = readDexieStores(appRoot, dbFile);
122
+ if (!stores || !stores[table]) return null;
123
+
124
+ let Dexie;
125
+ try {
126
+ require(path.join(appRoot, 'node_modules/fake-indexeddb/auto'));
127
+ const mod = require(path.join(appRoot, 'node_modules/dexie'));
128
+ Dexie = mod.Dexie || mod.default || mod;
129
+ } catch {
130
+ return { status: 'skipped', detail: 'dexie/fake-indexeddb no disponible para simular el efecto' };
131
+ }
132
+
133
+ try {
134
+ const db = new Dexie(`fyodos-probe-${Date.now()}`);
135
+ const verObj = {};
136
+ for (const [t, idx] of Object.entries(stores)) verObj[t] = idx;
137
+ db.version(1).stores(verObj);
138
+ const before = await db.table(table).count();
139
+ const params = sampleParams(entry);
140
+ // Execute the EXACT generated expression with db + params in scope.
141
+ // eslint-disable-next-line no-new-func
142
+ const run = new Function(dbVar, 'params', 'crypto', `return (async () => { return ${expr}; })();`);
143
+ await run(db, params, globalThis.crypto || require('crypto').webcrypto);
144
+ const after = await db.table(table).count();
145
+ const op = dbMatch[3];
146
+ const grew = after > before;
147
+ const changed = after !== before || /update|put|delete/.test(op);
148
+ if (op === 'delete') return { status: 'effect-pass', detail: `delete ejecutó sobre la tabla '${table}'` };
149
+ if (grew || changed) return { status: 'effect-pass', detail: `'${table}' cambió (${before}→${after}) ejecutando la expresión real` };
150
+ return { status: 'fail', detail: `la expresión ejecutó pero '${table}' no cambió (${before}→${after})` };
151
+ } catch (err) {
152
+ return { status: 'fail', detail: `la expresión lanzó: ${err && err.message ? err.message : String(err)}` };
153
+ }
154
+ }
155
+
156
+ /** Reasonable test params from the action's declared manifest params. */
157
+ function sampleParams(entry) {
158
+ const p = {};
159
+ for (const mp of entry.manifestParams || []) {
160
+ const n = (mp.name || '').toLowerCase();
161
+ if (/title|text|name|label|todo|task|note/.test(n)) p[mp.name] = 'Fiodos probe item';
162
+ else if (/id|key/.test(n)) p[mp.name] = 1;
163
+ else if (/count|qty|amount|number/.test(n)) p[mp.name] = 1;
164
+ else if (/done|complete|checked/.test(n)) p[mp.name] = true;
165
+ else p[mp.name] = 'probe';
166
+ }
167
+ return p;
168
+ }
169
+
170
+ /**
171
+ * Effect verification for one action. Returns:
172
+ * { status: 'effect-pass'|'fail'|'skipped', detail }
173
+ * 'skipped' = couldn't simulate safely (component-local/sensitive); the caller
174
+ * then relies on the compile signal and reports honestly.
175
+ */
176
+ function detectComponentFramework(appRoot) {
177
+ try {
178
+ const pkg = JSON.parse(fs.readFileSync(path.join(appRoot, 'package.json'), 'utf8'));
179
+ const deps = { ...(pkg.dependencies || {}), ...(pkg.devDependencies || {}) };
180
+ if (deps['@angular/core']) return 'angular';
181
+ if (deps.vue || deps['@vue/runtime-dom']) return 'vue';
182
+ if (deps.svelte || deps['@sveltejs/kit']) return 'svelte';
183
+ if (deps.react || deps['react-dom']) return 'react';
184
+ } catch { /* ignore */ }
185
+ return 'web';
186
+ }
187
+
188
+ async function probeEffect(appRoot, entry, opts = {}) {
189
+ if (entry.kind === 'bridge') {
190
+ // Component-local state: prove the real effect by RENDERING the component and
191
+ // confirming the UI mutates (not just that it compiles). Sensitive actions are
192
+ // not fired inside the render (handled by the render probe itself).
193
+ const framework = (opts.framework && opts.framework !== 'web') ? opts.framework : detectComponentFramework(appRoot);
194
+ try {
195
+ const { probeComponentEffect } = require('./renderProbe');
196
+ return await probeComponentEffect(appRoot, entry, {
197
+ framework,
198
+ fyodosDirRel: opts.fyodosDirRel,
199
+ sources: opts.sources,
200
+ sensitive: opts.sensitive || entry.requireConfirmation,
201
+ });
202
+ } catch (err) {
203
+ return { status: 'unverifiable', detail: `render-probe no disponible (${err && err.message}); efecto real no verificable automáticamente — probar a mano` };
204
+ }
205
+ }
206
+ if (opts.sensitive || entry.requireConfirmation) {
207
+ return { status: 'skipped', detail: 'acción con confirmación: no se dispara su efecto en la simulación (seguridad)' };
208
+ }
209
+ // Module strategy: try the Dexie data-layer probe.
210
+ const dexie = await probeDexie(appRoot, entry, opts.fyodosDirRel);
211
+ if (dexie) return dexie;
212
+ return { status: 'skipped', detail: 'objetivo de módulo no simulable de forma segura en proceso; verificado por compilación' };
213
+ }
214
+
215
+ module.exports = { probeEffect, compileRegressed, extractErrorLines, readDexieStores };