supcoflow 0.8.0 → 0.9.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/README.md +112 -16
- package/dist/api.js +53 -4
- package/dist/argumentos.js +7 -2
- package/dist/cli.js +5 -2
- package/dist/comandos/contexto.js +102 -0
- package/dist/comandos/describir.js +18 -17
- package/dist/comandos/guia.js +3 -2
- package/dist/comandos/instalacion.js +70 -28
- package/dist/comandos/probar.js +101 -7
- package/dist/comandos/proyecto-del-repo.js +11 -1
- package/dist/comandos/proyecto.js +34 -20
- package/dist/comandos/widget.js +26 -0
- package/dist/diagnostico.js +46 -1
- package/dist/entorno.js +47 -9
- package/dist/flags-por-comando.js +5 -1
- package/dist/guardado/lugar.js +63 -0
- package/dist/guardado/protegido.js +38 -0
- package/dist/guardado/repo.js +98 -0
- package/dist/idempotencia.js +15 -27
- package/dist/instalacion.js +88 -15
- package/dist/pruebas-guardadas.js +28 -16
- package/dist/repo-local.js +90 -8
- package/package.json +1 -1
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
// src/comandos/instalacion.ts
|
|
2
|
-
// `install record --skill <versión> [--app <carpeta>] [--file <ruta>] [--dry-run]`
|
|
3
|
-
// `.supcoflow/instalacion.json` con qué versiones se instaló el chat y, desde la 0.
|
|
4
|
-
// (
|
|
5
|
-
//
|
|
2
|
+
// `install record --skill <versión> [--app <carpeta>] [--file <ruta>]... [--shape widget|own-interface] [--dry-run]`
|
|
3
|
+
// (bloque 6, 0.8.0 y 0.9.0): deja en `.supcoflow/instalacion.json` con qué versiones se instaló el chat y, desde la 0.9.0
|
|
4
|
+
// (v3, T8), qué se instaló (`--shape`: el widget o una interfaz propia de la app), en qué archivos quedó (`--file`, repetible)
|
|
5
|
+
// y qué versión del contrato de presentación trae el paquete instalado (su `contract.json`). Lee la configuración del motor y
|
|
6
|
+
// los `package.json` y `contract.json` de la app y del paquete instalado; nunca ejecuta nada del repo (ni `npm ls` ni `node`).
|
|
6
7
|
//
|
|
7
8
|
// La regla de §13 («no pisa archivos ni cambios sin commit») adaptada a un archivo que tiene que poder cambiar
|
|
8
9
|
// (decisión 11): se reescribe sólo si git ya guarda el anterior. El control se hace ANTES de escribir; si la
|
|
@@ -11,11 +12,13 @@
|
|
|
11
12
|
import { lstatSync, realpathSync, statSync } from "node:fs";
|
|
12
13
|
import path from "node:path";
|
|
13
14
|
import { resultadoDeFallo, SALIDA, taparSecretos } from "../contrato.js";
|
|
14
|
-
import {
|
|
15
|
+
import { anotadoEn, armarInstalacion, leerInstalacionAnotada, esSemver, PAQUETE_DEL_CHAT, RUTA_DE_LA_INSTALACION, serializar, validarLosArchivos } from "../instalacion.js";
|
|
15
16
|
import { NO_LEIDO } from "../lectura.js";
|
|
16
17
|
import { repoPedido } from "../repo-local.js";
|
|
17
18
|
import { fallo } from "./configurar.js";
|
|
18
19
|
import { proyectoDelRepo } from "./proyecto-del-repo.js";
|
|
20
|
+
// `--shape` en inglés; al archivo va el nombre de la API (como `--mode save` y `guardar`).
|
|
21
|
+
const FORMA_DE_LA_FLAG = { widget: "widget", "own-interface": "interfaz_propia" };
|
|
19
22
|
export function esEnlaceReal(p) {
|
|
20
23
|
try {
|
|
21
24
|
return lstatSync(p).isSymbolicLink();
|
|
@@ -59,10 +62,22 @@ function leerJson(d, p, nombre) {
|
|
|
59
62
|
return { ok: false, noExiste: false, resultado: fallo(SALIDA.inesperado, "paquete_ilegible", `${nombre} isn't valid JSON.`) };
|
|
60
63
|
}
|
|
61
64
|
}
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
65
|
+
function contratoDelPaquete(d, raiz, carpetaReal, avisos) {
|
|
66
|
+
const candidato = path.join(carpetaReal, "contract.json");
|
|
67
|
+
const real = d.rutaReal(candidato);
|
|
68
|
+
if (real === null)
|
|
69
|
+
return { ok: true, valor: null };
|
|
70
|
+
if (!adentro(raiz, real)) {
|
|
71
|
+
const aviso = `${path.relative(raiz, candidato)} points outside the repo; I don't read it and I record the contract as null.`;
|
|
72
|
+
avisos.push(aviso);
|
|
73
|
+
d.avisar(aviso);
|
|
74
|
+
return { ok: true, valor: null };
|
|
75
|
+
}
|
|
76
|
+
const j = leerJson(d, real, `the contract.json of ${PAQUETE_DEL_CHAT}`);
|
|
77
|
+
if (j.ok)
|
|
78
|
+
return j;
|
|
79
|
+
return { ok: false, resultado: j.noExiste ? fallo(SALIDA.inesperado, "paquete_ilegible", `I can't read the contract.json of ${PAQUETE_DEL_CHAT}.`) : j.resultado };
|
|
80
|
+
}
|
|
66
81
|
function paqueteInstalado(d, raiz, app, avisos) {
|
|
67
82
|
for (let dir = app;; dir = path.dirname(dir)) {
|
|
68
83
|
const candidato = path.join(dir, "node_modules", ...PAQUETE_DEL_CHAT.split("/"), "package.json");
|
|
@@ -72,16 +87,18 @@ function paqueteInstalado(d, raiz, app, avisos) {
|
|
|
72
87
|
const aviso = `${path.relative(raiz, candidato)} points outside the repo; I don't read it and I record the installed chat as null.`;
|
|
73
88
|
avisos.push(aviso);
|
|
74
89
|
d.avisar(aviso);
|
|
75
|
-
return { ok: true, valor: null };
|
|
90
|
+
return { ok: true, valor: null, contrato: null };
|
|
76
91
|
}
|
|
77
92
|
const j = leerJson(d, real, `the installed package.json of ${PAQUETE_DEL_CHAT}`);
|
|
78
|
-
if (j.ok)
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
93
|
+
if (!j.ok) {
|
|
94
|
+
// Existía hace un instante: si ahora no está, tampoco se anota «no está».
|
|
95
|
+
return { ok: false, resultado: j.noExiste ? fallo(SALIDA.inesperado, "paquete_ilegible", `I can't read the installed package.json of ${PAQUETE_DEL_CHAT}.`) : j.resultado };
|
|
96
|
+
}
|
|
97
|
+
const c = contratoDelPaquete(d, raiz, path.dirname(real), avisos);
|
|
98
|
+
return c.ok ? { ok: true, valor: j.valor, contrato: c.valor } : c;
|
|
82
99
|
}
|
|
83
100
|
if (dir === raiz || path.dirname(dir) === dir)
|
|
84
|
-
return { ok: true, valor: null };
|
|
101
|
+
return { ok: true, valor: null, contrato: null };
|
|
85
102
|
}
|
|
86
103
|
}
|
|
87
104
|
async function estadoDelArchivo(d, raizGit, existe) {
|
|
@@ -123,30 +140,41 @@ function diff(antes, despues) {
|
|
|
123
140
|
}
|
|
124
141
|
return lineas.join("\n");
|
|
125
142
|
}
|
|
126
|
-
const resumen = (x) => `skill ${x.skill}, terminal ${x.terminal}, chat declared ${x.chat.declarado ?? "—"} / installed ${x.chat.instalado ?? "—"}
|
|
143
|
+
const resumen = (x) => `skill ${x.skill}, terminal ${x.terminal}, chat declared ${x.chat.declarado ?? "—"} / installed ${x.chat.instalado ?? "—"}`
|
|
144
|
+
+ `${x.forma === null ? "" : `, ${x.forma === "widget" ? "widget" : "own interface"}`}`
|
|
145
|
+
+ `${x.archivos.length === 0 ? "" : `, ${x.archivos.length === 1 ? "file" : "files"} ${x.archivos.join(", ")}`}`
|
|
146
|
+
+ `${x.contrato === null ? "" : `, contract ${x.contrato}`}`;
|
|
127
147
|
export async function instalacionAnotar(d, flags) {
|
|
128
148
|
if (flags.skill === null)
|
|
129
149
|
return fallo(SALIDA.entradaInvalida, "falta_skill", "The skill version is missing: `supcoflow install record --skill 0.1.0`.");
|
|
130
150
|
if (!esSemver(flags.skill))
|
|
131
151
|
return fallo(SALIDA.entradaInvalida, "skill_invalida", "--skill goes with a semver version (for example 0.1.0).");
|
|
132
|
-
//
|
|
133
|
-
let
|
|
134
|
-
if (flags.
|
|
135
|
-
|
|
152
|
+
// La forma y los archivos del chat se miran antes de cualquier pedido: algo imposible no gasta una vuelta al motor.
|
|
153
|
+
let formaPedida = null;
|
|
154
|
+
if (flags.shape !== undefined && flags.shape !== null) {
|
|
155
|
+
formaPedida = Object.hasOwn(FORMA_DE_LA_FLAG, flags.shape) ? FORMA_DE_LA_FLAG[flags.shape] : null;
|
|
156
|
+
if (formaPedida === null)
|
|
157
|
+
return fallo(SALIDA.entradaInvalida, "forma_invalida", "--shape goes with `widget` (the packaged widget) or `own-interface` (a chat the app builds with its own components).");
|
|
158
|
+
}
|
|
159
|
+
const crudos = flags.files !== undefined && flags.files.length > 0 ? flags.files : flags.file !== undefined && flags.file !== null ? [flags.file] : [];
|
|
160
|
+
let archivosPedidos = null;
|
|
161
|
+
if (crudos.length > 0) {
|
|
162
|
+
const v = validarLosArchivos(crudos);
|
|
136
163
|
if (!v.ok)
|
|
137
164
|
return fallo(SALIDA.entradaInvalida, "archivo_invalido", v.motivo);
|
|
138
|
-
|
|
165
|
+
archivosPedidos = v.rutas;
|
|
139
166
|
}
|
|
140
167
|
// ─── La raíz y la app, antes de cualquier pedido ───
|
|
141
168
|
let raizGit;
|
|
169
|
+
let sinClon;
|
|
142
170
|
try {
|
|
143
|
-
raizGit =
|
|
171
|
+
({ raiz: raizGit, sinClon } = await repoPedido({ flagRepo: flags.repo, git: d.git, cwd: d.cwd }));
|
|
144
172
|
}
|
|
145
173
|
catch (e) {
|
|
146
174
|
return resultadoDeFallo(e);
|
|
147
175
|
}
|
|
148
176
|
if (raizGit === null)
|
|
149
|
-
return fallo(SALIDA.entradaInvalida, "sin_clon",
|
|
177
|
+
return fallo(SALIDA.entradaInvalida, "sin_clon", `${sinClon ?? "I don't have that repo's clone here."} ${RUTA_DE_LA_INSTALACION} goes in the repo's clone: stand in it.`);
|
|
150
178
|
const raiz = d.rutaReal(raizGit);
|
|
151
179
|
if (raiz === null)
|
|
152
180
|
return fallo(SALIDA.inesperado, "inesperado", "I couldn't resolve the repo's root.");
|
|
@@ -161,7 +189,7 @@ export async function instalacionAnotar(d, flags) {
|
|
|
161
189
|
return fallo(SALIDA.entradaInvalida, "app_inexistente", "--app has to be a folder (the app's), not a file.");
|
|
162
190
|
// `--file`: ni un enlace simbólico ni un camino que pase por uno (saldría del repo sin que se vea). El archivo todavía
|
|
163
191
|
// puede no existir (con `--dry-run` se anota antes de escribirlo), pero ninguna carpeta del camino puede ser un enlace.
|
|
164
|
-
|
|
192
|
+
for (const archivoPedido of archivosPedidos ?? []) {
|
|
165
193
|
const partes = archivoPedido.split("/");
|
|
166
194
|
for (let i = 1; i <= partes.length; i++) {
|
|
167
195
|
if (d.esEnlace(path.join(raiz, ...partes.slice(0, i)))) {
|
|
@@ -196,18 +224,32 @@ export async function instalacionAnotar(d, flags) {
|
|
|
196
224
|
const actual = d.leerArchivoChico(archivo, TOPE_DEL_ARCHIVO);
|
|
197
225
|
const existe = !("error" in actual) || actual.error !== NO_LEIDO.noExiste;
|
|
198
226
|
const antes = "bytes" in actual ? Buffer.from(actual.bytes).toString("utf8") : null;
|
|
199
|
-
// Re-correr sin `--file` no borra
|
|
200
|
-
|
|
227
|
+
// Re-correr sin `--shape` ni `--file` no borra lo que ya se anotó (v2: su `archivo` es el widget; v1: nada, sin error).
|
|
228
|
+
// `--file` reemplaza la lista entera y `--shape` la forma: lo que se pide manda, lo que no, se conserva.
|
|
229
|
+
// Un archivo de una terminal más nueva no se reescribe: se perdería lo que esa versión anotó.
|
|
230
|
+
if (leerInstalacionAnotada(antes).estado === "mas_nueva") {
|
|
231
|
+
return fallo(SALIDA.haceFaltaUnaPersona, "escrito_por_una_terminal_mas_nueva", `${RUTA_DE_LA_INSTALACION} was written by a newer supcoflow than this one; I didn't touch it. Update supcoflow (\`npx supcoflow@latest\`) and run again.`);
|
|
232
|
+
}
|
|
233
|
+
const anotado = anotadoEn(antes);
|
|
234
|
+
// Cambiar la forma sin decir los archivos no hereda los de la forma anterior (el envoltorio del widget no es la interfaz propia).
|
|
235
|
+
const cambiaLaForma = formaPedida !== null && anotado.forma !== null && formaPedida !== anotado.forma;
|
|
201
236
|
let x;
|
|
202
237
|
try {
|
|
203
|
-
x = armarInstalacion({
|
|
238
|
+
x = armarInstalacion({
|
|
239
|
+
skill: flags.skill, terminal: d.terminal, paqueteDeLaApp: deLaApp.ok ? deLaApp.valor : null, paqueteInstalado: instalado.valor, configuracion,
|
|
240
|
+
forma: formaPedida ?? anotado.forma, archivos: archivosPedidos ?? (cambiaLaForma ? [] : anotado.archivos), contrato: instalado.contrato,
|
|
241
|
+
});
|
|
204
242
|
}
|
|
205
243
|
catch (e) {
|
|
206
244
|
return resultadoDeFallo(e);
|
|
207
245
|
}
|
|
208
246
|
const nuevo = serializar(x);
|
|
209
247
|
// ─── ¿Se puede escribir? ───
|
|
210
|
-
|
|
248
|
+
// `archivo` (el primero) sigue en el JSON por las skills de antes de la 0.9.0; lo nuevo es `archivos`, `forma` y `contrato`.
|
|
249
|
+
const json = {
|
|
250
|
+
ok: true, ruta: RUTA_DE_LA_INSTALACION, instalacion: x, archivo: x.archivos[0] ?? null, archivos: x.archivos, forma: x.forma, contrato: x.contrato,
|
|
251
|
+
...(avisos.length > 0 ? { avisos } : {}),
|
|
252
|
+
};
|
|
211
253
|
if ("bytes" in actual && Buffer.from(actual.bytes).equals(Buffer.from(nuevo, "utf8"))) {
|
|
212
254
|
return { codigo: SALIDA.ok, json: { ...json, ...(flags.dryRun ? { dry_run: true } : {}), sin_cambios: true }, texto: `${RUTA_DE_LA_INSTALACION} already says that (${resumen(x)}); I didn't touch it.` };
|
|
213
255
|
}
|
package/dist/comandos/probar.js
CHANGED
|
@@ -1,5 +1,30 @@
|
|
|
1
|
+
// `supcoflow diagnose` y `supcoflow test` (bloque 7, la prueba): ¿un problema que entra por el
|
|
2
|
+
// chat llega hasta un issue? El diagnóstico es gratis y sólo lee. La prueba gasta un relevamiento (techo
|
|
3
|
+
// del motor, hoy US$ 2): sin `--authorize-spend` no se manda nada que escriba (R6).
|
|
4
|
+
//
|
|
5
|
+
// Lo que la persona vio es lo que viaja (R5, 0.8.0): `--authorize-spend` exige `--ceiling` (el número que se le
|
|
6
|
+
// mostró) y `--expect-policy` (la política con la que se le ofreció) y manda esos dos valores; no relee el
|
|
7
|
+
// diagnóstico para elegirlos. El motor los compara con los de ahora: `techo_distinto` / `politica_distinta` salen
|
|
8
|
+
// 4 con los valores nuevos y sin reintentar, y la persona decide de nuevo. El alias viejo `probar --autorizo-gasto`
|
|
9
|
+
// exige también `--ceiling` (un alias no puede ampliar lo autorizado) pero no `--expect-policy`.
|
|
10
|
+
//
|
|
11
|
+
// El ciclo de la clave (hallazgo 5), en el orden en que se lee abajo:
|
|
12
|
+
// 1. Sin intento guardado: clave nueva, y el intento ENTERO guardado antes del POST (estricto).
|
|
13
|
+
// 2. Con intento guardado: es un reintento; misma clave, mismo mensaje, mismo techo, misma política. Otro
|
|
14
|
+
// `--message`, otro `--ceiling` u otra `--expect-policy` sale 4 `intento_pendiente` sin mandar nada.
|
|
15
|
+
// 3. 202: la última prueba = la del motor y el intento cerrado, en una escritura (src/pruebas-guardadas.ts).
|
|
16
|
+
// Si falla, sale 1 con el id y el intento sigue abierto: el próximo recibe la misma prueba.
|
|
17
|
+
// 4. Ambigua (sin respuesta, 5xx, pausa, otro intento en curso): el intento se conserva. Rechazo
|
|
18
|
+
// definitivo: se cierra, el motor no creó nada con esa clave.
|
|
19
|
+
// 5. Una prueba nueva es `test --authorize-spend` sin intento abierto: clave nueva aunque el mensaje
|
|
20
|
+
// sea el mismo.
|
|
21
|
+
//
|
|
22
|
+
// `--wait` sigue la prueba por tramos (≤ 90 s el comando entero, como `login --wait`): el estado lo deriva
|
|
23
|
+
// el motor en cada llamada y acá sólo se traduce a un código de salida con la tabla de la prueba.
|
|
24
|
+
import path from "node:path";
|
|
1
25
|
import { resultadoDeFallo, SALIDA } from "../contrato.js";
|
|
2
|
-
import { agruparPuntos, contarBuenos, lineaDeLoRevisado, nombreDelPunto, puntoDeSesion, puntoDelRepo, TOTAL_DE_INSTALACION } from "../diagnostico.js";
|
|
26
|
+
import { agruparPuntos, contarBuenos, lineaDeLoRevisado, nombreDelPunto, puntoDeLaInterfaz, puntoDeSesion, puntoDelRepo, TOTAL_DE_INSTALACION } from "../diagnostico.js";
|
|
27
|
+
import { leerInstalacionAnotada, RUTA_DE_LA_INSTALACION } from "../instalacion.js";
|
|
3
28
|
import { leerRepoLocal } from "../repo-local.js";
|
|
4
29
|
import { PLAZO_MINIMO_DE_SONDEO_MS, TRAMO_MAXIMO_MS } from "../sondeo.js";
|
|
5
30
|
import { fallo, rutaDe } from "./configurar.js";
|
|
@@ -44,6 +69,61 @@ function datosDelDiagnostico(g) {
|
|
|
44
69
|
};
|
|
45
70
|
}
|
|
46
71
|
const lineasDePuntos = (puntos, nombre = (p) => p) => puntos.map((p) => ` [${p.ok ? "ok" : "missing"}] ${nombre(p.punto)}: ${p.mensaje}`);
|
|
72
|
+
// Lo que esta máquina puede o no hacer con el pedido de la prueba, visto ANTES del OK de gasto y sin escribir nada. Un
|
|
73
|
+
// prechequeo que falla es «no ok», nunca un error: avisar tarde es justo lo que esto evita.
|
|
74
|
+
async function revisarLaMaquina(d, proyectoId) {
|
|
75
|
+
try {
|
|
76
|
+
return await d.pruebas.revisar(proyectoId);
|
|
77
|
+
}
|
|
78
|
+
catch {
|
|
79
|
+
return { maquina: { ok: false, impedimentos: [{ codigo: "no_se_pudo_revisar", detalle: "I couldn't check whether this machine can save the test request." }] }, intentoPendiente: null };
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
const datosDeLaMaquina = (r) => ({ maquina: r.maquina });
|
|
83
|
+
const datosDelIntentoPendiente = (r) => ({
|
|
84
|
+
intento_pendiente: r.intentoPendiente === null ? null : { techo_usd: r.intentoPendiente.techoUsd, politica: r.intentoPendiente.politica },
|
|
85
|
+
});
|
|
86
|
+
const detalles = (r) => r.maquina.impedimentos.map((i) => i.detalle).join(" ");
|
|
87
|
+
function puntoDeLaMaquina(r) {
|
|
88
|
+
return r.maquina.ok
|
|
89
|
+
? { punto: "maquina", ok: true, mensaje: "No obstacle found to saving the test request on this machine.", grupo: "prueba" }
|
|
90
|
+
: { punto: "maquina", ok: false, mensaje: `This machine can't save the test request yet: ${detalles(r)}`, grupo: "prueba" };
|
|
91
|
+
}
|
|
92
|
+
// El punto 7 (T8): lee `.supcoflow/instalacion.json` del clon y, sólo si hay una interfaz propia anotada, mira que sus archivos
|
|
93
|
+
// sigan y le pregunta al proyecto qué contrato declara hoy. Todo lo que no se pueda mirar queda «sin mirar», nunca en contra.
|
|
94
|
+
async function puntoDeLaInterfazDeEsteClon(d, local, proyecto, clave) {
|
|
95
|
+
const esElClon = local.raiz !== null && local.repo !== null && local.repo.toLowerCase() === proyecto.repo.toLowerCase();
|
|
96
|
+
if (!esElClon || d.archivos === undefined)
|
|
97
|
+
return puntoDeLaInterfaz({ lectura: null, faltan: [], delProyecto: null });
|
|
98
|
+
let lectura;
|
|
99
|
+
try {
|
|
100
|
+
lectura = leerInstalacionAnotada(d.archivos.leer(path.join(local.raiz, RUTA_DE_LA_INSTALACION)));
|
|
101
|
+
}
|
|
102
|
+
catch {
|
|
103
|
+
lectura = { estado: "ilegible" };
|
|
104
|
+
}
|
|
105
|
+
if (lectura.estado !== "leida" || lectura.forma !== "interfaz_propia")
|
|
106
|
+
return puntoDeLaInterfaz({ lectura, faltan: [], delProyecto: null });
|
|
107
|
+
const faltan = lectura.archivos.filter((f) => {
|
|
108
|
+
try {
|
|
109
|
+
const completo = path.join(local.raiz, f);
|
|
110
|
+
return d.existe !== undefined ? !d.existe(completo) : d.archivos.leer(completo) === null;
|
|
111
|
+
}
|
|
112
|
+
catch {
|
|
113
|
+
return true;
|
|
114
|
+
}
|
|
115
|
+
});
|
|
116
|
+
let delProyecto = null;
|
|
117
|
+
if (d.leerWidget !== undefined) {
|
|
118
|
+
try {
|
|
119
|
+
delProyecto = (await d.leerWidget(d.api, clave, proyecto.id)).interfaz_propia?.contrato.version ?? null;
|
|
120
|
+
}
|
|
121
|
+
catch {
|
|
122
|
+
delProyecto = null; // no se pudo preguntar: no se compara
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
return puntoDeLaInterfaz({ lectura, faltan, delProyecto });
|
|
126
|
+
}
|
|
47
127
|
export async function diagnosticar(d, flags) {
|
|
48
128
|
const p = await proyectoDelRepo(d, flags);
|
|
49
129
|
if (!p.ok)
|
|
@@ -64,16 +144,23 @@ export async function diagnosticar(d, flags) {
|
|
|
64
144
|
catch {
|
|
65
145
|
local = { repo: null, raiz: null, motivo: "sin_git" };
|
|
66
146
|
}
|
|
67
|
-
const
|
|
147
|
+
const agrupados = agruparPuntos(g, [puntoDeSesion(), puntoDelRepo(local, p.proyecto.repo), await puntoDeLaInterfazDeEsteClon(d, local, p.proyecto, p.clave)]);
|
|
148
|
+
const instalacion = agrupados.instalacion;
|
|
149
|
+
// El punto local de la prueba (1A.1): ¿esta máquina puede guardar el pedido? Si no, la prueba no entra, y se dice acá y
|
|
150
|
+
// no después del OK de gasto. No cuenta en el «N of 7» de la instalación.
|
|
151
|
+
const revision = await revisarLaMaquina(d, p.proyecto.id);
|
|
152
|
+
const prueba = [puntoDeLaMaquina(revision), ...agrupados.prueba];
|
|
68
153
|
const buenos = contarBuenos(instalacion);
|
|
69
|
-
const listo = g.listo && instalacion.every((x) => x.ok || !["sign-in", "repo"].includes(x.punto));
|
|
154
|
+
const listo = g.listo && revision.maquina.ok && instalacion.every((x) => x.ok || !["sign-in", "repo"].includes(x.punto));
|
|
70
155
|
const datos = {
|
|
71
156
|
proyecto: { id: p.proyecto.id, repo: p.proyecto.repo },
|
|
72
157
|
...datosDelDiagnostico(g),
|
|
73
158
|
listo,
|
|
74
159
|
puntos: [...instalacion, ...prueba],
|
|
75
160
|
instalacion: { ok: buenos, total: TOTAL_DE_INSTALACION, puntos: instalacion },
|
|
76
|
-
prueba: { listo: g.listo, puntos: prueba },
|
|
161
|
+
prueba: { listo: g.listo && revision.maquina.ok, puntos: prueba },
|
|
162
|
+
...datosDeLaMaquina(revision),
|
|
163
|
+
...datosDelIntentoPendiente(revision),
|
|
77
164
|
};
|
|
78
165
|
const resumen = `Monthly balance ${usdONull(g.saldoUsd)} of ${usdONull(g.topeUsd)}; a test authorizes up to ${usd(g.techoUsd)}. Policy: ${enInglesLaPolitica(g.politica)}.`;
|
|
79
166
|
const motivos = [
|
|
@@ -155,7 +242,9 @@ export async function probar(d, flags) {
|
|
|
155
242
|
catch (e) {
|
|
156
243
|
return resultadoDeFallo(e);
|
|
157
244
|
}
|
|
158
|
-
|
|
245
|
+
// Lo que esta máquina puede hacer con el pedido, visto ANTES del sí de gasto (fricción 3).
|
|
246
|
+
const revision = await revisarLaMaquina(d, proyecto.id);
|
|
247
|
+
const datos = { proyecto: { id: proyecto.id, repo: proyecto.repo }, ...datosDelDiagnostico(g), ...datosDeLaMaquina(revision) };
|
|
159
248
|
const gasto = g.politica === "guardar"
|
|
160
249
|
? `With the \`save\` policy the test goes as far as the saved case and spends nothing; the ceiling it authorizes is still ${usd(g.techoUsd)}.`
|
|
161
250
|
: `A test runs an investigation that spends up to ${usd(g.techoUsd)} (monthly balance ${usdONull(g.saldoUsd)} of ${usdONull(g.topeUsd)}).`;
|
|
@@ -180,8 +269,13 @@ export async function probar(d, flags) {
|
|
|
180
269
|
};
|
|
181
270
|
}
|
|
182
271
|
const lanzar = `supcoflow test --authorize-spend --ceiling ${g.techoUsd} --expect-policy ${enInglesLaPolitica(g.politica)}`;
|
|
183
|
-
|
|
184
|
-
|
|
272
|
+
// Primero lo que impide, si algo: es lo que la persona tiene que saber antes de decir que sí.
|
|
273
|
+
const impide = revision.maquina.ok ? "" : `This machine can't save the test request yet: ${detalles(revision)} Fix that before authorizing. `;
|
|
274
|
+
const previo = revision.intentoPendiente;
|
|
275
|
+
const pendiente = previo === null ? ""
|
|
276
|
+
: ` There's a test request waiting for confirmation (ceiling ${usd(previo.techoUsd)}, policy ${enInglesLaPolitica(previo.politica)}): to recover that same request, repeat \`${comandoDeReintento({ clave: "", mensaje: null, techoUsd: previo.techoUsd, politicaEsperada: previo.politica })}\`.`;
|
|
277
|
+
const message = `${impide}${gasto} To launch it: \`${lanzar}\`. \`supcoflow diagnose\` first says whether everything is ready.${pendiente}`;
|
|
278
|
+
return { codigo: SALIDA.haceFaltaUnaPersona, json: { ok: false, error: "falta_autorizacion", message, ...datos, ...datosDelIntentoPendiente(revision) }, texto: message };
|
|
185
279
|
}
|
|
186
280
|
// ── Con --authorize-spend: el ciclo de la clave.
|
|
187
281
|
let intento;
|
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
import { resultadoDeFallo, SALIDA } from "../contrato.js";
|
|
2
2
|
import { repoPedido } from "../repo-local.js";
|
|
3
3
|
import { claveVigente } from "./clave-vigente.js";
|
|
4
|
+
import { coherenciaDelClon, leerElArchivoDelClon } from "./contexto.js";
|
|
4
5
|
export async function proyectoDelRepo(d, flags) {
|
|
5
6
|
let repo;
|
|
7
|
+
let raiz;
|
|
6
8
|
try {
|
|
7
|
-
repo =
|
|
9
|
+
({ repo, raiz } = await repoPedido({ flagRepo: flags.repo, git: d.git, cwd: d.cwd }));
|
|
8
10
|
}
|
|
9
11
|
catch (e) {
|
|
10
12
|
return { ok: false, resultado: resultadoDeFallo(e) };
|
|
@@ -12,8 +14,16 @@ export async function proyectoDelRepo(d, flags) {
|
|
|
12
14
|
const leida = await claveVigente(d.guardado, d.api.origin);
|
|
13
15
|
if (!leida.ok)
|
|
14
16
|
return { ok: false, resultado: leida.resultado };
|
|
17
|
+
// El archivo del clon se lee ANTES de actuar: un `proyecto.json` que no se entiende frena sin tocar nada, y uno que anota
|
|
18
|
+
// otro proyecto que el que la clave ve también (ningún pedido parcial actúa sobre un proyecto distinto del que anota el clon).
|
|
19
|
+
const archivo = leerElArchivoDelClon(d.archivos, raiz);
|
|
20
|
+
if (archivo.estado === "ilegible")
|
|
21
|
+
return { ok: false, resultado: coherenciaDelClon(archivo, null, repo) };
|
|
15
22
|
try {
|
|
16
23
|
const proyecto = await d.buscarProyecto(d.api, leida.vigente.clave, repo);
|
|
24
|
+
const incoherente = coherenciaDelClon(archivo, proyecto, repo);
|
|
25
|
+
if (incoherente !== null)
|
|
26
|
+
return { ok: false, resultado: incoherente };
|
|
17
27
|
if (proyecto === null) {
|
|
18
28
|
const message = `The repo ${repo} doesn't have a project yet. Create the project: \`supcoflow project create\`.`;
|
|
19
29
|
return { ok: false, resultado: { codigo: SALIDA.haceFaltaUnaPersona, json: { ok: false, error: "sin_proyecto", message, repo }, texto: message } };
|
|
@@ -5,11 +5,9 @@ import path from "node:path";
|
|
|
5
5
|
import { resultadoDeFallo, SALIDA } from "../contrato.js";
|
|
6
6
|
import { repoPedido } from "../repo-local.js";
|
|
7
7
|
import { claveVigente } from "./clave-vigente.js";
|
|
8
|
+
import { armarElContexto, coherenciaAlCrear, coherenciaDelClon, idDelArchivo, leerElArchivoDelClon, lineaDelContexto } from "./contexto.js";
|
|
8
9
|
const PLAZO_DE_LECTURA_MS = 15_000;
|
|
9
10
|
const OPERACION = "proyecto_crear";
|
|
10
|
-
// Lo único que se lee de un `.supcoflow/proyecto.json` ajeno es su id, y sólo si tiene esta forma:
|
|
11
|
-
// así lo que salga a la terminal no puede ser otra cosa que un id (Review Focus 11).
|
|
12
|
-
const FORMA_DEL_ID = /^proj_[a-z0-9-]{1,80}$/;
|
|
13
11
|
// Los 422/409 del alta que el plan no listaba (alta-desde-conexion.ts del motor).
|
|
14
12
|
const RECHAZOS_DE_UNA_PERSONA = new Set(["instalacion_no_verificada", "repo_fuera_de_la_instalacion"]);
|
|
15
13
|
const RECHAZOS_TRANSITORIOS = new Set(["slug_ocupado", "repo_ya_conectado"]);
|
|
@@ -23,18 +21,6 @@ const comillas = (s) => `"${s.replace(/["\\]/g, "\\$&")}"`;
|
|
|
23
21
|
function contenidoDelArchivo(p) {
|
|
24
22
|
return JSON.stringify({ id: p.id, slug: p.slug, repo: p.repo }, null, 2) + "\n";
|
|
25
23
|
}
|
|
26
|
-
function idDelArchivo(crudo) {
|
|
27
|
-
try {
|
|
28
|
-
const v = JSON.parse(crudo);
|
|
29
|
-
if (typeof v !== "object" || v === null)
|
|
30
|
-
return null;
|
|
31
|
-
const id = v.id;
|
|
32
|
-
return typeof id === "string" && FORMA_DEL_ID.test(id) ? id : null;
|
|
33
|
-
}
|
|
34
|
-
catch {
|
|
35
|
-
return null;
|
|
36
|
-
}
|
|
37
|
-
}
|
|
38
24
|
// Mismo contenido aunque el formato cambie (otra indentación, otro orden): no es «otro proyecto».
|
|
39
25
|
function mismoArchivo(crudo, p) {
|
|
40
26
|
if (crudo === contenidoDelArchivo(p))
|
|
@@ -96,12 +82,24 @@ export async function proyectoCrear(d, flags) {
|
|
|
96
82
|
if (!leida.ok)
|
|
97
83
|
return leida.resultado;
|
|
98
84
|
const clave = leida.vigente.clave;
|
|
85
|
+
// Antes de conectar o crear nada (fricción 6): el archivo del clon contra lo que la clave ve, y, si va a haber un alta, el
|
|
86
|
+
// dueño del repo contra la cuenta. Sale 4 `contexto_incoherente` sin tocar nada.
|
|
87
|
+
const archivo = leerElArchivoDelClon(d.archivos, raiz);
|
|
88
|
+
if (archivo.estado === "ilegible")
|
|
89
|
+
return coherenciaDelClon(archivo, null, repo);
|
|
99
90
|
let existente;
|
|
100
91
|
let conexion = null;
|
|
101
92
|
try {
|
|
102
93
|
existente = await d.buscarProyecto(d.api, clave, repo);
|
|
103
|
-
|
|
94
|
+
const incoherente = coherenciaDelClon(archivo, existente, repo);
|
|
95
|
+
if (incoherente !== null)
|
|
96
|
+
return incoherente;
|
|
97
|
+
if (existente === null) {
|
|
98
|
+
const alCrear = coherenciaAlCrear({ yo: await d.quienSoy(d.api, clave), fuente: leida.vigente.fuente, repo });
|
|
99
|
+
if (alCrear !== null)
|
|
100
|
+
return alCrear;
|
|
104
101
|
conexion = await d.ultimaConexion(d.api, clave, repo, PLAZO_DE_LECTURA_MS);
|
|
102
|
+
}
|
|
105
103
|
}
|
|
106
104
|
catch (e) {
|
|
107
105
|
return resultadoDeFallo(e);
|
|
@@ -184,8 +182,9 @@ export async function proyectoCrear(d, flags) {
|
|
|
184
182
|
}
|
|
185
183
|
export async function proyectoVer(d, flags) {
|
|
186
184
|
let repo;
|
|
185
|
+
let raiz;
|
|
187
186
|
try {
|
|
188
|
-
repo =
|
|
187
|
+
({ repo, raiz } = await repoPedido({ flagRepo: flags.repo, git: d.git, cwd: d.cwd }));
|
|
189
188
|
}
|
|
190
189
|
catch (e) {
|
|
191
190
|
return resultadoDeFallo(e);
|
|
@@ -194,17 +193,32 @@ export async function proyectoVer(d, flags) {
|
|
|
194
193
|
if (!leida.ok)
|
|
195
194
|
return leida.resultado;
|
|
196
195
|
const clave = leida.vigente.clave;
|
|
196
|
+
const archivo = leerElArchivoDelClon(d.archivos, raiz);
|
|
197
|
+
if (archivo.estado === "ilegible")
|
|
198
|
+
return coherenciaDelClon(archivo, null, repo);
|
|
197
199
|
try {
|
|
198
200
|
const encontrado = await d.buscarProyecto(d.api, clave, repo);
|
|
201
|
+
const incoherente = coherenciaDelClon(archivo, encontrado, repo);
|
|
202
|
+
if (incoherente !== null)
|
|
203
|
+
return incoherente;
|
|
204
|
+
// Quién soy, en qué equipo, qué repo y qué proyecto: la línea que la persona lee al empezar, también cuando todo cierra.
|
|
205
|
+
const yo = await d.quienSoy(d.api, clave);
|
|
206
|
+
const contexto = armarElContexto({ yo, fuente: leida.vigente.fuente, repo, proyecto: encontrado, archivo });
|
|
207
|
+
const linea = lineaDelContexto(contexto, yo.proyecto);
|
|
199
208
|
if (encontrado === null) {
|
|
200
|
-
|
|
209
|
+
// Sin proyecto, el recorrido sigue con `repo connect`: el freno por cuenta (dueño distinto, sin GitHub) tiene que llegar ANTES,
|
|
210
|
+
// no al crear. Con proyecto existente no se compara nada, y con una clave de consulta tampoco.
|
|
211
|
+
const alCrear = coherenciaAlCrear({ yo, fuente: leida.vigente.fuente, repo });
|
|
212
|
+
if (alCrear !== null)
|
|
213
|
+
return alCrear;
|
|
214
|
+
return { codigo: SALIDA.ok, json: { ok: true, repo, proyecto: null, contexto }, texto: `${linea} This repo doesn't have a project yet: \`supcoflow project create\`.` };
|
|
201
215
|
}
|
|
202
216
|
const { proyecto: p, github } = await d.leerProyecto(d.api, clave, encontrado.id);
|
|
203
217
|
const proyecto = { id: p.id, slug: p.slug, repo: p.repo, rama: p.rama, install_command: p.installCommand, test_command: p.testCommand };
|
|
204
218
|
return {
|
|
205
219
|
codigo: SALIDA.ok,
|
|
206
|
-
json: { ok: true, proyecto, github: { estado: github.estado, verificado_en: github.verificadoEn } },
|
|
207
|
-
texto: `${p.id} (${p.repo}, branch ${p.rama}): installs with \`${p.installCommand}\`, tests with \`${p.testCommand}\`. GitHub: ${github.estado ?? "not verified"}.`,
|
|
220
|
+
json: { ok: true, proyecto, github: { estado: github.estado, verificado_en: github.verificadoEn }, contexto },
|
|
221
|
+
texto: `${linea} ${p.id} (${p.repo}, branch ${p.rama}): installs with \`${p.installCommand}\`, tests with \`${p.testCommand}\`. GitHub: ${github.estado ?? "not verified"}.`,
|
|
208
222
|
};
|
|
209
223
|
}
|
|
210
224
|
catch (e) {
|
package/dist/comandos/widget.js
CHANGED
|
@@ -1,5 +1,30 @@
|
|
|
1
1
|
import { resultadoDeFallo, SALIDA } from "../contrato.js";
|
|
2
2
|
import { proyectoDelRepo } from "./proyecto-del-repo.js";
|
|
3
|
+
// La forma «interfaz propia» (T8): sin código para pegar. La app arma el chat con sus propios componentes; esto es lo que
|
|
4
|
+
// hay que instalar y el contrato que la interfaz tiene que cumplir. Todo sale del motor, tal cual (también las rutas). Con
|
|
5
|
+
// `sin_publicar` la versión todavía no está en npm: no se ofrece el camino (ni un `npm i` que fallaría); el JSON sí lo dice.
|
|
6
|
+
function textoDeLaInterfazPropia(p) {
|
|
7
|
+
if (p.estado !== "disponible") {
|
|
8
|
+
return ["", `Own interface: not published yet (${p.paquete}@${p.version} is not on npm); use the widget above.`];
|
|
9
|
+
}
|
|
10
|
+
return [
|
|
11
|
+
"",
|
|
12
|
+
"Own interface (build the chat with the app's own components, instead of steps 2 and 3; there is no code to paste):",
|
|
13
|
+
" Install the package (the exact version):",
|
|
14
|
+
` ${p.instalar}`,
|
|
15
|
+
` tenantId: ${p.opciones_del_nucleo.tenantId}`,
|
|
16
|
+
` projectId: ${p.opciones_del_nucleo.projectId}`,
|
|
17
|
+
` backendUrl: ${p.backend_url}`,
|
|
18
|
+
" Content Security Policy (CSP): if the app has `connect-src`, add this origin and keep what it already has:",
|
|
19
|
+
` ${p.csp}`,
|
|
20
|
+
` Presentation contract: ${p.contrato.version}. Once the package is installed, read it offline (paths relative to the folder where you ran npm i):`,
|
|
21
|
+
` ${p.contrato.archivo}`,
|
|
22
|
+
` ${p.contrato.documento}`,
|
|
23
|
+
" What the interface must do is in the contract; how it looks is the app's.",
|
|
24
|
+
` Entry points: ${p.entradas.nucleo} (core), ${p.entradas.react} (React), ${p.entradas.pruebas} and ${p.entradas.servidor_de_pruebas} (test bench).`,
|
|
25
|
+
` Docs: ${p.documentos.nucleo}, ${p.documentos.react}, ${p.documentos.pruebas}, ${p.documentos.conformidad}, ${p.documentos.protocolo}.`,
|
|
26
|
+
];
|
|
27
|
+
}
|
|
3
28
|
function textoDe(w) {
|
|
4
29
|
const anteriores = w.nextjs.archivos_anteriores;
|
|
5
30
|
return [
|
|
@@ -20,6 +45,7 @@ function textoDe(w) {
|
|
|
20
45
|
"",
|
|
21
46
|
"Alternative with <script> (available, not tested in a real app):",
|
|
22
47
|
` ${w.script.linea}`,
|
|
48
|
+
...(w.interfaz_propia === undefined ? [] : textoDeLaInterfazPropia(w.interfaz_propia)),
|
|
23
49
|
].join("\n");
|
|
24
50
|
}
|
|
25
51
|
export async function widget(d, flags) {
|
package/dist/diagnostico.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
|
|
1
|
+
import { compararSemver, RUTA_DE_LA_INSTALACION } from "./instalacion.js";
|
|
2
|
+
import { frasePorQueNoAtaClon } from "./repo-local.js";
|
|
2
3
|
// El orden y los nombres con que se dicen: los ids de los puntos del motor son datos suyos y no cambian.
|
|
3
4
|
const DE_LA_INSTALACION = [
|
|
4
5
|
{ punto: "sign-in", como: "sign-in" },
|
|
@@ -7,7 +8,9 @@ const DE_LA_INSTALACION = [
|
|
|
7
8
|
{ punto: "sitios", como: "allowed sites" },
|
|
8
9
|
{ punto: "chat", como: "chat" },
|
|
9
10
|
{ punto: "guia", como: "guide" },
|
|
11
|
+
{ punto: "interfaz", como: "interface" },
|
|
10
12
|
];
|
|
13
|
+
export const TOTAL_DE_INSTALACION = DE_LA_INSTALACION.length;
|
|
11
14
|
const DEL_MOTOR = new Set(["instalacion", "sitios", "chat", "guia"]);
|
|
12
15
|
export const lineaDeLoRevisado = (n) => `Checked ${DE_LA_INSTALACION.map((x) => x.como).slice(0, -1).join(", ")} and ${DE_LA_INSTALACION.at(-1).como}: ${n} of ${TOTAL_DE_INSTALACION}, at no cost.`;
|
|
13
16
|
export const nombreDelPunto = (punto) => DE_LA_INSTALACION.find((x) => x.punto === punto)?.como ?? punto;
|
|
@@ -21,6 +24,8 @@ export function puntoDelRepo(local, repoDelProyecto) {
|
|
|
21
24
|
return mal("This folder isn't a git repo, so I can't tell which repo it is.");
|
|
22
25
|
if (local.motivo === "sin_origin")
|
|
23
26
|
return mal("This repo has no «origin» remote, so I can't tell which repo it is.");
|
|
27
|
+
if (local.motivo === "origin_alias_ssh")
|
|
28
|
+
return mal(`The «origin» of this repo isn't a GitHub repo. ${frasePorQueNoAtaClon(local, null)}`);
|
|
24
29
|
return mal("The «origin» of this repo isn't a GitHub repo.");
|
|
25
30
|
}
|
|
26
31
|
if (local.repo.toLowerCase() !== repoDelProyecto.toLowerCase()) {
|
|
@@ -28,6 +33,46 @@ export function puntoDelRepo(local, repoDelProyecto) {
|
|
|
28
33
|
}
|
|
29
34
|
return { punto: "repo", ok: true, mensaje: `The origin of this folder is ${local.repo}, the project's repo.`, grupo: "instalacion" };
|
|
30
35
|
}
|
|
36
|
+
/**
|
|
37
|
+
* El punto 7 (T8): la interfaz propia de la app. Puro: recibe lo ya leído. Es un AVISO local: baja el «N of 7» pero no frena una
|
|
38
|
+
* prueba (la prueba mira que un problema llegue a un issue, no cómo se ve el chat). Sin interfaz propia anotada no aplica y
|
|
39
|
+
* cuenta como ok. El contenido de `instalacion.json` nunca se copia al mensaje.
|
|
40
|
+
* · `lectura`: lo que dice el archivo (o `null` si no se miró: el clon no es el del repo del proyecto).
|
|
41
|
+
* · `faltan`: los archivos anotados que ya no están.
|
|
42
|
+
* · `delProyecto`: el contrato que declara el proyecto hoy (`interfaz_propia.contrato.version`), o null si no se supo.
|
|
43
|
+
*/
|
|
44
|
+
const mayor = (v) => Number(v.split(".")[0]);
|
|
45
|
+
export function puntoDeLaInterfaz(i) {
|
|
46
|
+
const punto = (ok, mensaje) => ({ punto: "interfaz", ok, mensaje, grupo: "instalacion", aviso: true });
|
|
47
|
+
if (i.lectura === null)
|
|
48
|
+
return punto(true, "Not checked: this folder isn't a clone of the project's repo, so I don't read its installation record.");
|
|
49
|
+
const l = i.lectura;
|
|
50
|
+
if (l.estado === "ausente")
|
|
51
|
+
return punto(true, `Not applicable: there's no ${RUTA_DE_LA_INSTALACION}, so no own interface is recorded.`);
|
|
52
|
+
if (l.estado === "mas_nueva")
|
|
53
|
+
return punto(false, `${RUTA_DE_LA_INSTALACION} was written by a newer supcoflow than this one: update supcoflow (\`npm i -g supcoflow@latest\` or \`npx supcoflow@latest\`) to read it.`);
|
|
54
|
+
if (l.estado === "ilegible")
|
|
55
|
+
return punto(false, `I can't read ${RUTA_DE_LA_INSTALACION}: run \`supcoflow install record\` again.`);
|
|
56
|
+
if (l.forma !== "interfaz_propia") {
|
|
57
|
+
return punto(true, l.forma === "widget" ? "Not applicable: the chat is recorded as the packaged widget, which has no interface contract to check." : "Not applicable: no own interface is recorded.");
|
|
58
|
+
}
|
|
59
|
+
if (l.archivos.length === 0)
|
|
60
|
+
return punto(false, "The installation says the chat is an own interface but records none of its files: run `supcoflow install record --file …` again.");
|
|
61
|
+
if (i.faltan.length > 0)
|
|
62
|
+
return punto(false, `These files of the interface are recorded but gone: ${i.faltan.join(", ")}. Run the skill again to update the record.`);
|
|
63
|
+
if (l.contrato === null) {
|
|
64
|
+
return punto(false, "The interface doesn't record which presentation contract it follows (the installed @supcoflow/chat has no contract.json): update the package and run `supcoflow install record` again.");
|
|
65
|
+
}
|
|
66
|
+
const archivos = `${l.archivos.length} ${l.archivos.length === 1 ? "file" : "files"}`;
|
|
67
|
+
if (i.delProyecto !== null && compararSemver(i.delProyecto, l.contrato) > 0) {
|
|
68
|
+
// Un obligatorio nuevo sólo llega con una versión MAYOR del contrato (plan §2.5.8): una menor o de parche suma lo opcional.
|
|
69
|
+
if (mayor(i.delProyecto) > mayor(l.contrato)) {
|
|
70
|
+
return punto(false, `The interface (${archivos}) follows contract ${l.contrato}, and the project now declares ${i.delProyecto}, a new major version: run the skill again to see what changed and check that the interface still conforms.`);
|
|
71
|
+
}
|
|
72
|
+
return punto(true, `The interface (${archivos}) follows contract ${l.contrato}; the project declares ${i.delProyecto}, which only adds optional points: run the skill again to see them.`);
|
|
73
|
+
}
|
|
74
|
+
return punto(true, `The interface (${archivos}) follows contract ${l.contrato}${i.delProyecto === l.contrato ? ", the one the project declares" : ""}.`);
|
|
75
|
+
}
|
|
31
76
|
/** Reparte los puntos del motor en los dos grupos y suma los dos locales al de la instalación. */
|
|
32
77
|
export function agruparPuntos(g, locales) {
|
|
33
78
|
const delMotor = g.puntos.map((p) => ({ ...p, grupo: p.grupo ?? (DEL_MOTOR.has(p.punto) ? "instalacion" : "prueba") }));
|