@dforce2055/dai 0.13.2 → 0.14.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,80 @@
1
+ // dai · la PR/MR que YA existe para esta branch: cómo buscarla, cómo actualizarla (issue #46).
2
+ //
3
+ // El bug: con una MR ya abierta, `dai pr` pusheaba la branch (el diff quedaba al día) y
4
+ // después `glab mr create` fallaba porque la MR existía. El body NO se actualizaba y lo
5
+ // único que se veía era el comando crudo del forge, sin un error legible. Resultado: una
6
+ // MR con el diff correcto y una descripción que MIENTE sobre lo que contiene — el peor de
7
+ // los dos mundos, porque parece que salió bien.
8
+ //
9
+ // Acá está la parte pura y testeable: armar los comandos del forge y leer lo que devuelven.
10
+ // Los efectos (execFileSync) viven en dai.mjs.
11
+
12
+ // ── Buscar la PR/MR abierta de una branch ────────────────────────────────────
13
+ export function listPrCmd(tool, branch) {
14
+ if (tool === "gh") {
15
+ return ["pr", "list", "--head", branch, "--state", "open", "--limit", "5",
16
+ "--json", "number,url,title,baseRefName"];
17
+ }
18
+ return ["mr", "list", "--source-branch", branch, "--per-page", "5", "--output", "json"];
19
+ }
20
+
21
+ // Normaliza la respuesta de gh/glab a la MISMA forma: { number, url, title, base }.
22
+ // Devuelve null si no hay ninguna abierta. Tira si el JSON no parsea — quien llama decide
23
+ // si eso es fatal (no lo es: se sigue por el camino de crear, que también sabe fallar bien).
24
+ export function parsePrList(tool, stdout) {
25
+ const raw = String(stdout ?? "").trim();
26
+ if (!raw) return null;
27
+ const arr = JSON.parse(raw);
28
+ const list = Array.isArray(arr) ? arr : Array.isArray(arr?.items) ? arr.items : [];
29
+ for (const it of list) {
30
+ // glab devuelve TODAS las MR de la branch si no se filtra por estado: las cerradas y
31
+ // mergeadas no cuentan — reabrir una MR mergeada no es lo que pidió nadie.
32
+ const state = String(it.state ?? "opened").toLowerCase();
33
+ if (!["open", "opened"].includes(state)) continue;
34
+ const number = it.number ?? it.iid ?? null;
35
+ if (number == null) continue;
36
+ return {
37
+ number: Number(number),
38
+ url: it.url ?? it.web_url ?? null,
39
+ title: it.title ?? null,
40
+ base: it.baseRefName ?? it.target_branch ?? null,
41
+ };
42
+ }
43
+ return null;
44
+ }
45
+
46
+ // ── Actualizar el body/título de una PR/MR existente ─────────────────────────
47
+ // gh lee el body de un archivo; glab lo toma como string (igual que en `mr create`, que
48
+ // ya funciona así). Por eso la firma pide los dos y cada uno usa el que le sirve.
49
+ export function updatePrCmd(tool, { number, title, body, bodyFile }) {
50
+ if (tool === "gh") {
51
+ return ["pr", "edit", String(number), "--title", title, "--body-file", bodyFile];
52
+ }
53
+ return ["mr", "update", String(number), "--title", title, "--description", body, "--yes"];
54
+ }
55
+
56
+ // ── "ya existe una PR para esta branch" ──────────────────────────────────────
57
+ // El forge lo dice de formas distintas y en inglés. Se reconoce para poder pasar al camino
58
+ // de actualizar en vez de morir con el comando crudo en pantalla.
59
+ const ALREADY = [
60
+ /already exists/i,
61
+ /a merge request already exists/i,
62
+ /existing (?:pull request|merge request)/i,
63
+ /pull request for branch .* already exists/i,
64
+ /open merge request already exists/i,
65
+ ];
66
+ export function isAlreadyExistsError(msg) {
67
+ const s = String(msg ?? "");
68
+ return ALREADY.some((re) => re.test(s));
69
+ }
70
+
71
+ // Lo que se le muestra al dev cuando dai detecta la PR existente: qué va a pasar con ella.
72
+ export function describeUpdate(pr, { base, tool }) {
73
+ const lines = [`ya hay una PR/MR abierta para esta branch: #${pr.number}${pr.url ? ` — ${pr.url}` : ""}`];
74
+ if (pr.base && base && pr.base !== base) {
75
+ lines.push(`ojo: apunta a '${pr.base}' y vos pediste '${base}'. dai NO cambia la base de una PR abierta: ` +
76
+ `si querés otra base, cerrala y creá una nueva.`);
77
+ }
78
+ lines.push(`dai va a ACTUALIZAR su título y su descripción con ${tool} (el diff ya lo actualiza el push).`);
79
+ return lines;
80
+ }
package/cli/lib/pr.mjs CHANGED
@@ -2,7 +2,12 @@
2
2
  // Parte pura y testeable: rellena el template con los datos del link + git + check.
