codetac 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,286 @@
1
+ // Deteção de projetos Python (Etapa 12 Python, especificação 3.10): sinais,
2
+ // interpretador, arranque e porta. Usado pelo detect.mjs; o caminho Node não
3
+ // muda. Uma parte Python tem os mesmos campos que uma parte Node, mais
4
+ // `language: 'python'` e `python` (interpretador, versão, módulos).
5
+ import { spawnSync } from 'node:child_process';
6
+ import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
7
+ import { basename, join, relative, sep } from 'node:path';
8
+ import { fileURLToPath } from 'node:url';
9
+
10
+ export const MINIMUM = [3, 12];
11
+ const PROBE = fileURLToPath(new URL('./python/probe.py', import.meta.url));
12
+ const SIGNALS = ['pyproject.toml', 'uv.lock', 'poetry.lock', 'Pipfile', 'Procfile', 'setup.py'];
13
+ const ENVIRONMENTS = ['.venv', 'venv', 'env', '.env'];
14
+ const SKIP = new Set(['node_modules', '__pycache__', 'site-packages', 'tests', 'test', 'migrations', 'alembic', 'docs', 'dist', 'build',
15
+ 'scripts', 'static', 'templates', 'venv', 'env']);
16
+ const PREFERRED = ['main.py', 'app.py', 'server.py', 'api.py', 'application.py', 'wsgi.py', 'asgi.py', '__init__.py'];
17
+ const DEFAULT_PORT = { FastAPI: 8000, Flask: 5000 };
18
+ // Tools a declared command may start with; they run as `python -m <tool>` with the project's interpreter.
19
+ const MODULE_TOOLS = new Set(['uvicorn', 'flask', 'fastapi', 'gunicorn', 'hypercorn']);
20
+ const RUNNERS = /^(?:uv run|poetry run|pipenv run|pdm run|rye run)\s+/;
21
+ const SERVER_OF = { uvicorn: 'uvicorn', fastapi: 'uvicorn', flask: 'werkzeug', gunicorn: 'gunicorn', hypercorn: 'hypercorn', granian: 'granian', waitress: 'waitress' };
22
+
23
+ function readText(path, limit = 200_000) {
24
+ try { return readFileSync(path, 'utf8').slice(0, limit); } catch { return ''; }
25
+ }
26
+ const isDirectory = path => { try { return statSync(path).isDirectory(); } catch { return false; } };
27
+ const binary = (environment, name) => process.platform === 'win32' ? join(environment, 'Scripts', `${name}.exe`) : join(environment, 'bin', name);
28
+
29
+ export function versionBelow(version, minimum = MINIMUM) {
30
+ const [major, minor] = String(version).split('.').map(Number);
31
+ return major < minimum[0] || (major === minimum[0] && minor < minimum[1]);
32
+ }
33
+
34
+ // A folder is a Python project with its own files (not only a virtual environment).
35
+ export function hasPythonSignals(folder) {
36
+ if (SIGNALS.some(name => existsSync(join(folder, name)))) return true;
37
+ try {
38
+ const names = readdirSync(folder);
39
+ if (names.some(name => /^requirements.*\.txt$/.test(name))) return true;
40
+ return ENVIRONMENTS.some(name => existsSync(join(folder, name, 'pyvenv.cfg'))) && names.some(name => name.endsWith('.py'));
41
+ } catch { return false; }
42
+ }
43
+
44
+ // The project's own environment: a virtual environment in the folder, or the
45
+ // one poetry or pipenv keeps elsewhere (only when the tool is installed).
46
+ export function findEnvironment(folder) {
47
+ for (const name of ENVIRONMENTS) {
48
+ const environment = join(folder, name);
49
+ if (existsSync(join(environment, 'pyvenv.cfg')) && existsSync(binary(environment, 'python'))) {
50
+ return { path: environment, interpreter: binary(environment, 'python'), source: name };
51
+ }
52
+ }
53
+ const external = [['poetry.lock', 'poetry', ['env', 'info', '--path']], ['Pipfile', 'pipenv', ['--venv']]];
54
+ for (const [file, tool, args] of external) {
55
+ if (!existsSync(join(folder, file))) continue;
56
+ const result = spawnSync(tool, args, { cwd: folder, encoding: 'utf8', timeout: 15_000 });
57
+ const path = result.status === 0 ? result.stdout.trim().split('\n').pop() : '';
58
+ if (path && existsSync(binary(path, 'python'))) return { path, interpreter: binary(path, 'python'), source: tool };
59
+ }
60
+ return null;
61
+ }
62
+
63
+ // What the interpreter has: version, modules, missing dependencies (src/python/probe.py).
64
+ export function probe(interpreter, folder) {
65
+ const result = spawnSync(interpreter, [PROBE, folder], { cwd: folder, encoding: 'utf8', timeout: 20_000,
66
+ env: { ...process.env, PYTHONPATH: '', PYTHONDONTWRITEBYTECODE: '1' } });
67
+ try { return JSON.parse(result.stdout.trim().split('\n').pop()); } catch { return null; }
68
+ }
69
+
70
+ // The application object: FastAPI(...) or Flask(...) assigned at module level.
71
+ export function findApp(folder) {
72
+ const found = [];
73
+ const visit = (directory, depth) => {
74
+ let names = [];
75
+ try { names = readdirSync(directory).sort((a, b) => (PREFERRED.indexOf(a) + 1 || 99) - (PREFERRED.indexOf(b) + 1 || 99)); } catch { return; }
76
+ for (const name of names) {
77
+ if (name.startsWith('.') || SKIP.has(name)) continue;
78
+ const path = join(directory, name);
79
+ if (name.endsWith('.py')) {
80
+ const text = readText(path);
81
+ const assigned = text.match(/^(\w+)\s*(?::[^=\n]+)?=\s*(FastAPI|Flask)\(/m);
82
+ const factory = !assigned && /\bFlask\(/.test(text) && text.match(/^def (create_app|make_app)\(/m);
83
+ if (assigned || factory) {
84
+ const file = relative(folder, path);
85
+ const module = file.replace(/\.py$/, '').replace(/[\\/]__init__$/, '').split(sep).join('.');
86
+ found.push({ file, module, variable: assigned ? assigned[1] : `${factory[1]}()`, framework: assigned ? assigned[2] : 'Flask', depth,
87
+ main: /^if __name__ == ['"]__main__['"]/m.test(text), text });
88
+ }
89
+ } else if (depth < 2 && isDirectory(path) && !existsSync(join(path, 'pyvenv.cfg'))) visit(path, depth + 1);
90
+ }
91
+ };
92
+ visit(folder, 0);
93
+ found.sort((a, b) => a.depth - b.depth);
94
+ return found[0] ?? null;
95
+ }
96
+
97
+ // Commands the project declares: Procfile, task runners in pyproject.toml,
98
+ // Makefile and README. Only those that start a Python server in development.
99
+ const SERVER_COMMAND = /^(?:(?:uv run|poetry run|pipenv run|pdm run|rye run)\s+)?(?:uvicorn\s+\S+:\S+|fastapi\s+(?:dev|run)\b|flask\b.*\brun\b|gunicorn\s|hypercorn\s|granian\s|python3?\s+(?:-\S+\s+)*[\w./-]+\.py\b|python3?\s+-m\s+(?:uvicorn|flask|fastapi|gunicorn|hypercorn))/;
100
+ const SERVES = /\b(?:app|application|server|api)\.run\(|\buvicorn\.run\(|\bserve\(|\bmake_server\(|\bsocketio\.run\(|\brun_simple\(/;
101
+ export function declaredCommands(folder) {
102
+ const found = [];
103
+ const add = (command, source) => {
104
+ const clean = command.trim().replace(/^\$\s+/, '').replace(/\s+#.*$/, '');
105
+ if (SERVER_COMMAND.test(clean) && !found.some(item => item.command === clean)) found.push({ command: clean, source });
106
+ };
107
+ const procfile = readText(join(folder, 'Procfile')).match(/^web:\s*(.+)$/m);
108
+ if (procfile) add(procfile[1], 'Procfile');
109
+ const pyproject = readText(join(folder, 'pyproject.toml'));
110
+ for (const section of pyproject.split(/^\[/m).filter(part => /^tool\.(taskipy\.tasks|poe\.tasks|pdm\.scripts)\]/.test(part))) {
111
+ for (const match of section.matchAll(/^(dev|start|run|serve|server)\s*=\s*(?:\{[^}]*?cmd\s*=\s*)?["'](.+?)["']/gm)) add(match[2], `pyproject.toml (${match[1]})`);
112
+ }
113
+ const makefile = readText(join(folder, 'Makefile'));
114
+ for (const match of makefile.matchAll(/^(dev|run|start|serve|server):[^\n]*\n((?:\t[^\n]*\n?)+)/gm)) {
115
+ for (const line of match[2].split('\n')) add(line.replace(/^\t@?/, ''), `Makefile (${match[1]})`);
116
+ }
117
+ // README: only commands for development (reload, dev, debug, flask run, python file.py).
118
+ const readme = ['README.md', 'readme.md', 'README.rst', 'README'].map(name => readText(join(folder, name))).find(Boolean) ?? '';
119
+ for (const line of readme.split('\n')) {
120
+ const clean = line.trim().replace(/^\$\s+/, '').replace(/^`|`$/g, '');
121
+ if (/--reload|\bdev\b|--debug|flask\b.*\brun\b|^python3?\s+[\w./-]+\.py/.test(clean)) add(clean, 'README');
122
+ }
123
+ // A command that points at a file or module missing here belongs to another folder;
124
+ // `python file.py` counts only when that file starts a server (not a data script).
125
+ return found.filter(({ command }) => {
126
+ const script = command.match(/python3?\s+(?:-\S+\s+)*([\w./-]+\.py)\b/)?.[1];
127
+ if (script) return existsSync(join(folder, script)) && SERVES.test(readText(join(folder, script)));
128
+ const file = command.match(/fastapi\s+(?:dev|run)\s+([\w./-]+\.py)/)?.[1];
129
+ if (file) return existsSync(join(folder, file));
130
+ const target = command.match(/(?:uvicorn|gunicorn|hypercorn|granian)\s+(?:.*?\s)?([\w.]+):\w+/)?.[1];
131
+ if (target) return existsSync(join(folder, ...target.split('.')) + '.py') || existsSync(join(folder, ...target.split('.'), '__init__.py'));
132
+ return true;
133
+ });
134
+ }
135
+
136
+ function portFromCommand(command) {
137
+ const match = command.match(/(?:--port[= ]|-p[= ]?)(\d{2,5})\b/) ?? command.match(/(?:--bind|-b)[= ]\S*:(\d{2,5})\b/)
138
+ ?? command.match(/\$\{PORT:-(\d{2,5})\}/);
139
+ return match ? Number(match[1]) : null;
140
+ }
141
+
142
+ export function pythonPort(folder, { command = '', app = null, framework = null } = {}) {
143
+ const fromCommand = portFromCommand(command);
144
+ if (fromCommand) return { value: fromCommand, source: 'comando' };
145
+ for (const name of ['.env.local', '.env.development', '.env', '.flaskenv']) {
146
+ if (isDirectory(join(folder, name))) continue;
147
+ const match = readText(join(folder, name)).match(/^\s*(?:export\s+)?(?:FLASK_RUN_|APP_|UVICORN_)?PORT\s*=\s*["']?(\d{2,5})/m);
148
+ if (match) return { value: Number(match[1]), source: name };
149
+ }
150
+ // python app.py: the port the file itself passes (app.run(port=…), uvicorn.run(…, port=…)).
151
+ if (app && /python/.test(command)) {
152
+ const match = app.text.match(/\bport\s*=\s*(?:int\(\s*os\.(?:environ\.get|getenv)\(\s*["']PORT["']\s*,\s*["']?)?(\d{2,5})/);
153
+ if (match) return { value: Number(match[1]), source: app.file };
154
+ }
155
+ const name = framework ?? app?.framework;
156
+ if (/fastapi dev|uvicorn/.test(command) || name === 'FastAPI') return { value: 8000, source: 'omissão do uvicorn' };
157
+ if (/flask/.test(command) || name === 'Flask') return { value: 5000, source: 'omissão do Flask' };
158
+ return null;
159
+ }
160
+
161
+ // The command as argv, with the project's interpreter and the port.
162
+ export function commandFor(part, port) {
163
+ const interpreter = part.python.interpreter ?? 'python3';
164
+ if (part.python.declared) {
165
+ let text = part.python.declared.replace(RUNNERS, '');
166
+ text = text.replace(/\$\{PORT(?::-\d+)?\}|\$PORT\b/g, String(port));
167
+ if (portFromCommand(text) && !/\$\{PORT/.test(part.python.declared)) {
168
+ text = text.replace(/(--port[= ]|-p[= ]?)\d{2,5}\b/, `$1${port}`).replace(/((?:--bind|-b)[= ]\S*:)\d{2,5}\b/, `$1${port}`);
169
+ }
170
+ const [first, ...rest] = text.split(/\s+/).filter(Boolean);
171
+ // A known server without a port option gets one: the port must be ours to choose.
172
+ const tool = first === 'python' || first === 'python3' ? (rest[0] === '-m' ? rest[1] : null) : first;
173
+ if (!portFromCommand(text) && !/\$\{?PORT/.test(part.python.declared)) {
174
+ if (tool === 'uvicorn' || tool === 'hypercorn' || (tool === 'fastapi' && /\b(dev|run)\b/.test(text)) || (tool === 'flask' && /\brun\b/.test(text))) rest.push('--port', String(port));
175
+ else if (tool === 'gunicorn') rest.push('--bind', `127.0.0.1:${port}`);
176
+ }
177
+ if (/^python3?$/.test(first)) return [interpreter, ...rest];
178
+ if (MODULE_TOOLS.has(first)) return [interpreter, '-m', first, ...rest];
179
+ return [first, ...rest];
180
+ }
181
+ const { app } = part.python;
182
+ switch (part.python.how) {
183
+ case 'fastapi dev': return [interpreter, '-m', 'fastapi', 'dev', app.file, '--port', String(port)];
184
+ case 'uvicorn': return [interpreter, '-m', 'uvicorn', `${app.module}:${app.variable}`, '--reload', '--port', String(port)];
185
+ case 'flask': return [interpreter, '-m', 'flask', '--app', app.variable.endsWith('()') ? `${app.module}:${app.variable}` : app.module, 'run', '--debug', '--port', String(port)];
186
+ case 'python': return [interpreter, app.file];
187
+ default: return null;
188
+ }
189
+ }
190
+
191
+ // Things to say before starting (limits of the capture for this command).
192
+ function notesFor(command, python) {
193
+ const notes = [];
194
+ const text = command.join(' ');
195
+ if (python.version && versionBelow(python.version)) {
196
+ notes.push(`O Python do projeto é o ${python.version}: a app corre em modo mínimo (pedidos, fronteiras e browser, sem seguir as funções). Para as ver, use o Python ${MINIMUM.join('.')} ou mais recente no ambiente do projeto.`);
197
+ }
198
+ const flags = text.match(/python3?(?:\.\d+)?\s+((?:-[a-zA-Z]+\s+)+)/)?.[1] ?? '';
199
+ const ignored = flags.split(/\s+/).filter(flag => /^-[a-zA-Z]*[EIS]/.test(flag));
200
+ if (ignored.length) notes.push(`O comando usa ${ignored.join(' ')}: com essa opção o Python ignora o PYTHONPATH ou o site, e a app corre sem ser observada. Retire-a para usar o CodeTAC.`);
201
+ if (/\bgranian\b/.test(text)) notes.push('O granian chama a app a partir de Rust e o CodeTAC ainda não o reconhece: a app corre, mas os pedidos não ficam com dossier. Com o uvicorn, fica tudo visível.');
202
+ if (/\bhypercorn\b/.test(text)) notes.push('O hypercorn ainda não é reconhecido: a app corre, mas os pedidos não ficam com dossier. Com o uvicorn, fica tudo visível.');
203
+ return notes;
204
+ }
205
+
206
+ // One folder with a Python project: how to start it.
207
+ export function describePythonFolder(folder, top = folder) {
208
+ if (!hasPythonSignals(folder)) return null;
209
+ const environment = findEnvironment(folder);
210
+ const probed = environment ? probe(environment.interpreter, folder) : null;
211
+ const modules = new Set(probed?.modules ?? []);
212
+ const app = findApp(folder);
213
+ const declared = declaredCommands(folder)[0] ?? null;
214
+ const django = existsSync(join(folder, 'manage.py'));
215
+ let how = null;
216
+ if (declared) how = 'declared';
217
+ else if (app?.framework === 'FastAPI') {
218
+ // Without an environment, the command is the one it will have once created.
219
+ if (!probed || (modules.has('fastapi_cli') && modules.has('fastapi.__main__'))) how = 'fastapi dev';
220
+ else if (modules.has('uvicorn')) how = 'uvicorn';
221
+ else if (app.main) how = 'python';
222
+ else how = 'uvicorn';
223
+ } else if (app?.framework === 'Flask') how = modules.has('flask') || !probed ? 'flask' : app.main ? 'python' : 'flask';
224
+ const python = { interpreter: environment?.interpreter ?? null, environment: environment?.path ?? null, source: environment?.source ?? null,
225
+ version: probed?.version ?? null, modules: [...modules], missing: probed?.missing ?? [], app, how, declared: declared?.command ?? null,
226
+ declaredSource: declared?.source ?? null, django };
227
+ const framework = app?.framework ?? (/fastapi|uvicorn/.test(declared?.command ?? '') ? 'FastAPI' : /flask/.test(declared?.command ?? '') ? 'Flask' : null);
228
+ const port = how ? pythonPort(folder, { command: declared?.command ?? (how === 'python' ? 'python' : how), app, framework }) : null;
229
+ const part = {
230
+ folder, part: relative(top, folder) || '.', name: basename(folder), language: 'python', manager: existsSync(join(folder, 'uv.lock')) ? 'uv'
231
+ : existsSync(join(folder, 'poetry.lock')) ? 'poetry' : existsSync(join(folder, 'Pipfile')) ? 'pipenv' : 'pip',
232
+ stack: framework, script: declared ? { name: declared.source, command: declared.command } : null, entry: null,
233
+ command: null, foreign: null, port, installed: Boolean(probed) && !python.missing.length, workspaces: false, python,
234
+ };
235
+ if (how) part.command = commandFor(part, port?.value ?? DEFAULT_PORT[framework] ?? 8000);
236
+ // python app.py: the server is the one the file starts, known only when it runs.
237
+ part.server = SERVER_OF[part.command?.find(token => SERVER_OF[token]) ?? ''] ?? null;
238
+ part.notes = part.command ? notesFor(part.command, python) : [];
239
+ // The configuration the app may need (cp .env.example .env): the user's to make.
240
+ if (part.command && !existsSync(join(folder, '.env')) && ['.env.example', '.env.sample', '.env.template'].some(name => existsSync(join(folder, name)))) {
241
+ part.notes.push('O projeto tem um .env.example, mas não um .env. Se a app precisar da configuração, copie-o (cp .env.example .env) e preencha-o.');
242
+ }
243
+ return part;
244
+ }
245
+
246
+ // The same part on another port (a taken port, or one the others must follow).
247
+ export function moveTo(part, port) {
248
+ const command = commandFor(part, port);
249
+ const moved = { ...part, port: { value: port, source: 'escolhida pelo CodeTAC' }, command: command ?? part.command };
250
+ // python app.py, or a declared command without a port option: the PORT variable.
251
+ if (!command || !command.join(' ').includes(String(port))) moved.env = { ...part.env, PORT: String(port) };
252
+ return moved;
253
+ }
254
+
255
+ // The environment of a Python part: its virtual environment first in PATH.
256
+ export function environmentOf(part) {
257
+ const env = { ...part.env };
258
+ if (part.python?.environment && part.python.source !== 'poetry' && part.python.source !== 'pipenv') env.VIRTUAL_ENV = part.python.environment;
259
+ if (part.python?.environment) env.PATH = [join(part.python.environment, process.platform === 'win32' ? 'Scripts' : 'bin'), process.env.PATH].join(process.platform === 'win32' ? ';' : ':');
260
+ return env;
261
+ }
262
+
263
+ // Interpreters on this computer, newest first (for a new environment).
264
+ export function interpreters() {
265
+ const names = ['python3.14', 'python3.13', 'python3.12', 'python3.11', 'python3.10', 'python3', 'python'];
266
+ const found = [];
267
+ for (const name of names) {
268
+ const result = spawnSync(name, ['-c', 'import sys; print("%d.%d.%d" % sys.version_info[:3]); print(sys.executable)'], { encoding: 'utf8', timeout: 10_000 });
269
+ if (result.status !== 0) continue;
270
+ const [version, executable] = result.stdout.trim().split('\n');
271
+ if (!found.some(item => item.executable === executable)) found.push({ name, version, executable });
272
+ }
273
+ const key = version => version.split('.').map(Number).reduce((total, part) => total * 1000 + part, 0);
274
+ return found.sort((a, b) => key(b.version) - key(a.version));
275
+ }
276
+
277
+ // The Vite proxy of a frontend: which ports it sends to, and the variable that sets each one.
278
+ export function proxyOf(folder, script = '') {
279
+ // The configuration named in the script (vite --config ./config/vite.config.ts), else the usual names.
280
+ const named = String(script).match(/--config[= ](\S+)/)?.[1];
281
+ const text = [...(named ? [named] : []), 'vite.config.ts', 'vite.config.js', 'vite.config.mjs', 'vite.config.mts'].map(name => readText(join(folder, name))).find(Boolean) ?? '';
282
+ if (!/\bproxy\b/.test(text)) return null;
283
+ const variables = [...text.matchAll(/process\.env\.(\w+)\s*(?:\?\?|\|\|)\s*['"]?(\d{2,5})/g)].map(match => ({ variable: match[1], port: Number(match[2]) }));
284
+ const literal = [...text.matchAll(/(?:localhost|127\.0\.0\.1|0\.0\.0\.0|\[::1\]):(\d{2,5})\b/g)].map(match => Number(match[1]));
285
+ return { variables, literal };
286
+ }
package/src/detect.mjs CHANGED
@@ -3,6 +3,7 @@
3
3
  // fica em `missing`, para o comando perguntar ao utilizador.
4
4
  import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
5
5
  import { basename, join, relative } from 'node:path';
6
+ import { describePythonFolder, hasPythonSignals } from './detect-python.mjs';
6
7
 
7
8
  // Ordem de preferência dos scripts de desenvolvimento.
8
9
  const SCRIPTS = ['dev', 'start:dev', 'develop', 'serve', 'dev:server', 'server', 'start'];
@@ -26,7 +27,8 @@ const STACKS = [
26
27
  ];
27
28
  const ENTRIES = ['server.js', 'server.mjs', 'server.ts', 'index.js', 'index.mjs', 'index.ts', 'app.js', 'app.ts',
28
29
  '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
+ const SKIP_FOLDERS = new Set(['node_modules', 'dist', 'build', 'out', 'coverage', 'public', 'docs', 'test', 'tests', 'e2e', 'scripts',
31
+ 'venv', 'env', '__pycache__', 'site-packages']);
30
32
 
31
33
  function readJson(path) {
32
34
  try { return JSON.parse(readFileSync(path, 'utf8')); } catch { return null; }
@@ -138,7 +140,8 @@ function childFolders(root) {
138
140
  if (name.startsWith('.') || SKIP_FOLDERS.has(name)) continue;
139
141
  const path = join(folder, name);
140
142
  try { if (!statSync(path).isDirectory()) continue; } catch { continue; }
141
- if (existsSync(join(path, 'package.json'))) found.push(path);
143
+ // A folder with its own package.json, or a Python project (api/, backend/…).
144
+ if (existsSync(join(path, 'package.json')) || hasPythonSignals(path)) found.push(path);
142
145
  else if (depth < 1) visit(path, depth + 1);
143
146
  if (depth < 1 && ['apps', 'packages'].includes(name)) visit(path, depth + 1);
144
147
  }
@@ -175,15 +178,23 @@ function plainEntry(root) {
175
178
  // package.json whose script already starts everything is enough on its own;
176
179
  // otherwise the parts with their own scripts are offered (client + server…).
177
180
  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);
181
+ const node = describeFolder(root);
182
+ // A package.json that starts nothing (only tooling) does not hide a Python app in the same folder.
183
+ const top = (node?.command ? node : null) ?? describePythonFolder(root) ?? node ?? plainEntry(root);
184
+ const parts = childFolders(root).map(folder => describeFolder(folder, root) ?? describePythonFolder(folder, root)).filter(part => part?.command);
180
185
  let start = [];
181
186
  // Without a "dev" script, several "dev:*" scripts (dev:server + dev:client…)
182
187
  // are started together: that is how such projects run in development.
183
188
  const pieces = top ? devPieces(top) : [];
184
189
  if (pieces.length > 1) start = pieces;
185
- else if (top?.command) start = [top];
190
+ // A frontend at the top and a Python API in a subfolder (backend/, api/): both run,
191
+ // unless the top script already starts Python itself.
192
+ else if (top?.command && top.language !== 'python' && !/^(python3?|uvicorn)$/.test(top.foreign ?? '') && parts.some(part => part.language === 'python')) {
193
+ start = [top, ...parts.filter(part => part.language === 'python')];
194
+ } else if (top?.command) start = [top];
186
195
  else if (parts.length === 1) start = parts;
196
+ // A frontend and a Python API (web/ + api/): both run in development.
197
+ else if (!top?.command && parts.some(part => part.language === 'python') && parts.some(part => part.language !== 'python')) start = parts;
187
198
  const missing = [];
188
199
  if (!top && !parts.length) missing.push('package');
189
200
  else if (!start.length && parts.length > 1) missing.push('part');
@@ -193,6 +204,10 @@ export function detectProject(root) {
193
204
 
194
205
  // The sentence the command shows before starting.
195
206
  export function describeStart(part) {
196
- const what = part.script ? `${part.command.join(' ')} (script «${part.script.name}»: ${part.script.command})` : part.command.join(' ');
207
+ const shown = part.language === 'python' && part.python.interpreter
208
+ ? part.command.map(token => token === part.python.interpreter ? relative(part.folder, token) || token : token) : part.command;
209
+ const what = part.language === 'python'
210
+ ? `${shown.join(' ')}${part.python.declared ? ` (de ${part.python.declaredSource})` : ''}${part.python.version ? ` · Python ${part.python.version}` : ''}`
211
+ : part.script ? `${part.command.join(' ')} (script «${part.script.name}»: ${part.script.command})` : part.command.join(' ');
197
212
  return `${part.part === '.' ? '' : `[${part.part}] `}${part.stack ? `${part.stack} · ` : ''}${what}`;
198
213
  }
package/src/diagnose.mjs CHANGED
@@ -5,6 +5,7 @@ import http from 'node:http';
5
5
  import { join, relative } from 'node:path';
6
6
  import { dataDirectory } from './home.mjs';
7
7
  import { detectProject, describeStart } from './detect.mjs';
8
+ import { MINIMUM, versionBelow } from './detect-python.mjs';
8
9
  import { recordingsOf, summarize } from './recording.mjs';
9
10
  import { describeConfig, loadConfig } from './ai.mjs';
10
11
 
@@ -18,6 +19,7 @@ const REASONS = {
18
19
  'generator-skipped': 'generators (correm, mas não aparecem)',
19
20
  'parameter-redeclaration-skipped': 'funções que redeclaram um parâmetro (correm, mas não aparecem)',
20
21
  'direct-eval-skipped': 'funções com eval direto (correm, mas não aparecem)',
22
+ 'template-lines-unmapped': 'templates sem ligação às linhas do ficheiro (aparecem como um só passo)',
21
23
  };
22
24
 
23
25
  function ping(port) {
@@ -41,6 +43,23 @@ function panelConfig(port) {
41
43
  });
42
44
  }
43
45
 
46
+ // A Python part: interpreter, version, dependencies, server and start (Etapa 12 Python).
47
+ function pythonPart(part, { ok, bad, note }) {
48
+ const label = part.part === '.' ? '' : `[${part.part}] `;
49
+ const { python } = part;
50
+ if (!python.interpreter) bad(`${label}Sem ambiente Python (.venv).`, 'codetac cria-o e instala as dependências (pergunta antes; --sim responde que sim).');
51
+ else if (!python.version) bad(`${label}O Python do ambiente (${relative(part.folder, python.interpreter)}) não respondeu.`, 'O ambiente pode estar partido: apague o .venv e volte a correr codetac.');
52
+ else if (versionBelow(python.version)) note(`${label}Python ${python.version} (${relative(part.folder, python.interpreter)}): só modo mínimo (pedidos, fronteiras e browser, sem as funções).`,
53
+ `Crie o ambiente com o Python ${MINIMUM.join('.')} ou mais recente.`);
54
+ else ok(`${label}Python ${python.version} (${relative(part.folder, python.interpreter) || python.interpreter}): consegue seguir as funções do projeto.`);
55
+ if (python.interpreter && python.missing.length) bad(`${label}Faltam dependências: ${python.missing.slice(0, 8).join(', ')}${python.missing.length > 8 ? '…' : ''}.`, 'codetac instala-as (pergunta antes).');
56
+ ok(`Arranque: ${describeStart(part)}`);
57
+ if (part.server) ok(`${label}Servidor: ${part.server}.`);
58
+ if (part.port) ok(`${label}Porta provável: ${part.port.value} (${part.port.source}). Se estiver ocupada, o comando escolhe outra.`);
59
+ // The version note is already the first line.
60
+ for (const text of (part.notes ?? []).filter(text => !text.startsWith('O Python do projeto'))) note(`${label}${text}`);
61
+ }
62
+
44
63
  export async function diagnose(root, { panelPort = 4000, out = text => process.stdout.write(`${text}\n`) } = {}) {
45
64
  let problems = 0;
46
65
  const ok = text => out(` ✓ ${text}`);
@@ -58,8 +77,12 @@ export async function diagnose(root, { panelPort = 4000, out = text => process.s
58
77
  const project = detectProject(root);
59
78
  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
79
  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>');
80
+ else if (project.missing.includes('command') && project.top?.language === 'python') {
81
+ if (project.top.python.django) bad('Projeto Django: o CodeTAC ainda não o suporta (só FastAPI e Flask).');
82
+ else bad('Projeto Python, mas não encontrei a app (FastAPI(...) ou Flask(...)) nem um comando no Procfile, Makefile ou README.', 'Indique o comando: codetac . -- uvicorn main:app --reload');
83
+ } 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
84
  for (const part of project.start.length ? project.start : project.parts) {
85
+ if (part.language === 'python') { pythonPart(part, { ok, bad, note }); continue; }
63
86
  ok(`Arranque: ${describeStart(part)}`);
64
87
  if (part.port) ok(`Porta provável: ${part.port.value} (${part.port.source}). A porta real é lida quando a app arranca.`);
65
88
  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)`);
@@ -85,16 +108,23 @@ export async function diagnose(root, { panelPort = 4000, out = text => process.s
85
108
  return problems ? 1 : 0;
86
109
  }
87
110
  const summary = summarize(last.folder);
88
- ok(`${last.name} (${new Date(last.changed).toLocaleString('pt-PT')}), ${summary.processes.size} processo(s) Node observados.`);
111
+ // Processes by runtime; a supervisor (reloader) serves nothing and is left out.
112
+ const serving = summary.starts.filter(start => !summary.supervisors.has(start.process));
113
+ const pythons = [...new Set(serving.filter(start => start.python).map(start => start.python))];
114
+ const nodes = serving.filter(start => !start.python).length;
115
+ const runtimes = [nodes && `${nodes} Node`, pythons.length && `${serving.length - nodes} Python ${pythons.join(', ')}`].filter(Boolean).join(', ');
116
+ ok(`${last.name} (${new Date(last.changed).toLocaleString('pt-PT')}), ${serving.length} processo(s) observados${runtimes ? ` (${runtimes})` : ''}.`);
117
+ if (summary.supervisors.size) ok(`${summary.supervisors.size} processo(s) supervisor do reloader (só vigiam os ficheiros; não contam).`);
118
+ if (summary.servers.size) ok(`Servidor: ${[...summary.servers].join(', ')}.`);
89
119
  const minimal = summary.starts.find(start => start.level === 'minimo');
90
120
  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
121
  'Se a app arranca sem o CodeTAC mas não com ele, envie estas linhas e as mensagens do arranque.');
92
122
  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.');
123
+ 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 nem Python (bun, deno…), não é observado.');
94
124
  if (!minimal) {
95
125
  if (summary.files) ok(`Ficheiros do projeto preparados: ${summary.files} (${summary.functions} funções).`);
96
126
  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.');
127
+ 'O servidor pode estar a correr código já empacotado sem source map, ou fora da pasta do projeto. Os dossiers mostram só pedidos e fronteiras.');
98
128
  const failed = summary.failed.length;
99
129
  if (failed) {
100
130
  note(`${failed} ficheiro(s) correm sem ser seguidos (não foi possível prepará-los):`);
@@ -112,7 +142,7 @@ export async function diagnose(root, { panelPort = 4000, out = text => process.s
112
142
  } else note('Ainda não chegou nenhum pedido.', 'Abra a app no browser e use-a.');
113
143
  if (summary.pages) ok(`Páginas servidas com a barra do CodeTAC: ${summary.pages}.`);
114
144
  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.');
145
+ 'Se a página vem de outro servidor (um processo que o CodeTAC não observa), abra-a através do servidor observado da app.');
116
146
  if (summary.actions.size) ok(`Ações do browser gravadas: ${summary.actions.size}.`);
117
147
  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
148
 
package/src/digest.mjs CHANGED
@@ -1,4 +1,4 @@
1
- // Vista agrupada do dossiê (Fase 3): árvore de passos com o ruído agrupado,
1
+ // Vista agrupada do dossier (Fase 3): árvore de passos com o ruído agrupado,
2
2
  // uma frase de finalidade por função e por fronteira, e o resumo dos efeitos
3
3
  // permanentes. Tudo deriva dos factos gravados; nada é removido, só agrupado
4
4
  // (cada grupo guarda os passos originais para a expansão).
@@ -46,7 +46,7 @@ function generated(node, root) {
46
46
  return within.split('/').slice(0, -1).some(part => part.startsWith('.'));
47
47
  }
48
48
  function trivial(node) {
49
- return node.type === 'function' && !node.children.length && !node.error && !node.detail && node.finished !== false
49
+ return node.type === 'function' && !node.opaque && !node.children.length && !node.error && !node.detail && node.finished !== false
50
50
  && (node.durationMs ?? 0) < TRIVIAL_MS && (node.endLine == null || node.endLine - node.line <= TRIVIAL_LINES);
51
51
  }
52
52
  const sum = (nodes, key = 'durationMs') => nodes.reduce((total, node) => total + (node[key] ?? 0), 0);
@@ -154,10 +154,16 @@ function blocks(nodes, ctx) {
154
154
  }
155
155
 
156
156
  // What a function did, from the boundaries in its subtree and the functions it called.
157
+ // Code compiled from a template (Jinja, and any engine whose functions keep
158
+ // the template's file): what it does is produce HTML.
159
+ const TEMPLATE_FILE = /\.(html?|jinja2?|j2|njk|twig|hbs|ejs|mustache|liquid)$/i;
160
+
157
161
  function functionSentence(node, ctx) {
158
162
  const t = texts(ctx.lang);
159
163
  const facts = collect(node);
160
164
  const parts = [];
165
+ const template = TEMPLATE_FILE.test(node.file ?? '');
166
+ if (template && !node.opaque) parts.push(t.templatePart(node.file.split('/').pop()));
161
167
  const db = facts.boundaries.filter(item => item.kind === 'base-de-dados');
162
168
  const structure = db.filter(item => STRUCTURE.has(item.operation));
163
169
  if (structure.length) parts.push(t.dbSetupPart(structure.length));
@@ -187,7 +193,9 @@ function functionSentence(node, ctx) {
187
193
  const auth = [...new Set(kinds('autenticação').map(item => item.provider ?? item.library))];
188
194
  if (auth.length) parts.push(t.authPart(names(auth, t.and)));
189
195
  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);
196
+ // An opaque step (compiled code, an unmapped template): only what it is.
197
+ if (node.opaque) parts.push(t.opaque);
198
+ else if (!facts.boundaries.length && !template) parts.push(t.noBoundary);
191
199
  if (callees.length) parts.push(t.calls(names(callees, t.and)));
192
200
  if (node.error) parts.push(t.error);
193
201
  else if (node.finished === false) parts.push(t.unfinished);
@@ -307,7 +315,13 @@ export function digestAction(dossier, { lang = 'pt-PT' } = {}) {
307
315
  const trigger = dossier.timeline.find(item => item.type === 'trigger')?.trigger;
308
316
  const a = t.action;
309
317
  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));
318
+ // Requests of the page (fetch, XHR) and those of a full navigation (a form
319
+ // that loads the next page): the server parts of each new document.
320
+ // Requests to another origin (an API on another port without a proxy, an
321
+ // outside service) are requests too: named with their host.
322
+ const requests = timeline.flatMap(item => item.type === 'request' && item.browser ? [a.request(item.browser.method,
323
+ item.browser.sameOrigin || !item.browser.host ? item.browser.path : `${item.browser.host}${item.browser.path}`, item.browser.status)]
324
+ : item.type === 'document' ? (item.server ?? []).map(part => a.request(part.request.method, part.request.path, part.request.status)) : []);
311
325
  const navigations = timeline.filter(item => item.type === 'navigation' || item.type === 'document').map(item => a.navigates(item.path ?? item.page?.path));
312
326
  const screens = dossier.timeline.filter(item => item.type === 'screen');
313
327
  const changed = screens.some(item => { const s = item.screen ?? {}; return s.added || s.removed || s.text || s.attributes || s.title || s.stateChanged?.length; });
package/src/page.mjs CHANGED
@@ -156,6 +156,22 @@ function withoutLength(headers) {
156
156
  return Object.fromEntries(Object.entries(headers).filter(([key]) => !DROP.has(key.toLowerCase())));
157
157
  }
158
158
 
159
+ // Where the tag goes: after <head ...>; in a whole document without a head,
160
+ // before <body>, or else after <html ...> or <!doctype html> (error pages such
161
+ // as werkzeug's have neither head nor body). Fragments (partial HTML
162
+ // responses) are left intact: -1. null while more of the body is needed.
163
+ // The same rule is in src/python/codetac_py/page.py.
164
+ export function tagPosition(view, final) {
165
+ const head = view.match(/<head(?:\s[^>]*)?>/i);
166
+ if (head) return head.index + head[0].length;
167
+ if (!final) return null;
168
+ const body = view.search(/<body[\s>]/i);
169
+ if (body >= 0) return body;
170
+ const opening = view.match(/^(?:\s|<!--[\s\S]*?-->)*(?:<!doctype html[^>]*>(?:\s|<!--[\s\S]*?-->)*)?<html(?:\s[^>]*)?>/i)
171
+ ?? view.match(/^(?:\s|<!--[\s\S]*?-->)*<!doctype html[^>]*>/i);
172
+ return opening ? opening[0].length : -1;
173
+ }
174
+
159
175
  export function injectScript(request, response) {
160
176
  delete request.headers['accept-encoding'];
161
177
  let mode = null; // null: undecided; 'pass'; 'buffer'; 'done'
@@ -181,15 +197,11 @@ export function injectScript(request, response) {
181
197
  const decide = self => { if (mode === null && !self.headersSent) self.writeHead(self.statusCode); if (mode === null) mode = 'pass'; };
182
198
  const toBuffer = (chunk, encoding) => Buffer.isBuffer(chunk) ? chunk : chunk instanceof Uint8Array ? Buffer.from(chunk)
183
199
  : Buffer.from(String(chunk), typeof encoding === 'string' ? encoding : 'utf8');
184
- // Inserts the tag after <head ...>, or before <body> when there is no head.
185
- // Fragments (no head/body, as in partial HTML responses) are left intact.
186
200
  function take(final) {
187
201
  const all = Buffer.concat(chunks);
188
- const view = all.toString('latin1');
189
- const head = view.match(/<head(?:\s[^>]*)?>/i);
190
- let at = head ? head.index + head[0].length : -1;
191
- if (at < 0 && (final || all.length > MAX_BUFFER)) at = view.search(/<body[\s>]/i);
192
- if (at < 0) return final || all.length > MAX_BUFFER ? all : null;
202
+ const at = tagPosition(all.toString('latin1'), final || all.length > MAX_BUFFER);
203
+ if (at === null) return null;
204
+ if (at < 0) return all;
193
205
  return Buffer.concat([all.subarray(0, at), Buffer.from(TAG), all.subarray(at)]);
194
206
  }
195
207