@ingeniomaps/cauce 0.97.0 → 0.99.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.
Files changed (93) hide show
  1. package/CHANGELOG.md +162 -0
  2. package/README.md +1 -0
  3. package/agents/roles/system/accounting-specialist/learning/AUTOMATION.md +2 -2
  4. package/agents/roles/system/ai-governance-lead/learning/AUTOMATION.md +1 -1
  5. package/agents/roles/system/ai-product-manager/learning/AUTOMATION.md +1 -1
  6. package/agents/roles/system/analytics-engineer/learning/AUTOMATION.md +1 -1
  7. package/agents/roles/system/backend-engineer/learning/AUTOMATION.md +2 -2
  8. package/agents/roles/system/business-strategist/learning/AUTOMATION.md +2 -2
  9. package/agents/roles/system/cloud-architect/learning/AUTOMATION.md +1 -1
  10. package/agents/roles/system/community-manager/learning/AUTOMATION.md +1 -1
  11. package/agents/roles/system/content-specialist/learning/AUTOMATION.md +2 -2
  12. package/agents/roles/system/customer-success-manager/learning/AUTOMATION.md +2 -2
  13. package/agents/roles/system/customer-support-specialist/learning/AUTOMATION.md +2 -2
  14. package/agents/roles/system/data-analyst/learning/AUTOMATION.md +2 -2
  15. package/agents/roles/system/data-engineer/learning/AUTOMATION.md +1 -1
  16. package/agents/roles/system/data-governance-steward/learning/AUTOMATION.md +2 -2
  17. package/agents/roles/system/data-scientist/learning/AUTOMATION.md +1 -1
  18. package/agents/roles/system/database-administrator/learning/AUTOMATION.md +1 -1
  19. package/agents/roles/system/developer-relations-engineer/learning/AUTOMATION.md +1 -1
  20. package/agents/roles/system/devops-engineer/learning/AUTOMATION.md +2 -2
  21. package/agents/roles/system/engineering-manager/learning/AUTOMATION.md +1 -1
  22. package/agents/roles/system/financial-controller/learning/AUTOMATION.md +2 -2
  23. package/agents/roles/system/finops-engineer/learning/AUTOMATION.md +2 -2
  24. package/agents/roles/system/fraud-risk-analyst/learning/AUTOMATION.md +2 -2
  25. package/agents/roles/system/frontend-engineer/learning/AUTOMATION.md +2 -2
  26. package/agents/roles/system/growth-marketer/learning/AUTOMATION.md +2 -2
  27. package/agents/roles/system/implementation-manager/learning/AUTOMATION.md +1 -1
  28. package/agents/roles/system/integrations-engineer/learning/AUTOMATION.md +3 -3
  29. package/agents/roles/system/kyc-aml-specialist/learning/AUTOMATION.md +3 -3
  30. package/agents/roles/system/legal-counsel/learning/AUTOMATION.md +1 -1
  31. package/agents/roles/system/logistics-operations-manager/learning/AUTOMATION.md +2 -2
  32. package/agents/roles/system/machine-learning-engineer/learning/AUTOMATION.md +1 -1
  33. package/agents/roles/system/mlops-engineer/learning/AUTOMATION.md +1 -1
  34. package/agents/roles/system/mobile-engineer/learning/AUTOMATION.md +2 -2
  35. package/agents/roles/system/partnerships-manager/learning/AUTOMATION.md +1 -1
  36. package/agents/roles/system/people-operations-manager/learning/AUTOMATION.md +1 -1
  37. package/agents/roles/system/privacy-compliance-specialist/learning/AUTOMATION.md +2 -2
  38. package/agents/roles/system/procurement-manager/learning/AUTOMATION.md +1 -1
  39. package/agents/roles/system/product-manager/learning/AUTOMATION.md +3 -3
  40. package/agents/roles/system/product-marketing-manager/learning/AUTOMATION.md +2 -2
  41. package/agents/roles/system/project-manager/learning/AUTOMATION.md +1 -1
  42. package/agents/roles/system/qa-engineer/learning/AUTOMATION.md +2 -2
  43. package/agents/roles/system/release-manager/learning/AUTOMATION.md +1 -1
  44. package/agents/roles/system/sales-representative/learning/AUTOMATION.md +2 -2
  45. package/agents/roles/system/security-engineer/learning/AUTOMATION.md +2 -2
  46. package/agents/roles/system/site-reliability-engineer/learning/AUTOMATION.md +2 -2
  47. package/agents/roles/system/software-architect/learning/AUTOMATION.md +2 -2
  48. package/agents/roles/system/solutions-engineer/learning/AUTOMATION.md +1 -1
  49. package/agents/roles/system/tech-lead/learning/AUTOMATION.md +2 -2
  50. package/agents/roles/system/technical-program-manager/learning/AUTOMATION.md +1 -1
  51. package/agents/roles/system/technical-writer/learning/AUTOMATION.md +1 -1
  52. package/agents/roles/system/treasury-analyst/learning/AUTOMATION.md +2 -2
  53. package/agents/roles/system/ui-designer/learning/AUTOMATION.md +3 -3
  54. package/agents/roles/system/user-researcher/learning/AUTOMATION.md +3 -3
  55. package/agents/roles/system/ux-designer/learning/AUTOMATION.md +3 -3
  56. package/automatization/hooks/README.md +10 -5
  57. package/automatization/runners/antigravity/README.md +4 -1
  58. package/automatization/runners/antigravity/hook.js +84 -22
  59. package/automatization/runners/antigravity/manifest.json +1 -0
  60. package/automatization/shared/acceptance.js +26 -0
  61. package/automatization/workflows/agent-propose.js +2 -2
  62. package/automatization/workflows/autobuild.js +121 -38
  63. package/engine/agents/learning-files.js +18 -0
  64. package/engine/agents/learning-seal.js +24 -12
  65. package/engine/agents/learning-sources.js +12 -10
  66. package/engine/agents/learning.js +29 -7
  67. package/engine/automation/index.js +11 -2
  68. package/engine/automation/registration.js +89 -0
  69. package/engine/cli/args.js +2 -2
  70. package/engine/cli/catalog.js +12 -12
  71. package/engine/cli/ops.js +2 -1
  72. package/engine/cli/validate.js +3 -0
  73. package/engine/cli/wiring.js +1 -1
  74. package/engine/config/validate.js +30 -11
  75. package/engine/core/migrations.js +275 -0
  76. package/engine/core/repos.js +18 -4
  77. package/engine/hooks/approval.js +38 -7
  78. package/engine/hooks/chat.js +97 -27
  79. package/engine/hooks/files.js +6 -104
  80. package/engine/hooks/input.js +67 -11
  81. package/engine/hooks/migrations.js +93 -0
  82. package/engine/hooks/push.js +8 -5
  83. package/engine/hooks/run.js +10 -5
  84. package/engine/hooks/self-approval.js +2 -2
  85. package/engine/hooks/shell.js +34 -0
  86. package/engine/hooks/verify.js +79 -22
  87. package/engine/planning/acceptance.js +18 -0
  88. package/engine/planning/contracts.js +78 -45
  89. package/engine/schemas/ops-config.schema.json +9 -0
  90. package/package.json +1 -1
  91. package/template/AGENTS.md +8 -5
  92. package/template/Makefile +1 -1
  93. package/template/planning/PROTOCOL.md +6 -2
