codetac 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/src/detect.mjs ADDED
@@ -0,0 +1,198 @@
1
+ // Deteção do projeto (Fase 5): gestor de pacotes, stack, script de arranque e
2
+ // porta provável, sem lista fechada de stacks. O que não se consegue deduzir
3
+ // fica em `missing`, para o comando perguntar ao utilizador.
4
+ import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
5
+ import { basename, join, relative } from 'node:path';
6
+
7
+ // Ordem de preferência dos scripts de desenvolvimento.
8
+ const SCRIPTS = ['dev', 'start:dev', 'develop', 'serve', 'dev:server', 'server', 'start'];
9
+ // Stacks conhecidas só para dar um nome e uma porta por omissão; as outras
10
+ // arrancam na mesma, pelo script.
11
+ const STACKS = [
12
+ { dep: 'next', name: 'Next.js', port: 3000 },
13
+ { dep: 'nuxt', name: 'Nuxt', port: 3000 },
14
+ { dep: '@remix-run/dev', name: 'Remix', port: 5173 },
15
+ { dep: '@react-router/dev', name: 'React Router', port: 5173 },
16
+ { dep: '@sveltejs/kit', name: 'SvelteKit', port: 5173 },
17
+ { dep: 'astro', name: 'Astro', port: 4321 },
18
+ { dep: '@angular/core', name: 'Angular', port: 4200 },
19
+ { dep: '@nestjs/core', name: 'NestJS', port: 3000 },
20
+ { dep: 'react-scripts', name: 'Create React App', port: 3000 },
21
+ { dep: 'fastify', name: 'Fastify', port: 3000 },
22
+ { dep: 'hono', name: 'Hono', port: 3000 },
23
+ { dep: 'koa', name: 'Koa', port: 3000 },
24
+ { dep: 'express', name: 'Express', port: 3000 },
25
+ { dep: 'vite', name: 'Vite', port: 5173 },
26
+ ];
27
+ const ENTRIES = ['server.js', 'server.mjs', 'server.ts', 'index.js', 'index.mjs', 'index.ts', 'app.js', 'app.ts',
28
+ 'src/server.ts', 'src/server.js', 'src/index.ts', 'src/index.js', 'src/main.ts', 'src/app.ts', 'server/index.ts', 'server/index.js'];
29
+ const SKIP_FOLDERS = new Set(['node_modules', 'dist', 'build', 'out', 'coverage', 'public', 'docs', 'test', 'tests', 'e2e', 'scripts']);
30
+
31
+ function readJson(path) {
32
+ try { return JSON.parse(readFileSync(path, 'utf8')); } catch { return null; }
33
+ }
34
+ function readText(path, limit = 200_000) {
35
+ try { return readFileSync(path, 'utf8').slice(0, limit); } catch { return ''; }
36
+ }
37
+
38
+ export function packageManager(root) {
39
+ let folder = root;
40
+ // Workspaces keep the lockfile at the top; look up to three levels.
41
+ for (let level = 0; level < 4; level++) {
42
+ if (existsSync(join(folder, 'pnpm-lock.yaml'))) return 'pnpm';
43
+ if (existsSync(join(folder, 'yarn.lock'))) return 'yarn';
44
+ if (existsSync(join(folder, 'bun.lockb')) || existsSync(join(folder, 'bun.lock'))) return 'bun';
45
+ if (existsSync(join(folder, 'package-lock.json'))) return 'npm';
46
+ const up = join(folder, '..');
47
+ if (up === folder) break;
48
+ folder = up;
49
+ }
50
+ return 'npm';
51
+ }
52
+
53
+ function dependencies(pkg) {
54
+ return { ...pkg?.dependencies, ...pkg?.devDependencies };
55
+ }
56
+
57
+ export function stackOf(pkg, script = '') {
58
+ const deps = dependencies(pkg);
59
+ // The script names the tool actually used (a project may carry several).
60
+ const byScript = STACKS.find(stack => new RegExp(`(^|[\\s/"'])${stack.dep.split('/').pop()}(\\s|$)`).test(script) && deps[stack.dep]);
61
+ return byScript ?? STACKS.find(stack => deps[stack.dep]) ?? null;
62
+ }
63
+
64
+ // Runtimes other than Node cannot load CodeTAC's hooks.
65
+ export function foreignRuntime(command) {
66
+ const match = String(command).match(/(?:^|[\s;&|(])(bun|deno|python3?|uvicorn|php|go run|cargo)\b(?!-)/);
67
+ return match ? match[1] : null;
68
+ }
69
+
70
+ export function chooseScript(pkg) {
71
+ const scripts = pkg?.scripts ?? {};
72
+ for (const name of SCRIPTS) {
73
+ const command = scripts[name];
74
+ // A "start" that only serves a production build is still a way to run it,
75
+ // but a "dev" is preferred (it is first in the list).
76
+ if (typeof command === 'string' && command.trim()) return { name, command };
77
+ }
78
+ return null;
79
+ }
80
+
81
+ // Port from the script arguments, the .env files, the source, or the stack default.
82
+ export function portOf(root, script, stack) {
83
+ const fromScript = String(script ?? '').match(/(?:--port[= ]|-p[= ]?|PORT=)(\d{2,5})\b/);
84
+ if (fromScript) return { value: Number(fromScript[1]), source: 'script' };
85
+ for (const name of ['.env.development.local', '.env.local', '.env.development', '.env']) {
86
+ const match = readText(join(root, name)).match(/^\s*(?:export\s+)?(?:APP_)?PORT\s*=\s*["']?(\d{2,5})/m);
87
+ if (match) return { value: Number(match[1]), source: name };
88
+ }
89
+ const configs = ['vite.config.ts', 'vite.config.js', 'vite.config.mjs', 'astro.config.mjs', 'nuxt.config.ts'];
90
+ for (const name of configs) {
91
+ const match = readText(join(root, name)).match(/\bport\s*:\s*(\d{2,5})/);
92
+ if (match) return { value: Number(match[1]), source: name };
93
+ }
94
+ for (const name of ENTRIES) {
95
+ const text = readText(join(root, name));
96
+ const match = text.match(/PORT\s*(?:\|\||\?\?)\s*["']?(\d{2,5})/) ?? text.match(/\.listen\(\s*(\d{2,5})\b/)
97
+ ?? text.match(/\bport\s*[:=]\s*(\d{4,5})\b/);
98
+ if (match) return { value: Number(match[1]), source: name };
99
+ }
100
+ if (stack?.port) return { value: stack.port, source: `omissão de ${stack.name}` };
101
+ return null;
102
+ }
103
+
104
+ function entryFile(root, pkg) {
105
+ const main = pkg?.main && existsSync(join(root, pkg.main)) ? pkg.main : null;
106
+ return main ?? ENTRIES.find(name => existsSync(join(root, name))) ?? null;
107
+ }
108
+
109
+ // One folder with a package.json: how to start it.
110
+ export function describeFolder(folder, top = folder) {
111
+ const pkg = readJson(join(folder, 'package.json'));
112
+ if (!pkg) return null;
113
+ const manager = packageManager(folder);
114
+ const script = chooseScript(pkg);
115
+ const entry = script ? null : entryFile(folder, pkg);
116
+ const stack = stackOf(pkg, script?.command);
117
+ const foreign = script ? foreignRuntime(script.command) : null;
118
+ // A Bun lockfile does not require Bun at run time: the scripts run with Node.
119
+ const runner = manager === 'bun' || foreign === 'bun' ? 'npm' : manager;
120
+ let command = null;
121
+ if (script) command = [runner, 'run', script.name];
122
+ else if (entry) command = ['node', entry];
123
+ return {
124
+ folder, part: relative(top, folder) || '.', name: pkg.name ?? basename(folder), manager, runner,
125
+ stack: stack?.name ?? null, script, entry, command, foreign,
126
+ port: portOf(folder, script?.command, stack),
127
+ installed: existsSync(join(folder, 'node_modules')) || existsSync(join(top, 'node_modules')),
128
+ workspaces: Boolean(pkg.workspaces) || existsSync(join(folder, 'pnpm-workspace.yaml')),
129
+ };
130
+ }
131
+
132
+ function childFolders(root) {
133
+ const found = [];
134
+ const visit = (folder, depth) => {
135
+ let names = [];
136
+ try { names = readdirSync(folder); } catch { return; }
137
+ for (const name of names) {
138
+ if (name.startsWith('.') || SKIP_FOLDERS.has(name)) continue;
139
+ const path = join(folder, name);
140
+ try { if (!statSync(path).isDirectory()) continue; } catch { continue; }
141
+ if (existsSync(join(path, 'package.json'))) found.push(path);
142
+ else if (depth < 1) visit(path, depth + 1);
143
+ if (depth < 1 && ['apps', 'packages'].includes(name)) visit(path, depth + 1);
144
+ }
145
+ };
146
+ visit(root, 0);
147
+ return [...new Set(found)];
148
+ }
149
+
150
+ function devPieces(part) {
151
+ const pkg = readJson(join(part.folder, 'package.json'));
152
+ const scripts = pkg?.scripts ?? {};
153
+ if (scripts.dev) return [];
154
+ return Object.keys(scripts).filter(name => /^dev:(?!.*(db|test|lint|type|build|seed|migrat))/.test(name)).map(name => {
155
+ // "cd server && npm run dev" or "npm --prefix server run dev": the piece
156
+ // lives in a subfolder, with its own dependencies.
157
+ const sub = scripts[name].match(/^\s*cd\s+([\w./-]+)\s*&&|--prefix[= ]([\w./-]+)|(?:^|\s)-C\s+([\w./-]+)/);
158
+ const inner = sub ? describeFolder(join(part.folder, sub[1] ?? sub[2] ?? sub[3]), part.folder) : null;
159
+ const stack = inner?.stack ? { name: inner.stack } : stackOf({ ...pkg, dependencies: {} }, scripts[name]);
160
+ return { ...part, part: `. (${name})`, script: { name, command: scripts[name] }, command: [part.runner, 'run', name],
161
+ stack: stack?.name ?? null, port: inner?.port ?? portOf(part.folder, scripts[name], stack), foreign: foreignRuntime(scripts[name]) ?? inner?.foreign ?? null,
162
+ ...(inner ? { installFolder: inner.folder, installed: inner.installed && existsSync(join(inner.folder, 'node_modules')), manager: inner.manager } : {}) };
163
+ });
164
+ }
165
+
166
+ // A folder without package.json but with a server file (node server.js).
167
+ function plainEntry(root) {
168
+ const entry = ENTRIES.find(name => existsSync(join(root, name)));
169
+ if (!entry) return null;
170
+ return { folder: root, part: '.', name: basename(root), manager: 'npm', runner: 'npm', stack: null, script: null, entry,
171
+ command: ['node', entry], foreign: null, port: portOf(root, '', null), installed: true, workspaces: false };
172
+ }
173
+
174
+ // The project: the top folder, and the parts that can be started. A top
175
+ // package.json whose script already starts everything is enough on its own;
176
+ // otherwise the parts with their own scripts are offered (client + server…).
177
+ export function detectProject(root) {
178
+ const top = describeFolder(root) ?? plainEntry(root);
179
+ const parts = childFolders(root).map(folder => describeFolder(folder, root)).filter(part => part?.command);
180
+ let start = [];
181
+ // Without a "dev" script, several "dev:*" scripts (dev:server + dev:client…)
182
+ // are started together: that is how such projects run in development.
183
+ const pieces = top ? devPieces(top) : [];
184
+ if (pieces.length > 1) start = pieces;
185
+ else if (top?.command) start = [top];
186
+ else if (parts.length === 1) start = parts;
187
+ const missing = [];
188
+ if (!top && !parts.length) missing.push('package');
189
+ else if (!start.length && parts.length > 1) missing.push('part');
190
+ else if (!start.length) missing.push('command');
191
+ return { root, name: top?.name ?? basename(root), top, parts, start, missing };
192
+ }
193
+
194
+ // The sentence the command shows before starting.
195
+ export function describeStart(part) {
196
+ const what = part.script ? `${part.command.join(' ')} (script «${part.script.name}»: ${part.script.command})` : part.command.join(' ');
197
+ return `${part.part === '.' ? '' : `[${part.part}] `}${part.stack ? `${part.stack} · ` : ''}${what}`;
198
+ }
@@ -0,0 +1,121 @@
1
+ // `codetac diagnostico` (Fase 5): o que está e o que não está a funcionar,
2
+ // em linguagem simples, a partir do projeto, do painel e da última gravação.
3
+ import { registerHooks } from 'node:module';
4
+ import http from 'node:http';
5
+ import { join, relative } from 'node:path';
6
+ import { dataDirectory } from './home.mjs';
7
+ import { detectProject, describeStart } from './detect.mjs';
8
+ import { recordingsOf, summarize } from './recording.mjs';
9
+ import { describeConfig, loadConfig } from './ai.mjs';
10
+
11
+ const directory = dataDirectory();
12
+
13
+ const REASONS = {
14
+ 'module-transform-failed': 'ficheiros que não foi possível preparar',
15
+ 'source-map-unreadable': 'ficheiros com source map ilegível',
16
+ 'eval-code-transform-failed': 'blocos de código do bundler que não foi possível preparar',
17
+ 'constructor-skipped': 'construtores de classes (correm, mas não aparecem)',
18
+ 'generator-skipped': 'generators (correm, mas não aparecem)',
19
+ 'parameter-redeclaration-skipped': 'funções que redeclaram um parâmetro (correm, mas não aparecem)',
20
+ 'direct-eval-skipped': 'funções com eval direto (correm, mas não aparecem)',
21
+ };
22
+
23
+ function ping(port) {
24
+ return new Promise(done => {
25
+ const request = http.get({ host: '127.0.0.1', port, path: '/api/ping', timeout: 1500, headers: { host: `127.0.0.1:${port}` } }, response => {
26
+ response.resume();
27
+ done(response.statusCode === 200);
28
+ });
29
+ request.on('timeout', () => { request.destroy(); done(false); });
30
+ request.on('error', () => done(false));
31
+ });
32
+ }
33
+
34
+ function panelConfig(port) {
35
+ return new Promise(done => {
36
+ http.get({ host: '127.0.0.1', port, path: '/api/config', timeout: 1500, headers: { host: `127.0.0.1:${port}` } }, response => {
37
+ let body = '';
38
+ response.on('data', chunk => { body += chunk; });
39
+ response.on('end', () => { try { done(JSON.parse(body)); } catch { done(null); } });
40
+ }).on('error', () => done(null));
41
+ });
42
+ }
43
+
44
+ export async function diagnose(root, { panelPort = 4000, out = text => process.stdout.write(`${text}\n`) } = {}) {
45
+ let problems = 0;
46
+ const ok = text => out(` ✓ ${text}`);
47
+ const bad = (text, fix) => { problems++; out(` ✗ ${text}`); if (fix) out(` → ${fix}`); };
48
+ const note = (text, fix) => { out(` ! ${text}`); if (fix) out(` → ${fix}`); };
49
+
50
+ out(`Diagnóstico do CodeTAC · ${root}\n`);
51
+ out('Este computador');
52
+ const [major] = process.versions.node.split('.').map(Number);
53
+ if (major >= 24 && typeof registerHooks === 'function') ok(`Node ${process.version}: consegue seguir as funções do projeto.`);
54
+ else if (typeof registerHooks === 'function') note(`Node ${process.version}: funciona, mas o CodeTAC foi verificado no Node 24 ou superior.`, 'Instale o Node 24 ou mais recente.');
55
+ else bad(`Node ${process.version}: não permite seguir as funções; só o modo mínimo (pedidos e fronteiras).`, 'Instale o Node 24 ou mais recente.');
56
+
57
+ out('\nO projeto');
58
+ const project = detectProject(root);
59
+ if (project.missing.includes('package')) bad('Não há package.json nem ficheiro de servidor (server.js, index.js…) nesta pasta.', 'Corra o comando na pasta da app, ou indique o arranque: codetac . -- node server.js');
60
+ else if (project.missing.includes('part')) note(`Tem várias partes: ${project.parts.map(part => part.part).join(', ')}. O comando pergunta quais arrancar.`);
61
+ else if (project.missing.includes('command')) bad('O package.json não tem um script de arranque (dev, start…).', 'Indique o comando: codetac . -- <comando>');
62
+ for (const part of project.start.length ? project.start : project.parts) {
63
+ ok(`Arranque: ${describeStart(part)}`);
64
+ if (part.port) ok(`Porta provável: ${part.port.value} (${part.port.source}). A porta real é lida quando a app arranca.`);
65
+ if (!part.installed) bad(`As dependências de ${part.part === '.' ? 'o projeto' : part.part} não estão instaladas.`, `${part.manager === 'bun' ? 'npm' : part.manager} install (ou codetac --sim, que instala)`);
66
+ if (part.foreign) note(`O script usa ${part.foreign}, que não é o Node: essa parte corre, mas não é observada por dentro.`);
67
+ }
68
+
69
+ out('\nO painel');
70
+ const panelOn = await ping(panelPort);
71
+ if (panelOn) ok(`Está a correr em http://127.0.0.1:${panelPort}.`);
72
+ else note(`Não está a correr na porta ${panelPort}.`, 'O comando codetac arranca-o; sozinho: npm run panel, na pasta do CodeTAC.');
73
+ try {
74
+ // The running panel's own configuration, else the one it would load.
75
+ const config = (panelOn && await panelConfig(panelPort)) || describeConfig(await loadConfig({ directory }));
76
+ ok(`Frases de finalidade: ${config.active ? `${config.model} (${config.provider}${config.local ? ', local: nada sai da máquina' : ', excertos redigidos são enviados'})`
77
+ : `fixas, geradas dos factos (${config.problem ?? 'sem modelo de IA configurado'})`}.`);
78
+ } catch {}
79
+
80
+ out('\nA última gravação deste projeto');
81
+ const [last] = recordingsOf(directory, root);
82
+ if (!last) {
83
+ note('Ainda não há gravações deste projeto.', 'Arranque com: codetac (na pasta do projeto). Depois volte a correr o diagnóstico.');
84
+ out(problems ? `\n${problems} problema(s) a resolver.` : '\nNada impede o arranque.');
85
+ return problems ? 1 : 0;
86
+ }
87
+ const summary = summarize(last.folder);
88
+ ok(`${last.name} (${new Date(last.changed).toLocaleString('pt-PT')}), ${summary.processes.size} processo(s) Node observados.`);
89
+ const minimal = summary.starts.find(start => start.level === 'minimo');
90
+ if (minimal) note(`Modo mínimo: as funções do projeto não foram seguidas (${minimal.reason ?? 'sem motivo registado'}). Pedidos, fronteiras e browser sim.`,
91
+ 'Se a app arranca sem o CodeTAC mas não com ele, envie estas linhas e as mensagens do arranque.');
92
+ if (summary.ports.size) ok(`Portas abertas pela app: ${[...summary.ports].join(', ')}.`);
93
+ else if (!summary.requests) bad('A captura não viu a app abrir nenhuma porta.', 'A app arrancou? Se o servidor não for Node (bun, deno…), não é observado.');
94
+ if (!minimal) {
95
+ if (summary.files) ok(`Ficheiros do projeto preparados: ${summary.files} (${summary.functions} funções).`);
96
+ else if (summary.requests) bad('Nenhum ficheiro do projeto passou pelo CodeTAC.',
97
+ 'O servidor pode estar a correr código já empacotado sem source map, ou fora da pasta do projeto. Os dossiês mostram só pedidos e fronteiras.');
98
+ const failed = summary.failed.length;
99
+ if (failed) {
100
+ note(`${failed} ficheiro(s) correm sem ser seguidos (não foi possível prepará-los):`);
101
+ for (const item of summary.failed.slice(0, 5)) out(` - ${relative(root, item.file ?? '') || item.file}${item.detail ? `: ${item.detail}` : ''}`);
102
+ }
103
+ for (const [reason, count] of summary.limitations) {
104
+ if (reason === 'module-transform-failed' || reason === 'source-map-unreadable') continue;
105
+ note(`${REASONS[reason] ?? reason}: ${count}.`);
106
+ }
107
+ }
108
+ if (summary.requests) {
109
+ ok(`Pedidos gravados: ${summary.requests}. Com funções do projeto: ${summary.withFunctions.size}. Com fronteiras: ${summary.withBoundaries.size}.`);
110
+ if (!minimal && summary.files && !summary.withFunctions.size) note('Nenhum pedido passou por funções do projeto até agora.',
111
+ 'Normal numa app só de frontend (Vite, páginas estáticas): o servidor só entrega ficheiros; o que interessa está no browser.');
112
+ } else note('Ainda não chegou nenhum pedido.', 'Abra a app no browser e use-a.');
113
+ if (summary.pages) ok(`Páginas servidas com a barra do CodeTAC: ${summary.pages}.`);
114
+ else if (summary.requests && !summary.actions.size) note('Nenhuma página HTML passou pelo servidor observado: a barra não foi injetada.',
115
+ 'Se a página vem de outro servidor (outro processo, não Node), abra-a através do servidor Node da app.');
116
+ if (summary.actions.size) ok(`Ações do browser gravadas: ${summary.actions.size}.`);
117
+ else if (summary.pages) note('A barra está na página, mas ainda não chegou nenhuma ação.', 'Clique em algo na app. Se nada aparecer, veja a consola do browser (erros de CSP?).');
118
+
119
+ out(problems ? `\n${problems} problema(s) a resolver.` : '\nTudo o que foi possível verificar está a funcionar.');
120
+ return problems ? 1 : 0;
121
+ }
package/src/digest.mjs ADDED
@@ -0,0 +1,317 @@
1
+ // Vista agrupada do dossiê (Fase 3): árvore de passos com o ruído agrupado,
2
+ // uma frase de finalidade por função e por fronteira, e o resumo dos efeitos
3
+ // permanentes. Tudo deriva dos factos gravados; nada é removido, só agrupado
4
+ // (cada grupo guarda os passos originais para a expansão).
5
+ import { boundarySentence, duration, names, texts } from './sentences.mjs';
6
+
7
+ const STRUCTURE = new Set(['CREATE', 'DROP', 'ALTER', 'TRUNCATE']);
8
+ const WRITES = new Set(['INSERT', 'UPDATE', 'DELETE', 'UPSERT', 'REPLACE', 'MERGE']);
9
+ const TRIVIAL_MS = 5;
10
+ const TRIVIAL_LINES = 10;
11
+ // Requests made by the development tooling, not by the application.
12
+ const DEV_TOOLS = /(\.hot-update\.(json|js)$|\/_next\/webpack-hmr|\/__nextjs_|\/__webpack_hmr|\/@vite\/|\/@react-refresh|\/__vite_ping|\/_next\/static\/webpack\/)/;
13
+
14
+ export function isDevToolRequest(path) { return DEV_TOOLS.test(path ?? ''); }
15
+
16
+ function tree(steps) {
17
+ const ids = new Map(steps.map(step => [step.id, { ...step, children: [] }]));
18
+ const roots = [];
19
+ for (const step of steps) {
20
+ const node = ids.get(step.id);
21
+ const parent = step.parentId ? ids.get(step.parentId) : null;
22
+ (parent ? parent.children : roots).push(node);
23
+ }
24
+ return roots;
25
+ }
26
+
27
+ // Everything the subtree of a node did outside the project's code.
28
+ function collect(node, into = { boundaries: [], functions: 0, errors: 0 }) {
29
+ for (const child of node.children ?? []) {
30
+ if (child.type === 'boundary') into.boundaries.push(child);
31
+ else into.functions++;
32
+ if (child.error) into.errors++;
33
+ collect(child, into);
34
+ }
35
+ return into;
36
+ }
37
+
38
+ function signature(node) {
39
+ if (node.type === 'group') return `g:${node.group}:${signature(node.children[0])}`;
40
+ if (node.type === 'function') return `f:${node.file}:${node.line}`;
41
+ return `b:${node.kind}:${node.operation}:${(node.tables ?? []).join(',')}:${node.provider ?? ''}:${node.host ?? ''}:${node.method ?? ''}:${node.path ?? ''}`;
42
+ }
43
+ function generated(node, root) {
44
+ if (node.type !== 'function' || !node.file) return false;
45
+ const within = root && node.file.startsWith(root + '/') ? node.file.slice(root.length + 1) : node.file;
46
+ return within.split('/').slice(0, -1).some(part => part.startsWith('.'));
47
+ }
48
+ function trivial(node) {
49
+ return node.type === 'function' && !node.children.length && !node.error && !node.detail && node.finished !== false
50
+ && (node.durationMs ?? 0) < TRIVIAL_MS && (node.endLine == null || node.endLine - node.line <= TRIVIAL_LINES);
51
+ }
52
+ const sum = (nodes, key = 'durationMs') => nodes.reduce((total, node) => total + (node[key] ?? 0), 0);
53
+
54
+ // Groups consecutive siblings; the order of the sequence is kept.
55
+ function group(children, parent, ctx) {
56
+ const t = texts(ctx.lang);
57
+ const out = [];
58
+ let index = 0;
59
+ while (index < children.length) {
60
+ const node = children[index];
61
+ // 1. Database setup: a run of structure commands (M14).
62
+ if (node.type === 'boundary' && node.kind === 'base-de-dados') {
63
+ let end = index;
64
+ while (end < children.length && children[end].type === 'boundary' && children[end].kind === 'base-de-dados') end++;
65
+ const run = children.slice(index, end);
66
+ const structure = run.filter(item => STRUCTURE.has(item.operation));
67
+ if (run.length >= 3 && structure.length * 2 >= run.length) {
68
+ const counts = {};
69
+ for (const item of run) counts[item.operation] = (counts[item.operation] ?? 0) + 1;
70
+ const tables = new Set(run.flatMap(item => item.tables ?? []));
71
+ const failed = run.filter(item => item.error || item.result?.error).length;
72
+ const changed = run.filter(item => WRITES.has(item.operation)).reduce((total, item) => total + (item.result?.affectedRows ?? 0), 0);
73
+ const parentOk = parent && parent.type === 'function' && parent.finished && !parent.error;
74
+ out.push({ type: 'group', group: 'preparacao-bd', id: `g:${run[0].id}`, count: run.length, durationMs: sum(run), errors: failed,
75
+ sentence: t.dbSetup(run.length, Object.entries(counts).map(([op, n]) => `${n} ${op}`).join(', '), tables.size, failed, changed, parentOk),
76
+ children: run });
77
+ index = end;
78
+ continue;
79
+ }
80
+ }
81
+ // 2. Bundler glue, two or more in a row (before repetitions: glue often
82
+ // repeats the same wrapper).
83
+ let end = index + 1;
84
+ if (generated(node, ctx.root)) {
85
+ while (end < children.length && generated(children[end], ctx.root)) end++;
86
+ if (end - index >= 2) {
87
+ const run = children.slice(index, end);
88
+ out.push({ type: 'group', group: 'gerado', id: `g:${node.id}`, count: run.length, durationMs: sum(run), errors: run.filter(item => item.error).length,
89
+ sentence: t.generated(run.length), children: run });
90
+ index = end;
91
+ continue;
92
+ }
93
+ end = index + 1;
94
+ }
95
+ // 3. The same step repeated in a row.
96
+ while (end < children.length && signature(children[end]) === signature(node)) end++;
97
+ if (end - index >= 2) {
98
+ const run = children.slice(index, end);
99
+ out.push({ type: 'group', group: 'repeticao', id: `g:${node.id}`, count: run.length, durationMs: sum(run),
100
+ errors: run.filter(item => item.error).length, sentence: t.repeated(node.purpose.text, run.length), first: node, children: run });
101
+ index = end;
102
+ continue;
103
+ }
104
+ // 4. Trivial helpers, two or more in a row.
105
+ if (trivial(node) && !generated(node, ctx.root)) {
106
+ end = index + 1;
107
+ while (end < children.length && trivial(children[end]) && !generated(children[end], ctx.root)) end++;
108
+ if (end - index >= 2) {
109
+ const run = children.slice(index, end);
110
+ const fns = [...new Set(run.map(item => item.function))];
111
+ out.push({ type: 'group', group: 'auxiliares', id: `g:${node.id}`, count: run.length, durationMs: sum(run), errors: run.filter(item => item.error).length,
112
+ sentence: t.helpers(run.length, names(fns, t.and)), children: run });
113
+ index = end;
114
+ continue;
115
+ }
116
+ }
117
+ out.push(node);
118
+ index++;
119
+ }
120
+ return blocks(out, ctx);
121
+ }
122
+
123
+ // The same sequence of 2 to 6 steps repeated in a row (a loop over records):
124
+ // one line, with every step kept in order for the expansion.
125
+ function blocks(nodes, ctx) {
126
+ const t = texts(ctx.lang);
127
+ const out = [];
128
+ let index = 0;
129
+ while (index < nodes.length) {
130
+ let best = null;
131
+ for (let size = 2; size <= 6 && index + size * 2 <= nodes.length; size++) {
132
+ const pattern = nodes.slice(index, index + size).map(signature);
133
+ let times = 1;
134
+ while (index + (times + 1) * size <= nodes.length
135
+ && nodes.slice(index + times * size, index + (times + 1) * size).every((node, i) => signature(node) === pattern[i])) times++;
136
+ if (times >= 2 && (!best || times * size > best.times * best.size)) best = { size, times };
137
+ }
138
+ if (!best) { out.push(nodes[index++]); continue; }
139
+ const run = nodes.slice(index, index + best.size * best.times);
140
+ const first = nodes.slice(index, index + best.size);
141
+ const inside = run.flatMap(node => [node, ...collect(node).boundaries]).filter(node => node.type === 'boundary');
142
+ const reads = [...new Set(inside.filter(item => item.kind === 'base-de-dados' && item.operation === 'SELECT').flatMap(item => item.tables ?? []))];
143
+ const writes = [...new Set(inside.filter(item => item.kind === 'base-de-dados' && WRITES.has(item.operation)).flatMap(item => item.tables ?? []))];
144
+ const fns = [...new Set(first.flatMap(node => node.type === 'function' ? [node.function] : node.type === 'group' && node.children[0].type === 'function' ? [node.children[0].function] : []))];
145
+ const parts = [];
146
+ if (reads.length) parts.push(t.readPart(names(reads, t.and)));
147
+ if (writes.length) parts.push(t.writePart(t.writeVerbs.UPDATE, names(writes, t.and)));
148
+ if (fns.length) parts.push(t.calls(names(fns, t.and)));
149
+ out.push({ type: 'group', group: 'bloco', id: `g:b:${run[0].id}`, count: run.length, durationMs: sum(run), errors: run.filter(item => item.error || item.errors).length,
150
+ sentence: t.block(best.times, best.size, parts.join(' · ')), children: run });
151
+ index += run.length;
152
+ }
153
+ return out;
154
+ }
155
+
156
+ // What a function did, from the boundaries in its subtree and the functions it called.
157
+ function functionSentence(node, ctx) {
158
+ const t = texts(ctx.lang);
159
+ const facts = collect(node);
160
+ const parts = [];
161
+ const db = facts.boundaries.filter(item => item.kind === 'base-de-dados');
162
+ const structure = db.filter(item => STRUCTURE.has(item.operation));
163
+ if (structure.length) parts.push(t.dbSetupPart(structure.length));
164
+ const reads = [...new Set(db.filter(item => item.operation === 'SELECT').flatMap(item => item.tables ?? []))];
165
+ if (reads.length) parts.push(t.readPart(names(reads, t.and)));
166
+ // Writes that changed no rows (or failed) are not presented as changes.
167
+ const idle = new Set();
168
+ for (const op of WRITES) {
169
+ const writes = db.filter(item => item.operation === op);
170
+ const done = writes.filter(item => !item.error && !item.result?.error && item.result?.affectedRows !== 0);
171
+ const tables = [...new Set(done.flatMap(item => item.tables ?? []))];
172
+ if (tables.length) parts.push(t.writePart(t.writeVerbs[op], names(tables, t.and)));
173
+ for (const item of writes) if (!done.includes(item)) for (const table of item.tables ?? []) if (!tables.includes(table)) idle.add(table);
174
+ }
175
+ if (idle.size) parts.push(t.idleWritePart(names([...idle], t.and)));
176
+ const kinds = kind => facts.boundaries.filter(item => item.kind === kind);
177
+ const hosts = [...new Set(kinds('http').map(item => item.host))];
178
+ if (hosts.length) parts.push(t.httpPart(names(hosts, t.and)));
179
+ const ai = [...new Set(kinds('ia').map(item => item.provider))];
180
+ if (ai.length) parts.push(t.aiPart(names(ai, t.and)));
181
+ const mail = kinds('email').length + kinds('mensagem').length;
182
+ if (mail) parts.push(t.emailPart(mail));
183
+ const pay = [...new Set(kinds('pagamento').map(item => item.provider))];
184
+ if (pay.length) parts.push(t.paymentPart(names(pay, t.and)));
185
+ const files = [...new Set(kinds('ficheiros').map(item => item.operation))];
186
+ if (files.length) parts.push(t.filePart(names(files, t.and)));
187
+ const auth = [...new Set(kinds('autenticação').map(item => item.provider ?? item.library))];
188
+ if (auth.length) parts.push(t.authPart(names(auth, t.and)));
189
+ const callees = [...new Set(node.children.filter(item => item.type === 'function' && !generated(item, ctx.root)).map(item => item.function))];
190
+ if (!facts.boundaries.length) parts.push(t.noBoundary);
191
+ if (callees.length) parts.push(t.calls(names(callees, t.and)));
192
+ if (node.error) parts.push(t.error);
193
+ else if (node.finished === false) parts.push(t.unfinished);
194
+ const text = parts.join(' · ');
195
+ return text[0].toUpperCase() + text.slice(1);
196
+ }
197
+
198
+ function annotate(node, ctx) {
199
+ for (const child of node.children) annotate(child, ctx);
200
+ node.purpose = { text: node.type === 'function' ? functionSentence(node, ctx) : boundarySentence(node, ctx.lang), source: 'factos' };
201
+ node.children = group(node.children, node, ctx);
202
+ return node;
203
+ }
204
+
205
+ function counts(nodes) {
206
+ let visible = 0;
207
+ let total = 0;
208
+ const walk = (list, open) => {
209
+ for (const node of list) {
210
+ if (node.type === 'group') {
211
+ if (open) visible++;
212
+ walk(node.children, false);
213
+ } else {
214
+ total++;
215
+ if (open) visible++;
216
+ walk(node.children ?? [], open);
217
+ }
218
+ }
219
+ };
220
+ walk(nodes, true);
221
+ return { visible, total };
222
+ }
223
+
224
+ // Lasting effects of the steps of one or more requests.
225
+ export function effects(dossiers, lang, { cookies = [] } = {}) {
226
+ const t = texts(lang).effects;
227
+ const items = [];
228
+ const add = (key, category, make, amount = 1) => {
229
+ let found = items.find(item => item.key === key);
230
+ if (!found) items.push(found = { key, category, n: 0, total: null, make });
231
+ found.n++;
232
+ if (amount != null) found.total = (found.total ?? 0) + amount;
233
+ else found.unknown = true;
234
+ };
235
+ let structureOk = 0;
236
+ let structureFailed = 0;
237
+ const steps = dossiers.flatMap(dossier => dossier.steps ?? []);
238
+ for (const step of steps) {
239
+ if (step.type !== 'boundary') continue;
240
+ const r = step.result ?? {};
241
+ const failed = Boolean(step.error || r.error);
242
+ const table = (step.tables ?? []).join(', ') || '?';
243
+ if (step.kind === 'base-de-dados' && STRUCTURE.has(step.operation)) {
244
+ if (failed) structureFailed++; else structureOk++;
245
+ } else if (step.kind === 'base-de-dados' && WRITES.has(step.operation) && !failed) {
246
+ const n = r.affectedRows;
247
+ if (n === 0) add(`none:${step.operation}:${table}`, 'sem-alteracao', (item) => t.noChange(step.operation, table, item.n));
248
+ else {
249
+ const make = step.operation === 'INSERT' ? item => t.added(item.unknown ? null : item.total, table)
250
+ : step.operation === 'UPDATE' ? item => t.changed(item.unknown ? null : item.total, table)
251
+ : step.operation === 'DELETE' ? item => t.deleted(item.unknown ? null : item.total, table)
252
+ : () => t.otherWrite(step.operation, table);
253
+ add(`db:${step.operation}:${table}`, 'base-de-dados', make, n ?? null);
254
+ }
255
+ } else if (step.kind === 'email') add(`mail:${step.id}`, 'email', () => t.email(step.to ?? [], step.subject, failed));
256
+ else if (step.kind === 'mensagem') add(`msg:${step.id}`, 'email', () => t.message(step.provider ?? step.library, failed));
257
+ else if (step.kind === 'pagamento' && !failed) add(`pay:${step.id}`, 'pagamento', () => t.payment(step.provider, step.operation, step.mode));
258
+ else if (step.kind === 'ficheiros' && !failed && !['leitura', 'verificação', 'GET', 'HEAD'].includes(step.operation)) {
259
+ const where = step.bucket ? `${step.provider} ${step.bucket}` : step.path ?? step.provider;
260
+ add(`file:${step.operation}:${where}`, 'ficheiros', () => t.file(step.operation, where));
261
+ } else if (step.kind === 'ia') add(`ia:${step.id}`, 'custo', () => t.ai(step.provider, r.model ?? step.model, r.usage, step.costUsd));
262
+ else if (step.kind === 'http' && !step.local && !failed) {
263
+ add(`http:${step.method}:${step.host}`, step.method === 'GET' || step.method === 'HEAD' ? 'leitura-externa' : 'externo',
264
+ item => t.external(step.method, step.host, item.n));
265
+ }
266
+ }
267
+ const set = [...new Set(cookies.filter(item => !item.cleared).map(item => item.name))];
268
+ const cleared = [...new Set(cookies.filter(item => item.cleared).map(item => item.name))];
269
+ const result = items.map(item => ({ category: item.category, text: item.make(item) }));
270
+ if (set.length) result.push({ category: 'browser', text: t.cookieSet(set.join(', ')) });
271
+ if (cleared.length) result.push({ category: 'browser', text: t.cookieCleared(cleared.join(', ')) });
272
+ if (structureOk + structureFailed) result.push({ category: 'estrutura', text: t.structure(structureOk, structureFailed) });
273
+ const lasting = result.filter(item => !['sem-alteracao', 'leitura-externa', 'custo'].includes(item.category));
274
+ return { items: result, lasting: lasting.length, none: lasting.length ? null : t.none, unseen: t.unseen };
275
+ }
276
+
277
+ // The grouped view of one request dossier.
278
+ export function digestRequest(dossier, { lang = 'pt-PT' } = {}) {
279
+ const ctx = { lang, root: dossier.root };
280
+ const roots = tree(dossier.steps ?? []);
281
+ const pseudo = { type: 'request', children: roots };
282
+ for (const node of roots) annotate(node, ctx);
283
+ const nodes = group(roots, pseudo, ctx);
284
+ return { nodes, lines: counts(nodes), effects: effects([dossier], lang, { cookies: dossier.request?.cookies ?? [] }),
285
+ duration: duration(dossier.request?.durationMs, lang) };
286
+ }
287
+
288
+ // A whole action: every server dossier grouped, development-tool requests
289
+ // grouped (M8), a one-line summary and the effects of all its requests.
290
+ export function digestAction(dossier, { lang = 'pt-PT' } = {}) {
291
+ const t = texts(lang);
292
+ const servers = [];
293
+ const timeline = [];
294
+ for (const item of dossier.timeline) {
295
+ for (const server of item.server ?? []) {
296
+ server.digest = digestRequest(server, { lang });
297
+ servers.push(server);
298
+ }
299
+ const dev = item.type === 'request' && isDevToolRequest(item.browser?.path);
300
+ const previous = timeline.at(-1);
301
+ if (dev && previous?.type === 'dev-group') previous.items.push(item);
302
+ else if (dev) timeline.push({ type: 'dev-group', items: [item] });
303
+ else timeline.push(item);
304
+ }
305
+ for (const item of timeline) if (item.type === 'dev-group') item.sentence = t.devTools(item.items.length);
306
+ // One line: what was pressed, what the server was asked, what it changed.
307
+ const trigger = dossier.timeline.find(item => item.type === 'trigger')?.trigger;
308
+ const a = t.action;
309
+ const opening = !trigger ? a.continuation : trigger.event === 'submit' ? a.submit(dossier.label) : trigger.event === 'change' ? a.change(dossier.label) : a.click(dossier.label);
310
+ const requests = timeline.filter(item => item.type === 'request' && item.browser?.sameOrigin).map(item => a.request(item.browser.method, item.browser.path, item.browser.status));
311
+ const navigations = timeline.filter(item => item.type === 'navigation' || item.type === 'document').map(item => a.navigates(item.path ?? item.page?.path));
312
+ const screens = dossier.timeline.filter(item => item.type === 'screen');
313
+ const changed = screens.some(item => { const s = item.screen ?? {}; return s.added || s.removed || s.text || s.attributes || s.title || s.stateChanged?.length; });
314
+ const all = effects(servers, lang, { cookies: servers.flatMap(server => server.request?.cookies ?? []) });
315
+ const summary = [opening, requests.length ? requests.join(', ') : a.noServer, ...new Set(navigations), changed ? a.screen : a.noScreen].join(' → ');
316
+ return { timeline, summary, effects: all, lines: servers.reduce((total, server) => total + server.digest.lines.visible, 0) };
317
+ }
package/src/home.mjs ADDED
@@ -0,0 +1,15 @@
1
+ // Where CodeTAC keeps its recordings, its database and its configuration
2
+ // (Phase 6): CODETAC_HOME; in a Git checkout of CodeTAC, its own .codetac
3
+ // folder (as in the earlier phases); installed from npm, ~/.codetac.
4
+ import { existsSync } from 'node:fs';
5
+ import { homedir } from 'node:os';
6
+ import { join, resolve } from 'node:path';
7
+ import { fileURLToPath } from 'node:url';
8
+
9
+ export const install = fileURLToPath(new URL('../', import.meta.url));
10
+
11
+ export function dataDirectory(env = process.env) {
12
+ if (env.CODETAC_HOME) return resolve(env.CODETAC_HOME);
13
+ if (existsSync(join(install, '.git'))) return join(install, '.codetac');
14
+ return join(homedir(), '.codetac');
15
+ }