@dforce2055/dai 0.9.0 → 0.11.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/{.env.example → .env.dai.example} +3 -3
- package/CHANGELOG.md +148 -0
- package/README.md +47 -26
- package/VERSION +1 -1
- package/cli/dai.mjs +528 -61
- package/cli/lib/bootstrap.mjs +44 -9
- package/cli/lib/branch-scope.mjs +144 -0
- package/cli/lib/env.mjs +12 -0
- package/cli/lib/forge-api.mjs +57 -0
- package/cli/lib/pm-adapter.mjs +16 -2
- package/cli/lib/pm-clickup.mjs +18 -3
- package/cli/lib/pm-jira.mjs +23 -3
- package/cli/lib/skills-source.mjs +8 -0
- package/cli/lib/us-format.mjs +147 -0
- package/docs/EJEMPLO-END-TO-END.md +78 -46
- package/docs/MANIFIESTO.md +2 -2
- package/docs/METODOLOGIA.md +25 -15
- package/docs/PROBAR.md +25 -15
- package/docs/SCRUM-CON-IA.md +11 -11
- package/docs/adr/0003-deteccion-y-estampado-son-comandos.md +1 -1
- package/docs/adr/0006-distribucion-y-licencia.md +1 -1
- package/docs/adr/0013-skills-externas-install-from.md +11 -4
- package/docs/adr/0015-jira-corporativo.md +1 -1
- package/docs/adr/0017-env-dai.md +64 -0
- package/docs/adr/0018-alcance-de-stamp-y-gate-de-ci.md +183 -0
- package/docs/adr/README.md +2 -0
- package/docs/detalle/01-refinamiento.md +20 -5
- package/docs/detalle/03-ramas.md +2 -2
- package/docs/detalle/04-tdd.md +15 -9
- package/docs/detalle/06-code-review.md +8 -5
- package/docs/detalle/07-merge-trazabilidad.md +13 -2
- package/docs/detalle/08-daily.md +1 -1
- package/docs/detalle/README.md +1 -1
- package/docs/glosario.md +3 -3
- package/docs/guias/dev.md +31 -7
- package/docs/guias/index.md +12 -0
- package/docs/guias/lead.md +1 -1
- package/docs/guias/po.md +53 -8
- package/docs/index.md +35 -0
- package/docs/public/favicon.svg +12 -0
- package/docs/public/logo-link.svg +12 -0
- package/docs/public/logo.svg +12 -0
- package/docs/public/tutoriales/clickup-1-settings.png +0 -0
- package/docs/public/tutoriales/clickup-2-api.png +0 -0
- package/docs/public/tutoriales/clickup-3-generate-copy.png +0 -0
- package/docs/public/tutoriales/jira-1-avatar.png +0 -0
- package/docs/public/tutoriales/jira-2-seguridad-tokens.png +0 -0
- package/docs/public/tutoriales/jira-3-crear-token.png +0 -0
- package/docs/public/tutoriales/jira-4-nombre-vencimiento.png +0 -0
- package/docs/public/tutoriales/jira-5-copiar.png +0 -0
- package/docs/tutoriales/claves-ssh.md +93 -0
- package/docs/tutoriales/configurar-git.md +53 -0
- package/docs/tutoriales/index.md +18 -0
- package/docs/tutoriales/instalar-glab.md +74 -0
- package/docs/tutoriales/token-clickup.md +73 -0
- package/docs/tutoriales/token-jira.md +74 -0
- package/governance/ci-rules.md +47 -8
- package/package.json +9 -3
- package/skills/dai-review/SKILL.md +1 -1
- package/skills/grill-epic/SKILL.md +2 -2
- package/skills/grill-intent/SKILL.md +1 -1
- package/skills/grill-user-story/SKILL.md +58 -10
- package/templates/ci-dai-gate.yml +50 -0
package/cli/lib/bootstrap.mjs
CHANGED
|
@@ -47,13 +47,38 @@ function unquoteScalar(s) {
|
|
|
47
47
|
// Parsea el frontmatter YAML de un SKILL.md → { name, description, body }.
|
|
48
48
|
// Devuelve los valores YA sin comillas, para que quien los reserialice (skillToCursor)
|
|
49
49
|
// no los cite dos veces.
|
|
50
|
+
// Lee un campo del frontmatter. Soporta escalar de una línea (con/sin comillas) y BLOQUE
|
|
51
|
+
// YAML (`|` literal, `>` plegado, con chomp `-`/`+`): junta las líneas indentadas siguientes
|
|
52
|
+
// hasta la próxima clave (columna 0). El parser de dai es regex, no un YAML completo — esto
|
|
53
|
+
// cubre lo común: descripciones multilínea, que es como se escriben las skills reales.
|
|
54
|
+
function readFmField(fm, key) {
|
|
55
|
+
const lines = fm.split(/\r?\n/);
|
|
56
|
+
const keyRe = new RegExp(`^${key}:\\s*(.*)$`);
|
|
57
|
+
for (let i = 0; i < lines.length; i++) {
|
|
58
|
+
const m = lines[i].match(keyRe);
|
|
59
|
+
if (!m) continue;
|
|
60
|
+
const inline = m[1].trim();
|
|
61
|
+
const blk = inline.match(/^([|>])[-+]?\s*$/); // |, >, |-, |+, >-, >+
|
|
62
|
+
if (!blk) return unquoteScalar(inline) || null; // escalar de una línea
|
|
63
|
+
const buf = [];
|
|
64
|
+
for (let j = i + 1; j < lines.length; j++) {
|
|
65
|
+
if (lines[j].trim() === "") { buf.push(""); continue; }
|
|
66
|
+
if (!/^\s/.test(lines[j])) break; // sin indentar → empezó otra clave
|
|
67
|
+
buf.push(lines[j]);
|
|
68
|
+
}
|
|
69
|
+
const indent = ((buf.find((l) => l.trim() !== "") || "").match(/^\s*/) || [""])[0].length;
|
|
70
|
+
const text = buf.map((l) => l.slice(indent)).join("\n").replace(/\s+$/, "");
|
|
71
|
+
// `>` plegado: un salto simple → espacio; los dobles (párrafo) se conservan.
|
|
72
|
+
return (blk[1] === ">" ? text.replace(/([^\n])\n(?!\n)/g, "$1 ") : text) || null;
|
|
73
|
+
}
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
|
|
50
77
|
export function parseFrontmatter(md) {
|
|
51
78
|
const m = md.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n([\s\S]*)$/);
|
|
52
79
|
if (!m) return { name: null, description: null, body: md.trim() };
|
|
53
80
|
const fm = m[1];
|
|
54
|
-
|
|
55
|
-
const description = unquoteScalar((fm.match(/^description:\s*(.+)$/m) || [])[1]) || null;
|
|
56
|
-
return { name, description, body: m[2].trim() };
|
|
81
|
+
return { name: readFmField(fm, "name"), description: readFmField(fm, "description"), body: m[2].trim() };
|
|
57
82
|
}
|
|
58
83
|
|
|
59
84
|
// Valida el contrato MÍNIMO de un SKILL.md para que dai lo ingiera y los asistentes lo
|
|
@@ -66,7 +91,11 @@ export function validateSkill(md) {
|
|
|
66
91
|
if (!name) return "falta 'name' en el frontmatter";
|
|
67
92
|
if (!description) return "falta 'description' en el frontmatter";
|
|
68
93
|
for (const key of ["name", "description"]) {
|
|
69
|
-
const
|
|
94
|
+
const raw = rawFrontmatterValue(md, key);
|
|
95
|
+
// Un bloque YAML (`|`, `>`, con chomp) es literal → siempre válido; su contenido va en las
|
|
96
|
+
// líneas indentadas, no en el valor de la clave. No lo pasamos por el scalar-check.
|
|
97
|
+
if (/^[|>][-+]?\s*$/.test(String(raw ?? "").trim())) continue;
|
|
98
|
+
const issue = yamlScalarIssue(raw);
|
|
70
99
|
if (issue) return `'${key}' no es YAML válido: ${issue} — citá el valor con comillas dobles`;
|
|
71
100
|
}
|
|
72
101
|
return null;
|
|
@@ -98,7 +127,7 @@ export function skillToCursor(md) {
|
|
|
98
127
|
// la URL canónica que devuelve el tracker, así que el `/t/{id}` que escribíamos acá
|
|
99
128
|
// tapaba la de ClickUp con team_id. Queda como override manual para trackers raros.
|
|
100
129
|
export function envFor(pm) {
|
|
101
|
-
const head = "# Config de dai —
|
|
130
|
+
const head = "# Config de dai — va en .env.dai (NO versionado), no en el .env del equipo.\n# Completá lo que falte. NUNCA commitees tokens.\n";
|
|
102
131
|
if (pm === "clickup") {
|
|
103
132
|
return head + "DAI_PM=clickup\nDAI_CLICKUP_TOKEN=\nDAI_CLICKUP_LIST_ID=\n";
|
|
104
133
|
}
|
|
@@ -156,7 +185,10 @@ export function reconcileGitignore(text, want) {
|
|
|
156
185
|
// `.dai/` NO va acá: ahí vive config que SÍ se versiona (jira-fields.json). Solo se
|
|
157
186
|
// ignora `.dai/reviews/`, que son borradores de `dai forge review` — efímeros, con
|
|
158
187
|
// hallazgos a medio editar, y no tienen por qué viajar en un commit.
|
|
159
|
-
|
|
188
|
+
// `.env.dai` (config y secretos de dai) SÍ se ignora; el `.env` del equipo NO lo tocamos:
|
|
189
|
+
// es suyo y muchas orgs lo versionan como política (ADR-0017). La plantilla
|
|
190
|
+
// `.env.dai.example` sí se versiona (no matchea `.env.dai` exacto, así que no se ignora).
|
|
191
|
+
const ensure = [".env.dai", ".dai/reviews/"];
|
|
160
192
|
if (want.claude) { broad.add("CLAUDE.md"); broad.add(".claude"); ensure.push(".claude/settings.local.json"); }
|
|
161
193
|
if (want.cursor) { broad.add(".cursor"); }
|
|
162
194
|
let changed = false;
|
|
@@ -165,8 +197,11 @@ export function reconcileGitignore(text, want) {
|
|
|
165
197
|
if (broad.has(norm(line))) { changed = true; return false; }
|
|
166
198
|
return true;
|
|
167
199
|
});
|
|
168
|
-
|
|
169
|
-
|
|
200
|
+
// Se compara NORMALIZADO: `.dai/reviews`, `/.dai/reviews/` y `.dai/reviews/` son la
|
|
201
|
+
// misma regla para git. Comparando el texto crudo agregábamos un duplicado al repo
|
|
202
|
+
// de quien ya la había puesto a mano.
|
|
203
|
+
const have = new Set(lines.filter((l) => !l.trim().startsWith("#")).map((l) => norm(l)));
|
|
204
|
+
const add = ensure.filter((e) => !have.has(norm(e)));
|
|
170
205
|
if (add.length) {
|
|
171
206
|
changed = true;
|
|
172
207
|
if (lines.length && lines[lines.length - 1].trim() !== "") lines.push("");
|
|
@@ -199,7 +234,7 @@ export function constitution(kind) {
|
|
|
199
234
|
- **El link se autora una vez** (\`implements.yaml\`); la cobertura se **deriva** (nunca a mano).
|
|
200
235
|
- **Verifica el comportamiento, no solo que compile:** que pase el chequeo estático o el build no prueba que funcione; ejercita el flujo real antes de darlo por hecho.
|
|
201
236
|
- **La IA confirma antes de construir:** el asistente declara que entendió esta constitución y la va a obedecer antes de generar código.
|
|
202
|
-
- **Secretos:** en \`.env\` (
|
|
237
|
+
- **Secretos:** en \`.env.dai\` (NO versionado; el \`.env\` del equipo no se toca). git por **SSH**, APIs por **token scopeado**.
|
|
203
238
|
- **No bajes la seguridad para avanzar:** si una llamada falla por el certificado, declara la CA (\`NODE_EXTRA_CA_CERTS\`). **Nunca** \`NODE_TLS_REJECT_UNAUTHORIZED=0\`, \`verify=False\`, \`-k\` ni equivalentes: apagan la verificación de toda la conexión, y por ahí viajan los tokens.
|
|
204
239
|
- **Si el CLI no llega, para y dilo:** cuando \`dai\` no cubre un caso, repórtalo — no improvises una llamada a la API por fuera. El atajo publica igual, pero rompe el link QUÉ↔CÓMO en silencio y nadie se entera hasta que la trazabilidad ya está mal.
|
|
205
240
|
- **Docs vivas:** una constitución o arquitectura desactualizada es un defecto, no documentación.
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
// dai · qué US toca esta branch (governance/branch-naming.md, ADR-0004).
|
|
2
|
+
//
|
|
3
|
+
// Dos preguntas que el CLI se hacía a ojo y respondía mal:
|
|
4
|
+
// 1. `dai stamp` — ¿cuál de las US del repo estoy cerrando? (issue #22: estampaba TODAS,
|
|
5
|
+
// archivadas incluidas, así que un sprint entero recibía un comentario por PR).
|
|
6
|
+
// 2. `dai check --ci` — ¿esta branch está OBLIGADA a tener implements.yaml? Una `chore/`
|
|
7
|
+
// o una `fix/` sin US no lo está, y bloquearla sería mentirle al equipo.
|
|
8
|
+
//
|
|
9
|
+
// Todo acá es puro: entra el nombre de la branch y la lista de implements, sale la
|
|
10
|
+
// decisión. La red, git y los prompts viven en dai.mjs.
|
|
11
|
+
|
|
12
|
+
import { isPlaceholderId } from "./implements.mjs";
|
|
13
|
+
|
|
14
|
+
// Prefijos que NO son trabajo de producto: no exigen link (branch-naming.md).
|
|
15
|
+
// `fix/` es "corrección sobre una US ya implementada": puede llevar ID o no, y muchas
|
|
16
|
+
// veces es un hotfix sin ticket. Se exige link solo si el nombre trae el ID.
|
|
17
|
+
const EXEMPT = new Set(["chore", "docs", "ci", "build", "test", "refactor", "style", "release", "hotfix", "revert"]);
|
|
18
|
+
const ALWAYS = new Set(["feature", "feat"]);
|
|
19
|
+
|
|
20
|
+
// El tipo de branch = lo que hay antes de la primera `/`. Sin `/` → "" (main, develop…).
|
|
21
|
+
export function branchType(branch) {
|
|
22
|
+
const s = String(branch ?? "").trim();
|
|
23
|
+
const i = s.indexOf("/");
|
|
24
|
+
return i === -1 ? "" : s.slice(0, i).toLowerCase();
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// Los ids candidatos que aparecen en el nombre de la branch, en orden de aparición.
|
|
28
|
+
// Cubre las dos formas que emite `link-us` (ver slugify/branchName en link-us.mjs):
|
|
29
|
+
// feature/ABC-482-titulo → Jira-like (LETRAS-números)
|
|
30
|
+
// feature/86acme482-titulo → ClickUp-like (alfanumérico, sin guion)
|
|
31
|
+
// Devuelve TODOS los candidatos porque el segmento alfanumérico es ambiguo: `historia`
|
|
32
|
+
// también matchea. Quién decide es matchBranchToImplements, comparando contra las US reales.
|
|
33
|
+
export function branchIdCandidates(branch) {
|
|
34
|
+
const s = String(branch ?? "").trim();
|
|
35
|
+
const tail = s.includes("/") ? s.slice(s.indexOf("/") + 1) : s;
|
|
36
|
+
const out = [];
|
|
37
|
+
for (const m of tail.matchAll(/[A-Za-z][A-Za-z0-9_]*-\d+|[A-Za-z0-9]+/g)) out.push(m[0]);
|
|
38
|
+
return out;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Un key de tracker con pinta de tal: MAYÚSCULAS + guion + números (ABC-482). El case
|
|
42
|
+
// importa — es lo único que separa un key real de una palabra del slug con un número
|
|
43
|
+
// pegado: `feat/issues-22-26` no implementa la US "issues-22", y sugerírsela al dev es
|
|
44
|
+
// mandarlo a crear un link inventado. Los ids de ClickUp (`86acme482`) no matchean acá:
|
|
45
|
+
// no llevan guion, y para esos el nombre de la branch no alcanza — se resuelve comparando
|
|
46
|
+
// contra las US que el repo declara (matchBranchToImplements).
|
|
47
|
+
const KEYISH = /^[A-Z][A-Z0-9_]*-\d+$/;
|
|
48
|
+
|
|
49
|
+
// Los keys de tracker que nombra la branch (0, 1 o varios).
|
|
50
|
+
export function trackerKeysIn(branch) {
|
|
51
|
+
return branchIdCandidates(branch).filter((c) => KEYISH.test(c));
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// ¿Esta branch tiene que declarar una US? (gate de CI, issue #26)
|
|
55
|
+
// feature/ → siempre sí
|
|
56
|
+
// chore/, docs/, ci/… → nunca
|
|
57
|
+
// fix/ y el resto → solo si el nombre trae un ID con pinta de key de tracker
|
|
58
|
+
// `main`/`develop`/sin branch → no (no es una branch de trabajo).
|
|
59
|
+
export function requiresLink(branch) {
|
|
60
|
+
const t = branchType(branch);
|
|
61
|
+
if (ALWAYS.has(t)) return { required: true, reason: `'${t}/' es trabajo de producto: requiere US` };
|
|
62
|
+
if (EXEMPT.has(t)) return { required: false, reason: `'${t}/' está exenta de US (governance/branch-naming.md)` };
|
|
63
|
+
if (t === "") return { required: false, reason: "sin prefijo tipo/, no es una branch de trabajo: sin gate de link" };
|
|
64
|
+
// Tipo desconocido (fix/, spike/, lo que el repo use): si nombró un ID, lo tomamos
|
|
65
|
+
// como intención de implementar una US y se lo exigimos. Si no, no inventamos.
|
|
66
|
+
const keyish = trackerKeysIn(branch).length > 0;
|
|
67
|
+
return keyish
|
|
68
|
+
? { required: true, reason: `'${t}/' con un ID en el nombre: se toma como trabajo de producto` }
|
|
69
|
+
: { required: false, reason: `'${t}/' sin ID en el nombre: no exige US (governance/branch-naming.md)` };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Aplana los implements descubiertos a filas { path, change, repo, id, version, ac_hash },
|
|
73
|
+
// salteando los placeholders de plantilla (ABC-###).
|
|
74
|
+
export function flattenImplements(found) {
|
|
75
|
+
const rows = [];
|
|
76
|
+
for (const f of found || []) {
|
|
77
|
+
for (const im of f.implements || []) {
|
|
78
|
+
if (isPlaceholderId(im.id)) continue;
|
|
79
|
+
rows.push({ path: f.path, change: f.change, repo: f.repo, id: im.id, version: im.version, ac_hash: im.ac_hash });
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
return rows;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// ¿Cuál de estas US es la de la branch? Compara sin distinguir mayúsculas, porque el
|
|
86
|
+
// slug de la branch va en minúsculas y el key de Jira en mayúsculas (ABC-482 → abc-482).
|
|
87
|
+
export function matchBranchToImplements(branch, rows) {
|
|
88
|
+
const cands = branchIdCandidates(branch).map((c) => c.toLowerCase());
|
|
89
|
+
if (cands.length === 0) return [];
|
|
90
|
+
const seen = new Set();
|
|
91
|
+
const hit = [];
|
|
92
|
+
for (const c of cands) {
|
|
93
|
+
for (const r of rows) {
|
|
94
|
+
if (String(r.id).toLowerCase() === c && !seen.has(r.path + " " + r.id)) {
|
|
95
|
+
seen.add(r.path + " " + r.id);
|
|
96
|
+
hit.push(r);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
return hit;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// La decisión completa de `dai stamp`: qué estampar y por qué.
|
|
104
|
+
//
|
|
105
|
+
// { mode, targets, candidates, reason }
|
|
106
|
+
//
|
|
107
|
+
// mode "explicit" → los ids que pidió el usuario (dai stamp ABC-482)
|
|
108
|
+
// mode "all" → --all: todo, archivadas incluidas (el comportamiento viejo)
|
|
109
|
+
// mode "branch" → la branch nombra una US del repo: esa
|
|
110
|
+
// mode "only" → hay una sola US viva: esa
|
|
111
|
+
// mode "ambiguous"→ varias y ninguna pista: NO estampa, pide confirmación
|
|
112
|
+
// mode "none" → no hay nada que estampar
|
|
113
|
+
//
|
|
114
|
+
// `rows` viene de flattenImplements sobre el discover SIN archivados; `allRows` incluye
|
|
115
|
+
// los archivados y solo se usa para --all y para resolver un id explícito de un change
|
|
116
|
+
// ya archivado (estampar después del merge es exactamente ese caso).
|
|
117
|
+
export function stampScope({ branch, rows, allRows = rows, ids = [], all = false }) {
|
|
118
|
+
if (all) {
|
|
119
|
+
return { mode: "all", targets: allRows, candidates: allRows, reason: "--all: todas las US del repo (archivadas incluidas)" };
|
|
120
|
+
}
|
|
121
|
+
if (ids.length) {
|
|
122
|
+
const want = ids.map((i) => String(i).toLowerCase());
|
|
123
|
+
const targets = allRows.filter((r) => want.includes(String(r.id).toLowerCase()));
|
|
124
|
+
// `missing` conserva la grafía que tipeó el usuario: si le devolvemos su ABC-404 en
|
|
125
|
+
// minúsculas, lo primero que hace es dudar de si el problema era el case.
|
|
126
|
+
const missing = ids.filter((i) => !allRows.some((r) => String(r.id).toLowerCase() === String(i).toLowerCase()));
|
|
127
|
+
return { mode: "explicit", targets, candidates: allRows, missing, reason: `ids pedidos: ${ids.join(", ")}` };
|
|
128
|
+
}
|
|
129
|
+
if (rows.length === 0) return { mode: "none", targets: [], candidates: [], reason: "no hay implements.yaml vivos para estampar" };
|
|
130
|
+
|
|
131
|
+
const hit = matchBranchToImplements(branch, rows);
|
|
132
|
+
if (hit.length === 1) {
|
|
133
|
+
return { mode: "branch", targets: hit, candidates: rows, reason: `la branch '${branch}' nombra ${hit[0].id}` };
|
|
134
|
+
}
|
|
135
|
+
if (rows.length === 1) {
|
|
136
|
+
return { mode: "only", targets: rows, candidates: rows, reason: "es la única US viva del repo" };
|
|
137
|
+
}
|
|
138
|
+
return {
|
|
139
|
+
mode: "ambiguous",
|
|
140
|
+
targets: [],
|
|
141
|
+
candidates: hit.length > 1 ? hit : rows,
|
|
142
|
+
reason: `hay ${hit.length > 1 ? hit.length : rows.length} US vivas y la branch '${branch}' no dice cuál`,
|
|
143
|
+
};
|
|
144
|
+
}
|
package/cli/lib/env.mjs
CHANGED
|
@@ -4,6 +4,18 @@
|
|
|
4
4
|
|
|
5
5
|
import { readFileSync } from "node:fs";
|
|
6
6
|
|
|
7
|
+
// Config de dai: `.env.dai` (propio, nunca versionado) tiene prioridad sobre `.env`.
|
|
8
|
+
// Pensado para equipos que versionan el `.env` (política de empresa): dai deja ese
|
|
9
|
+
// archivo en paz y pone sus claves y secretos en `.env.dai` (gitignored). Se carga
|
|
10
|
+
// `.env.dai` PRIMERO porque el loader es "primero-gana" (ver más abajo), así la
|
|
11
|
+
// precedencia queda: entorno (shell/CI) > .env.dai > .env. Leer también `.env`
|
|
12
|
+
// mantiene compatibilidad con repos previos que tienen los DAI_* ahí.
|
|
13
|
+
export function loadDaiEnv(env = process.env) {
|
|
14
|
+
loadEnv(".env.dai", env);
|
|
15
|
+
loadEnv(".env", env);
|
|
16
|
+
return env;
|
|
17
|
+
}
|
|
18
|
+
|
|
7
19
|
export function loadEnv(path = ".env", env = process.env) {
|
|
8
20
|
let text;
|
|
9
21
|
try { text = readFileSync(path, "utf8"); } catch { return env; } // sin .env: no pasa nada
|
package/cli/lib/forge-api.mjs
CHANGED
|
@@ -72,6 +72,10 @@ export function inlinePosition(ref, c, { diffRefs } = {}) {
|
|
|
72
72
|
return { body: c.body, position };
|
|
73
73
|
}
|
|
74
74
|
|
|
75
|
+
// La variable de entorno que autentica contra este forge, y su valor.
|
|
76
|
+
export function tokenVar(ref) { return ref.forge === "gitlab" ? "GITLAB_TOKEN" : "GITHUB_TOKEN"; }
|
|
77
|
+
export function tokenFor(ref, env = process.env) { return String(env[tokenVar(ref)] || "").trim(); }
|
|
78
|
+
|
|
75
79
|
export function authHeaders(ref, env = process.env) {
|
|
76
80
|
if (ref.forge === "github") {
|
|
77
81
|
return { Authorization: `Bearer ${env.GITHUB_TOKEN || ""}`, Accept: "application/vnd.github+json", "User-Agent": "dai" };
|
|
@@ -79,6 +83,59 @@ export function authHeaders(ref, env = process.env) {
|
|
|
79
83
|
return { "PRIVATE-TOKEN": env.GITLAB_TOKEN || "" };
|
|
80
84
|
}
|
|
81
85
|
|
|
86
|
+
// Traduce el fallo del forge a algo accionable (issue #24).
|
|
87
|
+
//
|
|
88
|
+
// El mensaje viejo era `¿token? ¿ref correcta?` para TODO: sin token, token vencido,
|
|
89
|
+
// token sin scope, PR inexistente y repo privado caían en la misma frase. Costó una
|
|
90
|
+
// sesión de diagnóstico equivocado. Cada causa tiene una acción distinta, así que se
|
|
91
|
+
// nombran por separado — y "no hay token" se detecta ANTES de salir a la red.
|
|
92
|
+
//
|
|
93
|
+
// Ojo con el 404 de GitHub: en un repo privado, un token sin permiso devuelve 404 (no
|
|
94
|
+
// 403) para no filtrar la existencia del repo. Por eso el 404 nombra las dos causas.
|
|
95
|
+
export function describeForgeError(ref, { status = null, body = "", env = process.env, cause = null } = {}) {
|
|
96
|
+
const v = tokenVar(ref);
|
|
97
|
+
const has = tokenFor(ref, env) !== "";
|
|
98
|
+
const where = `${ref.projectPath}#${ref.number} (${ref.host})`;
|
|
99
|
+
const setIt = ` Configuralo en tu .env.dai (o exportalo en la shell): ${v}=<token>`;
|
|
100
|
+
|
|
101
|
+
if (!has) {
|
|
102
|
+
return `no hay ${v} configurado, así que la llamada al forge salió sin credencial.\n` +
|
|
103
|
+
`${setIt}\n` +
|
|
104
|
+
(ref.forge === "github"
|
|
105
|
+
? " Token: https://github.com/settings/tokens · scope 'repo' (o fine-grained con Pull requests: read+write)."
|
|
106
|
+
: " Token: <tu-gitlab>/-/user_settings/personal_access_tokens · scope 'api'.");
|
|
107
|
+
}
|
|
108
|
+
if (status === 401) {
|
|
109
|
+
return `el forge rechazó tu ${v} (401): el token existe pero NO es válido — vencido, revocado, o mal copiado.\n` +
|
|
110
|
+
` Probalo: ${ref.forge === "github"
|
|
111
|
+
? "curl -sI -H \"Authorization: Bearer $GITHUB_TOKEN\" https://api.github.com/user"
|
|
112
|
+
: `curl -sI -H "PRIVATE-TOKEN: $GITLAB_TOKEN" https://${ref.host}/api/v4/user`}\n` +
|
|
113
|
+
` Si da 200, el token sirve y el problema es otro. Si da 401, generá uno nuevo.\n${setIt}`;
|
|
114
|
+
}
|
|
115
|
+
if (status === 403) {
|
|
116
|
+
const rate = /rate limit|api rate/i.test(String(body));
|
|
117
|
+
return rate
|
|
118
|
+
? `el forge te frenó por rate limit (403). Esperá unos minutos, o usá un ${v} con más cuota.`
|
|
119
|
+
: `tu ${v} es válido pero NO tiene permiso sobre ${where} (403).\n` +
|
|
120
|
+
` Le falta scope (github: 'repo' / fine-grained con Pull requests read+write · gitlab: 'api'),\n` +
|
|
121
|
+
" o tu usuario no tiene acceso a ese repo.";
|
|
122
|
+
}
|
|
123
|
+
if (status === 404) {
|
|
124
|
+
return `el forge no encontró ${where} (404). Dos causas posibles, y no se distinguen desde afuera:\n` +
|
|
125
|
+
" 1. La PR/MR no existe con ese número en ese repo (revisá la ref).\n" +
|
|
126
|
+
` 2. El repo es privado y tu ${v} no tiene permiso — GitHub devuelve 404, no 403, para no filtrar que existe.`;
|
|
127
|
+
}
|
|
128
|
+
if (status != null) return `el forge respondió ${status} sobre ${where}.${body ? `\n ${String(body).slice(0, 400)}` : ""}`;
|
|
129
|
+
return `no pude hablar con ${ref.host}${cause ? `: ${cause}` : ""}. ¿Hay red / proxy / VPN de por medio?`;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// El error que tiran getPR/postComment/postReview lleva el status pegado al mensaje
|
|
133
|
+
// (`forge 401: {...}`). Esto lo vuelve a separar para poder explicarlo.
|
|
134
|
+
export function parseForgeError(e) {
|
|
135
|
+
const m = String(e?.message || e || "").match(/^forge (\d{3}): ([\s\S]*)$/);
|
|
136
|
+
return m ? { status: Number(m[1]), body: m[2] } : { status: null, body: "", cause: String(e?.message || e || "") };
|
|
137
|
+
}
|
|
138
|
+
|
|
82
139
|
// Comentario estándar de dai-review (mismo formato lo emita el CLI o la skill).
|
|
83
140
|
export function renderReviewComment(r) {
|
|
84
141
|
const { us, version, checkStatus, dod, errors = [], improvements = [], good = [] } = r;
|
package/cli/lib/pm-adapter.mjs
CHANGED
|
@@ -7,12 +7,17 @@
|
|
|
7
7
|
// clickup — REST v2 (pm-clickup.mjs)
|
|
8
8
|
//
|
|
9
9
|
// Interfaz (fetchUS/stamp pueden ser sync o async — el CLI siempre await-ea):
|
|
10
|
-
// fetchUS(id) → { id, title, spec_version, ac_hash, url } | null
|
|
10
|
+
// fetchUS(id) → { id, title, spec_version, ac_hash, url, raw } | null
|
|
11
11
|
// stamp(id, record) → destino donde quedó la cobertura
|
|
12
|
+
// createUS({...}) → { id, url } (opcional: `dai publish`)
|
|
13
|
+
// updateUS(id, {...}) → { id, url } (opcional: `dai update-us`)
|
|
12
14
|
// kind → nombre del backend
|
|
13
15
|
//
|
|
14
16
|
// `url` es la URL web canónica de la US según el tracker (opcional: null si el backend
|
|
15
17
|
// no la sabe, como md). El CLI la prefiere sobre la derivada — ver tracker-url.mjs.
|
|
18
|
+
// `raw` es el markdown COMPLETO de la US tal como vive en el backend: es lo que
|
|
19
|
+
// `dai edit-us` baja y te abre. Sin esto solo teníamos el parseo (título + hash), que
|
|
20
|
+
// alcanza para detectar drift pero no para editar.
|
|
16
21
|
|
|
17
22
|
import { readFileSync, writeFileSync, mkdirSync, existsSync } from "node:fs";
|
|
18
23
|
import { join, dirname } from "node:path";
|
|
@@ -32,7 +37,8 @@ function mdAdapter(env) {
|
|
|
32
37
|
fetchUS(id) {
|
|
33
38
|
const p = join(dir, `${id}.md`);
|
|
34
39
|
if (!existsSync(p)) return null;
|
|
35
|
-
|
|
40
|
+
const raw = readFileSync(p, "utf8");
|
|
41
|
+
return { id, ...parseUS(raw), raw };
|
|
36
42
|
},
|
|
37
43
|
stamp(id, record) {
|
|
38
44
|
const p = join(dir, `${id}.coverage.md`);
|
|
@@ -47,6 +53,14 @@ function mdAdapter(env) {
|
|
|
47
53
|
writeFileSync(p, descriptionMarkdown.startsWith("# ") ? descriptionMarkdown : `# ${title}\n\n${descriptionMarkdown}`);
|
|
48
54
|
return { id, url: p };
|
|
49
55
|
},
|
|
56
|
+
// Sin tracker, "actualizar la US" es pisar el .md canónico. Se exige que exista:
|
|
57
|
+
// si no, `dai update-us` estaría creando una US con el id equivocado en silencio.
|
|
58
|
+
updateUS(id, { title, descriptionMarkdown }) {
|
|
59
|
+
const p = join(dir, `${id}.md`);
|
|
60
|
+
if (!existsSync(p)) throw new Error(`no existe ${p} — con DAI_PM=md, update-us actualiza el .md canónico. Creá la US primero (dai publish).`);
|
|
61
|
+
writeFileSync(p, descriptionMarkdown.startsWith("# ") ? descriptionMarkdown : `# ${title}\n\n${descriptionMarkdown}`);
|
|
62
|
+
return { id, url: p };
|
|
63
|
+
},
|
|
50
64
|
};
|
|
51
65
|
}
|
|
52
66
|
|
package/cli/lib/pm-clickup.mjs
CHANGED
|
@@ -22,7 +22,7 @@ export function clickupTaskToText(json) {
|
|
|
22
22
|
}
|
|
23
23
|
|
|
24
24
|
export function clickupAdapter(env) {
|
|
25
|
-
if (!env.DAI_CLICKUP_TOKEN) throw new Error("falta DAI_CLICKUP_TOKEN en el .env (backend clickup).");
|
|
25
|
+
if (!env.DAI_CLICKUP_TOKEN) throw new Error("falta DAI_CLICKUP_TOKEN en el .env.dai (backend clickup).");
|
|
26
26
|
return {
|
|
27
27
|
kind: "clickup",
|
|
28
28
|
async fetchUS(id) {
|
|
@@ -30,8 +30,9 @@ export function clickupAdapter(env) {
|
|
|
30
30
|
if (res.status === 404) return null;
|
|
31
31
|
if (!res.ok) throw new Error(`clickup ${res.status}: ${await res.text()}`);
|
|
32
32
|
const j = await res.json();
|
|
33
|
+
const raw = clickupTaskToText(j);
|
|
33
34
|
// `url` es la canónica (/t/<team_id>/<id>): la sabe ClickUp, no la deducimos.
|
|
34
|
-
return { id, ...parseUS(
|
|
35
|
+
return { id, ...parseUS(raw), url: j.url || null, raw };
|
|
35
36
|
},
|
|
36
37
|
async stamp(id, record) {
|
|
37
38
|
const res = await fetch(clickupCommentUrl(id), {
|
|
@@ -41,9 +42,23 @@ export function clickupAdapter(env) {
|
|
|
41
42
|
if (!res.ok) throw new Error(`clickup ${res.status}: ${await res.text()}`);
|
|
42
43
|
return `task ${id} (comentario)`;
|
|
43
44
|
},
|
|
45
|
+
// PUT /task/<id>: ClickUp acepta `markdown_content` para pisar la descripción.
|
|
46
|
+
// Solo mandamos lo que cambió — un PUT con `name` vacío renombraría la tarea.
|
|
47
|
+
async updateUS(id, { title, descriptionMarkdown }) {
|
|
48
|
+
const payload = {};
|
|
49
|
+
if (title) payload.name = title;
|
|
50
|
+
if (descriptionMarkdown != null) payload.markdown_content = descriptionMarkdown;
|
|
51
|
+
const res = await fetch(`${API}/task/${encodeURIComponent(id)}`, {
|
|
52
|
+
method: "PUT", headers: clickupAuthHeaders(env), body: JSON.stringify(payload),
|
|
53
|
+
});
|
|
54
|
+
if (res.status === 404) throw new Error(`clickup: no existe la tarea '${id}' (404). Revisá el id.`);
|
|
55
|
+
if (!res.ok) throw new Error(`clickup ${res.status}: ${await res.text()}`);
|
|
56
|
+
const j = await res.json().catch(() => ({}));
|
|
57
|
+
return { id, url: j.url || null };
|
|
58
|
+
},
|
|
44
59
|
async createUS({ title, descriptionMarkdown }) {
|
|
45
60
|
const list = env.DAI_CLICKUP_LIST_ID;
|
|
46
|
-
if (!list) throw new Error("falta DAI_CLICKUP_LIST_ID en el .env (la lista donde crear la tarea).");
|
|
61
|
+
if (!list) throw new Error("falta DAI_CLICKUP_LIST_ID en el .env.dai (la lista donde crear la tarea).");
|
|
47
62
|
const res = await fetch(`${API}/list/${encodeURIComponent(list)}/task`, {
|
|
48
63
|
method: "POST", headers: clickupAuthHeaders(env),
|
|
49
64
|
body: JSON.stringify({ name: title, markdown_content: descriptionMarkdown }),
|
package/cli/lib/pm-jira.mjs
CHANGED
|
@@ -15,7 +15,7 @@ const trim = (b) => String(b || "").replace(/\/+$/, "");
|
|
|
15
15
|
// error de config más común: `dai init` deja DAI_JIRA_PROJECT vacío y quien lo completa
|
|
16
16
|
// suele pegar la épica que tiene a mano. Jira responde un 400 que no lo explica.
|
|
17
17
|
export function assertProjectKey(key) {
|
|
18
|
-
if (!key) throw new Error("falta DAI_JIRA_PROJECT en el .env (la clave del proyecto donde crear el issue).");
|
|
18
|
+
if (!key) throw new Error("falta DAI_JIRA_PROJECT en el .env.dai (la clave del proyecto donde crear el issue).");
|
|
19
19
|
const k = String(key).trim();
|
|
20
20
|
const m = k.match(/^([A-Za-z][A-Za-z0-9_]*)-\d+$/);
|
|
21
21
|
if (m) {
|
|
@@ -136,14 +136,15 @@ export function createHint(status, body = "") {
|
|
|
136
136
|
|
|
137
137
|
export function jiraAdapter(env) {
|
|
138
138
|
const base = env.DAI_JIRA_BASE_URL;
|
|
139
|
-
if (!base) throw new Error("falta DAI_JIRA_BASE_URL en el .env (backend jira).");
|
|
139
|
+
if (!base) throw new Error("falta DAI_JIRA_BASE_URL en el .env.dai (backend jira).");
|
|
140
140
|
return {
|
|
141
141
|
kind: "jira",
|
|
142
142
|
async fetchUS(id) {
|
|
143
143
|
const res = await daiFetch(jiraIssueUrl(base, id), { headers: jiraAuthHeaders(env) });
|
|
144
144
|
if (res.status === 404) return null;
|
|
145
145
|
if (!res.ok) throw new Error(`jira ${res.status}: ${await res.text()}`);
|
|
146
|
-
|
|
146
|
+
const raw = jiraIssueToText(await res.json());
|
|
147
|
+
return { id, ...parseUS(raw), url: `${trim(base)}/browse/${id}`, raw };
|
|
147
148
|
},
|
|
148
149
|
async stamp(id, record) {
|
|
149
150
|
const res = await daiFetch(jiraCommentUrl(base, id), {
|
|
@@ -153,6 +154,25 @@ export function jiraAdapter(env) {
|
|
|
153
154
|
if (!res.ok) throw new Error(`jira ${res.status}: ${await res.text()}`);
|
|
154
155
|
return `${trim(base)}/browse/${id}`;
|
|
155
156
|
},
|
|
157
|
+
// PUT /issue/<id>: Jira responde 204 SIN cuerpo, así que la URL la componemos
|
|
158
|
+
// nosotros. Solo se mandan los campos que cambian — un PUT con summary vacío
|
|
159
|
+
// borraría el título de la US.
|
|
160
|
+
async updateUS(id, { title, descriptionMarkdown, fields }) {
|
|
161
|
+
const payload = { ...(fields || {}) };
|
|
162
|
+
if (title) payload.summary = title;
|
|
163
|
+
if (descriptionMarkdown != null) payload.description = markdownToAdf(descriptionMarkdown);
|
|
164
|
+
if (Object.keys(payload).length === 0) throw new Error("nada que actualizar (ni título, ni descripción, ni campos).");
|
|
165
|
+
const res = await daiFetch(`${trim(base)}/rest/api/3/issue/${encodeURIComponent(id)}`, {
|
|
166
|
+
method: "PUT", headers: jiraAuthHeaders(env), body: JSON.stringify({ fields: payload }),
|
|
167
|
+
});
|
|
168
|
+
if (res.status === 404) throw new Error(`jira: no existe el issue '${id}' (404), o tu usuario no lo ve.`);
|
|
169
|
+
if (res.status === 403) throw new Error(`jira: sin permiso para editar '${id}' (403). ¿Tu usuario puede editar issues en ese proyecto?`);
|
|
170
|
+
if (!res.ok) {
|
|
171
|
+
const body = await res.text();
|
|
172
|
+
throw new Error(`jira ${res.status}: ${body}${createHint(res.status, body)}`);
|
|
173
|
+
}
|
|
174
|
+
return { id, url: `${trim(base)}/browse/${id}` };
|
|
175
|
+
},
|
|
156
176
|
// `fields` son los campos propios del proyecto ya resueltos (ver jira-fields.mjs);
|
|
157
177
|
// `parent` cuelga la US de su épica. Ambos son opcionales: un Jira sin campos
|
|
158
178
|
// obligatorios sigue publicando igual que antes.
|
|
@@ -15,6 +15,14 @@ export function parseSource(src) {
|
|
|
15
15
|
const hash = raw.lastIndexOf("#");
|
|
16
16
|
if (hash > 0) { ref = raw.slice(hash + 1) || null; loc = raw.slice(0, hash); }
|
|
17
17
|
|
|
18
|
+
// Paquete npm: `npm:@scope/pkg[@version]`. dai hace `npm pack` a un temp, respetando el
|
|
19
|
+
// `.npmrc` del repo (así resuelve registries privados con scope). La versión va en el
|
|
20
|
+
// propio spec (`@1.2.3`), no como ref con '#'.
|
|
21
|
+
if (loc.startsWith("npm:")) {
|
|
22
|
+
const spec = loc.slice(4).trim();
|
|
23
|
+
if (!spec) throw new Error("fuente npm vacía (usá npm:@scope/paquete)");
|
|
24
|
+
return { type: "npm", location: spec, ref: null };
|
|
25
|
+
}
|
|
18
26
|
// Path local explícito (./ ../ / ~/).
|
|
19
27
|
if (/^(\.\.?\/|\/|~\/)/.test(loc)) return { type: "path", location: loc, ref };
|
|
20
28
|
// git por sintaxis de URL (https, ssh, scp git@host:…).
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
// dai · validación del formato de una US y manejo de `spec_version`.
|
|
2
|
+
//
|
|
3
|
+
// El molde canónico vive en templates/formato-us.md. Acá está la parte MECÁNICA de ese
|
|
4
|
+
// molde: ¿tiene título? ¿tiene criterios? ¿los criterios son Gherkin completo?
|
|
5
|
+
//
|
|
6
|
+
// Lo que NO hace: juzgar si un criterio es *bueno*. Eso es criterio (ADR-0002) y vive en
|
|
7
|
+
// la skill /grill-user-story, que te interroga. Acá solo se verifica lo que una máquina
|
|
8
|
+
// puede verificar sin opinar — y por eso casi todo es WARNING, no error: dai es una
|
|
9
|
+
// herramienta, no un mandato. Bloquean únicamente las tres cosas sin las cuales el link
|
|
10
|
+
// QUÉ↔CÓMO no existe: título, sección de criterios, y al menos un criterio.
|
|
11
|
+
|
|
12
|
+
import { extractAcBlock } from "./ac-hash.mjs";
|
|
13
|
+
import { extractTitle } from "./link-us.mjs";
|
|
14
|
+
|
|
15
|
+
// Limpia el marcado editorial de una línea para poder mirarla (mismo espíritu que
|
|
16
|
+
// normalizeAcBlock, pero conservando las líneas: acá importa la ESTRUCTURA).
|
|
17
|
+
const strip = (line) => line
|
|
18
|
+
.replace(/^\s*>\s?/, "")
|
|
19
|
+
.replace(/^\s*[-*+]\s+\[[ xX]\]\s*/, "")
|
|
20
|
+
.replace(/^\s*[-*+]\s+/, "")
|
|
21
|
+
.replace(/^\s*\d+[.)]\s+/, "")
|
|
22
|
+
.replace(/[*_`]+/g, "")
|
|
23
|
+
.trim();
|
|
24
|
+
|
|
25
|
+
const GHERKIN = { dado: /^dado\b/i, cuando: /^cuando\b/i, entonces: /^entonces\b/i };
|
|
26
|
+
|
|
27
|
+
// Parte el bloque de criterios en criterios individuales.
|
|
28
|
+
//
|
|
29
|
+
// Soporta las dos formas que se ven en la práctica:
|
|
30
|
+
// a) etiquetadas — `- [ ] **AC-1** —` seguida de las tres líneas Gherkin
|
|
31
|
+
// b) sueltas — una tanda de `- Dado … / - Cuando … / - Entonces …`
|
|
32
|
+
// En (b) cada `Dado` abre un criterio nuevo, que es como se leen naturalmente.
|
|
33
|
+
export function splitCriteria(block) {
|
|
34
|
+
const lines = (block || "").split(/\r?\n/).map(strip).filter((l) => l !== "");
|
|
35
|
+
const hasLabels = lines.some((l) => /^AC[-\s]?\d+\b/i.test(l));
|
|
36
|
+
const out = [];
|
|
37
|
+
let cur = null;
|
|
38
|
+
const open = (label) => { cur = { label: label || null, lines: [] }; out.push(cur); };
|
|
39
|
+
|
|
40
|
+
for (const l of lines) {
|
|
41
|
+
const label = l.match(/^(AC[-\s]?\d+)\b\s*[—:\-]?\s*(.*)$/i);
|
|
42
|
+
if (hasLabels && label) {
|
|
43
|
+
open(label[1]);
|
|
44
|
+
if (label[2]) cur.lines.push(label[2]);
|
|
45
|
+
continue;
|
|
46
|
+
}
|
|
47
|
+
if (!hasLabels && GHERKIN.dado.test(l)) { open(null); }
|
|
48
|
+
if (!cur) open(null);
|
|
49
|
+
cur.lines.push(l);
|
|
50
|
+
}
|
|
51
|
+
return out.map((c, i) => {
|
|
52
|
+
const text = c.lines.join(" ");
|
|
53
|
+
return {
|
|
54
|
+
label: c.label || `#${i + 1}`,
|
|
55
|
+
text,
|
|
56
|
+
dado: c.lines.some((l) => GHERKIN.dado.test(l)),
|
|
57
|
+
cuando: c.lines.some((l) => GHERKIN.cuando.test(l)),
|
|
58
|
+
entonces: c.lines.some((l) => GHERKIN.entonces.test(l)),
|
|
59
|
+
};
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export const isGherkin = (c) => c.dado && c.cuando && c.entonces;
|
|
64
|
+
|
|
65
|
+
// Palabras que delatan que el criterio se metió en el CÓMO. El QUÉ es funcional: si un
|
|
66
|
+
// criterio habla de endpoints o de tablas, el PO está diseñando la solución y ya no
|
|
67
|
+
// describe el comportamiento observable (Art. 1). Es un aviso, no un bloqueo: a veces
|
|
68
|
+
// el dominio realmente usa esas palabras.
|
|
69
|
+
const TECNICAS = /\b(endpoint|API REST|base de datos|tabla SQL|migraci[oó]n|componente React|microservicio|query|schema|foreign key|índice|index)\b/i;
|
|
70
|
+
|
|
71
|
+
// ¿Esta US está lista para viajar al tracker?
|
|
72
|
+
// → { ok, errors, warnings, criteria, title, acHashable }
|
|
73
|
+
// `ok` es false SOLO si hay errors. Los warnings se muestran y se siguen (salvo --strict).
|
|
74
|
+
export function validateUS(md) {
|
|
75
|
+
const errors = [], warnings = [];
|
|
76
|
+
const title = extractTitle(md);
|
|
77
|
+
if (!title) errors.push("falta el título: la US tiene que empezar con un '# Título'.");
|
|
78
|
+
else if (title.split(/\s+/).length > 10) {
|
|
79
|
+
warnings.push(`el título tiene ${title.split(/\s+/).length} palabras — el molde pide 3 a 6 (de ahí sale el nombre de la branch).`);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const block = extractAcBlock(md);
|
|
83
|
+
if (block == null) {
|
|
84
|
+
errors.push("falta la sección '## Criterios de aceptación'. Sin criterios no hay ac_hash, y sin ac_hash no hay link QUÉ↔CÓMO.");
|
|
85
|
+
return { ok: false, errors, warnings, criteria: [], title, acHashable: false };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const criteria = splitCriteria(block);
|
|
89
|
+
if (criteria.length === 0) {
|
|
90
|
+
errors.push("la sección 'Criterios de aceptación' está vacía.");
|
|
91
|
+
return { ok: false, errors, warnings, criteria, title, acHashable: false };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
for (const c of criteria) {
|
|
95
|
+
if (!isGherkin(c)) {
|
|
96
|
+
const falta = [!c.dado && "Dado", !c.cuando && "Cuando", !c.entonces && "Entonces"].filter(Boolean).join(" / ");
|
|
97
|
+
warnings.push(`${c.label}: no es Gherkin completo — falta ${falta}. Un criterio sin las tres partes es difícil de volver un test.`);
|
|
98
|
+
}
|
|
99
|
+
if (TECNICAS.test(c.text)) {
|
|
100
|
+
warnings.push(`${c.label}: menciona implementación (${c.text.match(TECNICAS)[0]}). El QUÉ describe comportamiento observable; el CÓMO lo decide el dev.`);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
return { ok: errors.length === 0, errors, warnings, criteria, title, acHashable: true };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// ── spec_version ─────────────────────────────────────────────────────────────
|
|
107
|
+
// El número que COMUNICA el cambio del QUÉ; el ac_hash es el que lo DETECTA
|
|
108
|
+
// (METODOLOGIA §4). Sube cuando el cambio es material, y eso lo sabe el PO: dai
|
|
109
|
+
// mirando el hash no puede distinguir un criterio nuevo de un typo corregido.
|
|
110
|
+
|
|
111
|
+
export const parseSpecVersion = (md) => {
|
|
112
|
+
const m = String(md || "").match(/spec[_ ]version[^\n]*?\b(v\d+)\b/i);
|
|
113
|
+
return m ? m[1] : null;
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
export const bumpSpecVersion = (v) => {
|
|
117
|
+
const n = Number(String(v || "v1").replace(/^v/i, "")) || 1;
|
|
118
|
+
return `v${n + 1}`;
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
// Reescribe el spec_version en el markdown. Si la US no declara ninguno (típico de una
|
|
122
|
+
// traída de un tracker que nunca usó el molde), lo INSERTA justo debajo del título — no
|
|
123
|
+
// al final, donde nadie lo ve, ni en una tabla que no existe.
|
|
124
|
+
export function setSpecVersion(md, version) {
|
|
125
|
+
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}`);
|
|
128
|
+
}
|
|
129
|
+
const lines = text.split(/\r?\n/);
|
|
130
|
+
const i = lines.findIndex((l) => /^#\s+\S/.test(l));
|
|
131
|
+
const stamp = `> **spec_version:** ${version}`;
|
|
132
|
+
if (i === -1) return `${stamp}\n\n${text}`;
|
|
133
|
+
lines.splice(i + 1, 0, "", stamp);
|
|
134
|
+
return lines.join("\n");
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// Render de los hallazgos para la terminal. Devuelve las líneas ya formateadas.
|
|
138
|
+
export function renderValidation({ errors, warnings, criteria }) {
|
|
139
|
+
const out = [];
|
|
140
|
+
for (const e of errors) out.push(` ✗ ${e}`);
|
|
141
|
+
for (const w of warnings) out.push(` ⚠ ${w}`);
|
|
142
|
+
if (errors.length === 0) {
|
|
143
|
+
const g = criteria.filter(isGherkin).length;
|
|
144
|
+
out.push(` ✓ formato válido — ${criteria.length} criterio(s)${criteria.length ? `, ${g} en Gherkin completo` : ""}`);
|
|
145
|
+
}
|
|
146
|
+
return out;
|
|
147
|
+
}
|