@ingeniomaps/cauce 0.98.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.
@@ -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,
@@ -118,6 +118,15 @@
118
118
  "pattern": "^[a-z0-9]+$"
119
119
  },
120
120
  "minItems": 1
121
+ },
122
+ "paths": {
123
+ "type": "array",
124
+ "description": "Carpetas donde viven las migraciones, relativas y sin «.» ni «..»: el guard las reconoce en cualquier lugar de la ruta, así que \"alembic/versions\" también alcanza a \"api/alembic/versions\". Declararlas reemplaza el default [\"migrations\", \"migration\", \"migrate\"], y `check` avisa la que no alcanza a ningún archivo.",
125
+ "items": {
126
+ "type": "string",
127
+ "pattern": "^[A-Za-z0-9_-][A-Za-z0-9._-]*(?:/[A-Za-z0-9_-][A-Za-z0-9._-]*)*$"
128
+ },
129
+ "minItems": 1
121
130
  }
122
131
  }
123
132
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ingeniomaps/cauce",
3
- "version": "0.98.0",
3
+ "version": "0.99.0",
4
4
  "description": "Sistema portable de planificación y ejecución verificable para cualquier proyecto",
5
5
  "keywords": [
6
6
  "planning",
@@ -114,7 +114,9 @@ preguntando, no pasa. **Con los gates de un commit se pregunta cada vez**, como
114
114
  stack y los lockfiles no heredan lo que autorizaste en un mensaje anterior, porque cada commit es otra
115
115
  operación. Y `plan-first` no te pide un plan cuando el cambio lo pediste vos: el plan es para el trabajo que va
116
116
  por tareas. Funciona en Claude Code, Codex y Gemini, que le avisan a Cauce cuando mandás un mensaje; en
117
- Antigravity, y cuando nadie está en el chat —CI, un recorrido, un subagente—, queda el archivo de abajo.
117
+ Antigravity, y cuando nadie está en el chat —CI, un recorrido—, queda el archivo de abajo. Si lo frenado
118
+ lo hacía un subagente, tu confirmación le llega igual; un pedido tuyo que lo nombraba, no: ése vale sólo
119
+ para el agente con el que hablás.
118
120
 
119
121
  **Sin chat, la salida de todos ellos es la misma**: escribir en `planning/.ops-approval` las rutas que
120
122
  autorizás, una por línea, con `#` para lo que no sea una ruta. En sidecar es el `planning/` de la
@@ -16,7 +16,8 @@ invariantes.
16
16
  reclamo, `done/` o la evidencia registrada pide algo que todavía no existe cuando se la mira, y su lugar
17
17
  son los campos `tests:`, `qa:` y `commit:` de DONE, que ya lo exigen. `check` lo avisa sobre la cola. Si
18
18
  aun así corresponde dejarla ahí, se declara en la propia condición con `(fuera de verify: <razón>)` —la
19
- misma salida explícita que `(sin partir: …)` y que `n/a — razón`— y deja de avisarse.
19
+ misma salida explícita que `(sin partir: …)` y que `n/a — razón`— y deja de avisarse. El recorrido la
20
+ saca de lo que manda a Verify y a QA, y la lleva a Done para que quede cumplida en esos campos.
20
21
  - DONE: un archivo por tarea cerrada, `done/<slug>.md`, con su entrada `[x]` y los campos `acept:`,
21
22
  `fecha:` en AAAA-MM-DD, `done:`, `qa:`, `tests:`, `commit:` y `lane:`. `lane:` repite el carril con el
22
23
  que la tarea corrió —`express`, `directo`, `lite`, `full`— o `sin clasificar` si su línea no lo
@@ -28,7 +29,10 @@ invariantes.
28
29
  que convoca revisor con una revisión que no corrió es la ADR incumplida, escrita en el propio registro. La fecha es la del cierre, y es lo que
29
30
  ordena una evidencia que ya no depende de su posición dentro de un archivo. `tests:` enlaza cada criterio
30
31
  mediante `CN → prueba`; usa `A → prueba` cuando no hay épica o `n/a — razón` si no existe una
31
- superficie ejecutable. `decisions:` es opcional y, si aparece, cita `[fuente: ...]` o
32
+ superficie ejecutable. Un `tests:` que sea todo `n/a` es un error de `check` si su `commit:` toca algún
33
+ archivo que no sea un documento (`.md`, `.txt`, `.adoc`) o una imagen (`.png`, `.jpg`, `.jpeg`, `.gif`,
34
+ `.svg`, `.webp`): lo demás se ejecuta y se prueba, también lo que no tiene extensión, como un `Makefile`.
35
+ Rige para lo cerrado desde el 2026-09-24; lo anterior no se juzga con ella. `decisions:` es opcional y, si aparece, cita `[fuente: ...]` o
32
36
  `[supuesto: ...]`. `commit:` apunta a `<sha> <asunto>`, o a `n/a — razón` cuando la tarea no
33
37
  produce commit.
34
38
  - Acción humana: fila `| tarea | estado | origen | acción y condición de desbloqueo |`, con el estado