@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
package/src/llm.js
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Layer 3 — EXPLICIT LLM calls inside the pipeline.
|
|
3
|
+
*
|
|
4
|
+
* The static layers (1+2) extract facts; this layer makes judgment calls the
|
|
5
|
+
* code cannot express, and every call is LOGGED so the final report can show
|
|
6
|
+
* exactly which decisions came from the model vs from static analysis:
|
|
7
|
+
*
|
|
8
|
+
* 1. classifyApp — app type vs the pattern catalog
|
|
9
|
+
* 2. curateActions — which detected callables deserve agent exposure,
|
|
10
|
+
* category, sensitivity, parameters, label
|
|
11
|
+
* 3. writeRouteMeta — intent ids, labels and example phrases for routes
|
|
12
|
+
* 4. patternGapFill — given the matched pattern, propose what's missing
|
|
13
|
+
*
|
|
14
|
+
* Model: gpt-4o-mini via the OpenAI REST API (OPENAI_API_KEY env var).
|
|
15
|
+
*/
|
|
16
|
+
'use strict';
|
|
17
|
+
|
|
18
|
+
const LLM_LOG = [];
|
|
19
|
+
|
|
20
|
+
function getLog() {
|
|
21
|
+
return LLM_LOG;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
async function callLLM(taskName, system, user, { maxTokens = 2000 } = {}) {
|
|
25
|
+
const apiKey = process.env.OPENAI_API_KEY;
|
|
26
|
+
if (!apiKey) throw new Error('OPENAI_API_KEY is required for the LLM layer');
|
|
27
|
+
|
|
28
|
+
const t0 = Date.now();
|
|
29
|
+
const res = await fetch('https://api.openai.com/v1/chat/completions', {
|
|
30
|
+
method: 'POST',
|
|
31
|
+
headers: {
|
|
32
|
+
'Content-Type': 'application/json',
|
|
33
|
+
Authorization: `Bearer ${apiKey}`,
|
|
34
|
+
},
|
|
35
|
+
body: JSON.stringify({
|
|
36
|
+
model: process.env.AUTO_MANIFEST_MODEL || 'gpt-4o-mini',
|
|
37
|
+
messages: [
|
|
38
|
+
{ role: 'system', content: system },
|
|
39
|
+
{ role: 'user', content: user },
|
|
40
|
+
],
|
|
41
|
+
temperature: 0.2,
|
|
42
|
+
max_tokens: maxTokens,
|
|
43
|
+
response_format: { type: 'json_object' },
|
|
44
|
+
}),
|
|
45
|
+
});
|
|
46
|
+
if (!res.ok) {
|
|
47
|
+
const body = await res.text();
|
|
48
|
+
throw new Error(`LLM call '${taskName}' failed: ${res.status} ${body.slice(0, 300)}`);
|
|
49
|
+
}
|
|
50
|
+
const data = await res.json();
|
|
51
|
+
const content = data.choices?.[0]?.message?.content || '{}';
|
|
52
|
+
let parsed;
|
|
53
|
+
try {
|
|
54
|
+
parsed = JSON.parse(content);
|
|
55
|
+
} catch {
|
|
56
|
+
throw new Error(`LLM call '${taskName}' returned non-JSON output`);
|
|
57
|
+
}
|
|
58
|
+
LLM_LOG.push({
|
|
59
|
+
task: taskName,
|
|
60
|
+
ms: Date.now() - t0,
|
|
61
|
+
promptChars: system.length + user.length,
|
|
62
|
+
usage: data.usage || null,
|
|
63
|
+
});
|
|
64
|
+
return parsed;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** 1. Classify the app against the available pattern catalog. */
|
|
68
|
+
async function classifyApp(appSummary, patternSummaries) {
|
|
69
|
+
const system =
|
|
70
|
+
'You classify a mobile app into one of the available integration patterns, or "none". ' +
|
|
71
|
+
'Answer JSON: {"pattern": "<id-or-none>", "confidence": 0..1, "appKind": "<free text>", "reason": "<short>"}';
|
|
72
|
+
const user =
|
|
73
|
+
`Available patterns:\n${patternSummaries.map((p) => `- id=${p.id}: ${p.summary}`).join('\n')}\n\n` +
|
|
74
|
+
`App evidence (from static analysis):\n${JSON.stringify(appSummary, null, 1).slice(0, 4000)}`;
|
|
75
|
+
return callLLM('classifyApp', system, user, { maxTokens: 300 });
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* 2. Curate detected callables into agent actions.
|
|
80
|
+
* Batched: large candidate lists are processed in chunks so no candidate is
|
|
81
|
+
* silently dropped by prompt-size truncation.
|
|
82
|
+
*/
|
|
83
|
+
async function curateActions(appKind, candidates) {
|
|
84
|
+
const BATCH = 14;
|
|
85
|
+
const all = [];
|
|
86
|
+
for (let i = 0; i < candidates.length; i += BATCH) {
|
|
87
|
+
const chunk = candidates.slice(i, i + BATCH);
|
|
88
|
+
const res = await curateActionsBatch(appKind, chunk);
|
|
89
|
+
all.push(...(res.actions || []));
|
|
90
|
+
}
|
|
91
|
+
return { actions: all };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
async function curateActionsBatch(appKind, candidates) {
|
|
95
|
+
const system =
|
|
96
|
+
'You design the action surface of an in-app voice agent. Input: callables detected by static ' +
|
|
97
|
+
'analysis of a mobile app (handlers, store actions, buttons, alerts). For EACH candidate decide:\n' +
|
|
98
|
+
'- expose: should the voice agent be able to trigger it? (skip pure-UI plumbing like toggling pickers, ' +
|
|
99
|
+
'rendering callbacks, backdrop handlers, text-input onChange)\n' +
|
|
100
|
+
'- intent: snake_case id; label: short human label (English); category: one of discovery|workout|history|account|navigation|other\n' +
|
|
101
|
+
'- requireConfirmation: true only for destructive/sensitive operations (deleting data, losing progress, signing out, spending money)\n' +
|
|
102
|
+
'- confirmationMessage: required question if requireConfirmation\n' +
|
|
103
|
+
'- parameters: object of {name:{type,required,description}} ONLY when the user must provide a value by voice (e.g. a search query)\n' +
|
|
104
|
+
'- requiresVisibleContent: true when the action operates on an item the user is looking at\n' +
|
|
105
|
+
'- examples: 3-4 natural English voice phrases\n' +
|
|
106
|
+
'- evidence_used: which input evidence justified your decision\n' +
|
|
107
|
+
'Be conservative: when a candidate looks like internal plumbing, expose=false. ' +
|
|
108
|
+
'Sign-in/sign-up/verification flows requiring typed credentials should be expose=false ' +
|
|
109
|
+
'(a voice agent cannot dictate passwords safely).\n' +
|
|
110
|
+
'Return a decision for EVERY candidate. "intent" MUST be a plain string.\n' +
|
|
111
|
+
'Answer JSON: {"actions": [{...candidate fields above, "source_name": "<input name>"}]}';
|
|
112
|
+
const user =
|
|
113
|
+
`App kind: ${appKind}\n\nCandidates (with static evidence):\n` +
|
|
114
|
+
JSON.stringify(candidates, null, 1).slice(0, 14000);
|
|
115
|
+
return callLLM('curateActions', system, user, { maxTokens: 4000 });
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** 3. Route intents, labels, examples. */
|
|
119
|
+
async function writeRouteMeta(appKind, routes) {
|
|
120
|
+
const system =
|
|
121
|
+
'You name navigation intents for an in-app voice agent. For each route give: ' +
|
|
122
|
+
'intent (snake_case), label (short English), examples (3-4 natural English voice phrases). ' +
|
|
123
|
+
'Skip auth screens the agent should not navigate to mid-session if any seem like sign-in/sign-up (mark skip=true). ' +
|
|
124
|
+
'For dynamic routes ([param]) mark dynamic=true and skip=true (they need a runtime parameter the static manifest cannot provide). ' +
|
|
125
|
+
'Answer JSON: {"routes": [{"routePath": "...", "intent": "...", "label": "...", "examples": [...], "skip": false, "skipReason": ""}]}';
|
|
126
|
+
const user = `App kind: ${appKind}\n\nRoutes:\n${JSON.stringify(routes, null, 1)}`;
|
|
127
|
+
return callLLM('writeRouteMeta', system, user, { maxTokens: 2500 });
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** 4. Pattern gap-fill: what does the matched pattern expect that we missed? */
|
|
131
|
+
async function patternGapFill(patternId, patternText, draftManifestSummary) {
|
|
132
|
+
const system =
|
|
133
|
+
'You compare a draft voice-agent manifest against an integration pattern document. ' +
|
|
134
|
+
'List actions/routes the pattern suggests SHOULD usually exist for this app type but are missing or ' +
|
|
135
|
+
'under-specified in the draft. Only suggest things genuinely implied by the pattern; do not invent. ' +
|
|
136
|
+
'Answer JSON: {"suggestions": [{"kind": "action"|"route", "intent": "...", "label": "...", "why": "...", ' +
|
|
137
|
+
'"confidence": 0..1}], "patternUseful": true|false, "notes": "<honest assessment of how much this pattern helped>"}';
|
|
138
|
+
const user =
|
|
139
|
+
`Pattern (${patternId}):\n${patternText.slice(0, 5000)}\n\nDraft manifest summary:\n` +
|
|
140
|
+
JSON.stringify(draftManifestSummary, null, 1).slice(0, 5000);
|
|
141
|
+
return callLLM('patternGapFill', system, user, { maxTokens: 1200 });
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
module.exports = { classifyApp, curateActions, writeRouteMeta, patternGapFill, getLog };
|
package/src/loadEnv.js
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Load KEY=VALUE pairs from local .env files into process.env.
|
|
3
|
+
* Does not overwrite variables already set in the shell.
|
|
4
|
+
*
|
|
5
|
+
* Search order (first found wins per key, later files only fill gaps):
|
|
6
|
+
* 1. tools/auto-manifest/.env
|
|
7
|
+
* 2. fyodos/backend/.env
|
|
8
|
+
*/
|
|
9
|
+
'use strict';
|
|
10
|
+
|
|
11
|
+
const fs = require('fs');
|
|
12
|
+
const path = require('path');
|
|
13
|
+
|
|
14
|
+
function parseEnvFile(filePath) {
|
|
15
|
+
if (!fs.existsSync(filePath)) return {};
|
|
16
|
+
const out = {};
|
|
17
|
+
for (const line of fs.readFileSync(filePath, 'utf8').split('\n')) {
|
|
18
|
+
const trimmed = line.trim();
|
|
19
|
+
if (!trimmed || trimmed.startsWith('#')) continue;
|
|
20
|
+
const eq = trimmed.indexOf('=');
|
|
21
|
+
if (eq <= 0) continue;
|
|
22
|
+
const key = trimmed.slice(0, eq).trim();
|
|
23
|
+
let value = trimmed.slice(eq + 1).trim();
|
|
24
|
+
if (
|
|
25
|
+
(value.startsWith('"') && value.endsWith('"')) ||
|
|
26
|
+
(value.startsWith("'") && value.endsWith("'"))
|
|
27
|
+
) {
|
|
28
|
+
value = value.slice(1, -1);
|
|
29
|
+
}
|
|
30
|
+
out[key] = value;
|
|
31
|
+
}
|
|
32
|
+
return out;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function applyEnv(parsed) {
|
|
36
|
+
for (const [key, value] of Object.entries(parsed)) {
|
|
37
|
+
if (process.env[key] === undefined || process.env[key] === '') {
|
|
38
|
+
process.env[key] = value;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Derive FYODOS_API_KEY from FYODOS_CLIENT_KEYS (api_key:client_id,...). */
|
|
44
|
+
function deriveFiodosApiKey() {
|
|
45
|
+
if (process.env.FYODOS_API_KEY) return;
|
|
46
|
+
const raw = process.env.FYODOS_CLIENT_KEYS || '';
|
|
47
|
+
const first = raw.split(',')[0]?.trim();
|
|
48
|
+
if (!first || !first.includes(':')) return;
|
|
49
|
+
const [apiKey] = first.split(':');
|
|
50
|
+
if (apiKey) process.env.FYODOS_API_KEY = apiKey;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Prefer the target app's Fiodos API key from .env when publishing that app. */
|
|
54
|
+
function loadAppEnv(appRoot) {
|
|
55
|
+
if (!appRoot) return;
|
|
56
|
+
const appEnv = parseEnvFile(path.join(appRoot, '.env'));
|
|
57
|
+
const appKey =
|
|
58
|
+
appEnv.VITE_FYODOS_API_KEY?.trim() ||
|
|
59
|
+
appEnv.NEXT_PUBLIC_FYODOS_API_KEY?.trim() ||
|
|
60
|
+
appEnv.EXPO_PUBLIC_FYODOS_API_KEY?.trim();
|
|
61
|
+
if (appKey) process.env.FYODOS_API_KEY = appKey;
|
|
62
|
+
const appUrl =
|
|
63
|
+
appEnv.VITE_FYODOS_API_URL?.trim() ||
|
|
64
|
+
appEnv.NEXT_PUBLIC_FYODOS_API_URL?.trim() ||
|
|
65
|
+
appEnv.EXPO_PUBLIC_FYODOS_API_URL?.trim();
|
|
66
|
+
if (appUrl) process.env.FYODOS_API_URL = appUrl;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function loadEnv() {
|
|
70
|
+
const root = path.join(__dirname, '..');
|
|
71
|
+
const files = [
|
|
72
|
+
path.join(root, '.env'),
|
|
73
|
+
path.join(root, '../../backend/.env'),
|
|
74
|
+
];
|
|
75
|
+
for (const file of files) {
|
|
76
|
+
applyEnv(parseEnvFile(file));
|
|
77
|
+
}
|
|
78
|
+
deriveFiodosApiKey();
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
module.exports = { loadEnv, loadAppEnv };
|
package/src/patterns.js
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Layer 4 — pattern knowledge base.
|
|
3
|
+
*
|
|
4
|
+
* Loads patterns/*.md from the Fiodos monorepo, gives the LLM a summary list
|
|
5
|
+
* for classification, and (when a pattern matches) feeds the full pattern to
|
|
6
|
+
* the gap-fill step. With only 2-3 patterns today this layer is a seed: the
|
|
7
|
+
* mechanism works, the coverage will come with volume.
|
|
8
|
+
*/
|
|
9
|
+
'use strict';
|
|
10
|
+
|
|
11
|
+
const fs = require('fs');
|
|
12
|
+
const path = require('path');
|
|
13
|
+
|
|
14
|
+
function loadPatterns(patternsDir) {
|
|
15
|
+
if (!fs.existsSync(patternsDir)) return [];
|
|
16
|
+
return fs
|
|
17
|
+
.readdirSync(patternsDir)
|
|
18
|
+
.filter((f) => f.endsWith('.md') && f !== 'README.md')
|
|
19
|
+
.map((f) => {
|
|
20
|
+
const text = fs.readFileSync(path.join(patternsDir, f), 'utf8');
|
|
21
|
+
const id = f.replace(/\.md$/, '');
|
|
22
|
+
// First heading line as summary
|
|
23
|
+
const firstLine = text.split('\n').find((l) => l.startsWith('#')) || id;
|
|
24
|
+
return { id, file: f, text, summary: firstLine.replace(/^#+\s*/, '') };
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
module.exports = { loadPatterns };
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* postWireTest — verify the app still builds after Fiodos wired the handlers and
|
|
3
|
+
* (when needed) edited the user's own components. This is the safety net that
|
|
4
|
+
* lets wireHandlers REVERT instead of leaving a broken app.
|
|
5
|
+
*
|
|
6
|
+
* It runs the app's real build/typecheck (the strongest "didn't break" signal
|
|
7
|
+
* for a web project) with a timeout, and returns a structured result. It NEVER
|
|
8
|
+
* throws — a thrown/timed-out process is reported as a failed/untested stage so
|
|
9
|
+
* the caller can decide to revert.
|
|
10
|
+
*
|
|
11
|
+
* Contract:
|
|
12
|
+
* runPostWireTest(appRoot, opts) -> {
|
|
13
|
+
* ok: boolean, // true only when the build/typecheck succeeded
|
|
14
|
+
* stage: string, // 'build' | 'typecheck' | 'skipped-no-deps' | 'timeout' | 'error'
|
|
15
|
+
* command: string, // what was run
|
|
16
|
+
* output: string, // tail of stdout+stderr (trimmed)
|
|
17
|
+
* }
|
|
18
|
+
*
|
|
19
|
+
* When dependencies are not installed we cannot build; we return ok:true with
|
|
20
|
+
* stage 'skipped-no-deps' so the caller marks the wiring 'applied-untested'
|
|
21
|
+
* (a warning) rather than reverting a perfectly good wiring.
|
|
22
|
+
*/
|
|
23
|
+
'use strict';
|
|
24
|
+
|
|
25
|
+
const fs = require('fs');
|
|
26
|
+
const path = require('path');
|
|
27
|
+
const { spawnSync } = require('child_process');
|
|
28
|
+
|
|
29
|
+
function readPkg(appRoot) {
|
|
30
|
+
try {
|
|
31
|
+
return JSON.parse(fs.readFileSync(path.join(appRoot, 'package.json'), 'utf8'));
|
|
32
|
+
} catch {
|
|
33
|
+
return {};
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Choose the lightest reliable "does it still compile" command for this app. */
|
|
38
|
+
function chooseCommand(appRoot, pkg) {
|
|
39
|
+
const scripts = pkg.scripts || {};
|
|
40
|
+
// Prefer an explicit typecheck (fast, no bundling) when available.
|
|
41
|
+
for (const name of ['typecheck', 'type-check', 'check']) {
|
|
42
|
+
if (scripts[name]) return { stage: 'typecheck', args: ['run', name], label: `npm run ${name}` };
|
|
43
|
+
}
|
|
44
|
+
if (scripts.build) return { stage: 'build', args: ['run', 'build'], label: 'npm run build' };
|
|
45
|
+
// Fallback: bare tsc --noEmit when there is a tsconfig.
|
|
46
|
+
if (fs.existsSync(path.join(appRoot, 'tsconfig.json'))) {
|
|
47
|
+
return { stage: 'typecheck', args: ['exec', '--', 'tsc', '--noEmit'], label: 'npx tsc --noEmit' };
|
|
48
|
+
}
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function tail(s, n = 60) {
|
|
53
|
+
const lines = String(s || '').split('\n');
|
|
54
|
+
return lines.slice(-n).join('\n').trim();
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async function runPostWireTest(appRoot, opts = {}) {
|
|
58
|
+
const timeoutMs = opts.timeoutMs || 240000;
|
|
59
|
+
if (!fs.existsSync(path.join(appRoot, 'node_modules'))) {
|
|
60
|
+
return {
|
|
61
|
+
ok: true,
|
|
62
|
+
stage: 'skipped-no-deps',
|
|
63
|
+
command: '(none)',
|
|
64
|
+
output: 'node_modules ausente: no se pudo ejecutar el build (instala dependencias para verificarlo).',
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
const pkg = readPkg(appRoot);
|
|
68
|
+
const chosen = chooseCommand(appRoot, pkg);
|
|
69
|
+
if (!chosen) {
|
|
70
|
+
return { ok: true, stage: 'skipped-no-deps', command: '(none)', output: 'no hay script de build/typecheck.' };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const res = spawnSync('npm', chosen.args, {
|
|
74
|
+
cwd: appRoot,
|
|
75
|
+
encoding: 'utf8',
|
|
76
|
+
timeout: timeoutMs,
|
|
77
|
+
env: { ...process.env, CI: '1', NODE_ENV: process.env.NODE_ENV || 'production' },
|
|
78
|
+
maxBuffer: 64 * 1024 * 1024,
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
if (res.error && res.error.code === 'ETIMEDOUT') {
|
|
82
|
+
return { ok: false, stage: 'timeout', command: chosen.label, output: `build excedió ${Math.round(timeoutMs / 1000)}s` };
|
|
83
|
+
}
|
|
84
|
+
if (res.error) {
|
|
85
|
+
return { ok: false, stage: 'error', command: chosen.label, output: String(res.error.message || res.error) };
|
|
86
|
+
}
|
|
87
|
+
const output = tail(`${res.stdout || ''}\n${res.stderr || ''}`);
|
|
88
|
+
return { ok: res.status === 0, stage: chosen.stage, command: chosen.label, output };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
module.exports = { runPostWireTest, chooseCommand };
|
|
@@ -0,0 +1,333 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* renderProbe — REAL-EFFECT verification for component-local state.
|
|
3
|
+
*
|
|
4
|
+
* For actions whose state lives inside a component (React useState / Vue ref /
|
|
5
|
+
* Angular), "it compiles + the symbol exists" is NOT proof the action works. So
|
|
6
|
+
* here we actually MOUNT the user's real component in a headless DOM, invoke the
|
|
7
|
+
* generated handler/bridge with test params, and confirm the rendered UI changes
|
|
8
|
+
* as it should (the item appears, disappears, or the view mutates).
|
|
9
|
+
*
|
|
10
|
+
* Returns one of:
|
|
11
|
+
* { status: 'effect-pass', detail } mounted, invoked, UI changed as expected
|
|
12
|
+
* { status: 'fail', detail } mounted + invoked but NO/incorrect effect
|
|
13
|
+
* (→ feeds the auto-correction loop)
|
|
14
|
+
* { status: 'unverifiable', detail } could not mount/seed reliably in headless
|
|
15
|
+
* (honest: "review manually", never faked)
|
|
16
|
+
*
|
|
17
|
+
* Security: callers pass `sensitive` for requireConfirmation actions; those are
|
|
18
|
+
* NEVER executed here — we mount and confirm the wiring resolves, but do not fire
|
|
19
|
+
* the (possibly irreversible) effect.
|
|
20
|
+
*
|
|
21
|
+
* Standard per-framework tooling (resolved from the APP so versions match):
|
|
22
|
+
* React → react-dom/client + act, in jsdom
|
|
23
|
+
* Vue → vue (createApp) + @vue/compiler-sfc, in jsdom
|
|
24
|
+
* Angular→ (headless TestBed is not reliable in a bare CLI; reported honestly
|
|
25
|
+
* as unverifiable with the reason)
|
|
26
|
+
*/
|
|
27
|
+
'use strict';
|
|
28
|
+
|
|
29
|
+
const fs = require('fs');
|
|
30
|
+
const os = require('os');
|
|
31
|
+
const path = require('path');
|
|
32
|
+
|
|
33
|
+
const SENTINEL = `FiodosProbe_${Math.random().toString(36).slice(2, 8)}`;
|
|
34
|
+
const SEED_ID = 'fyodos-seed-id-1';
|
|
35
|
+
|
|
36
|
+
function reqFrom(appRoot, mod) {
|
|
37
|
+
try { return require(path.join(appRoot, 'node_modules', mod)); } catch { return null; }
|
|
38
|
+
}
|
|
39
|
+
function loadEsbuild(appRoot) {
|
|
40
|
+
return reqFrom(appRoot, 'esbuild') || (() => { try { return require('esbuild'); } catch { return null; } })();
|
|
41
|
+
}
|
|
42
|
+
function loadJsdom() {
|
|
43
|
+
try { return require('jsdom'); } catch { return null; }
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** esbuild plugin that empties style/asset imports (works on old esbuild that
|
|
47
|
+
* lacks the 'empty' loader). Side-effect CSS imports become no-ops. */
|
|
48
|
+
function emptyAssetsPlugin() {
|
|
49
|
+
return {
|
|
50
|
+
name: 'fyodos-empty-assets',
|
|
51
|
+
setup(build) {
|
|
52
|
+
build.onLoad({ filter: /\.(css|scss|sass|less|styl|svg|png|jpe?g|gif|webp|avif|woff2?|ttf|eot|ico)$/ }, () => ({ contents: 'export default "";', loader: 'js' }));
|
|
53
|
+
},
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Classify the action so we know what UI change to expect. */
|
|
58
|
+
function actionKind(entry) {
|
|
59
|
+
const s = `${entry.intent || ''} ${entry.handler || ''} ${entry.label || ''}`.toLowerCase();
|
|
60
|
+
if (/\b(add|create|new|insert|append|save|submit)\b|add|create|new/.test(s)) return 'add';
|
|
61
|
+
if (/delete|remove|destroy|clear|trash/.test(s)) return 'delete';
|
|
62
|
+
if (/toggle|complete|done|check|mark|finish/.test(s)) return 'toggle';
|
|
63
|
+
if (/open|show|navigate|go|close|hide|view|select/.test(s)) return 'ui';
|
|
64
|
+
return 'other';
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Build test params, putting a unique sentinel into the main text-like field. */
|
|
68
|
+
function probeParams(entry, kind) {
|
|
69
|
+
const p = {};
|
|
70
|
+
for (const mp of entry.manifestParams || []) {
|
|
71
|
+
const n = (mp.name || '').toLowerCase();
|
|
72
|
+
if (/title|text|name|label|todo|task|note|desc|content|message/.test(n)) p[mp.name] = SENTINEL;
|
|
73
|
+
else if (/\bid\b|key|index/.test(n)) p[mp.name] = SEED_ID;
|
|
74
|
+
else if (/done|complete|check|status/.test(n)) p[mp.name] = true;
|
|
75
|
+
else if (/count|qty|amount|num/.test(n)) p[mp.name] = 1;
|
|
76
|
+
else p[mp.name] = SENTINEL;
|
|
77
|
+
}
|
|
78
|
+
return p;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** localStorage keys the component hydrates from, so we can seed an item. */
|
|
82
|
+
function detectStorageKeys(sources) {
|
|
83
|
+
const keys = new Set();
|
|
84
|
+
for (const s of sources || []) {
|
|
85
|
+
const re = /localStorage\.getItem\(\s*['"]([^'"]+)['"]/g;
|
|
86
|
+
let m;
|
|
87
|
+
while ((m = re.exec(s.content || ''))) keys.add(m[1]);
|
|
88
|
+
}
|
|
89
|
+
return [...keys];
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Generic seed record covering the common todo/item field shapes. */
|
|
93
|
+
function seedRecord() {
|
|
94
|
+
return { id: SEED_ID, title: SENTINEL, text: SENTINEL, name: SENTINEL, label: SENTINEL, completed: false, done: false };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// ── React ──────────────────────────────────────────────────────────────────────
|
|
98
|
+
|
|
99
|
+
async function probeReactEffect(appRoot, entry, ctx) {
|
|
100
|
+
const esbuild = loadEsbuild(appRoot);
|
|
101
|
+
const jsdomMod = loadJsdom();
|
|
102
|
+
if (!esbuild || !jsdomMod) {
|
|
103
|
+
return { status: 'unverifiable', detail: `no se pudo montar el render headless (${!esbuild ? 'esbuild' : 'jsdom'} no disponible); cableado aplicado, efecto real no verificable automáticamente — probar a mano` };
|
|
104
|
+
}
|
|
105
|
+
const fyodosDirAbs = path.join(appRoot, ctx.fyodosDirRel);
|
|
106
|
+
const componentAbs = path.join(appRoot, entry.bridge.file);
|
|
107
|
+
const registryAbs = path.join(fyodosDirAbs, 'handlers.generated.js');
|
|
108
|
+
const entryRel = './' + path.relative(fyodosDirAbs, componentAbs).split(path.sep).join('/');
|
|
109
|
+
|
|
110
|
+
const harnessAbs = path.join(fyodosDirAbs, '__fyodos_probe_entry.jsx');
|
|
111
|
+
const outAbs = path.join(os.tmpdir(), `fyodos-probe-${Date.now()}.cjs`);
|
|
112
|
+
const harness =
|
|
113
|
+
`import * as Comp from '${entryRel}';\n` +
|
|
114
|
+
`import { fyodosGeneratedRegistries } from './handlers.generated';\n` +
|
|
115
|
+
`import * as ReactNS from 'react';\n` +
|
|
116
|
+
`import { createRoot } from 'react-dom/client';\n` +
|
|
117
|
+
`import { flushSync } from 'react-dom';\n` +
|
|
118
|
+
`export const probe = { React: ReactNS.default || ReactNS, createRoot, flushSync, Comp, registry: fyodosGeneratedRegistries };\n`;
|
|
119
|
+
|
|
120
|
+
let restore = () => {};
|
|
121
|
+
try {
|
|
122
|
+
fs.writeFileSync(harnessAbs, harness);
|
|
123
|
+
await esbuild.build({
|
|
124
|
+
entryPoints: [harnessAbs],
|
|
125
|
+
outfile: outAbs,
|
|
126
|
+
bundle: true,
|
|
127
|
+
format: 'cjs',
|
|
128
|
+
platform: 'node',
|
|
129
|
+
jsx: 'automatic',
|
|
130
|
+
jsxDev: false,
|
|
131
|
+
define: { 'process.env.NODE_ENV': '"development"' },
|
|
132
|
+
plugins: [emptyAssetsPlugin()],
|
|
133
|
+
logLevel: 'silent',
|
|
134
|
+
absWorkingDir: fyodosDirAbs,
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
restore = installDom(jsdomMod, { storageKeys: ctx.storageKeys, kind: ctx.kind });
|
|
138
|
+
delete require.cache[outAbs];
|
|
139
|
+
const mod = require(outAbs);
|
|
140
|
+
const { React, createRoot, flushSync, Comp, registry } = mod.probe;
|
|
141
|
+
const Component = pickComponent(Comp);
|
|
142
|
+
if (!Component) return { status: 'unverifiable', detail: `no encontré un componente React montable exportado por '${entry.bridge.file}' — efecto real no verificable a mano` };
|
|
143
|
+
|
|
144
|
+
const container = document.createElement('div');
|
|
145
|
+
document.body.appendChild(container);
|
|
146
|
+
let root;
|
|
147
|
+
const settle = () => new Promise((r) => setTimeout(r, 0));
|
|
148
|
+
try {
|
|
149
|
+
flushSync(() => { root = createRoot(container); root.render(React.createElement(Component)); });
|
|
150
|
+
await settle();
|
|
151
|
+
flushSync(() => {});
|
|
152
|
+
} catch (err) {
|
|
153
|
+
return { status: 'unverifiable', detail: `el componente no se pudo renderizar en aislamiento (${short(err)}); efecto real no verificable automáticamente — probar a mano` };
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const beforeHTML = captureSnapshot(container);
|
|
157
|
+
const beforeText = container.textContent || '';
|
|
158
|
+
const handler = registry.handlers[entry.handler];
|
|
159
|
+
if (typeof handler !== 'function') { try { root.unmount(); } catch {} return { status: 'fail', detail: `el registro no expone el handler '${entry.handler}'` }; }
|
|
160
|
+
|
|
161
|
+
if (ctx.sensitive) {
|
|
162
|
+
// Do NOT fire the effect; we proved it mounts and the handler resolves to a
|
|
163
|
+
// live bridge function. Verify the bridge has the method, nothing more.
|
|
164
|
+
try { root.unmount(); } catch {}
|
|
165
|
+
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' };
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
let invokeErr = null;
|
|
169
|
+
try { await handler(ctx.params); } catch (e) { invokeErr = e; }
|
|
170
|
+
await settle();
|
|
171
|
+
try { flushSync(() => {}); } catch {}
|
|
172
|
+
if (invokeErr) { try { root.unmount(); } catch {} return { status: 'fail', detail: `al invocar el handler la app real lanzó: ${short(invokeErr)}` }; }
|
|
173
|
+
|
|
174
|
+
const afterHTML = captureSnapshot(container);
|
|
175
|
+
const afterText = container.textContent || '';
|
|
176
|
+
try { root.unmount(); } catch {}
|
|
177
|
+
return decide(ctx.kind, { beforeHTML, beforeText, afterHTML, afterText });
|
|
178
|
+
} catch (err) {
|
|
179
|
+
return { status: 'unverifiable', detail: `no se pudo preparar el render de prueba (${short(err)}); efecto real no verificable automáticamente — probar a mano` };
|
|
180
|
+
} finally {
|
|
181
|
+
try { fs.existsSync(harnessAbs) && fs.rmSync(harnessAbs); } catch {}
|
|
182
|
+
try { fs.existsSync(outAbs) && fs.rmSync(outAbs); } catch {}
|
|
183
|
+
try { restore(); } catch {}
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** Snapshot that also captures form-control state (checked/value), which React
|
|
188
|
+
* sets as DOM properties and would NOT show up in innerHTML alone (e.g. toggles). */
|
|
189
|
+
function captureSnapshot(container) {
|
|
190
|
+
let s = container.innerHTML;
|
|
191
|
+
try {
|
|
192
|
+
const inputs = container.querySelectorAll('input, textarea, select');
|
|
193
|
+
inputs.forEach((el, i) => { s += `|#${i}:${el.type || ''}:${el.checked ? '1' : '0'}:${el.value || ''}`; });
|
|
194
|
+
} catch { /* ignore */ }
|
|
195
|
+
return s;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function pickComponent(Comp) {
|
|
199
|
+
if (!Comp) return null;
|
|
200
|
+
if (typeof Comp.default === 'function') return Comp.default;
|
|
201
|
+
for (const v of Object.values(Comp)) {
|
|
202
|
+
if (typeof v === 'function' && /^[A-Z]/.test(v.name || '')) return v;
|
|
203
|
+
}
|
|
204
|
+
return null;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/** Decide pass/fail/unverifiable from the before/after DOM and the action kind. */
|
|
208
|
+
function decide(kind, snap) {
|
|
209
|
+
const { beforeHTML, beforeText, afterHTML, afterText } = snap;
|
|
210
|
+
const htmlChanged = beforeHTML !== afterHTML;
|
|
211
|
+
const sentinelAppeared = !beforeText.includes(SENTINEL) && afterText.includes(SENTINEL);
|
|
212
|
+
const sentinelDisappeared = beforeText.includes(SENTINEL) && !afterText.includes(SENTINEL);
|
|
213
|
+
|
|
214
|
+
if (kind === 'add') {
|
|
215
|
+
if (sentinelAppeared) return { status: 'effect-pass', detail: `tras invocar la acción, "${SENTINEL}" aparece en la UI renderizada (el item se añadió de verdad)` };
|
|
216
|
+
if (htmlChanged) return { status: 'effect-pass', detail: 'la UI cambió tras invocar la acción (efecto observado en el render)' };
|
|
217
|
+
return { status: 'fail', detail: 'la acción se invocó pero la UI no cambió ni apareció el valor enviado (no produce su efecto)' };
|
|
218
|
+
}
|
|
219
|
+
if (kind === 'delete') {
|
|
220
|
+
if (sentinelDisappeared) return { status: 'effect-pass', detail: 'el item sembrado desapareció de la UI tras invocar la acción (se borró de verdad)' };
|
|
221
|
+
if (htmlChanged) return { status: 'effect-pass', detail: 'la UI cambió tras invocar la acción de borrado' };
|
|
222
|
+
return { status: 'unverifiable', detail: 'no se observó cambio al borrar (quizá no había item sembrado en aislamiento); cableado aplicado, efecto no confirmado — probar a mano' };
|
|
223
|
+
}
|
|
224
|
+
if (kind === 'toggle') {
|
|
225
|
+
if (htmlChanged) return { status: 'effect-pass', detail: 'la UI cambió tras invocar la acción (estado del item alternado)' };
|
|
226
|
+
return { status: 'unverifiable', detail: 'no se observó cambio al alternar (quizá sin item sembrado en aislamiento); cableado aplicado, efecto no confirmado — probar a mano' };
|
|
227
|
+
}
|
|
228
|
+
// ui / other
|
|
229
|
+
if (htmlChanged) return { status: 'effect-pass', detail: 'la UI del componente cambió tras invocar la acción' };
|
|
230
|
+
return { status: 'unverifiable', detail: 'la acción no produjo un cambio observable en el render aislado; cableado aplicado, efecto no confirmado — probar a mano' };
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function short(err) {
|
|
234
|
+
return String((err && err.message) || err).split('\n')[0].slice(0, 200);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* Install a jsdom-backed DOM on the global scope (saving anything we overwrite so
|
|
239
|
+
* we can restore it). Seeds localStorage keys with a sentinel record so delete/
|
|
240
|
+
* toggle actions have something to act on.
|
|
241
|
+
*/
|
|
242
|
+
function installDom(jsdomMod, { storageKeys = [], kind } = {}) {
|
|
243
|
+
const { JSDOM } = jsdomMod;
|
|
244
|
+
const dom = new JSDOM('<!doctype html><html><body></body></html>', { url: 'http://localhost/', pretendToBeVisual: true });
|
|
245
|
+
const { window } = dom;
|
|
246
|
+
// Seed storage BEFORE the component hydrates (only useful for delete/toggle).
|
|
247
|
+
if (kind === 'delete' || kind === 'toggle') {
|
|
248
|
+
for (const k of storageKeys.length ? storageKeys : ['ITEMS', 'todos', 'tasks']) {
|
|
249
|
+
try { window.localStorage.setItem(k, JSON.stringify([seedRecord()])); } catch {}
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
const values = {
|
|
253
|
+
window,
|
|
254
|
+
document: window.document,
|
|
255
|
+
navigator: window.navigator,
|
|
256
|
+
HTMLElement: window.HTMLElement,
|
|
257
|
+
Node: window.Node,
|
|
258
|
+
Element: window.Element,
|
|
259
|
+
Event: window.Event,
|
|
260
|
+
CustomEvent: window.CustomEvent,
|
|
261
|
+
getComputedStyle: window.getComputedStyle.bind(window),
|
|
262
|
+
requestAnimationFrame: (cb) => setTimeout(() => cb(Date.now()), 0),
|
|
263
|
+
cancelAnimationFrame: (id) => clearTimeout(id),
|
|
264
|
+
localStorage: window.localStorage,
|
|
265
|
+
sessionStorage: window.sessionStorage,
|
|
266
|
+
location: window.location,
|
|
267
|
+
history: window.history,
|
|
268
|
+
MutationObserver: window.MutationObserver,
|
|
269
|
+
SVGElement: window.SVGElement,
|
|
270
|
+
Text: window.Text,
|
|
271
|
+
Comment: window.Comment,
|
|
272
|
+
DocumentFragment: window.DocumentFragment,
|
|
273
|
+
NodeList: window.NodeList,
|
|
274
|
+
HTMLInputElement: window.HTMLInputElement,
|
|
275
|
+
HTMLTextAreaElement: window.HTMLTextAreaElement,
|
|
276
|
+
HTMLSelectElement: window.HTMLSelectElement,
|
|
277
|
+
DOMParser: window.DOMParser,
|
|
278
|
+
XMLSerializer: window.XMLSerializer,
|
|
279
|
+
};
|
|
280
|
+
const saved = {};
|
|
281
|
+
const savedDesc = {};
|
|
282
|
+
for (const k of Object.keys(values)) {
|
|
283
|
+
saved[k] = global[k];
|
|
284
|
+
savedDesc[k] = Object.getOwnPropertyDescriptor(global, k);
|
|
285
|
+
try {
|
|
286
|
+
global[k] = values[k];
|
|
287
|
+
} catch {
|
|
288
|
+
// Some globals (navigator in Node ≥21) are getter-only → redefine.
|
|
289
|
+
try { Object.defineProperty(global, k, { value: values[k], configurable: true, writable: true }); } catch { /* skip */ }
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
const savedAct = global.IS_REACT_ACT_ENVIRONMENT;
|
|
293
|
+
global.IS_REACT_ACT_ENVIRONMENT = false;
|
|
294
|
+
return () => {
|
|
295
|
+
for (const k of Object.keys(values)) {
|
|
296
|
+
try {
|
|
297
|
+
if (savedDesc[k]) Object.defineProperty(global, k, savedDesc[k]);
|
|
298
|
+
else if (saved[k] === undefined) delete global[k];
|
|
299
|
+
else global[k] = saved[k];
|
|
300
|
+
} catch { /* best effort */ }
|
|
301
|
+
}
|
|
302
|
+
global.IS_REACT_ACT_ENVIRONMENT = savedAct;
|
|
303
|
+
try { window.close(); } catch {}
|
|
304
|
+
};
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
// ── Public dispatch ──────────────────────────────────────────────────────────
|
|
308
|
+
|
|
309
|
+
/**
|
|
310
|
+
* Verify component-local effect by rendering. `framework` selects the engine.
|
|
311
|
+
* `sources` are the project's source files (for storage-key detection, etc.).
|
|
312
|
+
*/
|
|
313
|
+
async function probeComponentEffect(appRoot, entry, opts = {}) {
|
|
314
|
+
const framework = (opts.framework || 'web').toLowerCase();
|
|
315
|
+
const kind = actionKind(entry);
|
|
316
|
+
const params = probeParams(entry, kind);
|
|
317
|
+
const storageKeys = detectStorageKeys(opts.sources);
|
|
318
|
+
const ctx = { fyodosDirRel: opts.fyodosDirRel || 'src/fyodos', framework, kind, params, storageKeys, sensitive: opts.sensitive || entry.requireConfirmation };
|
|
319
|
+
|
|
320
|
+
const withTimeout = (p) => Promise.race([
|
|
321
|
+
p,
|
|
322
|
+
new Promise((resolve) => setTimeout(() => resolve({ status: 'unverifiable', detail: 'el render de prueba excedió el tiempo límite (posible bucle/timer en el componente); efecto real no verificable automáticamente — probar a mano' }), opts.timeoutMs || 25000)),
|
|
323
|
+
]);
|
|
324
|
+
|
|
325
|
+
if (framework.includes('react')) return withTimeout(probeReactEffect(appRoot, entry, ctx));
|
|
326
|
+
if (framework.includes('vue')) return withTimeout(require('./renderProbeVue').probeVueEffect(appRoot, entry, ctx));
|
|
327
|
+
if (framework.includes('angular')) {
|
|
328
|
+
return { status: 'unverifiable', detail: 'Angular: el render headless de componentes con TestBed no es fiable fuera de ng test; cableado aplicado y verificado por compilación, efecto real no verificable automáticamente aquí — probar con `ng test` o a mano' };
|
|
329
|
+
}
|
|
330
|
+
return { status: 'unverifiable', detail: `framework '${framework}' sin render-probe; efecto real no verificable automáticamente` };
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
module.exports = { probeComponentEffect, actionKind, decide, SENTINEL, SEED_ID, installDom, pickComponent, detectStorageKeys, probeParams, captureSnapshot, short, loadEsbuild, loadJsdom, reqFrom, emptyAssetsPlugin };
|