@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.
- package/LICENSE +77 -0
- package/README.md +128 -0
- package/package.json +30 -0
- package/src/aiAnalyze.js +469 -0
- package/src/ast.js +263 -0
- package/src/collect.js +115 -0
- package/src/graph.js +160 -0
- package/src/index.js +667 -0
- package/src/llm.js +144 -0
- package/src/loadEnv.js +81 -0
- package/src/patterns.js +28 -0
- package/src/postWireTest.js +91 -0
- package/src/renderProbe.js +333 -0
- package/src/renderProbeVue.js +136 -0
- package/src/routes.js +81 -0
- package/src/verify.js +172 -0
- package/src/verifyWire.js +215 -0
- package/src/wireHandlers.js +1789 -0
- package/src/wireWeb.js +295 -0
- package/src/wireWebMount.js +435 -0
|
@@ -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 };
|