3
3
  // Los efectos (git push, gh/glab create) viven en dai.mjs.
4
4
 
5
- const EMOJI = { "al-dia": "✅ al día", atrasado: "⚠️ atrasado", "sin-us": "❓ sin US" };
5
+ // Más explícito que el label del CLI a propósito: esto queda PUBLICADO en la PR, donde
6
+ // quien lee no tiene el contexto de la corrida que la creó.
7
+ const EMOJI = {
8
+ "al-dia": "✅ al día", atrasado: "⚠️ atrasado", "sin-us": "❓ sin US",
9
+ "sin-respuesta": "⚠️ no verificado (el tracker no respondió)",
10
+ };
6
11
 
7
12
  // Las secciones que dai se compromete a entregar llenas. Si alguna sale con el molde
8
13
  // del template, la PR se publica vacía y el review no tiene qué mirar (era el bug:
@@ -115,12 +120,21 @@ export function composePrBody(template, d) {
115
120
  return upsertLinksBlock(b, d);
116
121
  }
117
122
 
123
+ // Los placeholders se rellenan DENTRO de la sección, no en todo el body: la misma frase
124
+ // ("verificado con `dai check` ✅") aparecía en la prosa que explica el método, así que un
125
+ // replace global le metía el estado de ESTA PR a una oración general y quedaba
126
+ // "verificado con dai check: ✅ al día. Sin esto, el código no sabe…". dai reescribiendo
127
+ // la prosa de otro es justo lo que el bloque de links ya evitaba con sus marcadores.
128
+ // Sin la sección (un template ajeno con otra forma) se cae al body entero: rellenar de más
129
+ // es recuperable, no rellenar deja la PR con `ABC-###` publicado.
118
130
  function fillUsHeader(template, d) {
119
- let b = template;
120
- b = b.replace(/`ABC-###`/g, `\`${d.id}\``);
121
- b = b.replace(/@ `vX`/g, `@ \`${d.version}\``);
122
- b = b.replace(/`<hash>`/g, `\`${d.ac_hash}\``);
123
- return b.replace(/verificado con `dai check` ✅/g, `verificado con \`dai check\`: ${EMOJI[d.status] || d.status}`);
131
+ const fill = (t) => t
132
+ .replace(/`ABC-###`/g, `\`${d.id}\``)
133
+ .replace(/@ `vX`/g, `@ \`${d.version}\``)
134
+ .replace(/`<hash>`/g, `\`${d.ac_hash}\``)
135
+ .replace(/verificado con `dai check` ✅/g, `verificado con \`dai check\`: ${EMOJI[d.status] || d.status}`);
136
+ const sec = sectionBody(template, "🔗 Implementa");
137
+ return sec == null ? fill(template) : replaceSection(template, "🔗 Implementa", fill(sec).trim());
124
138
  }
125
139
 