@@ -0,0 +1,93 @@
1
+ 'use strict'
2
+
3
+ // El guard `migrations`: no reescribir una migración que ya viajó y no aplicar un borrado sin que una
4
+ // persona lo apruebe. Qué archivo es una migración, qué parte aplica y qué cuenta como destruir lo decide
5
+ // `core/migrations`, que `check` también lee.
6
+
7
+ const fs = require('node:fs')
8
+ const path = require('node:path')
9
+ const { spawnSync } = require('node:child_process')
10
+ const { filesOf, contentOf, patchOf, cwdOf, block, configOf, opsRoot } = require('./input')
11
+ const AP = require('./approval')
12
+ const M = require('../core/migrations')
13
+
14
+ // Si la migración ya viajó a otra copia, que es lo que el bloqueo de abajo quiere saber y `existsSync`
15
+ // no contesta. Devuelve el motivo del bloqueo o cadena vacía.
16
+ //
17
+ // **`HEAD` y no el índice**: un archivo apenas `git add`eado no viajó a ninguna parte, y `git ls-files`
18
+ // lo daría por historial. Y **resolver la raíz es una pregunta aparte** de si el archivo está en `HEAD`:
19
+ // las dos fallan con 128 y confundirlas repite el error que este caso arregla —decidir por la respuesta
20
+ // equivocada—. Sin raíz resoluble se degrada a la conducta de antes, que bloquea de más, porque cuando
21
+ // no se puede saber ése es el lado correcto para equivocarse. Es la degradación que `check` ya declara
22
+ // cuando no puede resolver el repositorio de un servicio. Caso 086.
23
+ function alreadyShipped(file) {
24
+ if (!fs.existsSync(file)) return ''
25
+ const cwd = path.dirname(file)
26
+ const top = spawnSync('git', ['rev-parse', '--show-toplevel'], { cwd, encoding: 'utf8' })
27
+ if (top.status !== 0) {
28
+ return 'existe, y acá no hay repositorio con el que saber si ya viajó a otra copia'
29
+ }
30
+ const rel = path.relative(top.stdout.trim(), file).split(path.sep).join('/')
31
+ return spawnSync('git', ['cat-file', '-e', `HEAD:${rel}`], { cwd }).status === 0
32
+ ? 'ya está en el historial del repositorio'
33
+ : ''
34
+ }
35
+
36
+ const approved = (input, file) => !AP.pending(opsRoot(input), [file], input).length
37
+
38
+ // El `Edit` trae lo que reemplaza; el archivo en disco dice dónde cae (caso 185). Se lo lee sólo para
39
+ // ubicar el fragmento: lo que ya estaba no se juzga.
40
+ function editOf(input, file) {
41
+ const fields = input.tool_input || {}
42
+ if (typeof fields.old_string !== 'string' || typeof fields.content === 'string') return {}
43
+ let disk = null
44
+ try { disk = fs.readFileSync(file, 'utf8') } catch { /* sin archivo se juzga el fragmento */ }
45
+ return { disk, old: fields.old_string, all: fields.replace_all === true }
46
+ }
47
+
48
+ function migrations(input) {
49
+ if (process.env.OPS_MIGRATIONS_OVERRIDE === '1') return
50
+ // Las dos condiciones deciden sobre el mismo alcance, y por eso comparten el filtro. El bloqueo por
51
+ // SQL destructivo corría antes de este bucle, o sea sobre el contenido y sin mirar la ruta que ya
52
+ // tenía a mano: frenaba un ADR que citaba la migración o un comentario que advertía que eso no se
53
+ // hace, y encima afirmaba «La migración contiene…» sobre un archivo que no lo era. Un guard que
54
+ // frena donde no corresponde enseña a apagarlo, que es la salida más ancha que hay.
55
+ //
56
+ // El precio de compartir el filtro es que una migración escrita fuera de las carpetas declaradas deja de
57
+ // frenarse. Es deliberado: el otro chequeo ya vivía con esa convención, y dos condiciones de la misma
58
+ // función con dos alcances distintos es lo que hizo falta arreglar acá.
59
+ const root = opsRoot(input)
60
+ const isMigration = M.pattern(root ? configOf(root) : {})
61
+ // Un parche trae varios archivos en un sobre: cada uno se juzga por su sección, no por el sobre entero.
62
+ const sections = M.patchSections(patchOf(input))
63
+ for (const raw of filesOf(input)) {
64
+ const normalized = raw.replace(/\\/g, '/')
65
+ if (!isMigration.test(normalized)) continue
66
+ if (approved(input, normalized)) continue
67
+ const file = path.resolve(cwdOf(input), raw)
68
+ // El mensaje nombra el archivo y la sentencia: un falso positivo se lee igual que un bloqueo correcto
69
+ // mientras no diga sobre qué está decidiendo. Y cuando la migración se partió, dice que la reversión
70
+ // no se juzgó, para que quien lo lea no busque la sentencia en el bloque equivocado.
71
+ const section = sections.get(raw)
72
+ const scope = section
73
+ ? M.judgedPatch(normalized, section)
74
+ : M.judged(normalized, contentOf(input), editOf(input, file))
75
+ const found = M.destructive(scope.text)
76
+ if (found) {
77
+ const where = scope.label ? ` en el bloque que aplica (la reversión, \`${scope.label}\`, no se juzga)` : ''
78
+ block(`${raw} contiene ${found.kind}${where}: \`${found.what}\`.\n`
79
+ + AP.HOW('OPS_MIGRATIONS_OVERRIDE', [normalized], input))
80
+ }
81
+ // El mensaje nombra el hecho que sostiene el bloqueo y no su interpretación: «historial» era una
82
+ // lectura que `existsSync` no podía dar, y se la daba igual sobre stubs de la misma sesión. Y lleva
83
+ // la salida angosta, que hasta 0.79.0 sólo tenía el bloqueo hermano: éste es el que aparece en el
84
+ // flujo normal de escribir una migración, así que era justo el que no podía quedarse sin decirla.
85
+ const shipped = alreadyShipped(file)
86
+ if (shipped) {
87
+ block(`${raw} ${shipped}. Crea una nueva en vez de reescribirla.\n`
88
+ + AP.HOW('OPS_MIGRATIONS_OVERRIDE', [normalized], input))
89
+ }
90
+ }
91
+ }
92
+
93
+ module.exports = { migrations }
@@ -105,7 +105,7 @@ function liveMessage(live, input) {
105
105
  // una sesión real, con «pegando tal cual» a secas el agente se ofreció a escribirse la aprobación.
106
106
  return `'git push' publica cambios en ${names}, la rama viva, y requiere una acción humana: ni `
107
107
  + 'runner.allowPush ni una orden en el chat llegan ahí sin un permiso por rama. Decile a la persona qué '
108
- + 'se frenó y esperá: un «dale» no lo destraba. Lo da ella, nombrando la rama en '
108
+ + 'se frenó y esperá: confirmarlo en el chat no lo destraba. Lo da ella, nombrando la rama en '
109
109
  + 'runner.pushToLiveBranches de ops.config.json, que la deja como una rama de trabajo'
110
110
  + (lines.length ? `, o pegando ella tal cual en ${AP.where(input)} estas líneas:\n`
111
111
  + lines.map((line) => ` ${line}\n`).join('') : '.')
@@ -113,11 +113,14 @@ function liveMessage(live, input) {
113
113
 
114
114
  function workMessage(items, input) {
115
115
  const lines = items.filter((item) => item.startsWith('push '))
116
- const chat = CHAT.hold(input, items)
117
- const how = chat
118
- ? 'Decile a la persona qué se frenó y esperá: si contesta «dale», reintentá el mismo push y pasa. '
116
+ const held = CHAT.hold(input, items)
117
+ const dropped = held ? held.dropped : []
118
+ const chat = held && dropped.length < items.length
119
+ const how = (dropped.length ? AP.REFUSED(dropped, input) : '') + (chat
120
+ ? 'Decile a la persona qué se frenó y pedile que lo confirme con sus palabras: si lo que contesta es un '
121
+ + 'sí, reintentá el mismo push y pasa; si duda, pregunta o dice que no, no reintentes. '
119
122
  + 'También pasa si lo pide nombrando el remoto y la rama, como «subí feat/x a origin».'
120
- : 'Lo destraba una persona pidiéndolo en el chat con el remoto y la rama.'
123
+ : 'Lo destraba una persona pidiéndolo en el chat con el remoto y la rama.')
121
124
  const paste = lines.length
122
125
  ? `\nSi prefiere aprobarlo a mano, que pegue ella tal cual en ${AP.where(input)} estas líneas:\n`
123
126
  + lines.map((line) => ` ${line}\n`).join('')
@@ -13,6 +13,7 @@ const { readInput, block, opsRoot } = require('./input')
13
13
  const shell = require('./shell')
14
14
  const { verify } = require('./verify')
15
15
  const files = require('./files')
16
+ const { migrations } = require('./migrations')
16
17
  const chat = require('./chat')
17
18
  const { secretsShell } = require('./secrets-shell')
18
19
  const { opsConfig, opsConfigShell } = require('./ops-config')
@@ -50,7 +51,7 @@ const guards = {
50
51
  generated: files.generated,
51
52
  'workspace-boundary': files.workspaceBoundary,
52
53
  engine: files.engineWrites,
53
- migrations: files.migrations,
54
+ migrations,
54
55
  'integration-snapshot': files.integrationSnapshot,
55
56
  'test-evidence': files.testEvidence,
56
57
  'plan-first': files.planFirst,
@@ -145,8 +146,10 @@ const hookMetadata = [
145
146
  {
146
147
  name: 'migrations',
147
148
  event: 'PreToolUse · files',
148
- purpose: 'Protege migraciones existentes y bloquea SQL destructivo, sobre las extensiones que el '
149
- + 'proyecto declare en migrations.extensions — sólo .sql si no declara ninguna.',
149
+ purpose: 'Protege migraciones existentes y bloquea SQL destructivo o el borrado con la API del ORM en la '
150
+ + 'parte que aplica —no en su reversión—, sobre las extensiones y carpetas que el proyecto declare en '
151
+ + 'migrations.extensions y migrations.paths — sólo .sql bajo migrations/, migration/ o migrate/ si no '
152
+ + 'declara nada.',
150
153
  },
151
154
  {
152
155
  name: 'integration-snapshot',
@@ -194,11 +197,13 @@ function executeAll(names, input) {
194
197
  for (const name of resolve(names)) execute(name, input)
195
198
  }
196
199
 
200
+ // Asíncrono sólo por la lectura de stdin, que necesita un plazo (`input.js`); los guards siguen siendo
201
+ // sincrónicos y `executeAll` también, así que quien los llama directo no cambia.
197
202
  if (require.main === module) {
198
- try { executeAll(process.argv.slice(2), readInput()) } catch (error) {
203
+ readInput().then((input) => executeAll(process.argv.slice(2), input)).catch((error) => {
199
204
  console.error(`BLOQUEADO: ${error.message}`)
200
205
  process.exit(error.blocked ? 2 : 1)
201
- }
206
+ })
202
207
  }
203
208
 
204
209
  module.exports = {
@@ -24,7 +24,7 @@ const CHAT_RECORD = (file) => `${file} es el registro de lo que la persona dijo
24
24
  + 'runner, nunca una herramienta.'
25
25
 
26
26
  const SELF = (file) => `${file} es la aprobación de una persona, y escribírsela es aprobarse solo. Si la `
27
- + 'persona quiere autorizar algo, que lo diga en el chat —nombrándolo, o contestando «dale» al bloqueo— o '
27
+ + 'persona quiere autorizar algo, que lo diga en el chat —nombrándolo, o confirmando el bloqueo— o '
28
28
  + 'que edite el archivo ella.'
29
29
 
30
30
  // Las líneas van en el mensaje porque son lo que el «dale» va a aprobar: una confirmación que no muestra
@@ -32,7 +32,7 @@ const SELF = (file) => `${file} es la aprobación de una persona, y escribírsel
32
32
  const UNASKED = (file, missing) => `${file} es la aprobación de una persona y estas líneas no las pidió:\n`
33
33
  + missing.map((line) => ` ${line}\n`).join('')
34
34
  + 'Escribí sólo lo que ella nombró en su mensaje. Si hacen falta las otras, decile cuáles y por qué, y '
35
- + 'esperá: si contesta «dale», reintentá la misma escritura y pasa.'
35
+ + 'pedile que lo confirme con sus palabras: si lo que contesta es un sí, reintentá la misma escritura y pasa.'
36
36
 
37
37
  // **Por shell se frena toda escritura.** Es la misma decisión que toma `ops-config`, y por qué un comando
38
38
  // no se puede comparar está escrito allá. Lo que la trae hasta acá es que el contenido es lo único que
@@ -193,6 +193,39 @@ function gitAdd(input) {
193
193
  }
194
194
  }
195
195
 
196
+ // Un `package.json` cambia sin tocar nada de lo que su lockfile registra: un script, una descripción, la
197
+ // configuración de una herramienta. Frenar eso por la sola presencia del archivo en el índice obligaba a
198
+ // pedir permiso por un cambio sin riesgo (caso 181). Se deja pasar sólo lo medido, y lo demás frena como
199
+ // antes, incluido lo que no se puede comparar.
200
+ //
201
+ // La lista salió de correr `npm install --package-lock-only` con npm 11.16.0 cambiando una clave por vez:
202
+ // las claves name, version, license, engines, bin, funding, workspaces, os y cpu, y los scripts preinstall,
203
+ // install y postinstall, sí mueven el lock. Sólo se midió el lock de npm, así que con otro gestor no se
204
+ // afloja nada.
205
+ const INERT_KEYS = new Set([
206
+ 'scripts', 'description', 'keywords', 'author', 'repository', 'homepage', 'bugs', 'private', 'type',
207
+ 'main', 'files', 'exports', 'config', 'jest', 'eslintConfig', 'prettier',
208
+ ])
209
+ const INSTALL_SCRIPTS = ['preinstall', 'install', 'postinstall']
210
+
211
+ function lockUnaffected(dir, file, lockfiles) {
212
+ if (path.basename(file) !== 'package.json' || lockfiles.join() !== 'package-lock.json') return false
213
+ const read = (ref) => {
214
+ const shown = run('git', ['-C', dir, 'show', `${ref}:${file}`], dir)
215
+ if (!shown.ok) return null
216
+ try { return JSON.parse(shown.output) } catch { return null }
217
+ }
218
+ const before = read('HEAD')
219
+ const after = read('')
220
+ if (!before || !after || typeof before !== 'object' || typeof after !== 'object') return false
221
+ const same = (one, other) => JSON.stringify(one) === JSON.stringify(other)
222
+ const changed = [...new Set([...Object.keys(before), ...Object.keys(after)])]
223
+ .filter((key) => !same(before[key], after[key]))
224
+ if (changed.some((key) => !INERT_KEYS.has(key))) return false
225
+ const scripts = (one) => (one && typeof one.scripts === 'object' ? one.scripts : {}) || {}
226
+ return INSTALL_SCRIPTS.every((name) => same(scripts(before)[name], scripts(after)[name]))
227
+ }
228
+
196
229
  function dependencies(input) {
197
230
  if (process.env.OPS_DEPENDENCIES_OVERRIDE === '1') return
198
231
  const command = commandOf(input)
@@ -250,6 +283,7 @@ function dependencies(input) {
250
283
  block(`${parent}: hay varios lockfiles (${onDisk.join(', ')}). Conserva uno solo.`)
251
284
  }
252
285
  const manifests = sinAprobar(parent, state.manifests)
286
+ .filter((name) => !lockUnaffected(dir, name, existingLocks))
253
287
  if (state.manifests.length && existingLocks.length && !state.locks.length && manifests.length) {
254
288
  block(`${parent}: cambió ${state.manifests.join(', ')} sin actualizar su lockfile.\n`
255
289
  + AP.HOW('OPS_DEPENDENCIES_OVERRIDE', manifests, input))
@@ -144,33 +144,90 @@ function commitTree(dir, input) {
144
144
  return { root: temp, temp, env: { pnpm_config_verify_deps_before_run: 'false' } }
145
145
  }
146
146
 
147
+ // Dónde viven la consulta de sqlc y su generado lo declara cada proyecto en su config (`queries:` y
148
+ // `gen.go.out`), y acá no se lee: no hay parser de YAML y no se agrega uno para un guard. Lo que se usa
149
+ // en su lugar son dos hechos que no dependen del layout.
150
+ //
151
+ // La consulta se busca en `queries/` a cualquier profundidad —un monorepo la tiene en `api/db/queries/`
152
+ // (caso 192)—, y sólo cuenta si el repositorio tiene un `sqlc.yaml`, `sqlc.yml` o `sqlc.json`, los tres
153
+ // nombres que sqlc busca: sin esa condición, desanclar el patrón frenaría consultas de un reporte o
154
+ // fixtures que nadie genera, y cada freno falso se resuelve aprobándolo. Un `queries:` con otro nombre de
155
+ // carpeta sigue sin verse.
156
+ //
157
+ // El generado se reconoce por el nombre del archivo, no por su carpeta: sqlc escribe `<consulta>.sql.go`
158
+ // donde diga `out`, y esperar una carpeta `sqlc/` frenaba al que la llamó de otro modo (caso 187). La
159
+ // carpeta queda como respaldo. `.ts` o `.py` no: esos generadores son plugins y su nombre no se comprobó.
160
+ const SQL_SOURCE = /(?:^|\/)queries\/.*\.sql$/i
161
+ const SQL_GENERATED = /\.sql\.go$|(?:^|\/)(?:sqlc|generated)(?:\/|.*\.(?:go|ts|js|py)$)/i
162
+ function usesSqlc(dir) {
163
+ // `top` porque `dir` puede ser un subdirectorio y el índice se pregunta entero; `glob` para que `**/`
164
+ // alcance también la raíz. Se lee el índice y no el disco: una config sin trackear no es del proyecto.
165
+ // Si git no contesta se asume que sí: un guard que no pudo mirar no afloja.
166
+ const listed = run('git', ['-C', dir, 'ls-files', '--',
167
+ ...['yaml', 'yml', 'json'].map((ext) => `:(top,glob)**/sqlc.${ext}`)], dir)
168
+ return !listed.ok || Boolean(listed.output.trim())
169
+ }
170
+
171
+ // Una especificación OpenAPI se reconoce por lo que declara y no por la carpeta: `api/` guarda también la
172
+ // config de sqlc, un compose o fixtures, y cada uno pedía regenerar un cliente que no existe (caso 197). La
173
+ // carpeta queda como filtro barato antes de leer nada. Un fragmento de una especificación partida con `$ref`
174
+ // no declara nada, así que cuenta si su carpeta de primer nivel tiene en el índice una raíz que sí lo haga.
175
+ // Se lee el índice y no el disco, que es lo que el commit graba. Si git no contesta, dispara: un guard que
176
+ // no pudo mirar no afloja.
177
+ const OPENAPI_CANDIDATE = /^(?:(?:openapi|api|spec)\/(?:.*\/)?[^/]+|openapi|swagger)\.ya?ml$/i
178
+ const OPENAPI_ROOT = /^(?:openapi|swagger)\s*:/m
179
+
180
+ function declaresOpenApi(dir, file) {
181
+ // Un archivo borrado figura como cambio y ya no está en el índice: lo que era se lee en `HEAD`.
182
+ for (const revision of ['', 'HEAD']) {
183
+ const shown = run('git', ['-C', dir, 'show', `${revision}:${file}`], dir)
184
+ if (shown.ok) return OPENAPI_ROOT.test(shown.output.slice(0, 4096))
185
+ }
186
+ return true
187
+ }
188
+
189
+ function changedOpenApiSpec(dir, staged) {
190
+ const candidates = staged.filter((file) => OPENAPI_CANDIDATE.test(file))
191
+ if (candidates.some((file) => declaresOpenApi(dir, file))) return true
192
+ // Una sola búsqueda en el índice por todas las carpetas, y no un `git show` por archivo: un `spec/` con
193
+ // cientos de fixtures costaba cientos de procesos por commit. `git grep` sale con 1 si no encuentra nada, y
194
+ // con cualquier otro código no pudo mirar, que dispara igual.
195
+ const folders = [...new Set(candidates.filter((file) => file.includes('/')).map((file) => file.split('/')[0]))]
196
+ if (!folders.length) return false
197
+ const found = run('git', ['-C', dir, 'grep', '--cached', '-l', '-E', '^(openapi|swagger)[[:space:]]*:', '--',
198
+ ...folders.flatMap((folder) => [`:(top,glob)${folder}/**/*.yaml`, `:(top,glob)${folder}/**/*.yml`])], dir)
199
+ if (found.ok) return Boolean(found.output.trim())
200
+ return found.status !== 1
201
+ }
202
+
147
203
  function verify(input) {
148
204
  if (process.env.OPS_SKIP_VERIFY === '1') return
149
205
  const command = commandOf(input)
150
206
  if (!isCommit(command)) return
151
207
  const { dir, staged } = stagedForCommit(command, cwdOf(input))
152
- const changedOpenApi = staged.some((file) => /^(?:openapi|api|spec)(?:\/.*)?\/[^/]+\.ya?ml$/i.test(file))
153
- || staged.some((file) => /^(?:openapi|swagger)\.ya?ml$/i.test(file))
154
- const changedSqlSource = staged.some((file) => /^(?:db\/queries|queries)\/.*\.sql$/i.test(file))
208
+ const changedOpenApi = changedOpenApiSpec(dir, staged)
209
+ const changedSqlSource = staged.some((file) => SQL_SOURCE.test(file)) && usesSqlc(dir)
155
210
  const hasApiGenerated = staged.some((file) => /(?:^|\/)[^/]*(?:generated|\.gen)\.(?:go|ts|js|py)$/i.test(file))
156
- const hasSqlGenerated = staged.some((file) => /(?:^|\/)(?:sqlc|generated)(?:\/|.*\.(?:go|ts|js|py)$)/i.test(file))
211
+ const hasSqlGenerated = staged.some((file) => SQL_GENERATED.test(file))
157
212
  // Acá lo aprobado es el conjunto staged entero: decir «autorizo commitear exactamente estas rutas»
158
213
  // es lo que un gate en rojo necesita, y cambia en cuanto se stagea una más. La lista sale del índice
159
214
  // y no de una regla, que es lo que la vuelve una operación y no un permiso.
160
- const sinAprobar = AP.pendingNow(opsRoot(input), staged, input)
161
- const aprobado = !sinAprobar.length
162
- if (changedOpenApi && !hasApiGenerated && !aprobado) {
215
+ const unapproved = AP.pendingNow(opsRoot(input), staged, input)
216
+ const approved = !unapproved.length
217
+ if (changedOpenApi && !hasApiGenerated && !approved) {
163
218
  block('Cambió una fuente OpenAPI/Swagger sin incluir código regenerado. Ejecuta el generador y '
164
- + `stagea su salida.\n${AP.HOW('OPS_SKIP_VERIFY', sinAprobar, input)}`)
219
+ + `stagea su salida.\n${AP.HOW('OPS_SKIP_VERIFY', unapproved, input)}`)
165
220
  }
166
- if (changedSqlSource && !hasSqlGenerated && !aprobado) {
167
- block('Cambió una consulta SQL fuente sin artefactos regenerados. Ejecuta el generador.\n'
168
- + AP.HOW('OPS_SKIP_VERIFY', sinAprobar, input))
221
+ if (changedSqlSource && !hasSqlGenerated && !approved) {
222
+ block('Cambió una consulta SQL fuente sin artefactos regenerados: busqué en el índice un `*.sql.go`, '
223
+ + 'o algo bajo una carpeta `sqlc/` o `generated/`, y no hay ninguno. Si corriste `sqlc generate`, '
224
+ + 'stageá lo que escribió; si su `output_files_suffix` le cambia el nombre, esto no lo reconoce.\n'
225
+ + AP.HOW('OPS_SKIP_VERIFY', unapproved, input))
169
226
  }
170
227
  if (!staged.some((file) => /\.(?:ts|tsx|js|jsx|mjs|cjs|go|py|html|css|scss|prisma)$/.test(file))) return
171
228
  const { root, temp, env } = commitTree(dir, input)
172
229
  try {
173
- verifyGates(root, dir, sinAprobar, env, input)
230
+ verifyGates(root, dir, unapproved, env, input)
174
231
  } finally {
175
232
  if (temp) fs.rmSync(temp, { recursive: true, force: true })
176
233
  }
@@ -201,7 +258,7 @@ const ERROR_LINE = /error|err[_!]|fail|abort|not found|cannot|no such/i
201
258
  const FAILED_TEST = /^(?:✖|not ok\b|--- FAIL:|● |× |\d+\) |FAILED )/
202
259
  const PASSED_TEST = /^(?:✔|ok\b|--- PASS:)|::\S+ PASSED\b/
203
260
  const MAX_LINE = 160
204
- function fallo(gate, result) {
261
+ function failure(gate, result) {
205
262
  // La línea que empieza con `>` es el eco del script que npm y pnpm imprimen antes de correrlo, así
206
263
  // que lleva el comando entero y no dice nada de qué falló. Descartarla es lo que hace que la primera
207
264
  // coincidencia sea el error y no el comando — con el eco adentro, un script que **menciona** una
@@ -228,7 +285,7 @@ function howItReads(failures) {
228
285
  + 'una suite, así que mirá si llegaron a ejecutarse antes de aprobar esto como un rojo conocido.'
229
286
  }
230
287
 
231
- function verifyGates(root, dir, sinAprobar, env, input) {
288
+ function verifyGates(root, dir, unapproved, env, input) {
232
289
  const ops = opsRoot(input)
233
290
  const failures = []
234
291
  if (fs.existsSync(path.join(root, 'package.json'))) {
@@ -240,19 +297,19 @@ function verifyGates(root, dir, sinAprobar, env, input) {
240
297
  if (!pkg.scripts || !pkg.scripts[script]) continue
241
298
  const result = run(pm, ['run', script], root, env)
242
299
  EV.record(ops, script, result.status, result.ms)
243
- if (!result.ok) failures.push(fallo(script, result))
300
+ if (!result.ok) failures.push(failure(script, result))
244
301
  }
245
302
  } else if (fs.existsSync(path.join(root, 'go.mod'))) {
246
303
  const makefile = path.join(root, 'Makefile')
247
304
  if (fs.existsSync(makefile) && /^ci:/m.test(fs.readFileSync(makefile, 'utf8'))) {
248
305
  const result = run('make', ['ci'], root, env)
249
306
  EV.record(ops, 'make ci', result.status, result.ms)
250
- if (!result.ok) failures.push(fallo('make ci', result))
307
+ if (!result.ok) failures.push(failure('make ci', result))
251
308
  } else {
252
309
  for (const args of [['test', './...'], ['build', './...']]) {
253
310
  const result = run('go', args, root, env)
254
311
  EV.record(ops, `go ${args[0]}`, result.status, result.ms)
255
- if (!result.ok) failures.push(fallo(`go ${args[0]}`, result))
312
+ if (!result.ok) failures.push(failure(`go ${args[0]}`, result))
256
313
  }
257
314
  }
258
315
  } else if (fs.existsSync(path.join(root, 'pyproject.toml')) || fs.existsSync(path.join(root, 'requirements.txt'))) {
@@ -260,16 +317,16 @@ function verifyGates(root, dir, sinAprobar, env, input) {
260
317
  if (fs.existsSync(makefile) && /^test:/m.test(fs.readFileSync(makefile, 'utf8'))) {
261
318
  const result = run('make', ['test'], root, env)
262
319
  EV.record(ops, 'make test', result.status, result.ms)
263
- if (!result.ok) failures.push(fallo('make test', result))
320
+ if (!result.ok) failures.push(failure('make test', result))
264
321
  }
265
322
  }
266
- if (!failures.length || !sinAprobar.length) return
323
+ if (!failures.length || !unapproved.length) return
267
324
  // Se dice sobre qué corrió cuando no fue el árbol: un fallo que no se reproduce escribiendo el mismo
268
325
  // comando a mano se lee como que el guard miente, y lo que pasó es que midió lo que se va a grabar.
269
- const donde = root === dir ? '' : '\nCorrió sobre el índice, que es lo que el commit graba: si en tu '
326
+ const where = root === dir ? '' : '\nCorrió sobre el índice, que es lo que el commit graba: si en tu '
270
327
  + 'directorio pasa, es que en disco tenés algo que no está staged.'
271
- block(`Verify falló en ${path.basename(dir)}: ${howItReads(failures)}\nNo se commitea en rojo.${donde}\n`
272
- + AP.HOW('OPS_SKIP_VERIFY', sinAprobar, input))
328
+ block(`Verify falló en ${path.basename(dir)}: ${howItReads(failures)}\nNo se commitea en rojo.${where}\n`
329
+ + AP.HOW('OPS_SKIP_VERIFY', unapproved, input))
273
330
  }
274
331
 
275
332
  module.exports = { verify }
@@ -0,0 +1,18 @@
1
+ 'use strict'
2
+
3
+ // El lado del motor de `automatization/shared/acceptance.js`: lo evalúa en vez de copiarlo. El archivo
4
+ // declara constantes sueltas porque así lo incluye un recorrido, y un fragmento así no se puede
5
+ // `require`; evaluado aparte da los mismos valores que recibe el recorrido renderizado.
6
+
7
+ const fs = require('node:fs')
8
+ const path = require('node:path')
9
+ const vm = require('node:vm')
10
+
11
+ const SOURCE = path.join(__dirname, '..', '..', 'automatization', 'shared', 'acceptance.js')
12
+
13
+ const { acceptanceConditions, OUT_OF_VERIFY, NON_EXECUTABLE } = vm.runInNewContext(
14
+ `${fs.readFileSync(SOURCE, 'utf8')}\n;({ acceptanceConditions, OUT_OF_VERIFY, NON_EXECUTABLE })`,
15
+ {}, { filename: SOURCE },
16
+ )
17
+
18
+ module.exports = { acceptanceConditions, OUT_OF_VERIFY, NON_EXECUTABLE }
@@ -4,6 +4,7 @@ const fs = require('node:fs')
4
4
  const path = require('node:path')
5
5
  const P = require('./parser')
6
6
  const { PLACEHOLDERS } = require('../core/onboarding')
7
+ const { acceptanceConditions, OUT_OF_VERIFY, NON_EXECUTABLE } = require('./acceptance')
7
8
 
8
9
  const TEST_TRACE = /^(?:n\/a\s*[—-]\s*.+|(?:A|C\d+)\s*(?:→|->)\s*\S.+)$/i
9
10
  const DECISION_TRACE = /\[(?:fuente|supuesto):\s*[^\]]+\]/i
@@ -40,12 +41,13 @@ function validDecisionTrace(value) {
40
41
  // sha. La salida explícita existe porque hay tareas que no producen commit —abrir una fila en
41
42
  // HUMAN_ACTIONS, registrar un informe—, y forzarlas a llenar el campo produce un sha inventado, que es
42
43
  // peor que la ausencia: parece evidencia.
44
+ // Se corta en cada `|`, y en un `;` sólo cuando detrás viene un sha: el `;` aparece también dentro del
45
+ // paréntesis final —`(api@main; sin footer Task:)`— y cortar ahí convertiría una nota en un commit que
46
+ // falta. Cada tramo responde por sí mismo; validar sólo el primero dejaba pasar la mitad sin artefacto.
47
+ const commitParts = (value) => String(value || '').trim().split(/\s*(?:\||;(?=\s*[0-9a-f]{7,40}\s))\s*/)
48
+
43
49
  function validCommitTrace(value) {
44
- // Se corta en cada `|`, y en un `;` sólo cuando detrás viene un sha: el `;` aparece también dentro del
45
- // paréntesis final —`(api@main; sin footer Task:)`— y cortar ahí convertiría una nota en un commit que
46
- // falta. Cada tramo responde por sí mismo; validar sólo el primero dejaba pasar la mitad sin artefacto.
47
- return String(value || '').trim().split(/\s*(?:\||;(?=\s*[0-9a-f]{7,40}\s))\s*/)
48
- .every((part) => COMMIT_TRACE.test(part.trim()))
50
+ return commitParts(value).every((part) => COMMIT_TRACE.test(part.trim()))
49
51
  }
50
52
 
51
53
  // Los criterios que la evidencia realmente rastrea. `n/a — razón` no rastrea ninguno a propósito: es
@@ -118,8 +120,8 @@ function doneEntryErrors(entry, epics = []) {
118
120
  // Un carril declara **cuánta ceremonia** merecía la tarea; `n/a` en `review:` dice que la revisión no
119
121
  // corrió. `express` es el único que no convoca revisor, así que en los otros tres esa combinación es la
120
122
  // ADR incumplida, escrita en el propio registro.
121
- const CONVOCAN_REVISOR = ['directo', 'lite', 'full']
122
- const SIN_REVISION = /^n\/a\b/i
123
+ const REVIEWED_LANES = ['directo', 'lite', 'full']
124
+ const NO_REVIEW = /^n\/a\b/i
123
125
 
124
126
  // Lo que el registro puede decir sobre la ceremonia, y lo que todavía no. Los dos campos avisan en vez de
125
127
  // fallar por lo que dice `doneEntryErrors`, y cuentan en vez de listar porque al principio son todas: lo
@@ -134,22 +136,22 @@ const SIN_REVISION = /^n\/a\b/i
134
136
  // completo, pero eso lo sabe el recorrido y no la entrada. Avisar sobre lo que hay que deducir es lo que
135
137
  // llena de ruido un aviso que después nadie mira.
136
138
  function doneCeremonyWarnings(done, adopted = new Set()) {
137
- const propias = done.entries.filter((entry) => !adopted.has(entry.slug))
139
+ const own = done.entries.filter((entry) => !adopted.has(entry.slug))
138
140
  const warnings = []
139
- const sinLane = propias.filter((entry) => !entry.lane)
140
- if (sinLane.length) {
141
- warnings.push(`planning/done: ${sinLane.length} entrada(s) sin lane:, así que no se puede comprobar `
141
+ const withoutLane = own.filter((entry) => !entry.lane)
142
+ if (withoutLane.length) {
143
+ warnings.push(`planning/done: ${withoutLane.length} entrada(s) sin lane:, así que no se puede comprobar `
142
144
  + 'sobre el registro que la ceremonia que recibieron fue la que su superficie pedía (OPS-006)')
143
145
  }
144
- const sinReview = propias.filter((entry) => !entry.review)
145
- if (sinReview.length) {
146
- warnings.push(`planning/done: ${sinReview.length} entrada(s) sin review:, que es la dimensión con la `
146
+ const withoutReview = own.filter((entry) => !entry.review)
147
+ if (withoutReview.length) {
148
+ warnings.push(`planning/done: ${withoutReview.length} entrada(s) sin review:, que es la dimensión con la `
147
149
  + 'que OPS-006 dice que se mide si el carril elegido fue el correcto')
148
150
  }
149
- const saltadas = propias.filter((entry) => CONVOCAN_REVISOR.includes(entry.lane)
150
- && entry.review && SIN_REVISION.test(entry.review))
151
- if (saltadas.length) {
152
- warnings.push(`planning/done: ${saltadas.map((entry) => entry.slug).join(', ')} declara(n) un carril `
151
+ const skipped = own.filter((entry) => REVIEWED_LANES.includes(entry.lane)
152
+ && entry.review && NO_REVIEW.test(entry.review))
153
+ if (skipped.length) {
154
+ warnings.push(`planning/done: ${skipped.map((entry) => entry.slug).join(', ')} declara(n) un carril `
153
155
  + 'que convoca revisor y una revisión que no corrió: el carril reduce ceremonia, nunca evidencia')
154
156
  }
155
157
  return warnings
@@ -165,14 +167,6 @@ const POST_VERIFY = [
165
167
  [/\bel reclamo\b|\bclaims\//i, 'el reclamo'],
166
168
  ]
167
169
 
168
- // La salida explícita, con la forma que el repositorio ya usa dos veces: `(sin partir: …)` para el umbral
169
- // de R17 y `n/a — razón` para `tests:` y `commit:`. Acá vale lo mismo que allá —«como lleva su razón
170
- // escrita se lee en el propio artefacto sin que nadie la cruce»— y por eso no se intenta adivinar si la
171
- // prosa excluye a Verify. Adivinarlo es lo que no se puede: la única aceptación real que nombra el commit
172
- // lo hace justamente para decir que no es condición de Verify, y cualquier lista de frases que la
173
- // reconociera enseñaría a escribir esa frase exacta para silenciar el aviso.
174
- const OUT_OF_VERIFY = /\(fuera de verify:\s*[^)]+\)/i
175
-
176
170
  // Una condición de aceptación que nombra el registro en vez del producto no se puede cumplir nunca: se
177
171
  // comprueba en Verify, que corre antes que Commit y que Done. El recorrido lo detecta —y hace bien—, pero
178
172
  // recién ahí: en la corrida que originó esto fueron 1,2 M de tokens y once agentes para terminar con el
@@ -194,11 +188,11 @@ function unverifiableAcceptance(milestones = []) {
194
188
  const warnings = []
195
189
  for (const milestone of milestones) {
196
190
  for (const task of milestone.tasks || []) {
197
- for (const condition of String(task.acceptance || '').split(';').map((one) => one.trim())) {
198
- if (!condition || OUT_OF_VERIFY.test(condition)) continue
199
- const nombra = POST_VERIFY.filter(([pattern]) => pattern.test(condition))
200
- if (!nombra.length) continue
201
- warnings.push(`BACKLOG ${task.slug}: una condición nombra ${nombra.map(([, what]) => what).join(', ')}`
191
+ for (const condition of acceptanceConditions(task.acceptance)) {
192
+ if (OUT_OF_VERIFY.test(condition)) continue
193
+ const names = POST_VERIFY.filter(([pattern]) => pattern.test(condition))
194
+ if (!names.length) continue
195
+ warnings.push(`BACKLOG ${task.slug}: una condición nombra ${names.map(([, what]) => what).join(', ')}`
202
196
  + ', que existe después de Verify, así que no se puede comprobar cuando se la comprueba. Eso va '
203
197
  + 'en tests:, qa: o commit: de su entrada de DONE, que ya lo exigen; si de verdad va acá, '
204
198
  + 'declaralo con "(fuera de verify: <razón>)"')
@@ -208,6 +202,44 @@ function unverifiableAcceptance(milestones = []) {
208
202
  return warnings
209
203
  }
210
204
 
205
+ // `tests: n/a` dice que la tarea no produjo nada que se ejecute, y quien lo escribe es el mismo que quiere
206
+ // cerrar: desde el 189 el recorrido lo emite cuando Verify declara `no-surface`, que es la palabra de un
207
+ // modelo. Lo que la vuelve creíble es mirar qué tocó el commit, y eso no lo decide nadie. Se juzga el n/a
208
+ // entero y no el mixto: en una tarea que rastrea algún criterio con su prueba, el código del commit es el
209
+ // de esos criterios y no dice nada del que se declaró sin superficie.
210
+ //
211
+ // `filesOf` devuelve los archivos de un sha, o null si ningún repositorio lo conoce. Ahí calla, igual que
212
+ // el resto de lo que pregunta a git desde `check`: un sha que no se encuentra no es superficie, y avisarlo
213
+ // sería inventar el hallazgo. Lo adoptado queda afuera por lo mismo que en el resto de DONE: se escribió
214
+ // antes de que el contrato lo pidiera.
215
+ //
216
+ // Y rige desde que existe. Una entrada cerrada antes con `tests: n/a` sobre código no violó nada que se le
217
+ // hubiera pedido, y frenarla ahora pondría `check` en rojo con el `upgrade` —y con él al guard que lo corre
218
+ // antes de cada commit— por algo que nadie puede corregir sin reescribir evidencia. Es la misma razón por
219
+ // la que se exime lo adoptado, aplicada a lo que ya estaba cerrado cuando la regla llegó.
220
+ const NOT_APPLICABLE = /^n\/a\s*[—-]/i
221
+ const SURFACE_SINCE = '2026-09-24'
222
+
223
+ function surfaceWithoutTests(entries, filesOf, adopted = new Set()) {
224
+ const errors = []
225
+ for (const entry of entries) {
226
+ const traces = splitTraces(entry.tests)
227
+ if (adopted.has(entry.slug) || !traces.length || !traces.every((one) => NOT_APPLICABLE.test(one))) continue
228
+ if (!(String(entry.fecha || '') >= SURFACE_SINCE)) continue
229
+ for (const sha of commitParts(entry.commit).map((part) => (part.match(/^([0-9a-f]{7,40})\s/) || [])[1])) {
230
+ if (!sha) continue
231
+ const surface = (filesOf(sha) || [])
232
+ .filter((file) => !NON_EXECUTABLE.includes(path.extname(file).toLowerCase()))
233
+ if (!surface.length) continue
234
+ const shown = surface.length > 5 ? [...surface.slice(0, 5), `y ${surface.length - 5} más`] : surface
235
+ errors.push(`${entry.source} ${entry.slug}: tests: n/a dice que no hay superficie ejecutable y el commit `
236
+ + `${sha} toca ${shown.join(', ')}; sólo ${NON_EXECUTABLE.join(', ')} cuentan como no ejecutables, `
237
+ + 'así que esos criterios se rastrean con su prueba')
238
+ }
239
+ }
240
+ return errors
241
+ }
242
+
211
243
  function duplicates(values) {
212
244
  return [...new Set(values.filter((value, index) => values.indexOf(value) !== index))]
213
245
  }
@@ -260,22 +292,22 @@ function dependencyErrors(milestones, done) {
260
292
  // Recorrido en profundidad con el camino a cuestas: al reencontrar un slug que sigue en el camino,
261
293
  // ese camino **es** el ciclo, y nombrarlo entero es lo que lo hace reparable — decir sólo que hay uno
262
294
  // deja el trabajo de encontrarlo del lado de quien lee.
263
- const estado = new Map()
264
- const visitar = (slug, camino) => {
265
- if (estado.get(slug) === 'listo') return
266
- const desde = camino.indexOf(slug)
267
- if (desde >= 0) {
268
- const ciclo = [...camino.slice(desde), slug]
269
- errors.push(`BACKLOG: ciclo de dependencias ${ciclo.join(' → ')}`)
295
+ const state = new Map()
296
+ const visit = (slug, trail) => {
297
+ if (state.get(slug) === 'listo') return
298
+ const from = trail.indexOf(slug)
299
+ if (from >= 0) {
300
+ const cycle = [...trail.slice(from), slug]
301
+ errors.push(`BACKLOG: ciclo de dependencias ${cycle.join(' → ')}`)
270
302
  return
271
303
  }
272
304
  for (const dep of queued.get(slug) || []) {
273
305
  // La que se depende a sí misma ya tiene su error, más claro que un ciclo de un solo paso.
274
- if (dep !== slug && queued.has(dep)) visitar(dep, [...camino, slug])
306
+ if (dep !== slug && queued.has(dep)) visit(dep, [...trail, slug])
275
307
  }
276
- estado.set(slug, 'listo')
308
+ state.set(slug, 'listo')
277
309
  }
278
- for (const slug of queued.keys()) visitar(slug, [])
310
+ for (const slug of queued.keys()) visit(slug, [])
279
311
  return [...new Set(errors)]
280
312
  }
281
313
 
@@ -397,10 +429,10 @@ function validateState({
397
429
  // ofreciendo. `**slug: de qué se trata**` es la que sale natural, porque esta tabla la lee una
398
430
  // persona. Sin esto la ausencia no deja rastro, que es la forma de R15 aplicada a un mecanismo.
399
431
  if (backlogSlugs.has(row.task)) continue
400
- const casi = [...backlogSlugs].find((slug) => new RegExp(`\\b${slug}\\b`).test(row.task))
401
- if (casi) {
402
- errors.push(`HUMAN_ACTIONS: la fila "${row.task}" nombra a ${casi} y no bloquea nada, porque el `
403
- + `motor bloquea por la primera columna exacta. Dejá "${casi}" sola ahí y contá el resto en la `
432
+ const near = [...backlogSlugs].find((slug) => new RegExp(`\\b${slug}\\b`).test(row.task))
433
+ if (near) {
434
+ errors.push(`HUMAN_ACTIONS: la fila "${row.task}" nombra a ${near} y no bloquea nada, porque el `
435
+ + `motor bloquea por la primera columna exacta. Dejá "${near}" sola ahí y contá el resto en la `
404
436
  + 'acción, o nombrá la épica o el recorrido si lo que se frena no es esa tarea')
405
437
  }
406
438
  }
@@ -435,6 +467,7 @@ module.exports = {
435
467
  doneEntryErrors,
436
468
  doneCeremonyWarnings,
437
469
  unverifiableAcceptance,
470
+ surfaceWithoutTests,
438
471
  validCommitTrace,
439
472
  validDecisionTrace,
440
473
  validTestTrace,