@ingeniomaps/cauce 0.91.0 → 0.93.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/engine/cli/ops.js CHANGED
@@ -10,6 +10,7 @@ const { fail } = require('./io')
10
10
  const IN = require('./instance')
11
11
  const PL = require('./planning')
12
12
  const CT = require('./contract')
13
+ const BN = require('./bench')
13
14
  const VA = require('./validate')
14
15
  const AR = require('./archive')
15
16
  const CLM = require('./claims')
@@ -151,6 +152,7 @@ function usage() {
151
152
  ops tree <planning-dir> [--no-color] [--json]
152
153
  ops context <planning-dir> [--hito <slug>] [--json]
153
154
  ops contract <ops-root> [--json]
155
+ ops bench <suelto|tarea|sidecar> [--force]
154
156
  ops recurring <planning-dir> [--promote <qué>] [--json]
155
157
  ops runners <planning-dir> [--json]
156
158
  ops claim <planning-dir> <tarea>
@@ -211,6 +213,7 @@ async function run(cli) {
211
213
  else if (command === 'tree') PL.tree(arg[1], cli)
212
214
  else if (command === 'context') PL.context(arg[1], cli)
213
215
  else if (command === 'contract') CT.contract(arg[1], cli)
216
+ else if (command === 'bench') BN.bench(arg[1], cli)
214
217
  else if (command === 'recurring') PL.recurring(arg[1], cli)
215
218
  else if (command === 'runners') CLM.runners(arg[1], cli)
216
219
  else if (command === 'claim') CLM.claim(arg[1], arg[2], cli)
@@ -29,6 +29,7 @@ const C = require('../config/validate')
29
29
  const CP = require('../config/paths')
30
30
  const AG = require('../agents/catalog')
31
31
  const RL = require('../automation/rules')
32
+ const CT = require('./contract')
32
33
  const { fail, planningRoot, TODAY } = require('./io')
33
34
 
34
35
  function check(dir, cli) {
@@ -131,6 +132,7 @@ function check(dir, cli) {
131
132
  warnings.push(...R.unrecordedHumanActions(path.resolve(root, '..'), P.readHumanActions(root)))
132
133
  warnings.push(...AP.warnings(path.resolve(root, '..')))
133
134
  warnings.push(...TR.warnings(path.resolve(root, '..')))
135
+ warnings.push(...CT.warnings(path.resolve(root, '..')))
134
136
 
135
137
  // Lo que `upgrade` conserva por estar editado deja de recibir mejoras, y eso es una deuda que no
136
138
  // avisa sola: la instancia queda con medio molde viejo y todo se ve normal. Sale acá para que se vea
@@ -121,7 +121,7 @@ function validateWorkspaces(workspaces, errors) {
121
121
  continue
122
122
  }
123
123
  for (const key of Object.keys(workspace)) {
124
- if (!['name', 'path', 'verify'].includes(key)) {
124
+ if (!['name', 'path', 'verify', 'scope'].includes(key)) {
125
125
  errors.push(`ops.config.json: workspaceRoots[${index}].${key} no está permitido`)
126
126
  }
127
127
  }
@@ -130,6 +130,18 @@ function validateWorkspaces(workspaces, errors) {
130
130
  if ('verify' in workspace && (typeof workspace.verify !== 'string' || !workspace.verify.trim())) {
131
131
  errors.push(`ops.config.json: workspaceRoots[${index}].verify debe ser el comando, o no estar`)
132
132
  }
133
+ // Qué rutas lee esa puerta, y se rechaza vacío por lo que dice la línea de arriba. Lo propio de acá
134
+ // es que ausente **no** es lo mismo que vacío: sin el campo cuenta cualquier delta, que es lo que
135
+ // mantiene válida a toda instancia escrita antes de que existiera, mientras que una lista vacía diría
136
+ // que la puerta no lee nada y ningún delta la alcanzaría nunca.
137
+ if ('scope' in workspace) {
138
+ const scope = workspace.scope
139
+ if (!Array.isArray(scope) || !scope.length) {
140
+ errors.push(`ops.config.json: workspaceRoots[${index}].scope debe ser una lista de rutas, o no estar`)
141
+ } else if (scope.some((one) => typeof one !== 'string' || !one.trim())) {
142
+ errors.push(`ops.config.json: workspaceRoots[${index}].scope: cada entrada es un patrón de ruta`)
143
+ }
144
+ }
133
145
  if (typeof workspace.name !== 'string' || !workspace.name.trim()) {
134
146
  errors.push(`ops.config.json: workspaceRoots[${index}].name es obligatorio`)
135
147
  }
@@ -107,10 +107,37 @@ function sourceOf(relative) {
107
107
  // Dos caminos, no tres. La copia vendorizada en `.ops/` se retiró en 0.10.0 — ahorraba un
108
108
  // `package.json` a cambio de 5 MB en la historia de la empresa y de no poder enterarse de una versión
109
109
  // nueva, y Node hace falta igual en los dos casos.
110
+ //
111
+ // Y un tercero, que es el layout que produce el flujo documentado: `npm install @ingeniomaps/cauce` se
112
+ // corre en la carpeta de la empresa y `cauce init ops` deja la instancia adentro, así que el motor queda
113
+ // **arriba** de ella. Sin este candidato, `automation check` daba nueve errores sobre un motor instalado y
114
+ // el consejo mandaba a bajar una segunda copia un nivel más abajo (caso 158).
115
+ //
116
+ // Un nivel y no una búsqueda hacia arriba. Node sube hasta la raíz del disco y eso acá adivina: dentro de
117
+ // un monorepo con varios paquetes encontraría un motor de otra versión, y ese fallo es silencioso. Lo que
118
+ // se mira es la raíz que la instancia **declara** —la misma que `installRoot` usa para el runner—, así que
119
+ // un `<empresa>-ops` con su propio `node_modules` gana en el primer candidato y no cambia nada.
120
+ //
121
+ // Se lee acá y no se importa de `automation/runners`: ese módulo ya importa éste, y al revés se muerden.
122
+ //
123
+ // **Y esta cascada la repiten otros dos**, porque corren antes de poder cargar este módulo: el shim
124
+ // `automatization/hooks/run-hook.sh`, que lanza cada guard, y el bridge
125
+ // `automatization/runners/antigravity/hook.js`. Los tres se cambian juntos o el motor se encuentra desde
126
+ // el CLI y no desde los guards, que es medio arreglo y del lado que no se nota.
127
+ function declaredRoot(root) {
128
+ try {
129
+ const config = JSON.parse(fs.readFileSync(path.join(root, 'ops.config.json'), 'utf8'))
130
+ if (config.mode === 'sidecar') return path.resolve(root, '..')
131
+ } catch { /* sin configuración legible, sólo el propio */ }
132
+ return ''
133
+ }
134
+
110
135
  function packagePath(root, relative) {
136
+ const above = declaredRoot(root)
111
137
  const candidates = [
112
138
  path.join(root, 'node_modules', '@ingeniomaps', 'cauce', relative),
113
139
  path.join(root, relative),
140
+ ...(above ? [path.join(above, 'node_modules', '@ingeniomaps', 'cauce', relative)] : []),
114
141
  ]
115
142
  return candidates.find((candidate) => fs.existsSync(candidate)) || ''
116
143
  }
@@ -0,0 +1,130 @@
1
+ 'use strict'
2
+
3
+ // Qué rutas alcanza la puerta de una raíz, para poder decidir si un delta puede cambiar su veredicto.
4
+ //
5
+ // Existe porque `verify` declara **el comando** de la puerta y no lo que ese comando lee, así que
6
+ // `commitTree` sólo podía preguntarse *si hay* delta y nunca *qué* delta: cualquier archivo sucio
7
+ // —un README a medio escribir, la basura de una sonda— forzaba la copia del índice aunque el gate no
8
+ // lo fuera a abrir nunca (caso 156). Lo caro no es la copia sino lo que arrastra: adentro
9
+ // `node_modules` viaja por enlace y eso rompe cualquier build de Turbopack (caso 153).
10
+ //
11
+ // Vive en `core/` y recibe las raíces ya leídas: resolver la raíz ops es de `hooks/`, y hacerlo acá
12
+ // invertiría la única dirección de dependencia que el repositorio sostiene entera.
13
+ //
14
+ // El matcher es propio y mínimo, y eso es una decisión: el motor no tiene ninguno reusable —las once
15
+ // construcciones de `RegExp` que hay son para comandos de git, para el chat o para nombres de archivo
16
+ // generado— y agregar una dependencia para esto contradiría la primera convención del repositorio.
17
+
18
+ const path = require('node:path')
19
+
20
+ // Lo que un patrón puede traer y hay que neutralizar para que no signifique otra cosa dentro de la
21
+ // expresión regular. `*` y `?` se tratan aparte porque son justamente los que sí significan. Sin esto
22
+ // `package.json` aceptaría `packageXjson`, y el alcance sería más ancho que el declarado.
23
+ const ESCAPE = /[.+^${}()|[\]\\]/g
24
+
25
+ // El vocabulario es el mínimo que alguien espera al escribir una ruta, y no el de una shell:
26
+ //
27
+ // `**` cualquier cantidad de segmentos, incluido ninguno
28
+ // `*` cualquier cosa dentro de **un** segmento — no cruza `/`
29
+ // `?` un carácter, tampoco `/`
30
+ //
31
+ // No hay llaves ni clases de caracteres, y eso es a propósito: cada forma que se agrega es una forma
32
+ // más de escribir mal un alcance, y un alcance escrito de menos apaga el aislamiento sin que nada lo
33
+ // diga. Se agregan cuando alguien las necesite de verdad.
34
+ //
35
+ // `**/` se consume junto con su barra para que `src/**/x.js` acepte también `src/x.js`: si no, el
36
+ // patrón pediría un directorio intermedio obligatorio, que no es lo que nadie quiere decir.
37
+ function toRegExp(pattern) {
38
+ let out = ''
39
+ for (let index = 0; index < pattern.length; index += 1) {
40
+ const char = pattern[index]
41
+ if (char === '*' && pattern[index + 1] === '*') {
42
+ const slash = pattern[index + 2] === '/'
43
+ out += slash ? '(?:.*/)?' : '.*'
44
+ index += slash ? 2 : 1
45
+ continue
46
+ }
47
+ if (char === '*') { out += '[^/]*'; continue }
48
+ if (char === '?') { out += '[^/]'; continue }
49
+ out += char.replace(ESCAPE, '\\$&')
50
+ }
51
+ return new RegExp(`^${out}$`)
52
+ }
53
+
54
+ // La ruta relativa a la raíz declarada, que es donde vive quien escribió el patrón: en un monorepo el
55
+ // `scope` de `apps/web` habla de `src/**`, no de `apps/web/src/**`. Devuelve `null` cuando cae fuera de
56
+ // esa raíz, que no es lo mismo que no coincidir con ningún patrón — una es «no es tuya» y la otra «es
57
+ // tuya y no la mirás».
58
+ //
59
+ // `path.relative` normaliza la barra final —`build/` vuelve como `build`—, así que si hace falta saber
60
+ // que era un directorio hay que mirarlo antes, en la cadena cruda. Eso costó una prueba en rojo.
61
+ function relativeTo(rootDir, repoDir, file) {
62
+ const absolute = path.resolve(repoDir, file)
63
+ const inside = path.relative(rootDir, absolute)
64
+ if (!inside || inside.startsWith('..') || path.isAbsolute(inside)) return null
65
+ return inside.split(path.sep).join('/')
66
+ }
67
+
68
+ // Un directorio sin trackear llega como `build/` y **git no dice qué hay adentro**. Si el alcance
69
+ // declara `build/**/*.ts`, ninguna forma del directorio matchea, y quedarse con eso sería dejar de
70
+ // materializar sin saber qué contiene. Por eso cuenta también cuando algún patrón **apunta hacia
71
+ // adentro** de él: equivocarse hacia materializar de más devuelve el comportamiento de siempre;
72
+ // hacia materializar de menos devuelve el defecto que `commitTree` fue a cerrar.
73
+ function coversDirectory(relative, patterns, raw) {
74
+ const clean = relative.replace(/\/$/, '')
75
+ const forms = [clean, `${clean}/`]
76
+ if (forms.some((form) => patterns.some((one) => one.test(form)))) return true
77
+ return raw.some((pattern) => pattern.startsWith(`${clean}/`))
78
+ }
79
+
80
+ // La ruta de una línea de `git status --porcelain`: empieza en la columna 4 —`XY ` y después el
81
+ // nombre— y un rename llega como `viejo -> nuevo`, del que importa el destino, que es lo que queda en
82
+ // disco. Las comillas las pone git cuando el nombre trae caracteres raros.
83
+ const fileOf = (line) => line.slice(3).trim().replace(/^.* -> /, '').replace(/^"|"$/g, '')
84
+
85
+ // Si alguna de las rutas del delta cae dentro del alcance declarado de alguna raíz.
86
+ //
87
+ // **Sin ninguna raíz que declare `scope`, contesta siempre que sí**, y ahí está la compatibilidad: una
88
+ // instancia que no adopta el campo se comporta exactamente como antes de que existiera. Es la única
89
+ // respuesta segura, porque lo que se decide es si se puede confiar en el árbol, y el default tiene que
90
+ // ser el que no confía.
91
+ //
92
+ // Una ruta que no cae bajo **ninguna** raíz declarada también cuenta como adentro: puede ser de la
93
+ // instancia, de otro servicio sin declarar o de la raíz misma, y decidir que no cuenta sería la clase
94
+ // de suposición que este campo existe para no tener que hacer.
95
+ function reachesGate(roots, repoDir, files) {
96
+ const declared = roots.filter((one) => one && Array.isArray(one.scope) && one.scope.length)
97
+ if (!declared.length) return true
98
+ const compiled = declared.map((one) => ({
99
+ dir: path.resolve(repoDir, one.path),
100
+ raw: one.scope,
101
+ patterns: one.scope.map(toRegExp),
102
+ }))
103
+ return files.some((file) => {
104
+ const isDir = file.endsWith('/')
105
+ let owned = false
106
+ for (const root of compiled) {
107
+ const relative = relativeTo(root.dir, repoDir, file)
108
+ if (relative === null) continue
109
+ owned = true
110
+ const hit = isDir
111
+ ? coversDirectory(relative, root.patterns, root.raw)
112
+ : root.patterns.some((one) => one.test(relative))
113
+ if (hit) return true
114
+ }
115
+ return !owned
116
+ })
117
+ }
118
+
119
+ // La decisión completa, para que quien la consume sea una línea: ¿alcanza con correr sobre el árbol?
120
+ // Recibe las líneas del delta tal como las devuelve `git status --porcelain`, ya sin lo ignorado.
121
+ function staysInTree(roots, repoDir, deltaLines) {
122
+ const declared = Array.isArray(roots) ? roots : []
123
+ if (!declared.length) return false
124
+ return !reachesGate(declared, repoDir, deltaLines.map(fileOf))
125
+ }
126
+
127
+ // Sólo lo que otro módulo consume, por lo que dice el cierre de `cli/bench.js`. `toRegExp` se queda
128
+ // adentro y no pierde nada: lo que hay que fijar de él son sus respuestas, y se ven igual preguntándole
129
+ // a `reachesGate`.
130
+ module.exports = { reachesGate, staysInTree }
@@ -11,6 +11,7 @@ const os = require('node:os')
11
11
  const path = require('node:path')
12
12
  const { readInput, cwdOf, block, findOpsRoot } = require('./input')
13
13
  const shell = require('./shell')
14
+ const { verify } = require('./verify')
14
15
  const files = require('./files')
15
16
  const chat = require('./chat')
16
17
  const { secretsShell } = require('./secrets-shell')
@@ -40,7 +41,7 @@ const guards = {
40
41
  'git-add': shell.gitAdd,
41
42
  dependencies: shell.dependencies,
42
43
  governance: shell.governance,
43
- verify: shell.verify,
44
+ verify,
44
45
  'shell-boundary': shell.shellBoundary,
45
46
  'secrets-shell': secretsShell,
46
47
  'ops-config-shell': opsConfigShell,
@@ -1,8 +1,12 @@
1
1
  'use strict'
2
2
 
3
- // Los guards que juzgan un comando antes de que se ejecute: qué destruye, qué publica, qué toca una
4
- // dependencia y qué gate hay que haber corrido. Todos leen `commandOf` y miran el índice de git —de
5
- // ahí que vayan juntos—, y son el grupo `pre-shell` que el registro ya declaraba.
3
+ // Los guards que juzgan el **texto** de un comando antes de que se ejecute: qué destruye, qué publica,
4
+ // qué toca una dependencia y qué gobierna. Es el grupo `pre-shell` que el registro declara, y de lo que
5
+ // parten los cinco es `commandOf`. Dos miran además el índice —`dependencies` y `governance`, los dos
6
+ // acotados a un commit—; los otros tres deciden sólo con lo que el comando dice.
7
+ //
8
+ // Correr los gates de un commit era lo otro que hacía este archivo y hoy vive en `verify.js`, que de acá
9
+ // no usa más que `run`. Cambia por otra causa: una herramienta nueva, no una evasión nueva.
6
10
 
7
11
  const fs = require('node:fs')
8
12
  const os = require('node:os')
@@ -16,7 +20,6 @@ const AP = require('./approval')
16
20
  const CHAT = require('./chat')
17
21
  const { publish } = require('./push')
18
22
  const { selfApprovalShell } = require('./self-approval')
19
- const EV = require('../core/evidence')
20
23
 
21
24
  // Dónde empieza y dónde termina una palabra dentro de un comando. Tres reglas de la tabla de abajo lo
22
25
  // decidían por su cuenta admitiendo sólo un espacio, el principio o el fin, y en un shell una palabra
@@ -395,248 +398,5 @@ function run(program, args, cwd, extra = {}) {
395
398
  }
396
399
  }
397
400
 
398
- // Salidas de build y cachés que cualquier gate rehace solo. Se comparan contra el nombre entero de la
399
- // entrada para que valga también anidado —`packages/app/dist`—, y con el separador de `git status`, que
400
- // siempre usa `/`.
401
- const RECREABLE = new RegExp('(^|/)(?:dist|build|out|coverage|__pycache__'
402
- + '|\\.next|\\.nuxt|\\.svelte-kit|\\.turbo|\\.output|\\.parcel-cache|\\.pytest_cache)$')
403
-
404
- // Dónde tiene que correr un gate: sobre lo que el commit va a grabar, que es el índice y no el árbol.
405
- // El árbol se le parece casi siempre y por eso el error no se veía — puede tener encima otra versión de
406
- // un archivo staged, y puede tener uno sin trackear que el commit no lleva, que es el olvido de
407
- // `git add` de toda la vida. En los dos casos el verde se calcula sobre un código que nadie va a
408
- // commitear, y queda escrito como si fuera el del commit.
409
- //
410
- // Cuando árbol e índice coinciden, el árbol **es** el próximo commit y correr donde está no cuesta nada.
411
- // Sólo cuando difieren se materializa el índice: `checkout-index` sobre un temporal, medido en 157 ms
412
- // para las mil quinientas rutas de este repositorio, contra los segundos que tarda cualquier gate.
413
- //
414
- // Lo ignorado viaja por enlace y lo sin trackear no, y esa distinción es la mitad del arreglo:
415
- // `node_modules` o `.venv` son entorno que el commit no lleva y sin ellos no corre ningún gate, mientras
416
- // que un fuente sin agregar es justamente lo que hay que ver fallar. `git status --ignored` ya los
417
- // separa en `!!` y `??`, así que no hay que adivinar cuál es cuál.
418
- //
419
- // No se usa `git stash --keep-index`, que sería más corto: toca el árbol de quien está trabajando, y un
420
- // gate que muere a la mitad le deja el stash puesto.
421
- function commitTree(dir) {
422
- const status = run('git', ['-C', dir, 'status', '--porcelain', '--ignored'], dir)
423
- if (!status.ok) {
424
- block(`no se pudo leer el estado de ${dir}, así que no hay cómo saber qué va a grabar el commit.`)
425
- }
426
- const lines = status.output.split('\n').filter(Boolean)
427
- if (!lines.some((line) => !line.startsWith('!!') && line[1] !== ' ')) {
428
- return { root: dir, temp: null, env: {} }
429
- }
430
-
431
- const temp = fs.mkdtempSync(path.join(os.tmpdir(), 'ops-verify-'))
432
- const written = run('git', ['-C', dir, 'checkout-index', '-a', `--prefix=${temp}${path.sep}`], dir)
433
- if (!written.ok) {
434
- fs.rmSync(temp, { recursive: true, force: true })
435
- block(`no se pudo materializar el índice de ${dir} para correr los gates: ${written.output}`)
436
- }
437
- const linked = []
438
- for (const line of lines) {
439
- if (!line.startsWith('!! ')) continue
440
- const name = line.slice(3).trim().replace(/\/$/, '')
441
- // Lo que el gate puede fabricar no se le enlaza: lo construye adentro de la copia y se descarta con
442
- // ella. Enlazarlo hacía dos daños a la vez. Uno es del usuario: el gate corre sobre el índice, así
443
- // que le dejaba la salida de build con la versión **staged** mientras su fuente en disco tenía otra,
444
- // y nada lo decía —medido con un `dist/` que pasó de «lo-que-estoy-editando» a «staged» (caso 069)—.
445
- // El otro es del propio gate: construía sobre restos de la corrida anterior del usuario, así que su
446
- // veredicto dependía de un estado que nadie declaró.
447
- //
448
- // La lista envejece y eso pesa menos de lo que parece, porque sólo se aplica a rutas que git ya
449
- // marcó como ignoradas: un `dist/` ignorado es generado por definición. Errarle por defecto —que
450
- // falte un nombre— deja el comportamiento de antes; errarle por exceso hace que un gate reconstruya,
451
- // que es más lento y no incorrecto. Lo que **sí** se enlaza es lo que un gate no puede fabricar:
452
- // `node_modules`, un `.env`, las credenciales de una herramienta.
453
- if (RECREABLE.test(name)) continue
454
- const link = path.join(temp, name)
455
- if (fs.existsSync(link)) continue
456
- fs.mkdirSync(path.dirname(link), { recursive: true })
457
- fs.symlinkSync(path.join(dir, name), link, 'junction')
458
- linked.push(name)
459
- }
460
- // Un índice materializado no trae `.git`, y un gate que llama a git —listar lo trackeado— falla ahí
461
- // por no encontrarlo: el guard frenaría un commit correcto por su propia mecánica. La copia se vuelve
462
- // un repositorio propio, con su índice cargado desde lo que se acaba de materializar, así que `git`
463
- // contesta sobre lo que el commit va a grabar.
464
- //
465
- // Antes esto se resolvía exportando `GIT_DIR` del repositorio de verdad, y ahí el gate que **escribe**
466
- // con git escribía en él: la suite de un proyecto levanta repositorios de prueba y les commitea, y
467
- // esos commits caían en la rama del usuario junto con un `core.worktree` apuntando a un temporal ya
468
- // borrado. Nada lo anunciaba (caso 045).
469
- //
470
- // Lo que se pierde a cambio, y son dos cosas. La copia no tiene historia, así que un gate que lea una
471
- // etiqueta o un `git log` no la encuentra: falla y se ve. Y un gate cuyo efecto ES una escritura de
472
- // git —taggear, commitear un lockfile regenerado— la hace sobre la copia, que se borra: ese efecto se
473
- // pierde en silencio. Se elige el silencio de acá sobre el de antes, que era escribir en la rama de
474
- // quien commitea; un proyecto con un gate así tiene que sacar esa escritura del gate.
475
- const started = run('git', ['init', '--quiet'], temp)
476
- // Lo enlazado es entorno y no entra al índice de la copia, y el `.gitignore` no alcanza para eso: un
477
- // patrón con barra final sólo cubre directorios, y un enlace no lo es para git. `add --all` lo agregaba
478
- // y un gate que recorre lo trackeado lo leía como archivo del commit (caso 095).
479
- if (started.ok) {
480
- fs.mkdirSync(path.join(temp, '.git', 'info'), { recursive: true })
481
- fs.appendFileSync(path.join(temp, '.git', 'info', 'exclude'),
482
- linked.map((name) => `/${name.replace(/[\\*?[\]]/g, '\\$&')}\n`).join(''))
483
- run('git', ['add', '--all'], temp)
484
- }
485
- // Un gate no sólo lee su entorno: escribe en él. Lo ignorado se enlaza al original —eso es a propósito
486
- // y está arriba—, así que lo que el gate escriba cae en el árbol de quien commitea. Un gestor que se
487
- // sincroniza antes de correr un script lo lleva al extremo: ve que el árbol enlazado no coincide con
488
- // el lockfile de la copia y reinstala, lo que **empieza borrando** el `node_modules` del proyecto.
489
- //
490
- // Lo que se apaga es esa comprobación previa, que es el motivo por el que quiere tocar nada.
491
- // `verify-deps-before-run` la gobierna y tiene cinco valores —`install`, `warn`, `prompt`, `error` y
492
- // `false`—. El que hace el daño es `install`, que reinstala solo y es el default desde pnpm 11. `error`
493
- // tampoco serviría: frena el gate cuando el lockfile de la copia difiere de lo instalado, que es justo
494
- // lo que pasa al commitear un cambio de lockfile por partes. La copia no tiene que sincronizar nada:
495
- // tiene que medir el código.
496
- //
497
- // El prefijo es `pnpm_config_` y no `npm_config_`, y esa sola palabra es la diferencia entre apagar la
498
- // comprobación y no apagar nada: pnpm lee sus ajustes del entorno con su propio prefijo, así que con el
499
- // de npm la variable llega igual y se ignora en silencio. Acá estuvo `npm_config_` desde el arreglo del
500
- // 070 y no surtió efecto nunca (caso 151), con lo cual la protección que ese arreglo creyó poner no
501
- // estuvo puesta. Medido sobre pnpm 10.30.2 y 11.20.0, iguales las dos.
502
- //
503
- // Acá estuvo `CI: 'true'` y fue una regresión (caso 070). Resolvía el síntoma del 068 —pnpm dejaba de
504
- // preguntar antes de purgar— desarmando la confirmación en vez de quitarle el motivo, y esa
505
- // confirmación era lo único que protegía al `node_modules` del proyecto: sin ella la reinstalación
506
- // avanza y borra por el enlace. Además encendía `frozen-lockfile`, que el propio pnpm anuncia al
507
- // fallar, así que la variable armaba y desarmaba guardas distintas a la vez.
508
- //
509
- // La regla que queda: no se desarma la confirmación de una herramienta, se le quita el motivo de
510
- // preguntar. Una confirmación que estorba casi siempre está cuidando algo.
511
- return { root: temp, temp, env: { pnpm_config_verify_deps_before_run: 'false' } }
512
- }
513
-
514
- function verify(input) {
515
- if (process.env.OPS_SKIP_VERIFY === '1') return
516
- const command = commandOf(input)
517
- if (!isCommit(command)) return
518
- const { dir, staged } = stagedForCommit(command, cwdOf(input))
519
- const changedOpenApi = staged.some((file) => /^(?:openapi|api|spec)(?:\/.*)?\/[^/]+\.ya?ml$/i.test(file))
520
- || staged.some((file) => /^(?:openapi|swagger)\.ya?ml$/i.test(file))
521
- const changedSqlSource = staged.some((file) => /^(?:db\/queries|queries)\/.*\.sql$/i.test(file))
522
- const hasApiGenerated = staged.some((file) => /(?:^|\/)[^/]*(?:generated|\.gen)\.(?:go|ts|js|py)$/i.test(file))
523
- const hasSqlGenerated = staged.some((file) => /(?:^|\/)(?:sqlc|generated)(?:\/|.*\.(?:go|ts|js|py)$)/i.test(file))
524
- // Acá lo aprobado es el conjunto staged entero: decir «autorizo commitear exactamente estas rutas»
525
- // es lo que un gate en rojo necesita, y cambia en cuanto se stagea una más. La lista sale del índice
526
- // y no de una regla, que es lo que la vuelve una operación y no un permiso.
527
- const sinAprobar = AP.pendingNow(opsRoot(input), staged, input)
528
- const aprobado = !sinAprobar.length
529
- if (changedOpenApi && !hasApiGenerated && !aprobado) {
530
- block('Cambió una fuente OpenAPI/Swagger sin incluir código regenerado. Ejecuta el generador y '
531
- + `stagea su salida.\n${AP.HOW('OPS_SKIP_VERIFY', sinAprobar, input)}`)
532
- }
533
- if (changedSqlSource && !hasSqlGenerated && !aprobado) {
534
- block('Cambió una consulta SQL fuente sin artefactos regenerados. Ejecuta el generador.\n'
535
- + AP.HOW('OPS_SKIP_VERIFY', sinAprobar, input))
536
- }
537
- if (!staged.some((file) => /\.(?:ts|tsx|js|jsx|mjs|cjs|go|py|html|css|scss|prisma)$/.test(file))) return
538
- const { root, temp, env } = commitTree(dir)
539
- try {
540
- verifyGates(root, dir, sinAprobar, env, input)
541
- } finally {
542
- if (temp) fs.rmSync(temp, { recursive: true, force: true })
543
- }
544
- }
545
-
546
- // Corre lo que el stack declare y bloquea si algo sale en rojo. `root` es dónde corre —el índice
547
- // materializado o el árbol, que ahí son lo mismo— y `dir` es el repositorio, que es el nombre que le
548
- // dice algo a quien lee el mensaje.
549
- //
550
- // Cada gate deja su rastro en `ops`; para qué sirve ese registro lo dice `core/evidence.js`. Lo que se
551
- // decide acá es que el rojo se anota igual que el verde: un gate que falló y se commiteó con
552
- // aprobación es exactamente lo que alguien va a querer ver después.
553
- // Lo que se sabe de un gate que falló, en la forma en que se va a leer. El mensaje decía sólo
554
- // `test (exit 1)` y tiraba la salida de la herramienta: cualquier causa —una suite en rojo, un gestor
555
- // que se negó a arrancar el script, un binario que no está— llegaba con el mismo texto. Es la misma
556
- // forma de fallar que el caso 066 encontró en una prueba, acá en el mensaje que lee una persona.
557
- //
558
- // Se muestra **una** línea y acotada: la salida de un gate puede traer cualquier cosa del entorno, y lo
559
- // que hace falta para diagnosticar es la primera línea de error, no el volcado.
560
- const ERROR_LINE = /error|err[_!]|fail|abort|not found|cannot|no such/i
561
- // Cómo marca un reporte de pruebas cada resultado. Van sólo las comprobadas contra la herramienta (caso
562
- // 094): el nombre de una prueba verde puede decir «error», y sin mirar la marca la búsqueda por palabra se
563
- // quedaba con ella y el mensaje escondía la roja. Comprobadas con la salida entubada, como la ve un gate:
564
- // `node --test` en spec y en TAP (Node 24.18.0), `go test` (go 1.26.3), jest 30.5.1 (`● nombre`, y
565
- // `● Test suite failed to run`), vitest 5.0.0 (`× nombre`), mocha 12.0.1 (`1) nombre`; la verde es `✔`) y
566
- // pytest 9.1.1 (`FAILED archivo::prueba` en el resumen; `::prueba PASSED` la verde con `-v`). Jest y vitest
567
- // no imprimen las verdes sin `--verbose`, así que de ellos no hay marca de éxito.
568
- const FAILED_TEST = /^(?:✖|not ok\b|--- FAIL:|● |× |\d+\) |FAILED )/
569
- const PASSED_TEST = /^(?:✔|ok\b|--- PASS:)|::\S+ PASSED\b/
570
- const MAX_LINE = 160
571
- function fallo(gate, result) {
572
- // La línea que empieza con `>` es el eco del script que npm y pnpm imprimen antes de correrlo, así
573
- // que lleva el comando entero y no dice nada de qué falló. Descartarla es lo que hace que la primera
574
- // coincidencia sea el error y no el comando — con el eco adentro, un script que **menciona** una
575
- // palabra de error gana siempre.
576
- const lines = (result.output || '').split('\n').map((one) => one.trim())
577
- .filter((one) => one && !one.startsWith('>'))
578
- const line = lines.find((one) => FAILED_TEST.test(one))
579
- || lines.find((one) => !PASSED_TEST.test(one) && ERROR_LINE.test(one)) || lines[0] || ''
580
- return { gate, status: result.status, ms: result.ms, line: line.slice(0, MAX_LINE) }
581
- }
582
-
583
- // Un gate que vuelve en menos de esto no corrió una suite. No se afirma que **no** haya corrido —un
584
- // lint puede fallar rápido y de verdad— y por eso lo que se agrega es el número, no un veredicto: los
585
- // tres gates del caso 068 volvieron a un segundo uno de otro contra los trece de la corrida real.
586
- const DEMASIADO_RAPIDO = 2000
587
- function comoSeLee(failures) {
588
- const texto = failures
589
- .map((one) => `${one.gate} (exit ${one.status}, ${(one.ms / 1000).toFixed(1)} s)`
590
- + `${one.line ? `: ${one.line}` : ''}`)
591
- .join('; ')
592
- if (!failures.every((one) => one.ms < DEMASIADO_RAPIDO)) return texto
593
- const cuantos = failures.length === 1 ? 'Volvió' : `Los ${failures.length} volvieron`
594
- return `${texto}\n${cuantos} en menos de ${DEMASIADO_RAPIDO / 1000} s: eso no alcanza para correr `
595
- + 'una suite, así que mirá si llegaron a ejecutarse antes de aprobar esto como un rojo conocido.'
596
- }
597
-
598
- function verifyGates(root, dir, sinAprobar, env, input) {
599
- const ops = opsRoot(input)
600
- const failures = []
601
- if (fs.existsSync(path.join(root, 'package.json'))) {
602
- const pkg = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8'))
603
- const usesPnpm = fs.existsSync(path.join(root, 'pnpm-lock.yaml'))
604
- && !fs.existsSync(path.join(root, 'package-lock.json'))
605
- const pm = usesPnpm ? 'pnpm' : 'npm'
606
- for (const script of ['test', 'lint', 'typecheck', 'build']) {
607
- if (!pkg.scripts || !pkg.scripts[script]) continue
608
- const result = run(pm, ['run', script], root, env)
609
- EV.record(ops, script, result.status, result.ms)
610
- if (!result.ok) failures.push(fallo(script, result))
611
- }
612
- } else if (fs.existsSync(path.join(root, 'go.mod'))) {
613
- const makefile = path.join(root, 'Makefile')
614
- if (fs.existsSync(makefile) && /^ci:/m.test(fs.readFileSync(makefile, 'utf8'))) {
615
- const result = run('make', ['ci'], root, env)
616
- EV.record(ops, 'make ci', result.status, result.ms)
617
- if (!result.ok) failures.push(fallo('make ci', result))
618
- } else {
619
- for (const args of [['test', './...'], ['build', './...']]) {
620
- const result = run('go', args, root, env)
621
- EV.record(ops, `go ${args[0]}`, result.status, result.ms)
622
- if (!result.ok) failures.push(fallo(`go ${args[0]}`, result))
623
- }
624
- }
625
- } else if (fs.existsSync(path.join(root, 'pyproject.toml')) || fs.existsSync(path.join(root, 'requirements.txt'))) {
626
- const makefile = path.join(root, 'Makefile')
627
- if (fs.existsSync(makefile) && /^test:/m.test(fs.readFileSync(makefile, 'utf8'))) {
628
- const result = run('make', ['test'], root, env)
629
- EV.record(ops, 'make test', result.status, result.ms)
630
- if (!result.ok) failures.push(fallo('make test', result))
631
- }
632
- }
633
- if (!failures.length || !sinAprobar.length) return
634
- // Se dice sobre qué corrió cuando no fue el árbol: un fallo que no se reproduce escribiendo el mismo
635
- // comando a mano se lee como que el guard miente, y lo que pasó es que midió lo que se va a grabar.
636
- const donde = root === dir ? '' : '\nCorrió sobre el índice, que es lo que el commit graba: si en tu '
637
- + 'directorio pasa, es que en disco tenés algo que no está staged.'
638
- block(`Verify falló en ${path.basename(dir)}: ${comoSeLee(failures)}\nNo se commitea en rojo.${donde}\n`
639
- + AP.HOW('OPS_SKIP_VERIFY', sinAprobar, input))
640
- }
641
401
 
642
- module.exports = { destructive, gitAdd, dependencies, governance, verify, shellBoundary, run, writesWithBase }
402
+ module.exports = { destructive, gitAdd, dependencies, governance, shellBoundary, run, writesWithBase }