126
140
  // PR de una branch exenta (chore/, docs/, release/…): no implementa una US y no se le
@@ -108,8 +108,19 @@ export function validateUS(md) {
108
108
  // (METODOLOGIA §4). Sube cuando el cambio es material, y eso lo sabe el PO: dai
109
109
  // mirando el hash no puede distinguir un criterio nuevo de un typo corregido.
110
110
 
111
+ // La US se escribe a mano en un tracker, así que el nombre del campo llega como salga:
112
+ // `spec_version`, `spec version`, `spec-version` y —visto en un Jira real— `specversion`
113
+ // pegado. Con el `[_ ]` obligatorio de antes, `| specversion | v4 |` no matcheaba y el
114
+ // link se estampaba en `v1` sin que nada avisara (issue #46). El separador es opcional.
115
+ export const SPEC_VERSION_RE = /spec[\s_-]*version[^\n]*?\b(v\d+)\b/i;
116
+
117
+ // El valor que se escribe cuando la US NO declara versión. Un placeholder VISIBLE es
118
+ // mejor que un `v1` que parece correcto: el `v1` se publica en la PR y se estampa en el
119
+ // tracker como si fuera un dato, cuando es una suposición de dai.
120
+ export const PENDING_VERSION = "pendiente";
121
+
111
122
  export const parseSpecVersion = (md) => {
112
- const m = String(md || "").match(/spec[_ ]version[^\n]*?\b(v\d+)\b/i);
123
+ const m = String(md || "").match(SPEC_VERSION_RE);
113
124
  return m ? m[1] : null;
114
125
  };
115
126
 
@@ -123,8 +134,8 @@ export const bumpSpecVersion = (v) => {
123
134
  // al final, donde nadie lo ve, ni en una tabla que no existe.
124
135
  export function setSpecVersion(md, version) {
125
136
  const text = String(md || "");
126
- if (/spec[_ ]version[^\n]*?\bv\d+\b/i.test(text)) {
127
- return text.replace(/(spec[_ ]version[^\n]*?\b)v\d+\b/i, `$1${version}`);
137
+ if (SPEC_VERSION_RE.test(text)) {
138
+ return text.replace(/(spec[\s_-]*version[^\n]*?\b)v\d+\b/i, `$1${version}`);
128
139
  }
129
140
  const lines = text.split(/\r?\n/);
130
141
  const i = lines.findIndex((l) => /^#\s+\S/.test(l));
package/cli/lib/us.mjs CHANGED
@@ -3,21 +3,34 @@
3
3
 
4
4
  import { acHash } from "./ac-hash.mjs";
5
5
  import { extractTitle } from "./link-us.mjs";
6
+ import { parseSpecVersion } from "./us-format.mjs";
6
7
 
7
8
  // Parseo puro de una US (markdown o texto) → identidad + hash vivo.
8
9
  export function parseUS(raw) {
9
10
  const title = extractTitle(raw);
10
- const m = raw.match(/spec[_ ]version[^\n]*?\b(v\d+)\b/i);
11
- return { title, spec_version: m ? m[1] : null, ac_hash: acHash(raw) };
11
+ // El regex vive en us-format.mjs: es UNO solo para leer, escribir y bumpear la versión.
12
+ // Duplicado acá, la variante `specversion` (sin separador) se arreglaba en un lado y
13
+ // seguía rota en el otro — que es exactamente como nació el issue #46.
14
+ return { title, spec_version: parseSpecVersion(raw), ac_hash: acHash(raw) };
12
15
  }
13
16
 
14
17
  // Compara el hash estampado (implements.yaml) con el hash vivo de la US.
15
- export function coverageStatus(stampedHash, liveHash) {
18
+ //
19
+ // `sin-us` y `sin-respuesta` NO son lo mismo, y confundirlos es caro: el primero es una
20
+ // RESPUESTA del tracker (preguntamos y la US no está), el segundo es la AUSENCIA de
21
+ // respuesta (sin red, sin token, 5xx, certificado corporativo). Colapsados en un mismo
22
+ // `null`, dai terminaba AFIRMANDO que no había US cuando lo único cierto era que no había
23
+ // podido preguntar — y eso se publicaba en el cuerpo de una PR, al lado del id de la US.
24
+ export function coverageStatus(stampedHash, liveHash, { unreachable = false } = {}) {
25
+ if (unreachable) return "sin-respuesta";
16
26
  if (liveHash == null) return "sin-us";
17
27
  return stampedHash === liveHash ? "al-dia" : "atrasado";
18
28
  }
19
29
 
20
- const STATUS_LABEL = { "al-dia": "✅ al día", atrasado: "⚠️ atrasado", "sin-us": "❓ sin US" };
30
+ const STATUS_LABEL = {
31
+ "al-dia": "✅ al día", atrasado: "⚠️ atrasado",
32
+ "sin-us": "❓ sin US", "sin-respuesta": "⚠️ no verificado",
33
+ };
21
34
  export const statusLabel = (s) => STATUS_LABEL[s] || s;
22
35
 
23
36
  // Render de la cobertura como markdown (lo que un backend "estampa").
@@ -34,3 +47,25 @@ export function renderCoverage(id, r) {
34
47
  if (r.commitUrl) lines.push(`- commit: ${r.commit} → ${r.commitUrl} (ancla durable)`);
35
48
  return lines.join("\n") + "\n";
36
49
  }
50
+
51
+ // `fetch failed` es TODO lo que dice undici cuando no hay red, el host no resuelve, el DNS
52
+ // se cayó o el certificado no valida. Sin contexto es indistinguible de un bug de dai: el
53
+ // dev lee "dai: fetch failed" y no sabe ni qué se estaba consultando. Es el mismo modo de
54
+ // falla que el push por SSH de la 0.13.1 — el dato que resuelve el problema existe, y no
55
+ // llega. Acá se le pone alrededor qué, contra qué, y qué mirar.
56
+ export function explainFetchError(err, { kind, endpoint, id } = {}) {
57
+ const raw = String(err?.message ?? err ?? "").split("\n")[0] || "error desconocido";
58
+ const donde = [kind, endpoint].filter(Boolean).join(" · ");
59
+ const cabeza = `no pude consultar ${id ? `la US ${id}` : "el tracker"}${donde ? ` en ${donde}` : ""}: ${raw}`;
60
+ if (/fetch failed|ENOTFOUND|ECONNREFUSED|EAI_AGAIN|ETIMEDOUT|ECONNRESET|certificate|self.signed/i.test(raw)) {
61
+ return `${cabeza}\n No llegó a haber respuesta. Revisá la red y el host del backend; si estás detrás de un\n` +
62
+ ` proxy corporativo, declará la CA con NODE_EXTRA_CA_CERTS (nunca NODE_TLS_REJECT_UNAUTHORIZED=0).\n` +
63
+ ` Diagnóstico: dai doctor`;
64
+ }
65
+ if (/\b40[13]\b|unauthorized|forbidden/i.test(raw)) {
66
+ return `${cabeza}\n El tracker rechazó las credenciales: revisá el token del .env.dai (¿venció?) y sus permisos.\n` +
67
+ ` Diagnóstico: dai doctor`;
68
+ }
69
+ if (/\b5\d\d\b/.test(raw)) return `${cabeza}\n El error es del tracker, no tuyo: probá de nuevo en un rato.`;
70
+ return cabeza;
71
+ }
@@ -20,7 +20,21 @@ Ejemplo: `feature/ABC-482-finalizar-compra-sin-duplicado`
20
20
  - El **ID nunca se tipea a mano**: sale del argumento de `/link-us ABC-###`. Elimina
21
21
  el error de tipeo que rompe el link ([Art. 8](../docs/MANIFIESTO.md#art-8), Art. 9).
22
22
  - El **slug** deriva del título de la US: minúsculas, sin acentos ni ñ, espacios → `-`.
23
- - La **base** de la rama sigue la convención del repo (`main` o `develop`).
23
+ - La **base** de una PR no se recuerda ni se configura una por una: sale del **tipo de
24
+ rama**, leyendo las dos ramas de vida larga que el repo declara en su `.env.dai`.
25
+
26
+ | Tipo de rama | PR contra | Variable |
27
+ |---|---|---|
28
+ | `feature/`, `fix/`, el resto | la rama que integra | `DAI_BRANCH_DEV` |
29
+ | `release/`, `hotfix/` | la rama que despliega a producción | `DAI_BRANCH_PROD` |
30
+
31
+ `dai pr` y `dai done` usan ese mapa; `--base` siempre gana. Si `DAI_BRANCH_DEV` no está
32
+ declarada, dai usa la rama default del remoto y **avisa que la está adivinando**.
33
+ - Apuntarle a `DAI_BRANCH_PROD` pide una **confirmación explícita**: hay que escribir el
34
+ nombre de la rama, y con `--yes` hace falta `--to-prod`. En repos con ramas de ambiente
35
+ (`testing` integra, `main` va a producción) esto es lo que evita que una PR quede
36
+ proponiendo un despliegue que nadie pidió. Sin la variable declarada, dai no marca
37
+ ninguna rama como producción: no adivina cuál es.
24
38
  - Una rama, una US. Si una US toca varios repos, es una rama por repo, **todas con el
25
39
  mismo `ABC-###`** — así el índice las agrupa en una fila (federación).
26
40
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dforce2055/dai",
3
- "version": "0.13.2",
3
+ "version": "0.14.0",
4
4
  "description": "Metodología de desarrollo asistido por IA — CLI de acciones deterministas (trazabilidad QUÉ↔CÓMO).",
5
5
  "repository": { "type": "git", "url": "git+https://github.com/dforce2055/dai.git" },
6
6
  "homepage": "https://dforce2055.github.io/dai/",
@@ -5,19 +5,23 @@
5
5
  Lo puede pre-llenar `dai`/una skill a partir del implements.yaml y el diff.
6
6
  -->
7
7
 
8
- > **Un PR en dai entrega dos activos, y el review cubre los dos:**
9
- > 1. **La implementación** — el código que resuelve la US.
10
- > 2. **El spec trazable** — el `implements.yaml` con el link a la US y el `@version`
11
- > (`ac_hash`) verificado con `dai check` ✅. Sin esto, el código no sabe *a qué QUÉ*
12
- > responde, y el CI bloquea el PR (ver `governance/ci-rules.md`).
13
-
14
8
  ## 🔗 Implementa
15
9
 
16
10
  - **US:** `ABC-###` @ `vX` · ac_hash: `<hash>` · verificado con `dai check` ✅
17
- - **Link:** este PR está atado a la US vía `implements.yaml`.
18
11
 
19
- > Si este PR no implementa una US (chore/fix sin ticket), borra esta sección y
20
- > aclara el motivo no se le exige link.
12
+ <!--
13
+ Un PR en dai entrega DOS activos, y el review cubre los dos: la implementación (el código
14
+ que resuelve la US) y el spec trazable (el `implements.yaml` con el link a la US y el
15
+ `@version`/`ac_hash` verificado). Sin el link, el código no sabe a qué QUÉ responde y el
16
+ CI bloquea el PR — ver `governance/ci-rules.md`.
17
+
18
+ ¿Chore o fix sin ticket? No hay nada que borrar: `dai pr` lo detecta por el nombre de la
19
+ branch y escribe "Sin US" con el motivo. No se le exige link.
20
+
21
+ Esto es un comentario a propósito: es doctrina del método, igual en las 500 PRs del repo.
22
+ Repetirla a la vista en cada una entrena a saltear el principio del cuerpo, que es
23
+ justamente donde va la descripción.
24
+ -->
21
25
 
22
26
  ## Descripción
23
27